|
20 | 20 | // grid's consistent save stays silent), nor is a key the caller never sent; |
21 | 21 | // - a caller value another strip already took is reported once, under that |
22 | 22 | // strip's reason, never twice; |
23 | | -// - `strictReadonlyWrites` does NOT refuse a replaced value in 18.x (the |
24 | | -// maintainer's ruling 「甲」: only the loud half ships; the refusal waits |
25 | | -// for 19.0) — the write completes with the computed value and the report |
26 | | -// reaches the listener under strict too, while a strict write that ALSO |
27 | | -// carries a refusable drop is still refused, and fires no listener. |
| 23 | +// - #25000: under `strictReadonlyWrites` a replaced value is REFUSED like |
| 24 | +// every other strip's drop (the amended ruling 「甲」 ships it in 18.x) — |
| 25 | +// on insert (single, batch, `insertMany`), update by id and predicate |
| 26 | +// update: `ERR_READONLY_FIELD_REJECTED`, nothing written, no listener — |
| 27 | +// in one refusal with any other strip's drop; the computed value sent, or |
| 28 | +// no value, is never refused; and the same payload WITHOUT strict still |
| 29 | +// completes and is reported (the controls beside each refusal). |
28 | 30 |
|
29 | 31 | import { describe, it, expect, beforeEach } from 'vitest'; |
30 | 32 | import type { DroppedFieldsEvent } from '@objectstack/spec/data'; |
@@ -128,6 +130,8 @@ describe('[#24690] a caller value a stored expression replaced is reported as `s |
128 | 130 | quantity: { type: 'number', readonlyWhen: "parent.status == 'paid'" }, |
129 | 131 | unit_price: { type: 'currency', readonlyWhen: "parent.status == 'paid'" }, |
130 | 132 | description: { type: 'text' }, |
| 133 | + // A static lock, so a predicate update can carry a strip's drop. |
| 134 | + note: { type: 'text', readonly: true }, |
131 | 135 | amount: { type: 'currency', min: 0, expression: { dialect: 'cel', source: 'record.quantity * record.unit_price' } }, |
132 | 136 | }, |
133 | 137 | } as any); |
@@ -254,49 +258,150 @@ describe('[#24690] a caller value a stored expression replaced is reported as `s |
254 | 258 | }); |
255 | 259 | }); |
256 | 260 |
|
257 | | - describe('strictReadonlyWrites — a replaced value is REPORTED, not refused, in 18.x (ruling 「甲」)', () => { |
258 | | - it('update by id: the write completes with the computed value, and the report reaches the listener', async () => { |
| 261 | + describe('[#25000] strictReadonlyWrites — a replaced value is REFUSED, and nothing is written', () => { |
| 262 | + const STRICT = { strictReadonlyWrites: true } as const; |
| 263 | + async function refusal(write: Promise<unknown>): Promise<any> { |
| 264 | + const err: any = await write.then(() => undefined, (e: unknown) => e); |
| 265 | + expect(err).toBeInstanceOf(ReadonlyFieldRejectedError); |
| 266 | + expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED'); |
| 267 | + return err; |
| 268 | + } |
| 269 | + const snapshot = () => [...storeFor(LINE).values()].map((r) => ({ ...r })); |
| 270 | + |
| 271 | + it('update by id: refused, the row keeps its stored values, and no listener fires', async () => { |
| 272 | + const l = listener(); |
| 273 | + const before = snapshot(); |
| 274 | + const err = await refusal(engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped } as any)); |
| 275 | + expect(err.operation).toBe('update'); |
| 276 | + expect(err.fields).toEqual(['amount']); |
| 277 | + expect(err.drops).toEqual([AMOUNT]); |
| 278 | + expect(snapshot()).toEqual(before); |
| 279 | + expect(l.events).toEqual([]); |
| 280 | + }); |
| 281 | + |
| 282 | + it('control, update by id: the same payload without strict completes with the computed value, and is reported', async () => { |
259 | 283 | const l = listener(); |
260 | | - await engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped } as any); |
| 284 | + await engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { onFieldsDropped: l.onFieldsDropped }); |
261 | 285 | expect(line('line_draft')).toMatchObject({ quantity: 4, amount: 40 }); |
262 | 286 | expect(l.events).toEqual([AMOUNT]); |
263 | 287 | }); |
264 | 288 |
|
265 | | - it('insert: the row is written with the computed value, and reported', async () => { |
| 289 | + it('update by id under strict: the computed value sent, or no value, is not refused', async () => { |
| 290 | + await engine.update(LINE, { id: 'line_draft', quantity: 5, unit_price: 20, amount: 100 }, STRICT as any); |
| 291 | + expect(line('line_draft').amount).toBe(100); |
| 292 | + await engine.update(LINE, { id: 'line_draft', quantity: 7 }, STRICT as any); |
| 293 | + expect(line('line_draft').amount).toBe(140); |
| 294 | + }); |
| 295 | + |
| 296 | + it('insert, single row: refused, nothing written', async () => { |
| 297 | + const l = listener(); |
| 298 | + const err = await refusal(engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped })); |
| 299 | + expect(err.operation).toBe('insert'); |
| 300 | + expect(err.drops).toEqual([AMOUNT]); |
| 301 | + expect(storeFor(LINE).size).toBe(3); |
| 302 | + expect(l.events).toEqual([]); |
| 303 | + }); |
| 304 | + |
| 305 | + it('control, insert: the same row without strict is written with the computed value, and reported', async () => { |
266 | 306 | const l = listener(); |
267 | | - await engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped }); |
| 307 | + await engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }, { onFieldsDropped: l.onFieldsDropped }); |
268 | 308 | const rows = [...storeFor(LINE).values()]; |
269 | 309 | expect(rows).toHaveLength(4); |
270 | 310 | expect(rows.find((r) => r.amount === 20 && r.quantity === 2)).toBeDefined(); |
271 | 311 | expect(l.events).toEqual([AMOUNT]); |
272 | 312 | }); |
273 | 313 |
|
274 | | - it('predicate update: every matched row is written, and the replacement is reported', async () => { |
| 314 | + it('insert under strict: the computed value sent, or no value, is written', async () => { |
| 315 | + await engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 20 }, STRICT); |
| 316 | + await engine.insert(LINE, { invoice: 'INV-1004', quantity: 3, unit_price: 10 }, STRICT); |
| 317 | + expect(storeFor(LINE).size).toBe(5); |
| 318 | + }); |
| 319 | + |
| 320 | + it('insert, batch: one replaced row refuses the whole batch, and no row is written', async () => { |
| 321 | + const l = listener(); |
| 322 | + const err = await refusal(engine.insert(LINE, [ |
| 323 | + { invoice: 'INV-1004', quantity: 4, unit_price: 3, amount: 12 }, |
| 324 | + { invoice: 'INV-1004', quantity: 5, unit_price: 3, amount: 1 }, |
| 325 | + ], { ...STRICT, onFieldsDropped: l.onFieldsDropped })); |
| 326 | + expect(err.drops).toEqual([AMOUNT]); |
| 327 | + expect(storeFor(LINE).size).toBe(3); |
| 328 | + expect(l.events).toEqual([]); |
| 329 | + }); |
| 330 | + |
| 331 | + it('insertMany: refused like the other strict drops, and no row is written', async () => { |
275 | 332 | const l = listener(); |
276 | | - await engine.update(LINE, { quantity: 2, unit_price: 5, amount: 1 }, { where: { invoice: 'INV-1004' }, multi: true, strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped } as any); |
| 333 | + const err = await refusal(engine.insertMany(LINE, [ |
| 334 | + { invoice: 'INV-1004', quantity: 1, unit_price: 3, amount: 0 }, |
| 335 | + { invoice: 'INV-1004', quantity: 4, unit_price: 3, amount: 12 }, |
| 336 | + ], { ...STRICT, onFieldsDropped: l.onFieldsDropped })); |
| 337 | + expect(err.drops).toEqual([AMOUNT]); |
| 338 | + expect(storeFor(LINE).size).toBe(3); |
| 339 | + expect(l.events).toEqual([]); |
| 340 | + }); |
| 341 | + |
| 342 | + it('control, insertMany: without strict both rows are written and the replaced one carries its per-row report', async () => { |
| 343 | + const outcomes: any[] = await engine.insertMany(LINE, [ |
| 344 | + { invoice: 'INV-1004', quantity: 1, unit_price: 3, amount: 0 }, |
| 345 | + { invoice: 'INV-1004', quantity: 4, unit_price: 3, amount: 12 }, |
| 346 | + ]); |
| 347 | + expect(outcomes.map((o) => o.ok)).toEqual([true, true]); |
| 348 | + expect(outcomes[0].droppedFields).toEqual([AMOUNT]); |
| 349 | + expect(storeFor(LINE).size).toBe(5); |
| 350 | + }); |
| 351 | + |
| 352 | + it('predicate update: refused, and no matched row is written', async () => { |
| 353 | + const l = listener(); |
| 354 | + const before = snapshot(); |
| 355 | + const err = await refusal(engine.update(LINE, { quantity: 2, unit_price: 5, amount: 1 }, { where: { invoice: 'INV-1004' }, multi: true, ...STRICT, onFieldsDropped: l.onFieldsDropped } as any)); |
| 356 | + expect(err.drops).toEqual([AMOUNT]); |
| 357 | + expect(snapshot()).toEqual(before); |
| 358 | + expect(l.events).toEqual([]); |
| 359 | + }); |
| 360 | + |
| 361 | + it('control, predicate update: without strict every matched row is written, and the replacement is reported', async () => { |
| 362 | + const l = listener(); |
| 363 | + await engine.update(LINE, { quantity: 2, unit_price: 5, amount: 1 }, { where: { invoice: 'INV-1004' }, multi: true, onFieldsDropped: l.onFieldsDropped } as any); |
277 | 364 | expect([line('line_draft').amount, line('line_draft_2').amount]).toEqual([10, 10]); |
278 | 365 | expect(l.events).toEqual([AMOUNT]); |
279 | 366 | }); |
280 | 367 |
|
281 | | - it('a strict write that ALSO carries a refusable drop is refused for THAT drop alone, writes nothing, and fires no listener', async () => { |
| 368 | + it('a strict write that ALSO carries another strip\'s drop is refused ONCE, naming both, and writes nothing', async () => { |
282 | 369 | const l = listener(); |
283 | | - const err: any = await engine |
284 | | - .update(LINE, { id: 'line_paid', quantity: 60, amount: 2999.4 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped } as any) |
285 | | - .catch((e: unknown) => e); |
286 | | - expect(err).toBeInstanceOf(ReadonlyFieldRejectedError); |
287 | | - expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED'); |
288 | | - expect(err.drops).toEqual([{ object: LINE, fields: ['quantity'], reason: 'readonly_when' }]); |
| 370 | + const err = await refusal(engine.update(LINE, { id: 'line_paid', quantity: 60, amount: 2999.4 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped } as any)); |
| 371 | + expect(err.drops).toEqual([ |
| 372 | + { object: LINE, fields: ['quantity'], reason: 'readonly_when' }, |
| 373 | + AMOUNT, |
| 374 | + ]); |
| 375 | + expect(err.fields).toEqual(['quantity', 'amount']); |
289 | 376 | expect(line('line_paid')).toMatchObject({ quantity: 6, amount: 299.94 }); |
290 | 377 | expect(l.events).toEqual([]); |
291 | 378 | }); |
292 | 379 |
|
293 | | - it('insert: a strict write with a refusable drop is refused before the computation, carrying no stored_expression drop', async () => { |
| 380 | + it('predicate update: a strip\'s drop is refused BEFORE a divergent settle — ERR_READONLY_FIELD_REJECTED, not VALIDATION_FAILED (#5126 ordering)', async () => { |
294 | 381 | const l = listener(); |
| 382 | + const before = snapshot(); |
| 383 | + // quantity: 10 computes amount 100 on line_draft and 70 on line_draft_2, |
| 384 | + // which one statement cannot store; `note` is taken by the static strip. |
| 385 | + const err = await refusal(engine.update(LINE, { note: 'x', quantity: 10 }, { where: { invoice: 'INV-1004' }, multi: true, ...STRICT, onFieldsDropped: l.onFieldsDropped } as any)); |
| 386 | + expect(err.drops).toEqual([{ object: LINE, fields: ['note'], reason: 'readonly' }]); |
| 387 | + expect(snapshot()).toEqual(before); |
| 388 | + expect(l.events).toEqual([]); |
| 389 | + }); |
| 390 | + |
| 391 | + it('control, predicate update: the same divergent payload without strict is refused by the settle, VALIDATION_FAILED', async () => { |
| 392 | + const before = snapshot(); |
295 | 393 | const err: any = await engine |
296 | | - .insert('qa_locked_line', { quantity: 4, unit_price: 2, amount: 5 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped }) |
| 394 | + .update(LINE, { note: 'x', quantity: 10 }, { where: { invoice: 'INV-1004' }, multi: true } as any) |
297 | 395 | .catch((e: unknown) => e); |
298 | | - expect(err).toBeInstanceOf(ReadonlyFieldRejectedError); |
299 | | - expect(err.drops.map((d: DroppedFieldsEvent) => d.reason)).not.toContain('stored_expression'); |
| 396 | + expect(err?.code).toBe('VALIDATION_FAILED'); |
| 397 | + expect(err?.fields).toEqual([expect.objectContaining({ field: 'amount', code: 'invalid_value' })]); |
| 398 | + expect(snapshot()).toEqual(before); |
| 399 | + }); |
| 400 | + |
| 401 | + it('insert: a value the static `readonly` strip took is refused as `readonly` alone, never also as `stored_expression`', async () => { |
| 402 | + const l = listener(); |
| 403 | + const err = await refusal(engine.insert('qa_locked_line', { quantity: 4, unit_price: 2, amount: 5 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped })); |
| 404 | + expect(err.drops).toEqual([{ object: 'qa_locked_line', fields: ['amount'], reason: 'readonly' }]); |
300 | 405 | expect([...storeFor('qa_locked_line').values()]).toHaveLength(1); |
301 | 406 | expect(l.events).toEqual([]); |
302 | 407 | }); |
|
0 commit comments