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([]); + }); +});