|
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'; |
@@ -254,49 +256,129 @@ describe('[#24690] a caller value a stored expression replaced is reported as `s |
254 | 256 | }); |
255 | 257 | }); |
256 | 258 |
|
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 () => { |
| 259 | + describe('[#25000] strictReadonlyWrites — a replaced value is REFUSED, and nothing is written', () => { |
| 260 | + const STRICT = { strictReadonlyWrites: true } as const; |
| 261 | + async function refusal(write: Promise<unknown>): Promise<any> { |
| 262 | + const err: any = await write.then(() => undefined, (e: unknown) => e); |
| 263 | + expect(err).toBeInstanceOf(ReadonlyFieldRejectedError); |
| 264 | + expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED'); |
| 265 | + return err; |
| 266 | + } |
| 267 | + const snapshot = () => [...storeFor(LINE).values()].map((r) => ({ ...r })); |
| 268 | + |
| 269 | + it('update by id: refused, the row keeps its stored values, and no listener fires', async () => { |
| 270 | + const l = listener(); |
| 271 | + const before = snapshot(); |
| 272 | + const err = await refusal(engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped } as any)); |
| 273 | + expect(err.operation).toBe('update'); |
| 274 | + expect(err.fields).toEqual(['amount']); |
| 275 | + expect(err.drops).toEqual([AMOUNT]); |
| 276 | + expect(snapshot()).toEqual(before); |
| 277 | + expect(l.events).toEqual([]); |
| 278 | + }); |
| 279 | + |
| 280 | + it('control, update by id: the same payload without strict completes with the computed value, and is reported', async () => { |
259 | 281 | const l = listener(); |
260 | | - await engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped } as any); |
| 282 | + await engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { onFieldsDropped: l.onFieldsDropped }); |
261 | 283 | expect(line('line_draft')).toMatchObject({ quantity: 4, amount: 40 }); |
262 | 284 | expect(l.events).toEqual([AMOUNT]); |
263 | 285 | }); |
264 | 286 |
|
265 | | - it('insert: the row is written with the computed value, and reported', async () => { |
| 287 | + it('update by id under strict: the computed value sent, or no value, is not refused', async () => { |
| 288 | + await engine.update(LINE, { id: 'line_draft', quantity: 5, unit_price: 20, amount: 100 }, STRICT as any); |
| 289 | + expect(line('line_draft').amount).toBe(100); |
| 290 | + await engine.update(LINE, { id: 'line_draft', quantity: 7 }, STRICT as any); |
| 291 | + expect(line('line_draft').amount).toBe(140); |
| 292 | + }); |
| 293 | + |
| 294 | + it('insert, single row: refused, nothing written', async () => { |
| 295 | + const l = listener(); |
| 296 | + const err = await refusal(engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped })); |
| 297 | + expect(err.operation).toBe('insert'); |
| 298 | + expect(err.drops).toEqual([AMOUNT]); |
| 299 | + expect(storeFor(LINE).size).toBe(3); |
| 300 | + expect(l.events).toEqual([]); |
| 301 | + }); |
| 302 | + |
| 303 | + it('control, insert: the same row without strict is written with the computed value, and reported', async () => { |
266 | 304 | const l = listener(); |
267 | | - await engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped }); |
| 305 | + await engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }, { onFieldsDropped: l.onFieldsDropped }); |
268 | 306 | const rows = [...storeFor(LINE).values()]; |
269 | 307 | expect(rows).toHaveLength(4); |
270 | 308 | expect(rows.find((r) => r.amount === 20 && r.quantity === 2)).toBeDefined(); |
271 | 309 | expect(l.events).toEqual([AMOUNT]); |
272 | 310 | }); |
273 | 311 |
|
274 | | - it('predicate update: every matched row is written, and the replacement is reported', async () => { |
| 312 | + it('insert under strict: the computed value sent, or no value, is written', async () => { |
| 313 | + await engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 20 }, STRICT); |
| 314 | + await engine.insert(LINE, { invoice: 'INV-1004', quantity: 3, unit_price: 10 }, STRICT); |
| 315 | + expect(storeFor(LINE).size).toBe(5); |
| 316 | + }); |
| 317 | + |
| 318 | + it('insert, batch: one replaced row refuses the whole batch, and no row is written', async () => { |
| 319 | + const l = listener(); |
| 320 | + const err = await refusal(engine.insert(LINE, [ |
| 321 | + { invoice: 'INV-1004', quantity: 4, unit_price: 3, amount: 12 }, |
| 322 | + { invoice: 'INV-1004', quantity: 5, unit_price: 3, amount: 1 }, |
| 323 | + ], { ...STRICT, onFieldsDropped: l.onFieldsDropped })); |
| 324 | + expect(err.drops).toEqual([AMOUNT]); |
| 325 | + expect(storeFor(LINE).size).toBe(3); |
| 326 | + expect(l.events).toEqual([]); |
| 327 | + }); |
| 328 | + |
| 329 | + it('insertMany: refused like the other strict drops, and no row is written', async () => { |
275 | 330 | 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); |
| 331 | + const err = await refusal(engine.insertMany(LINE, [ |
| 332 | + { invoice: 'INV-1004', quantity: 1, unit_price: 3, amount: 0 }, |
| 333 | + { invoice: 'INV-1004', quantity: 4, unit_price: 3, amount: 12 }, |
| 334 | + ], { ...STRICT, onFieldsDropped: l.onFieldsDropped })); |
| 335 | + expect(err.drops).toEqual([AMOUNT]); |
| 336 | + expect(storeFor(LINE).size).toBe(3); |
| 337 | + expect(l.events).toEqual([]); |
| 338 | + }); |
| 339 | + |
| 340 | + it('control, insertMany: without strict both rows are written and the replaced one carries its per-row report', async () => { |
| 341 | + const outcomes: any[] = await engine.insertMany(LINE, [ |
| 342 | + { invoice: 'INV-1004', quantity: 1, unit_price: 3, amount: 0 }, |
| 343 | + { invoice: 'INV-1004', quantity: 4, unit_price: 3, amount: 12 }, |
| 344 | + ]); |
| 345 | + expect(outcomes.map((o) => o.ok)).toEqual([true, true]); |
| 346 | + expect(outcomes[0].droppedFields).toEqual([AMOUNT]); |
| 347 | + expect(storeFor(LINE).size).toBe(5); |
| 348 | + }); |
| 349 | + |
| 350 | + it('predicate update: refused, and no matched row is written', async () => { |
| 351 | + const l = listener(); |
| 352 | + const before = snapshot(); |
| 353 | + 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)); |
| 354 | + expect(err.drops).toEqual([AMOUNT]); |
| 355 | + expect(snapshot()).toEqual(before); |
| 356 | + expect(l.events).toEqual([]); |
| 357 | + }); |
| 358 | + |
| 359 | + it('control, predicate update: without strict every matched row is written, and the replacement is reported', async () => { |
| 360 | + const l = listener(); |
| 361 | + await engine.update(LINE, { quantity: 2, unit_price: 5, amount: 1 }, { where: { invoice: 'INV-1004' }, multi: true, onFieldsDropped: l.onFieldsDropped } as any); |
277 | 362 | expect([line('line_draft').amount, line('line_draft_2').amount]).toEqual([10, 10]); |
278 | 363 | expect(l.events).toEqual([AMOUNT]); |
279 | 364 | }); |
280 | 365 |
|
281 | | - it('a strict write that ALSO carries a refusable drop is refused for THAT drop alone, writes nothing, and fires no listener', async () => { |
| 366 | + it('a strict write that ALSO carries another strip\'s drop is refused ONCE, naming both, and writes nothing', async () => { |
282 | 367 | 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' }]); |
| 368 | + const err = await refusal(engine.update(LINE, { id: 'line_paid', quantity: 60, amount: 2999.4 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped } as any)); |
| 369 | + expect(err.drops).toEqual([ |
| 370 | + { object: LINE, fields: ['quantity'], reason: 'readonly_when' }, |
| 371 | + AMOUNT, |
| 372 | + ]); |
| 373 | + expect(err.fields).toEqual(['quantity', 'amount']); |
289 | 374 | expect(line('line_paid')).toMatchObject({ quantity: 6, amount: 299.94 }); |
290 | 375 | expect(l.events).toEqual([]); |
291 | 376 | }); |
292 | 377 |
|
293 | | - it('insert: a strict write with a refusable drop is refused before the computation, carrying no stored_expression drop', async () => { |
| 378 | + it('insert: a value the static `readonly` strip took is refused as `readonly` alone, never also as `stored_expression`', async () => { |
294 | 379 | const l = listener(); |
295 | | - const err: any = await engine |
296 | | - .insert('qa_locked_line', { quantity: 4, unit_price: 2, amount: 5 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped }) |
297 | | - .catch((e: unknown) => e); |
298 | | - expect(err).toBeInstanceOf(ReadonlyFieldRejectedError); |
299 | | - expect(err.drops.map((d: DroppedFieldsEvent) => d.reason)).not.toContain('stored_expression'); |
| 380 | + const err = await refusal(engine.insert('qa_locked_line', { quantity: 4, unit_price: 2, amount: 5 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped })); |
| 381 | + expect(err.drops).toEqual([{ object: 'qa_locked_line', fields: ['amount'], reason: 'readonly' }]); |
300 | 382 | expect([...storeFor('qa_locked_line').values()]).toHaveLength(1); |
301 | 383 | expect(l.events).toEqual([]); |
302 | 384 | }); |
|
0 commit comments