diff --git a/tests/unit/brainy/get.test.ts b/tests/unit/brainy/get.test.ts index b39bf2e1..97a19125 100644 --- a/tests/unit/brainy/get.test.ts +++ b/tests/unit/brainy/get.test.ts @@ -5,7 +5,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { +import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' +import { createAddParams, generateTestVector, createTestConfig, @@ -268,32 +269,75 @@ describe('Brainy.get()', () => { expect(entity!.id).toBe(id) }) - it('should get entity with very large metadata', async () => { - // Arrange + // THE INDEXABLE-ARRAY BOUND, from get()'s side. This case used to park a + // 1000-element array in the metadata bag and assert it came back. That + // shape is refused at the write door now — an array field mints one + // posting per element, so an unbounded array is an unbounded write — so + // the case pins BOTH halves of the law that replaced it: a large SCALAR + // payload still round-trips whole, and an array over the bound refuses by + // name. Every length derives from MAX_INDEXED_ARRAY_LENGTH so the pin + // follows the constant wherever it moves. + it('should get an entity with a large scalar metadata payload', async () => { + // Arrange — large in every dimension EXCEPT array length: a long string, + // many fields, deep nesting, and an array sitting exactly ON the bound. const largeMetadata = { - bigArray: new Array(1000).fill('item'), + atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), bigObject: Object.fromEntries( Array.from({ length: 100 }, (_, i) => [`key${i}`, `value${i}`]) ), + longString: 'x'.repeat(10_000), deepNesting: Array(10).fill(null).reduce( (acc) => ({ nested: acc }), { value: 'deep' } ) } - + const id = await brain.add(createAddParams({ data: 'Large metadata', type: 'thing', metadata: largeMetadata })) - + // Act const entity = await brain.get(id) - - // Assert + + // Assert — the payload comes back whole, first element to last expect(entity).not.toBeNull() - expect(entity!.metadata.bigArray).toHaveLength(1000) + expect(entity!.metadata.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) + expect(entity!.metadata.atTheBound[0]).toBe('item0') + expect(entity!.metadata.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1]) + .toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`) expect(Object.keys(entity!.metadata.bigObject)).toHaveLength(100) + expect(entity!.metadata.longString).toHaveLength(10_000) + + // ...including the deep nest, walked to the bottom. + let cursor: any = entity!.metadata.deepNesting + for (let depth = 0; depth < 10; depth++) cursor = cursor.nested + expect(cursor.value).toBe('deep') + }) + + it('should refuse a metadata array over the indexing bound, by name', async () => { + // Arrange + const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 + + // Act + const err = await brain + .add(createAddParams({ + data: 'Large metadata', + type: 'thing', + metadata: { bigArray: new Array(overTheBound).fill('item') } + })) + .catch((e: any) => e) + + // Assert — the field, the length and the bound, on the error and in the + // message, so a handler can report or repair without parsing prose. + expect(err).toBeInstanceOf(MetadataArrayTooLargeError) + expect(err.field).toBe('bigArray') + expect(err.length).toBe(overTheBound) + expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) + expect(err.message).toContain('bigArray') + expect(err.message).toContain(String(overTheBound)) + expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) }) }) diff --git a/tests/unit/brainy/relate.test.ts b/tests/unit/brainy/relate.test.ts index eb1a036e..bea35ba3 100644 --- a/tests/unit/brainy/relate.test.ts +++ b/tests/unit/brainy/relate.test.ts @@ -5,7 +5,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { +import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' +import { createAddParams, createTestConfig, } from '../../helpers/test-factory' @@ -248,16 +249,23 @@ describe('Brainy.relate()', () => { expect(matches.length).toBe(1) // Only one relationship should exist }) - it('should handle very long metadata', async () => { - // Arrange + // THE INDEXABLE-ARRAY BOUND, from relate()'s side. This case used to pass a + // 100-element array through relate() and assert it came back — a length + // hardcoded either side of a bound it never named, so it read green or red + // purely by where the constant happened to sit. Both halves of the law are + // pinned here instead, and every length derives from + // MAX_INDEXED_ARRAY_LENGTH so the pin follows the constant. + it('should handle a large scalar metadata payload on a relation', async () => { + // Arrange — large in every dimension EXCEPT array length: a long string, + // many fields, and an array sitting exactly ON the bound. const largeMetadata = { - bigArray: new Array(100).fill('item'), + atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), bigObject: Object.fromEntries( Array.from({ length: 50 }, (_, i) => [`key${i}`, `value${i}`]) ), - longString: 'x'.repeat(1000) + longString: 'x'.repeat(10_000) } - + // Act await brain.relate({ from: entity1Id, @@ -265,12 +273,46 @@ describe('Brainy.relate()', () => { type: 'relatedTo', metadata: largeMetadata }) - - // Assert + + // Assert — the payload comes back whole, first element to last const relations = await brain.related({ from: entity1Id }) const relation = relations.find(r => r.to === entity2Id) expect(relation).toBeDefined() - expect(relation!.metadata?.bigArray).toHaveLength(100) + expect(relation!.metadata?.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) + expect(relation!.metadata?.atTheBound[0]).toBe('item0') + expect(relation!.metadata?.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1]) + .toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`) + expect(Object.keys(relation!.metadata?.bigObject)).toHaveLength(50) + expect(relation!.metadata?.longString).toHaveLength(10_000) + }) + + it('should refuse a relation metadata array over the indexing bound, by name', async () => { + // Arrange + const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 + + // Act + const err = await brain + .relate({ + from: entity1Id, + to: entity3Id, + type: 'relatedTo', + metadata: { bigArray: new Array(overTheBound).fill('item') } + }) + .catch((e: any) => e) + + // Assert — the field, the length and the bound, on the error and in the + // message, so a handler can report or repair without parsing prose. + expect(err).toBeInstanceOf(MetadataArrayTooLargeError) + expect(err.field).toBe('bigArray') + expect(err.length).toBe(overTheBound) + expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) + expect(err.message).toContain('bigArray') + expect(err.message).toContain(String(overTheBound)) + expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) + + // Refused means not written: no relation of this shape exists. + const relations = await brain.related({ from: entity1Id }) + expect(relations.some(r => r.to === entity3Id && r.metadata?.bigArray)).toBe(false) }) it('should handle special characters in metadata', async () => { diff --git a/tests/unit/brainy/update.test.ts b/tests/unit/brainy/update.test.ts index 19fdad19..ec5f3fff 100644 --- a/tests/unit/brainy/update.test.ts +++ b/tests/unit/brainy/update.test.ts @@ -5,7 +5,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { +import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' +import { createAddParams, createTestConfig, } from '../../helpers/test-factory' @@ -355,36 +356,88 @@ describe('Brainy.update()', () => { expect(final!.metadata.counter).toBeLessThanOrEqual(10) }) - it('should handle very large metadata updates', async () => { + // THE INDEXABLE-ARRAY BOUND, from update()'s side. This case used to write + // a 1000-element array through update() and assert it came back. That + // shape is refused at the write door now — an array field mints one + // posting per element, so an unbounded array is an unbounded write — so + // the case pins BOTH halves of the law that replaced it. Every length + // derives from MAX_INDEXED_ARRAY_LENGTH so the pin follows the constant. + it('should handle a large scalar metadata update', async () => { // Arrange const id = await brain.add(createAddParams({ data: 'Large metadata test', type: 'thing' })) - + + // Large in every dimension EXCEPT array length: a long string, many + // fields, deep nesting, and an array sitting exactly ON the bound. const largeMetadata = { - bigArray: new Array(1000).fill('item'), + atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), bigObject: Object.fromEntries( Array.from({ length: 100 }, (_, i) => [`key${i}`, `value${i}`]) ), + longString: 'x'.repeat(10_000), deepNesting: Array(10).fill(null).reduce( (acc) => ({ nested: acc }), { value: 'deep' } ) } - + // Act await brain.update({ id, metadata: largeMetadata, merge: false }) - - // Assert + + // Assert — the payload comes back whole, first element to last const updated = await brain.get(id) expect(updated).not.toBeNull() - expect(updated!.metadata.bigArray).toHaveLength(1000) + expect(updated!.metadata.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) + expect(updated!.metadata.atTheBound[0]).toBe('item0') + expect(updated!.metadata.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1]) + .toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`) expect(Object.keys(updated!.metadata.bigObject)).toHaveLength(100) + expect(updated!.metadata.longString).toHaveLength(10_000) + + // ...including the deep nest, walked to the bottom. + let cursor: any = updated!.metadata.deepNesting + for (let depth = 0; depth < 10; depth++) cursor = cursor.nested + expect(cursor.value).toBe('deep') + }) + + it('should refuse an update whose metadata array is over the indexing bound, by name', async () => { + // Arrange + const id = await brain.add(createAddParams({ + data: 'Large metadata test', + type: 'thing', + metadata: { keep: 'me' } + })) + const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 + + // Act + const err = await brain + .update({ + id, + metadata: { bigArray: new Array(overTheBound).fill('item') }, + merge: false + }) + .catch((e: any) => e) + + // Assert — the field, the length and the bound, on the error and in the + // message, so a handler can report or repair without parsing prose. + expect(err).toBeInstanceOf(MetadataArrayTooLargeError) + expect(err.field).toBe('bigArray') + expect(err.length).toBe(overTheBound) + expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) + expect(err.message).toContain('bigArray') + expect(err.message).toContain(String(overTheBound)) + expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) + + // Refused means unchanged: the row still carries what it had before. + const unchanged = await brain.get(id) + expect(unchanged!.metadata.keep).toBe('me') + expect(unchanged!.metadata.bigArray).toBeUndefined() }) it('should preserve entity ID during update', async () => {