From 5bc1aa0eaf9d754330cb697e7b8daf63ac393d9a Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Wed, 11 Mar 2026 20:25:16 +0100 Subject: [PATCH] feat(SOSO-435): add UnknownFieldPolicy strategy for DynamicTable Introduce injectable UnknownFieldPolicyInterface to handle fields in row objects that are not defined in the table schema. DynamicTable now applies the policy before validation in add(), update(), and delete() operations. - Add UnknownFieldPolicyInterface in src/types/policies.ts - Add 3 built-in policies in src/utils/policies/: - StripUnknownFieldPolicy (default): silently removes unknown fields - IgnoreUnknownFieldPolicy: passes rows through unchanged - ErrorUnknownFieldPolicy: throws ValidationError for unknown fields - Extend DynamicTable constructor with optional 3rd param (default: Strip) - Extend DynamicTableFactory constructor to pass policy through - Add 18 unit tests for policy implementations - Add 16 integration tests for DynamicTable with all policies + custom DI - All 306 tests pass (272 existing + 34 new) --- src/client/DynamicTable.ts | 62 ++++- src/client/DynamicTableFactory.ts | 31 ++- src/types/index.ts | 3 + src/types/policies.ts | 57 +++++ src/utils/index.ts | 1 + src/utils/policies/ErrorUnknownFieldPolicy.ts | 67 +++++ .../policies/IgnoreUnknownFieldPolicy.ts | 48 ++++ src/utils/policies/StripUnknownFieldPolicy.ts | 58 +++++ src/utils/policies/index.ts | 10 + .../client/DynamicTable.unknownFields.test.ts | 241 ++++++++++++++++++ .../policies/UnknownFieldPolicies.test.ts | 146 +++++++++++ 11 files changed, 707 insertions(+), 17 deletions(-) create mode 100644 src/types/policies.ts create mode 100644 src/utils/policies/ErrorUnknownFieldPolicy.ts create mode 100644 src/utils/policies/IgnoreUnknownFieldPolicy.ts create mode 100644 src/utils/policies/StripUnknownFieldPolicy.ts create mode 100644 src/utils/policies/index.ts create mode 100644 tests/client/DynamicTable.unknownFields.test.ts create mode 100644 tests/utils/policies/UnknownFieldPolicies.test.ts diff --git a/src/client/DynamicTable.ts b/src/client/DynamicTable.ts index 24a4855..b01c569 100644 --- a/src/client/DynamicTable.ts +++ b/src/client/DynamicTable.ts @@ -4,8 +4,9 @@ * @category Client */ -import { AppSheetClientInterface, TableDefinition } from '../types'; +import { AppSheetClientInterface, TableDefinition, UnknownFieldPolicyInterface } from '../types'; import { AppSheetTypeValidator } from '../utils/validators'; +import { StripUnknownFieldPolicy } from '../utils/policies'; /** * Table client with schema-based operations and runtime validation. @@ -34,10 +35,22 @@ import { AppSheetTypeValidator } from '../utils/validators'; * ``` */ export class DynamicTable = Record> { + private readonly unknownFieldPolicy: UnknownFieldPolicyInterface; + + /** + * Creates a new DynamicTable instance. + * + * @param client - AppSheet client for API operations + * @param definition - Table schema definition + * @param unknownFieldPolicy - Optional policy for handling unknown fields (default: StripUnknownFieldPolicy) + */ constructor( private client: AppSheetClientInterface, - private definition: TableDefinition - ) {} + private definition: TableDefinition, + unknownFieldPolicy?: UnknownFieldPolicyInterface + ) { + this.unknownFieldPolicy = unknownFieldPolicy ?? new StripUnknownFieldPolicy(); + } /** * Find all rows in the table. @@ -145,12 +158,20 @@ export class DynamicTable = Record> { * ``` */ async add(rows: Partial[]): Promise { + // Apply unknown field policy before validation + const knownFields = Object.keys(this.definition.fields); + const processedRows = this.unknownFieldPolicy.apply( + this.definition.tableName, + rows, + knownFields + ); + // Validate rows - this.validateRows(rows); + this.validateRows(processedRows); const result = await this.client.add({ tableName: this.definition.tableName, - rows: rows as T[], + rows: processedRows as T[], }); return result.rows; } @@ -180,12 +201,20 @@ export class DynamicTable = Record> { * ``` */ async update(rows: Partial[]): Promise { + // Apply unknown field policy before validation + const knownFields = Object.keys(this.definition.fields); + const processedRows = this.unknownFieldPolicy.apply( + this.definition.tableName, + rows, + knownFields + ); + // Validate rows - this.validateRows(rows, false); + this.validateRows(processedRows, false); const result = await this.client.update({ tableName: this.definition.tableName, - rows: rows as T[], + rows: processedRows as T[], }); return result.rows; } @@ -214,9 +243,17 @@ export class DynamicTable = Record> { * ``` */ async delete(keys: Partial[]): Promise { + // Apply unknown field policy to delete keys too + const knownFields = Object.keys(this.definition.fields); + const processedKeys = this.unknownFieldPolicy.apply( + this.definition.tableName, + keys, + knownFields + ); + await this.client.delete({ tableName: this.definition.tableName, - rows: keys, + rows: processedKeys, }); return true; } @@ -315,10 +352,15 @@ export class DynamicTable = Record> { // Enum/EnumList validation if (fieldDef.allowedValues) { - AppSheetTypeValidator.validateEnum(fieldName, fieldType, fieldDef.allowedValues, value, i); + AppSheetTypeValidator.validateEnum( + fieldName, + fieldType, + fieldDef.allowedValues, + value, + i + ); } } } } - } diff --git a/src/client/DynamicTableFactory.ts b/src/client/DynamicTableFactory.ts index 9506b88..061d6c6 100644 --- a/src/client/DynamicTableFactory.ts +++ b/src/client/DynamicTableFactory.ts @@ -8,7 +8,13 @@ * @category Client */ -import { DynamicTableFactoryInterface, AppSheetClientFactoryInterface, SchemaConfig } from '../types'; +import { + DynamicTableFactoryInterface, + AppSheetClientFactoryInterface, + SchemaConfig, + UnknownFieldPolicyInterface, +} from '../types'; +import { StripUnknownFieldPolicy } from '../utils/policies'; import { DynamicTable } from './DynamicTable'; /** @@ -24,10 +30,13 @@ import { DynamicTable } from './DynamicTable'; * * @example * ```typescript - * // Create factory with client factory and schema + * // Create factory with client factory and schema (default: StripUnknownFieldPolicy) * const clientFactory = new AppSheetClientFactory(); * const tableFactory = new DynamicTableFactory(clientFactory, schema); * + * // Create factory with custom unknown field policy + * const strictFactory = new DynamicTableFactory(clientFactory, schema, new ErrorUnknownFieldPolicy()); + * * // Create table instances * const usersTable = tableFactory.create('worklog', 'users', 'user@example.com'); * const users = await usersTable.findAll(); @@ -39,16 +48,22 @@ import { DynamicTable } from './DynamicTable'; * ``` */ export class DynamicTableFactory implements DynamicTableFactoryInterface { + private readonly unknownFieldPolicy: UnknownFieldPolicyInterface; + /** * Creates a new DynamicTableFactory. * * @param clientFactory - Factory to create AppSheetClient instances * @param schema - Schema configuration with connection definitions + * @param unknownFieldPolicy - Optional policy for handling unknown fields in DynamicTable (default: StripUnknownFieldPolicy) */ constructor( private readonly clientFactory: AppSheetClientFactoryInterface, - private readonly schema: SchemaConfig - ) {} + private readonly schema: SchemaConfig, + unknownFieldPolicy?: UnknownFieldPolicyInterface + ) { + this.unknownFieldPolicy = unknownFieldPolicy ?? new StripUnknownFieldPolicy(); + } /** * Create a DynamicTable instance for a specific connection and table. @@ -75,7 +90,9 @@ export class DynamicTableFactory implements DynamicTableFactoryInterface { const connectionDef = this.schema.connections[connectionName]; if (!connectionDef) { const available = Object.keys(this.schema.connections).join(', ') || 'none'; - throw new Error(`Connection "${connectionName}" not found. Available connections: ${available}`); + throw new Error( + `Connection "${connectionName}" not found. Available connections: ${available}` + ); } // Create client using factory @@ -84,7 +101,7 @@ export class DynamicTableFactory implements DynamicTableFactoryInterface { // Get table definition (will throw if not found) const tableDef = client.getTable(tableName); - // Create and return DynamicTable - return new DynamicTable(client, tableDef); + // Create and return DynamicTable with injected policy + return new DynamicTable(client, tableDef, this.unknownFieldPolicy); } } diff --git a/src/types/index.ts b/src/types/index.ts index dfa1603..6022a3e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,3 +28,6 @@ export * from './factories'; // Selector builder interface export * from './selector'; + +// Policy interfaces +export * from './policies'; diff --git a/src/types/policies.ts b/src/types/policies.ts new file mode 100644 index 0000000..a46dcbd --- /dev/null +++ b/src/types/policies.ts @@ -0,0 +1,57 @@ +/** + * Unknown Field Policy Interface + * + * Defines how DynamicTable handles fields in row objects that are not + * defined in the table schema. Implementations decide what happens: + * ignore them, strip them, throw an error, or custom behavior. + * + * Analog to SelectorBuilderInterface — injectable via DynamicTableFactory constructor. + * + * @module types + * @category Types + */ + +/** + * Interface for handling fields in row objects that are not defined in the table schema. + * + * Implementations decide what happens when a row contains fields that are not + * in the schema: ignore them, strip them, throw an error, or custom behavior. + * + * Analog to SelectorBuilderInterface — injectable via DynamicTableFactory constructor. + * + * @category Types + * + * @example + * ```typescript + * // Use built-in policies + * import { StripUnknownFieldPolicy, ErrorUnknownFieldPolicy } from '@techdivision/appsheet'; + * + * // Or create a custom policy + * class LoggingStripPolicy implements UnknownFieldPolicyInterface { + * apply>( + * tableName: string, + * rows: Partial[], + * knownFields: string[] + * ): Partial[] { + * // Custom logging logic here + * return new StripUnknownFieldPolicy().apply(tableName, rows, knownFields); + * } + * } + * ``` + */ +export interface UnknownFieldPolicyInterface { + /** + * Process rows and handle any fields not defined in the table schema. + * + * @param tableName - The AppSheet table name (for error messages) + * @param rows - The row objects to process + * @param knownFields - Array of field names defined in the table schema + * @returns Processed rows (may be modified, filtered, or unchanged) + * @throws {ValidationError} If the policy rejects unknown fields (e.g. ErrorUnknownFieldPolicy) + */ + apply>( + tableName: string, + rows: Partial[], + knownFields: string[] + ): Partial[]; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 8525af2..92d2429 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './SchemaLoader'; export * from './SchemaManager'; export * from './validators'; export * from './SelectorBuilder'; +export * from './policies'; diff --git a/src/utils/policies/ErrorUnknownFieldPolicy.ts b/src/utils/policies/ErrorUnknownFieldPolicy.ts new file mode 100644 index 0000000..7fc6341 --- /dev/null +++ b/src/utils/policies/ErrorUnknownFieldPolicy.ts @@ -0,0 +1,67 @@ +/** + * ErrorUnknownFieldPolicy - Throw ValidationError for unknown fields + * + * Throws a ValidationError when rows contain fields not defined in the table schema. + * Use this for strict validation in CI/CD pipelines or development environments. + * + * @module utils/policies + * @category Policies + */ + +import { UnknownFieldPolicyInterface } from '../../types/policies'; +import { ValidationError } from '../../types/errors'; + +/** + * Policy that throws a ValidationError when unknown fields are detected. + * + * This is the strictest policy. Any field in a row that is not defined in the + * table schema will cause a ValidationError to be thrown immediately. + * + * @category Policies + * + * @example + * ```typescript + * import { ErrorUnknownFieldPolicy, DynamicTableFactory } from '@techdivision/appsheet'; + * + * // Strict mode for CI/CD + * const factory = new DynamicTableFactory( + * clientFactory, + * schema, + * new ErrorUnknownFieldPolicy() + * ); + * + * // Will throw: Unknown fields in table "solution" (row 0): id + * await table.add([{ solution_id: '1', id: '1' }]); + * ``` + */ +export class ErrorUnknownFieldPolicy implements UnknownFieldPolicyInterface { + /** + * Validates that all fields in each row are defined in the schema. + * Throws ValidationError if any unknown fields are found. + * + * @param tableName - The AppSheet table name (used in error messages) + * @param rows - The row objects to validate + * @param knownFields - Array of field names defined in the table schema + * @returns The original rows if no unknown fields are found + * @throws {ValidationError} If any row contains fields not in knownFields + */ + apply>( + tableName: string, + rows: Partial[], + knownFields: string[] + ): Partial[] { + const knownSet = new Set(knownFields); + for (let i = 0; i < rows.length; i++) { + const unknownFields = Object.keys(rows[i]).filter((key) => !knownSet.has(key)); + if (unknownFields.length > 0) { + throw new ValidationError( + `Unknown fields in table "${tableName}" (row ${i}): ${unknownFields.join(', ')}. ` + + `These fields are not defined in the schema. ` + + `Remove them or update the schema to include them.`, + { tableName, unknownFields, rowIndex: i } + ); + } + } + return rows; + } +} diff --git a/src/utils/policies/IgnoreUnknownFieldPolicy.ts b/src/utils/policies/IgnoreUnknownFieldPolicy.ts new file mode 100644 index 0000000..96cd290 --- /dev/null +++ b/src/utils/policies/IgnoreUnknownFieldPolicy.ts @@ -0,0 +1,48 @@ +/** + * IgnoreUnknownFieldPolicy - Pass rows through unchanged + * + * Does not modify rows. Unknown fields are passed to the AppSheet API as-is. + * Use this for legacy code or migration scenarios where unknown fields are expected. + * + * @module utils/policies + * @category Policies + */ + +import { UnknownFieldPolicyInterface } from '../../types/policies'; + +/** + * Policy that ignores unknown fields and passes rows through unchanged. + * + * Unknown fields are sent to the AppSheet API as-is. This is the least safe + * option but useful for legacy code or migration scenarios. + * + * @category Policies + * + * @example + * ```typescript + * import { IgnoreUnknownFieldPolicy, DynamicTableFactory } from '@techdivision/appsheet'; + * + * const factory = new DynamicTableFactory( + * clientFactory, + * schema, + * new IgnoreUnknownFieldPolicy() + * ); + * ``` + */ +export class IgnoreUnknownFieldPolicy implements UnknownFieldPolicyInterface { + /** + * Returns rows unchanged — unknown fields are not modified. + * + * @param tableName - The AppSheet table name (unused) + * @param rows - The row objects to process + * @param knownFields - Array of known field names (unused) + * @returns The original rows without modification + */ + apply>( + _tableName: string, + rows: Partial[], + _knownFields: string[] + ): Partial[] { + return rows; + } +} diff --git a/src/utils/policies/StripUnknownFieldPolicy.ts b/src/utils/policies/StripUnknownFieldPolicy.ts new file mode 100644 index 0000000..442285a --- /dev/null +++ b/src/utils/policies/StripUnknownFieldPolicy.ts @@ -0,0 +1,58 @@ +/** + * StripUnknownFieldPolicy - Remove unknown fields before API call (Default) + * + * Removes fields from row objects that are not defined in the table schema. + * This is the default policy and the safest option for production use. + * + * @module utils/policies + * @category Policies + */ + +import { UnknownFieldPolicyInterface } from '../../types/policies'; + +/** + * Policy that strips unknown fields from rows before sending to the API. + * + * This is the **default policy** used by DynamicTable and DynamicTableFactory. + * It silently removes any fields not defined in the table schema, preventing + * API errors caused by invalid field names. + * + * @category Policies + * + * @example + * ```typescript + * import { StripUnknownFieldPolicy } from '@techdivision/appsheet'; + * + * const policy = new StripUnknownFieldPolicy(); + * const result = policy.apply('solution', [ + * { solution_id: '1', name: 'Test', unknown_field: 'value' } + * ], ['solution_id', 'name']); + * // result: [{ solution_id: '1', name: 'Test' }] + * ``` + */ +export class StripUnknownFieldPolicy implements UnknownFieldPolicyInterface { + /** + * Returns new row objects with only known fields, stripping unknown ones. + * + * @param tableName - The AppSheet table name (unused, available for subclasses) + * @param rows - The row objects to process + * @param knownFields - Array of field names defined in the table schema + * @returns New row objects containing only known fields + */ + apply>( + _tableName: string, + rows: Partial[], + knownFields: string[] + ): Partial[] { + const knownSet = new Set(knownFields); + return rows.map((row) => { + const cleaned = {} as Partial; + for (const [key, value] of Object.entries(row)) { + if (knownSet.has(key)) { + (cleaned as Record)[key] = value; + } + } + return cleaned; + }); + } +} diff --git a/src/utils/policies/index.ts b/src/utils/policies/index.ts new file mode 100644 index 0000000..f341e3a --- /dev/null +++ b/src/utils/policies/index.ts @@ -0,0 +1,10 @@ +/** + * Unknown field policy implementations + * + * @module utils/policies + * @category Policies + */ + +export * from './IgnoreUnknownFieldPolicy'; +export * from './StripUnknownFieldPolicy'; +export * from './ErrorUnknownFieldPolicy'; diff --git a/tests/client/DynamicTable.unknownFields.test.ts b/tests/client/DynamicTable.unknownFields.test.ts new file mode 100644 index 0000000..0bb343a --- /dev/null +++ b/tests/client/DynamicTable.unknownFields.test.ts @@ -0,0 +1,241 @@ +/** + * Integration tests: DynamicTable with UnknownFieldPolicy + * + * Tests policy application in add(), update(), and delete() operations. + * Verifies DI injection pattern and default behavior. + * + * @see docs/SOSO-435/INTEGRATION_CONCEPT.md + */ + +import { DynamicTable } from '../../src/client/DynamicTable'; +import { + AppSheetClientInterface, + TableDefinition, + ValidationError, + UnknownFieldPolicyInterface, +} from '../../src/types'; +import { IgnoreUnknownFieldPolicy } from '../../src/utils/policies/IgnoreUnknownFieldPolicy'; +import { ErrorUnknownFieldPolicy } from '../../src/utils/policies/ErrorUnknownFieldPolicy'; + +/** + * Create a mock client that implements AppSheetClientInterface + * (Same pattern as DynamicTable.test.ts) + */ +function createMockClient(): jest.Mocked { + return { + add: jest.fn().mockResolvedValue({ rows: [], warnings: [] }), + find: jest.fn().mockResolvedValue({ rows: [], warnings: [] }), + update: jest.fn().mockResolvedValue({ rows: [], warnings: [] }), + delete: jest.fn().mockResolvedValue({ success: true, deletedCount: 0, warnings: [] }), + findAll: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + addOne: jest.fn().mockResolvedValue({}), + updateOne: jest.fn().mockResolvedValue({}), + deleteOne: jest.fn().mockResolvedValue(true), + getTable: jest.fn().mockReturnValue({ + tableName: 'test', + keyField: 'solution_id', + fields: { solution_id: { type: 'Text', required: true } }, + }), + }; +} + +/** + * Table definition for integration tests. + * Has 'solution_id' and 'name' as known fields. + * Any other field (e.g., 'id', 'unknown', 'extra') is unknown. + */ +const tableDef: TableDefinition = { + tableName: 'solution', + keyField: 'solution_id', + fields: { + solution_id: { type: 'Text', required: true }, + name: { type: 'Text', required: false }, + }, +}; + +describe('DynamicTable Unknown Field Handling', () => { + let mockClient: jest.Mocked; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + describe('default behavior (StripUnknownFieldPolicy)', () => { + it('should strip unknown fields in add()', async () => { + const table = new DynamicTable(mockClient, tableDef); + await table.add([{ solution_id: '1', unknown: 'value' }]); + + expect(mockClient.add).toHaveBeenCalledWith({ + tableName: 'solution', + rows: [{ solution_id: '1' }], + }); + }); + + it('should strip unknown fields in update()', async () => { + const table = new DynamicTable(mockClient, tableDef); + await table.update([{ solution_id: '1', unknown: 'value' }]); + + expect(mockClient.update).toHaveBeenCalledWith({ + tableName: 'solution', + rows: [{ solution_id: '1' }], + }); + }); + + it('should strip unknown fields in delete()', async () => { + const table = new DynamicTable(mockClient, tableDef); + await table.delete([{ solution_id: '1', id: '1' }]); + + // 'id' is NOT in the schema, should be stripped + expect(mockClient.delete).toHaveBeenCalledWith({ + tableName: 'solution', + rows: [{ solution_id: '1' }], + }); + }); + + it('should not strip known fields', async () => { + const table = new DynamicTable(mockClient, tableDef); + await table.add([{ solution_id: '1', name: 'Test' }]); + + expect(mockClient.add).toHaveBeenCalledWith({ + tableName: 'solution', + rows: [{ solution_id: '1', name: 'Test' }], + }); + }); + + it('should handle multiple rows with mixed unknown fields', async () => { + const table = new DynamicTable(mockClient, tableDef); + await table.add([ + { solution_id: '1', name: 'A', extra: 'x' }, + { solution_id: '2', bad: 'y' }, + ]); + + expect(mockClient.add).toHaveBeenCalledWith({ + tableName: 'solution', + rows: [{ solution_id: '1', name: 'A' }, { solution_id: '2' }], + }); + }); + }); + + describe('with injected IgnoreUnknownFieldPolicy', () => { + it('should pass unknown fields through in add()', async () => { + const table = new DynamicTable(mockClient, tableDef, new IgnoreUnknownFieldPolicy()); + await table.add([{ solution_id: '1', unknown: 'value' }]); + + expect(mockClient.add).toHaveBeenCalledWith({ + tableName: 'solution', + rows: [{ solution_id: '1', unknown: 'value' }], + }); + }); + + it('should pass unknown fields through in update()', async () => { + const table = new DynamicTable(mockClient, tableDef, new IgnoreUnknownFieldPolicy()); + await table.update([{ solution_id: '1', unknown: 'value' }]); + + expect(mockClient.update).toHaveBeenCalledWith({ + tableName: 'solution', + rows: [{ solution_id: '1', unknown: 'value' }], + }); + }); + + it('should pass unknown fields through in delete()', async () => { + const table = new DynamicTable(mockClient, tableDef, new IgnoreUnknownFieldPolicy()); + await table.delete([{ solution_id: '1', id: '1' }]); + + expect(mockClient.delete).toHaveBeenCalledWith({ + tableName: 'solution', + rows: [{ solution_id: '1', id: '1' }], + }); + }); + }); + + describe('with injected ErrorUnknownFieldPolicy', () => { + it('should throw in add() for unknown fields', async () => { + const table = new DynamicTable(mockClient, tableDef, new ErrorUnknownFieldPolicy()); + + await expect(table.add([{ solution_id: '1', unknown: 'value' }])).rejects.toThrow( + ValidationError + ); + // Should NOT have called the client + expect(mockClient.add).not.toHaveBeenCalled(); + }); + + it('should throw in update() for unknown fields', async () => { + const table = new DynamicTable(mockClient, tableDef, new ErrorUnknownFieldPolicy()); + + await expect(table.update([{ solution_id: '1', unknown: 'value' }])).rejects.toThrow( + ValidationError + ); + expect(mockClient.update).not.toHaveBeenCalled(); + }); + + it('should throw in delete() for unknown fields', async () => { + const table = new DynamicTable(mockClient, tableDef, new ErrorUnknownFieldPolicy()); + + await expect(table.delete([{ solution_id: '1', id: '1' }])).rejects.toThrow(ValidationError); + expect(mockClient.delete).not.toHaveBeenCalled(); + }); + + it('should not throw when all fields are known', async () => { + const table = new DynamicTable(mockClient, tableDef, new ErrorUnknownFieldPolicy()); + + await expect(table.add([{ solution_id: '1', name: 'Test' }])).resolves.not.toThrow(); + + expect(mockClient.add).toHaveBeenCalled(); + }); + }); + + describe('with custom policy (DI)', () => { + it('should accept any UnknownFieldPolicyInterface implementation', async () => { + const applySpy = jest.fn( + (_tableName: string, rows: Partial>[], _knownFields: string[]) => rows + ); + const customPolicy = { apply: applySpy } as unknown as UnknownFieldPolicyInterface; + const table = new DynamicTable(mockClient, tableDef, customPolicy); + await table.add([{ solution_id: '1', extra: 'value' }]); + + expect(applySpy).toHaveBeenCalledWith( + 'solution', + expect.any(Array), + expect.arrayContaining(['solution_id', 'name']) + ); + }); + + it('should use the custom policy return value', async () => { + // Custom policy that uppercases all string values + const applySpy = jest.fn( + (_tableName: string, rows: Partial>[], _knownFields: string[]) => + rows.map((row) => { + const newRow: Record = {}; + for (const [key, value] of Object.entries(row)) { + newRow[key] = typeof value === 'string' ? value.toUpperCase() : value; + } + return newRow; + }) + ); + const uppercasePolicy = { apply: applySpy } as unknown as UnknownFieldPolicyInterface; + const table = new DynamicTable(mockClient, tableDef, uppercasePolicy); + await table.add([{ solution_id: '1', name: 'test' }]); + + expect(mockClient.add).toHaveBeenCalledWith({ + tableName: 'solution', + rows: [{ solution_id: '1', name: 'TEST' }], + }); + }); + + it('should call policy for delete() operations', async () => { + const applySpy = jest.fn( + (_tableName: string, rows: Partial>[], _knownFields: string[]) => rows + ); + const customPolicy = { apply: applySpy } as unknown as UnknownFieldPolicyInterface; + const table = new DynamicTable(mockClient, tableDef, customPolicy); + await table.delete([{ solution_id: '1' }]); + + expect(applySpy).toHaveBeenCalledWith( + 'solution', + [{ solution_id: '1' }], + expect.arrayContaining(['solution_id', 'name']) + ); + }); + }); +}); diff --git a/tests/utils/policies/UnknownFieldPolicies.test.ts b/tests/utils/policies/UnknownFieldPolicies.test.ts new file mode 100644 index 0000000..646aa48 --- /dev/null +++ b/tests/utils/policies/UnknownFieldPolicies.test.ts @@ -0,0 +1,146 @@ +/** + * Unit tests for UnknownFieldPolicy implementations + * + * Tests all 3 built-in policies: + * - IgnoreUnknownFieldPolicy (pass-through) + * - StripUnknownFieldPolicy (default, removes unknown fields) + * - ErrorUnknownFieldPolicy (throws ValidationError) + * + * @see docs/SOSO-435/INTEGRATION_CONCEPT.md + */ + +import { IgnoreUnknownFieldPolicy } from '../../../src/utils/policies/IgnoreUnknownFieldPolicy'; +import { StripUnknownFieldPolicy } from '../../../src/utils/policies/StripUnknownFieldPolicy'; +import { ErrorUnknownFieldPolicy } from '../../../src/utils/policies/ErrorUnknownFieldPolicy'; +import { ValidationError } from '../../../src/types'; + +describe('IgnoreUnknownFieldPolicy', () => { + const policy = new IgnoreUnknownFieldPolicy(); + + it('should return rows unchanged', () => { + const rows = [{ solution_id: '1', unknown_field: 'value' }]; + const result = policy.apply('solution', rows, ['solution_id', 'name']); + expect(result).toEqual(rows); + expect(result[0]).toHaveProperty('unknown_field'); + }); + + it('should return the exact same array reference', () => { + const rows = [{ solution_id: '1', extra: 'val' }]; + const result = policy.apply('solution', rows, ['solution_id']); + expect(result).toBe(rows); + }); + + it('should handle empty rows array', () => { + const result = policy.apply('solution', [], ['solution_id']); + expect(result).toEqual([]); + }); + + it('should handle rows with only known fields', () => { + const rows = [{ solution_id: '1', name: 'Test' }]; + const result = policy.apply('solution', rows, ['solution_id', 'name']); + expect(result).toEqual(rows); + }); +}); + +describe('StripUnknownFieldPolicy', () => { + const policy = new StripUnknownFieldPolicy(); + + it('should remove unknown fields', () => { + const rows = [{ solution_id: '1', unknown_field: 'value', name: 'Test' }]; + const result = policy.apply('solution', rows, ['solution_id', 'name']); + expect(result[0]).toEqual({ solution_id: '1', name: 'Test' }); + expect(result[0]).not.toHaveProperty('unknown_field'); + }); + + it('should return rows unchanged if no unknown fields', () => { + const rows = [{ solution_id: '1', name: 'Test' }]; + const result = policy.apply('solution', rows, ['solution_id', 'name']); + expect(result).toEqual(rows); + }); + + it('should handle empty rows', () => { + const result = policy.apply('solution', [{}], ['solution_id']); + expect(result).toEqual([{}]); + }); + + it('should handle multiple rows', () => { + const rows: Record[] = [ + { solution_id: '1', bad: 'x' }, + { solution_id: '2', bad: 'y', name: 'Ok' }, + ]; + const result = policy.apply('solution', rows, ['solution_id', 'name']); + expect(result[0]).toEqual({ solution_id: '1' }); + expect(result[1]).toEqual({ solution_id: '2', name: 'Ok' }); + }); + + it('should handle rows with only unknown fields', () => { + const rows = [{ bad1: 'x', bad2: 'y' }]; + const result = policy.apply('solution', rows, ['solution_id', 'name']); + expect(result[0]).toEqual({}); + }); + + it('should not modify the original row objects', () => { + const originalRow = { solution_id: '1', unknown: 'value' }; + const rows = [originalRow]; + policy.apply('solution', rows, ['solution_id']); + // Original row should still have the unknown field + expect(originalRow).toHaveProperty('unknown'); + }); +}); + +describe('ErrorUnknownFieldPolicy', () => { + const policy = new ErrorUnknownFieldPolicy(); + + it('should throw ValidationError for unknown fields', () => { + const rows = [{ solution_id: '1', unknown_field: 'value' }]; + expect(() => policy.apply('solution', rows, ['solution_id', 'name'])).toThrow(ValidationError); + }); + + it('should include field names and row index in error', () => { + const rows: Record[] = [{ solution_id: '1' }, { solution_id: '2', bad: 'x' }]; + expect(() => policy.apply('solution', rows, ['solution_id', 'name'])).toThrow(/row 1/); + }); + + it('should include the unknown field name in error message', () => { + const rows = [{ solution_id: '1', bad_field: 'x' }]; + expect(() => policy.apply('solution', rows, ['solution_id'])).toThrow(/bad_field/); + }); + + it('should include the table name in error message', () => { + const rows = [{ solution_id: '1', bad: 'x' }]; + expect(() => policy.apply('solution', rows, ['solution_id'])).toThrow(/solution/); + }); + + it('should return rows unchanged if no unknown fields', () => { + const rows = [{ solution_id: '1', name: 'Test' }]; + const result = policy.apply('solution', rows, ['solution_id', 'name']); + expect(result).toEqual(rows); + }); + + it('should return the exact same array reference when valid', () => { + const rows = [{ solution_id: '1' }]; + const result = policy.apply('solution', rows, ['solution_id']); + expect(result).toBe(rows); + }); + + it('should report multiple unknown fields', () => { + const rows = [{ solution_id: '1', bad1: 'x', bad2: 'y' }]; + expect(() => policy.apply('solution', rows, ['solution_id'])).toThrow(/bad1/); + expect(() => policy.apply('solution', rows, ['solution_id'])).toThrow(/bad2/); + }); + + it('should throw on the first row with unknown fields', () => { + const rows: Record[] = [ + { solution_id: '1' }, + { solution_id: '2', bad: 'x' }, + { solution_id: '3', bad: 'y' }, + ]; + // Should throw for row 1 (0-indexed), not row 2 + expect(() => policy.apply('solution', rows, ['solution_id'])).toThrow(/row 1/); + }); + + it('should handle empty rows array', () => { + const result = policy.apply('solution', [], ['solution_id']); + expect(result).toEqual([]); + }); +});