n8n/packages/workflow/test/augment-object.test.ts
n8n-cat-bot[bot] d28e122fe7
test: Strengthen augment-object mutation coverage (#31854)
Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Declan Carroll <declan@n8n.io>
2026-06-08 09:17:34 +00:00

1096 lines
31 KiB
TypeScript

import fc from 'fast-check';
import { augmentArray, augmentObject } from '../src/augment-object';
import type { IDataObject } from '../src/interfaces';
import { deepCopy } from '../src/utils';
describe('AugmentObject', () => {
describe('augmentArray', () => {
test('should work with arrays', () => {
const originalObject = [1, 2, 3, 4, null];
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentArray(originalObject);
expect(augmentedObject.constructor.name).toEqual('Array');
expect(augmentedObject.push(5)).toEqual(6);
expect(augmentedObject).toEqual([1, 2, 3, 4, null, 5]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.pop()).toEqual(5);
expect(augmentedObject).toEqual([1, 2, 3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.shift()).toEqual(1);
expect(augmentedObject).toEqual([2, 3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.unshift(1)).toEqual(5);
expect(augmentedObject).toEqual([1, 2, 3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.splice(1, 1)).toEqual([2]);
expect(augmentedObject).toEqual([1, 3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.slice(1)).toEqual([3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.reverse()).toEqual([null, 4, 3, 1]);
expect(originalObject).toEqual(copyOriginal);
});
test('should work with arrays on any level', () => {
const originalObject = {
a: {
b: {
c: [
{
a3: {
b3: {
c3: '03' as string | null,
},
},
aa3: '01',
},
{
a3: {
b3: {
c3: '13',
},
},
aa3: '11',
},
],
},
},
aa: [
{
a3: {
b3: '2',
},
aa3: '1',
},
],
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
// On first level
augmentedObject.aa[0].a3.b3 = '22';
expect(augmentedObject.aa[0].a3.b3).toEqual('22');
expect(originalObject.aa[0].a3.b3).toEqual('2');
// Make sure that also array operations as push and length work as expected
// On lower levels
augmentedObject.a.b.c[0].a3.b3.c3 = '033';
expect(augmentedObject.a.b.c[0].a3.b3.c3).toEqual('033');
expect(originalObject.a.b.c[0].a3.b3.c3).toEqual('03');
augmentedObject.a.b.c[1].a3.b3.c3 = '133';
expect(augmentedObject.a.b.c[1].a3.b3.c3).toEqual('133');
expect(originalObject.a.b.c[1].a3.b3.c3).toEqual('13');
augmentedObject.a.b.c.push({
a3: {
b3: {
c3: '23',
},
},
aa3: '21',
});
augmentedObject.a.b.c[2].a3.b3.c3 = '233';
expect(augmentedObject.a.b.c[2].a3.b3.c3).toEqual('233');
augmentedObject.a.b.c[2].a3.b3.c3 = '2333';
expect(augmentedObject.a.b.c[2].a3.b3.c3).toEqual('2333');
augmentedObject.a.b.c[2].a3.b3.c3 = null;
expect(augmentedObject.a.b.c[2].a3.b3.c3).toEqual(null);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.a.b.c.length).toEqual(3);
expect(augmentedObject.aa).toEqual([
{
a3: {
b3: '22',
},
aa3: '1',
},
]);
expect(augmentedObject.a.b.c).toEqual([
{
a3: {
b3: {
c3: '033',
},
},
aa3: '01',
},
{
a3: {
b3: {
c3: '133',
},
},
aa3: '11',
},
{
a3: {
b3: {
c3: null,
},
},
aa3: '21',
},
]);
expect(augmentedObject).toEqual({
a: {
b: {
c: [
{
a3: {
b3: {
c3: '033',
},
},
aa3: '01',
},
{
a3: {
b3: {
c3: '133',
},
},
aa3: '11',
},
{
a3: {
b3: {
c3: null,
},
},
aa3: '21',
},
],
},
},
aa: [
{
a3: {
b3: '22',
},
aa3: '1',
},
],
});
expect(originalObject).toEqual(copyOriginal);
});
});
describe('augmentObject', () => {
test('should work with simple values on first level', () => {
const date = new Date(1680089084200);
const regexp = new RegExp('^test$', 'ig');
const originalObject: IDataObject = {
1: 11,
2: '22',
a: 111,
b: '222',
d: date,
r: regexp,
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
expect(augmentedObject.constructor.name).toEqual('Object');
augmentedObject[1] = 911;
expect(originalObject[1]).toEqual(11);
expect(augmentedObject[1]).toEqual(911);
augmentedObject[2] = '922';
expect(originalObject[2]).toEqual('22');
expect(augmentedObject[2]).toEqual('922');
augmentedObject.a = 9111;
expect(originalObject.a).toEqual(111);
expect(augmentedObject.a).toEqual(9111);
augmentedObject.b = '9222';
expect(originalObject.b).toEqual('222');
expect(augmentedObject.b).toEqual('9222');
augmentedObject.c = 3;
expect({ ...originalObject, d: date.toJSON(), r: {} }).toEqual(copyOriginal);
expect(augmentedObject).toEqual({
1: 911,
2: '922',
a: 9111,
b: '9222',
c: 3,
d: date.toJSON(),
r: regexp.toString(),
});
});
test('should work with simple values on sub-level', () => {
const originalObject = {
a: {
b: {
cc: '3',
},
bb: '2',
},
aa: '1',
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
augmentedObject.a.bb = '92';
expect(originalObject.a.bb).toEqual('2');
expect(augmentedObject.a.bb).toEqual('92');
augmentedObject.a.b.cc = '93';
expect(originalObject.a.b.cc).toEqual('3');
expect(augmentedObject.a.b.cc).toEqual('93');
// @ts-ignore
augmentedObject.a.b.ccc = {
d: '4',
};
// @ts-ignore
expect(augmentedObject.a.b.ccc).toEqual({ d: '4' });
// @ts-ignore
augmentedObject.a.b.ccc.d = '94';
// @ts-ignore
expect(augmentedObject.a.b.ccc.d).toEqual('94');
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject).toEqual({
a: {
b: {
cc: '93',
ccc: {
d: '94',
},
},
bb: '92',
},
aa: '1',
});
});
test('should work with complex values on first level', () => {
const originalObject: any = {
a: {
b: {
cc: '3',
c2: null,
},
bb: '2',
},
aa: '1',
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
augmentedObject.a = { new: 'NEW' };
expect(originalObject.a).toEqual({
b: {
c2: null,
cc: '3',
},
bb: '2',
});
expect(augmentedObject.a).toEqual({ new: 'NEW' });
augmentedObject.aa = '11';
expect(originalObject.aa).toEqual('1');
expect(augmentedObject.aa).toEqual('11');
augmentedObject.aaa = {
bbb: {
ccc: '333',
},
};
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject).toEqual({
a: {
new: 'NEW',
},
aa: '11',
aaa: {
bbb: {
ccc: '333',
},
},
});
});
test('should work with delete and reset', () => {
const originalObject = {
a: {
b: {
c: {
d: '4' as string | undefined,
} as { d?: string; dd?: string } | undefined,
cc: '3' as string | undefined,
},
bb: '2' as string | undefined,
},
aa: '1' as string | undefined,
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
// Remove multiple values
delete augmentedObject.a.b.c!.d;
expect(augmentedObject.a.b.c!.d).toEqual(undefined);
expect(originalObject.a.b.c!.d).toEqual('4');
expect(augmentedObject).toEqual({
a: {
b: {
c: {},
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
delete augmentedObject.a.b.c;
expect(augmentedObject.a.b.c).toEqual(undefined);
expect(originalObject.a.b.c).toEqual({ d: '4' });
expect(augmentedObject).toEqual({
a: {
b: {
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
// Set deleted values again
augmentedObject.a.b.c = { dd: '444' };
expect(augmentedObject.a.b.c).toEqual({ dd: '444' });
expect(originalObject).toEqual(copyOriginal);
augmentedObject.a.b.c.d = '44';
expect(augmentedObject).toEqual({
a: {
b: {
c: {
d: '44',
dd: '444',
},
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
});
// Is almost identical to above test
test('should work with setting to undefined and reset', () => {
const originalObject = {
a: {
b: {
c: {
d: '4' as string | undefined,
} as { d?: string; dd?: string } | undefined,
cc: '3' as string | undefined,
},
bb: '2' as string | undefined,
},
aa: '1' as string | undefined,
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
// Remove multiple values
augmentedObject.a.b.c!.d = undefined;
expect(augmentedObject.a.b.c!.d).toEqual(undefined);
expect(originalObject.a.b.c!.d).toEqual('4');
expect(augmentedObject).toEqual({
a: {
b: {
c: {},
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
augmentedObject.a.b.c = undefined;
expect(augmentedObject.a.b.c).toEqual(undefined);
expect(originalObject.a.b.c).toEqual({ d: '4' });
expect(augmentedObject).toEqual({
a: {
b: {
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
// Set deleted values again
augmentedObject.a.b.c = { dd: '444' };
expect(augmentedObject.a.b.c).toEqual({ dd: '444' });
expect(originalObject).toEqual(copyOriginal);
augmentedObject.a.b.c.d = '44';
expect(augmentedObject).toEqual({
a: {
b: {
c: {
d: '44',
dd: '444',
},
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
});
test('should ignore non-enumerable keys', () => {
const originalObject = { a: 1, b: 2 };
Object.defineProperty(originalObject, '__hiddenProp', { enumerable: false });
const augmentedObject = augmentObject(originalObject);
expect(Object.keys(augmentedObject)).toEqual(['a', 'b']);
});
test('should return property descriptors', () => {
const originalObject: any = {
x: {
y: {},
z: {},
},
};
const augmentedObject = augmentObject(originalObject);
expect(Object.getOwnPropertyDescriptor(augmentedObject.x, 'y')).toEqual({
configurable: true,
enumerable: true,
value: {},
writable: true,
});
delete augmentedObject.x.y;
expect(augmentedObject.x.hasOwnProperty('y')).toEqual(false);
augmentedObject.x.y = 42;
expect(augmentedObject.x.hasOwnProperty('y')).toEqual(true);
expect(Object.getOwnPropertyDescriptor(augmentedObject.x, 'y')).toEqual({
configurable: true,
enumerable: true,
value: 42,
writable: true,
});
});
test('should return valid values on `has` calls', () => {
const originalObject: any = {
x: {
y: {},
},
};
const augmentedObject = augmentObject(originalObject);
expect('y' in augmentedObject.x).toBe(true);
expect('z' in augmentedObject.x).toBe(false);
augmentedObject.x.z = 5;
expect('z' in augmentedObject.x).toBe(true);
expect('y' in augmentedObject.x).toBe(true);
});
test('should ignore non-enumerable keys', () => {
const originalObject: { toString?: string } = { toString: '123' };
const augmentedObject = augmentObject(originalObject);
expect('toString' in augmentedObject).toBe(true);
expect(Object.keys(augmentedObject)).toEqual(['toString']);
expect(Object.getOwnPropertyDescriptor(augmentedObject, 'toString')?.value).toEqual(
originalObject.toString,
);
expect(augmentedObject.toString).toEqual(originalObject.toString);
augmentedObject.toString = '456';
expect(augmentedObject.toString).toBe('456');
delete augmentedObject.toString;
expect(augmentedObject.toString).toBeUndefined();
});
test('should handle constructor property correctly', () => {
const originalObject: any = {
a: {
b: {
c: {
d: '4',
},
},
},
};
const augmentedObject = augmentObject(originalObject);
expect(augmentedObject.constructor.name).toEqual('Object');
expect(augmentedObject.a.constructor.name).toEqual('Object');
expect(augmentedObject.a.b.constructor.name).toEqual('Object');
expect(augmentedObject.a.b.c.constructor.name).toEqual('Object');
augmentedObject.constructor = {};
expect(augmentedObject.constructor.name).toEqual('Object');
delete augmentedObject.constructor;
expect(augmentedObject.constructor.name).toEqual('Object');
});
});
// Property-based invariants targeting the behavioural seams of the proxy
// traps. fast-check generates the random inputs; the assertions pin down
// the invariant each branch is supposed to preserve.
describe('invariants (fast-check)', () => {
// Primitive value generator — exercises the `typeof !== 'object' || null`
// fast-paths in both `augment` and the object `get` trap. NaN excluded
// because `Object.is` checks below would fail spuriously.
const arbPrimitive = fc.oneof(
fc.integer(),
fc.double({ noNaN: true }),
fc.string(),
fc.boolean(),
fc.constant(null),
fc.constant(undefined),
);
// Object/array keys. Excludes the proxy-special keys that would short-
// circuit the trap and reserved property names that would clobber
// inherited slots in unrelated ways.
const arbKey = fc
.string({ minLength: 1, maxLength: 6 })
.filter((k) => k !== 'constructor' && k !== '__proto__' && k !== 'toJSON');
// Plain (toJSON-free, RegExp-free, Date-free) JSON-ish values, suitable
// for use as set-trap inputs where we want identity to hold or where we
// want to assert non-mutation of the original.
const arbJsonScalar = fc.oneof(fc.integer(), fc.string(), fc.boolean(), fc.constant(null));
describe('augment dispatch — value-type fast paths', () => {
it('returns primitives untouched through the array proxy', () => {
fc.assert(
fc.property(arbPrimitive, (value) => {
const wrapped = augmentArray([value]);
expect(Object.is(wrapped[0], value)).toBe(true);
}),
);
});
it('returns RegExp instances by reference when nested in arrays', () => {
fc.assert(
fc.property(
fc.constantFrom('abc', '[a-z]+', '\\d+', 'x.y'),
fc.constantFrom('', 'g', 'i', 'gi', 'gim'),
(pattern, flags) => {
const regex = new RegExp(pattern, flags);
const wrapped = augmentArray([regex]);
expect(wrapped[0]).toBe(regex);
},
),
);
});
it('clones Date values inside arrays so mutations cannot bleed into the original', () => {
fc.assert(
fc.property(
// Avoid 1990 — we mutate to that year and need a strict inequality.
fc.integer({ min: 0, max: 2_000_000_000_000 }),
(epoch) => {
const original = new Date(epoch);
const originalYear = original.getFullYear();
fc.pre(originalYear !== 1990);
const wrapped = augmentArray([original]);
const fetched = wrapped[0] as Date;
expect(fetched).not.toBe(original);
expect(fetched).toBeInstanceOf(Date);
expect(fetched.valueOf()).toBe(original.valueOf());
fetched.setFullYear(1990);
expect(original.getFullYear()).toBe(originalYear);
},
),
);
});
it('clones Uint8Array values inside arrays', () => {
fc.assert(
fc.property(fc.uint8Array({ minLength: 1, maxLength: 16 }), (bytes) => {
const snapshot = Array.from(bytes);
const wrapped = augmentArray([bytes]);
const fetched = wrapped[0] as Uint8Array;
expect(fetched).not.toBe(bytes);
expect(fetched).toBeInstanceOf(Uint8Array);
expect(Array.from(fetched)).toEqual(snapshot);
fetched[0] = (fetched[0] + 1) & 0xff;
expect(Array.from(bytes)).toEqual(snapshot);
}),
);
});
});
describe('augmentArray', () => {
it('re-entry guard — augmenting an already-augmented array returns the same proxy', () => {
fc.assert(
fc.property(fc.array(fc.integer(), { maxLength: 10 }), (xs) => {
const first = augmentArray([...xs]);
expect(augmentArray(first)).toBe(first);
}),
);
});
it('re-entry guard holds via a nested object proxy', () => {
fc.assert(
fc.property(fc.array(fc.integer(), { maxLength: 5 }), (xs) => {
const wrapped = augmentObject({ list: [...xs] });
const list = wrapped.list;
expect(augmentArray(list)).toBe(list);
}),
);
});
it('constructor short-circuit — always returns Array even when newData has a poisoned constructor', () => {
fc.assert(
fc.property(fc.array(fc.integer(), { minLength: 1, maxLength: 5 }), (xs) => {
const wrapped = augmentArray<unknown>([...xs]);
(wrapped as unknown as { constructor: unknown }).constructor = function Fake() {};
expect(wrapped.constructor).toBe(Array);
expect(wrapped.constructor.name).toBe('Array');
}),
);
});
it('getOwnPropertyDescriptor pre-mutation delegates to the original target', () => {
fc.assert(
fc.property(
fc.array(fc.integer(), { minLength: 1, maxLength: 8 }),
fc.nat({ max: 7 }),
(xs, rawIdx) => {
fc.pre(xs.length > 0);
const idx = rawIdx % xs.length;
const wrapped = augmentArray([...xs]);
expect(Object.getOwnPropertyDescriptor(wrapped, idx)).toEqual({
value: xs[idx],
writable: true,
enumerable: true,
configurable: true,
});
expect(Object.getOwnPropertyDescriptor(wrapped, 'length')?.value).toBe(xs.length);
},
),
);
});
it('getOwnPropertyDescriptor post-push reports new length, leaves original intact', () => {
fc.assert(
fc.property(fc.array(fc.integer(), { maxLength: 8 }), fc.integer(), (xs, extra) => {
const original = [...xs];
const wrapped = augmentArray(original);
wrapped.push(extra);
expect(Object.getOwnPropertyDescriptor(wrapped, 'length')?.value).toBe(xs.length + 1);
expect(Object.getOwnPropertyDescriptor(original, 'length')?.value).toBe(xs.length);
}),
);
});
it('getOwnPropertyDescriptor for pushed-only indices returns the default-branch shape', () => {
fc.assert(
fc.property(fc.array(fc.integer(), { maxLength: 5 }), fc.integer(), (xs, extra) => {
const wrapped = augmentArray<number>([...xs]);
wrapped.push(extra);
const descriptor = Object.getOwnPropertyDescriptor(wrapped, xs.length);
// Pushed-only index → default frozen descriptor (no `value`,
// `writable: false`). Proxy machinery normalises the missing
// value to `undefined`. This branch is observably distinct from
// the original-index branch (writable: true) above.
expect(descriptor?.enumerable).toBe(true);
expect(descriptor?.configurable).toBe(true);
expect(descriptor?.writable).toBe(false);
expect(descriptor?.value).toBeUndefined();
}),
);
});
it('mutation traps — delete, has and ownKeys reflect the augmented state', () => {
fc.assert(
fc.property(
fc.array(fc.integer(), { minLength: 1, maxLength: 6 }),
fc.integer(),
fc.nat({ max: 5 }),
(xs, extra, rawIdx) => {
const original = [...xs];
const wrapped = augmentArray(original);
wrapped.push(extra);
const pushedIdx = xs.length;
expect(pushedIdx in wrapped).toBe(true);
expect(Object.keys(wrapped)).toContain(String(pushedIdx));
const idx = rawIdx % xs.length;
delete wrapped[idx];
expect(idx in wrapped).toBe(false);
expect(wrapped[idx]).toBeUndefined();
// Original is left untouched by every mutation above.
expect(original).toEqual(xs);
},
),
);
});
it('set on nested objects augments them so writes do not leak to the original element', () => {
fc.assert(
fc.property(fc.integer(), fc.integer(), (initial, overwrite) => {
const inner = { a: initial };
const wrapped = augmentArray<{ a: number }>([]);
wrapped.push(inner);
const fetched = wrapped[0];
fetched.a = overwrite;
expect(inner.a).toBe(initial);
expect(wrapped[0].a).toBe(overwrite);
}),
);
});
});
describe('augmentObject', () => {
it('re-entry guard — augmenting an already-augmented object returns the same proxy', () => {
fc.assert(
fc.property(fc.dictionary(arbKey, fc.integer(), { maxKeys: 5 }), (obj) => {
const first = augmentObject({ ...obj });
expect(augmentObject(first)).toBe(first);
}),
);
});
it('re-entry guard holds via a nested child proxy', () => {
fc.assert(
fc.property(fc.dictionary(arbKey, fc.integer(), { maxKeys: 5 }), (obj) => {
const wrapped = augmentObject({ inner: { ...obj } });
const inner = wrapped.inner;
expect(augmentObject(inner)).toBe(inner);
}),
);
});
it('constructor short-circuit — always returns Object', () => {
fc.assert(
fc.property(fc.dictionary(arbKey, fc.integer(), { maxKeys: 4 }), (obj) => {
const wrapped = augmentObject({ ...obj });
// Poison the constructor slot via a typed cast so the next read
// MUST hit the `key === 'constructor'` short-circuit branch.
(wrapped as unknown as { constructor: unknown }).constructor = function Fake() {};
expect(wrapped.constructor).toBe(Object);
expect(wrapped.constructor.name).toBe('Object');
}),
);
});
it('returns top-level primitives untouched', () => {
fc.assert(
fc.property(arbKey, arbPrimitive, (key, value) => {
const wrapped = augmentObject<Record<string, unknown>>({ [key]: value });
expect(Object.is(wrapped[key], value)).toBe(true);
}),
);
});
it('returns null directly without falling into the RegExp / toJSON branches', () => {
fc.assert(
fc.property(arbKey, (key) => {
const wrapped = augmentObject<Record<string, null>>({ [key]: null });
expect(wrapped[key]).toBeNull();
}),
);
});
it('serializes nested RegExp values via toString()', () => {
fc.assert(
fc.property(
fc.constantFrom('abc', '[a-z]+', '\\d+', 'x.y'),
fc.constantFrom('', 'g', 'i', 'gi', 'gim'),
(pattern, flags) => {
const regex = new RegExp(pattern, flags);
const wrapped = augmentObject({ nested: { pattern: regex } });
expect(wrapped.nested.pattern).toBe(regex.toString());
},
),
);
});
it('invokes toJSON when it is a function, returning its result', () => {
fc.assert(
fc.property(arbJsonScalar, (sentinel) => {
const wrapped = augmentObject({
nested: {
value: 42,
toJSON: () => sentinel,
},
});
expect(wrapped.nested).toBe(sentinel);
}),
);
});
it('does not call toJSON when the property is not a function', () => {
fc.assert(
fc.property(
arbJsonScalar.filter((v) => typeof v !== 'function'),
(notFunc) => {
const wrapped = augmentObject<{ nested: Record<string, unknown> }>({
nested: { toJSON: notFunc },
});
const fetched = wrapped.nested;
expect(fetched.toJSON).toBe(notFunc);
},
),
);
});
it('caches the augmented child so repeated reads return the same proxy', () => {
fc.assert(
fc.property(fc.dictionary(arbKey, fc.integer(), { maxKeys: 4 }), (obj) => {
const wrapped = augmentObject({ nested: { ...obj } });
const first = wrapped.nested;
const second = wrapped.nested;
expect(first).toBe(second);
}),
);
});
it('round-trips a fresh primitive set: after wrapped[k] = v, get/has/ownKeys/descriptor all agree', () => {
fc.assert(
fc.property(
arbKey,
arbJsonScalar.filter((v) => v !== null),
(key, value) => {
const wrapped = augmentObject<Record<string, unknown>>({});
wrapped[key] = value;
expect(Object.is(wrapped[key], value)).toBe(true);
expect(key in wrapped).toBe(true);
expect(Object.keys(wrapped)).toContain(key);
expect(Object.getOwnPropertyDescriptor(wrapped, key)?.value).toBe(value);
},
),
);
});
it('delete makes the key invisible through every read trap', () => {
fc.assert(
fc.property(fc.dictionary(arbKey, fc.integer(), { minKeys: 1, maxKeys: 5 }), (obj) => {
const keys = Object.keys(obj);
const target = keys[0];
const wrapped = augmentObject({ ...obj } as Record<string, number>);
delete wrapped[target];
expect(target in wrapped).toBe(false);
expect(wrapped[target]).toBeUndefined();
expect(Object.keys(wrapped)).not.toContain(target);
expect(Reflect.ownKeys(wrapped)).not.toContain(target);
expect(Object.getOwnPropertyDescriptor(wrapped, target)).toBeUndefined();
}),
);
});
it('set-to-undefined is observationally equivalent to delete (visibility-wise)', () => {
fc.assert(
fc.property(fc.dictionary(arbKey, fc.integer(), { minKeys: 1, maxKeys: 5 }), (obj) => {
const keys = Object.keys(obj);
const target = keys[0];
const deleted = augmentObject({ ...obj } as Record<string, number | undefined>);
const undef = augmentObject({ ...obj } as Record<string, number | undefined>);
delete deleted[target];
undef[target] = undefined;
expect(target in deleted).toBe(target in undef);
expect(Object.keys(deleted).sort()).toEqual(Object.keys(undef).sort());
expect(Reflect.ownKeys(deleted).sort()).toEqual(Reflect.ownKeys(undef).sort());
}),
);
});
// The delete trap purges newData[key] when the key was previously set
// (so the next get does not fall through to the cached value). Pin
// that explicitly — the deletedProperties check alone would not catch
// it, because a brand-new key never gets tracked as deleted.
it('delete after set on a brand-new key purges the cached newData value', () => {
fc.assert(
fc.property(arbKey, fc.integer(), (key, value) => {
const wrapped = augmentObject<Record<string, unknown>>({});
wrapped[key] = value;
delete wrapped[key];
expect(wrapped[key]).toBeUndefined();
expect(key in wrapped).toBe(false);
expect(Object.keys(wrapped)).not.toContain(key);
expect(Object.getOwnPropertyDescriptor(wrapped, key)).toBeUndefined();
}),
);
});
// Symmetric to the above for the set-to-undefined branch.
it('set-to-undefined after set on a brand-new key purges the cached newData value', () => {
fc.assert(
fc.property(arbKey, fc.integer(), (key, value) => {
const wrapped = augmentObject<Record<string, unknown>>({});
wrapped[key] = value;
wrapped[key] = undefined;
expect(wrapped[key]).toBeUndefined();
expect(key in wrapped).toBe(false);
expect(Object.keys(wrapped)).not.toContain(key);
expect(Object.getOwnPropertyDescriptor(wrapped, key)).toBeUndefined();
}),
);
});
it('re-setting a deleted key restores visibility through every trap', () => {
fc.assert(
fc.property(
arbKey,
fc.integer(),
arbJsonScalar.filter((v) => v !== null && v !== undefined),
(key, original, replacement) => {
const wrapped = augmentObject<Record<string, unknown>>({ [key]: original });
delete wrapped[key];
wrapped[key] = replacement;
expect(key in wrapped).toBe(true);
expect(Object.is(wrapped[key], replacement)).toBe(true);
expect(Reflect.ownKeys(wrapped)).toContain(key);
},
),
);
});
it('re-setting a key cleared via set-to-undefined restores visibility', () => {
fc.assert(
fc.property(
arbKey,
fc.integer(),
arbJsonScalar.filter((v) => v !== null && v !== undefined),
(key, original, replacement) => {
const wrapped = augmentObject<Record<string, unknown>>({ [key]: original });
wrapped[key] = undefined;
expect(key in wrapped).toBe(false);
wrapped[key] = replacement;
expect(key in wrapped).toBe(true);
expect(Object.is(wrapped[key], replacement)).toBe(true);
},
),
);
});
it('ownKeys never duplicates entries across original and new keys', () => {
fc.assert(
fc.property(
fc.dictionary(arbKey, fc.integer(), { maxKeys: 5 }),
fc.dictionary(arbKey, fc.integer(), { maxKeys: 5 }),
(original, additions) => {
const wrapped = augmentObject({ ...original } as Record<string, number>);
for (const [k, v] of Object.entries(additions)) wrapped[k] = v;
const keys = Reflect.ownKeys(wrapped);
expect(new Set(keys).size).toBe(keys.length);
},
),
);
});
});
// Model-based stateful test: drives a random sequence of set / delete /
// set-undefined operations and asserts the invariants every operation
// MUST preserve, including (a) original immutability and (b) read-trap
// consistency (has ⇔ ownKeys ⇔ getOwnPropertyDescriptor ⇔ get).
describe('stateful invariants', () => {
const arbOp = fc.oneof(
fc.record({ kind: fc.constant('set' as const), key: arbKey, value: fc.integer() }),
fc.record({ kind: fc.constant('delete' as const), key: arbKey }),
fc.record({ kind: fc.constant('setUndefined' as const), key: arbKey }),
);
it('arbitrary op sequences never mutate the original and keep read traps consistent', () => {
fc.assert(
fc.property(
fc.dictionary(arbKey, fc.integer(), { maxKeys: 4 }),
fc.array(arbOp, { maxLength: 20 }),
(initial, ops) => {
const original = { ...initial };
const snapshot = JSON.parse(JSON.stringify(original)) as Record<string, number>;
const wrapped = augmentObject(original as Record<string, number | undefined>);
for (const op of ops) {
if (op.kind === 'set') wrapped[op.key] = op.value;
else if (op.kind === 'delete') delete wrapped[op.key];
else wrapped[op.key] = undefined;
}
// Invariant 1: the original is structurally untouched.
expect(original).toEqual(snapshot);
// Invariant 2: read traps agree pairwise on every key the
// universe might mention.
const reflectKeys = Reflect.ownKeys(wrapped);
const objectKeys = Object.keys(wrapped);
expect(new Set(objectKeys)).toEqual(new Set(reflectKeys.map(String)));
const universe = new Set<string>([
...Object.keys(snapshot),
...ops.map((o) => o.key),
]);
for (const key of universe) {
const present = key in wrapped;
const inOwnKeys = reflectKeys.includes(key);
const descriptor = Object.getOwnPropertyDescriptor(wrapped, key);
expect(present).toBe(inOwnKeys);
expect(present).toBe(descriptor !== undefined);
if (!present) expect(wrapped[key]).toBeUndefined();
}
},
),
);
});
});
});
});