diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cfd4f..1f3a752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,101 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0] - 2024-11-30 + +### Added + +- **Factory Pattern for Dependency Injection** (SOSO-249) + - `AppSheetClientFactory`: Creates real AppSheetClient instances + - `MockAppSheetClientFactory`: Creates MockAppSheetClient instances for testing + - `DynamicTableFactory`: Creates DynamicTable instances from schema + - `AppSheetClientFactoryInterface`: Interface for custom factory implementations + - `DynamicTableFactoryInterface`: Interface for custom table factory implementations + +- **Enhanced Client Interface** + - Added `getTable(tableName)` method to `AppSheetClientInterface` + - Returns TableDefinition for tables in the connection + - Enables DynamicTableFactory to create tables with proper definitions + +- **SchemaManager Introspection Methods** + - `hasConnection(connectionName)`: Check if connection exists + - `hasTable(connectionName, tableName)`: Check if table exists in connection + - `getTableDefinition(connectionName, tableName)`: Get TableDefinition or undefined ([#7](https://github.com/techdivision/appsheet/issues/7)) + - `getFieldDefinition(connectionName, tableName, fieldName)`: Get FieldDefinition or undefined ([#7](https://github.com/techdivision/appsheet/issues/7)) + - `getAllowedValues(connectionName, tableName, fieldName)`: Get allowed values for Enum/EnumList fields ([#7](https://github.com/techdivision/appsheet/issues/7)) + +- **ConnectionManager Introspection Methods** + - `list()`: Returns array of all connection names + - `has(connectionName)`: Checks if connection exists + +### Changed + +- **BREAKING**: `AppSheetClient` constructor signature changed + - Old: `new AppSheetClient({ appId, applicationAccessKey, runAsUserEmail? })` + - New: `new AppSheetClient(connectionDef, runAsUserEmail)` + - `connectionDef` is a full `ConnectionDefinition` with tables + - `runAsUserEmail` is required (not optional) + +- **BREAKING**: `MockAppSheetClient` constructor signature changed + - Old: `new MockAppSheetClient({ appId, applicationAccessKey })` + - New: `new MockAppSheetClient(connectionDef, runAsUserEmail, dataProvider?)` + +- **BREAKING**: `ConnectionManager` now uses factory injection + - Old: `new ConnectionManager()` + `register()` + `get(name, userEmail?)` + - New: `new ConnectionManager(clientFactory, schema)` + `get(name, userEmail)` + - Both `connectionName` and `runAsUserEmail` are required in `get()` + +- **BREAKING**: `SchemaManager` now uses factory injection + - Old: `new SchemaManager(schema)` + `table(conn, table, userEmail?)` + - New: `new SchemaManager(clientFactory, schema)` + `table(conn, table, userEmail)` + - `runAsUserEmail` is required in `table()` (not optional) + +- **BREAKING**: `DynamicTable` constructor uses interface + - Now accepts `AppSheetClientInterface` instead of concrete `AppSheetClient` + - Enables proper dependency injection and testing + +### Removed + +- **BREAKING**: `AppSheetClient.getConfig()` - use `getTable()` instead +- **BREAKING**: `ConnectionManager.register()` - constructor accepts schema directly +- **BREAKING**: `ConnectionManager.remove()` - connections defined by schema +- **BREAKING**: `ConnectionManager.clear()` - connections defined by schema +- **BREAKING**: `ConnectionManager.ping()` - removed health check +- **BREAKING**: `ConnectionManager.healthCheck()` - removed health check +- **BREAKING**: `SchemaManager.getConnectionManager()` - internal only +- **BREAKING**: `SchemaManager.reload()` - create new instance instead + +### Deprecated + +- `AppSheetConfig` interface - use `ConnectionDefinition` instead +- `ConnectionConfig` interface - use factory injection pattern instead + +### Migration Guide + +See [CLAUDE.md](./CLAUDE.md) Breaking Changes section for detailed migration examples. + +**Quick Migration:** +```typescript +// Old (v2.x) +const client = new AppSheetClient({ appId, applicationAccessKey, runAsUserEmail }); +const db = new SchemaManager(schema); +const table = db.table('conn', 'table'); // optional user + +// New (v3.0.0) +const connectionDef = { appId, applicationAccessKey, tables: {...} }; +const client = new AppSheetClient(connectionDef, runAsUserEmail); + +const factory = new AppSheetClientFactory(); +const db = new SchemaManager(factory, schema); +const table = db.table('conn', 'table', runAsUserEmail); // required user +``` + +### Technical Details + +- **SemVer Level**: MAJOR (breaking changes) +- **Test Coverage**: 221 tests across 8 test suites +- **Breaking Changes**: Constructor signatures, required parameters, removed methods + ## [2.1.0] - 2024-11-24 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 4c06271..938a675 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,11 +82,10 @@ npx appsheet inspect --help # After npm install (uses bin entry) - Main API client with CRUD methods (add, find, update, delete) - Handles authentication, retries with exponential backoff, and error conversion - Base URL: `https://api.appsheet.com/api/v2` -- All operations require: appId, applicationAccessKey, tableName -- **User Configuration**: Optional global `runAsUserEmail` config automatically injected into all requests - - Set globally in AppSheetClient constructor: `new AppSheetClient({ appId, applicationAccessKey, runAsUserEmail: 'user@example.com' })` - - Per-operation override possible via `properties.RunAsUserEmail` - - Required for operations that need user context (permissions, auditing, etc.) +- **v3.0.0 Constructor**: `new AppSheetClient(connectionDef, runAsUserEmail)` + - `connectionDef`: Full ConnectionDefinition with appId, applicationAccessKey, and tables + - `runAsUserEmail`: Email of user to execute all operations as (required) +- **`getTable(tableName)`**: Returns TableDefinition for a table in the connection - **Response Handling**: Automatically handles both AppSheet API response formats: - Standard format: `{ Rows: [...], Warnings?: [...] }` - Direct array format: `[...]` (automatically converted to standard format) @@ -115,25 +114,32 @@ npx appsheet inspect --help # After npm install (uses bin entry) - Validates schema structure before use **SchemaManager** (`src/utils/SchemaManager.ts`) -- Central management class that: - 1. Validates loaded schema - 2. Initializes ConnectionManager with all connections - 3. Creates DynamicTable instances on-the-fly (no caching) - 4. Provides `table(connection, tableName, runAsUserEmail?)` method -- **Per-Request User Context**: Optional `runAsUserEmail` parameter on `table()` method - - Creates user-specific table clients on-the-fly without caching - - Enables multi-tenant MCP servers with per-request user context - - Backward compatible: omit parameter for default behavior +- Central management class using factory injection (v3.0.0) +- **v3.0.0 Constructor**: `new SchemaManager(clientFactory, schema)` + - `clientFactory`: AppSheetClientFactoryInterface (use AppSheetClientFactory or MockAppSheetClientFactory) + - `schema`: SchemaConfig from SchemaLoader +- **`table(connection, tableName, runAsUserEmail)`**: Creates DynamicTable instances on-the-fly + - `runAsUserEmail` is required in v3.0.0 (not optional) + - Each call creates a new client instance (lightweight operation) +- **`getTableDefinition(connection, tableName)`**: Returns TableDefinition or undefined +- **`getFieldDefinition(connection, tableName, fieldName)`**: Returns FieldDefinition or undefined +- **`getAllowedValues(connection, tableName, fieldName)`**: Returns allowed values for Enum/EnumList fields - Entry point for schema-based usage pattern **ConnectionManager** (`src/utils/ConnectionManager.ts`) -- Manages multiple AppSheet app connections by name -- Enables multi-instance support (multiple apps in one project) -- **Per-Request User Context**: Optional `runAsUserEmail` parameter on `get()` method - - Creates user-specific clients on-the-fly without caching - - Overrides global `runAsUserEmail` from schema when provided - - Backward compatible: omit parameter to get default client -- Provides health check functionality +- Simplified in v3.0.0 to use factory injection +- **v3.0.0 Constructor**: `new ConnectionManager(clientFactory, schema)` + - `clientFactory`: AppSheetClientFactoryInterface + - `schema`: SchemaConfig containing connection definitions +- **`get(connectionName, runAsUserEmail)`**: Creates client instances on-demand + - Both parameters are required (no default/optional user) +- **`list()`**: Returns array of connection names +- **`has(connectionName)`**: Checks if connection exists + +**Factory Classes** (v3.0.0) +- **AppSheetClientFactory**: Creates real AppSheetClient instances +- **MockAppSheetClientFactory**: Creates MockAppSheetClient instances for testing +- **DynamicTableFactory**: Creates DynamicTable instances from schema ### CLI Tool @@ -187,47 +193,108 @@ connections: - **References**: Ref, RefList - **Special**: Color, Show -### Two Usage Patterns +### Two Usage Patterns (v3.0.0) **Pattern 1: Direct Client** ```typescript -const client = new AppSheetClient({ - appId, - applicationAccessKey, - runAsUserEmail: 'user@example.com' // Optional -}); -await client.findAll('TableName'); +const connectionDef: ConnectionDefinition = { + appId: 'app-id', + applicationAccessKey: 'access-key', + tables: { + users: { tableName: 'extract_user', keyField: 'id', fields: {...} } + } +}; +const client = new AppSheetClient(connectionDef, 'user@example.com'); +await client.findAll('extract_user'); ``` -**Pattern 2: Schema-Based** (Recommended) +**Pattern 2: Schema-Based with Factory Injection** (Recommended) ```typescript +import { + SchemaLoader, + SchemaManager, + AppSheetClientFactory +} from '@techdivision/appsheet'; + +// Production setup +const clientFactory = new AppSheetClientFactory(); const schema = SchemaLoader.fromYaml('./config/schema.yaml'); -const db = new SchemaManager(schema); +const db = new SchemaManager(clientFactory, schema); -// Default behavior (uses global user from schema if configured) -const table = db.table('connection', 'tableName'); -await table.findAll(); +// Get table for user (runAsUserEmail is required in v3.0.0) +const table = db.table('connection', 'tableName', 'user@example.com'); +await table.findAll(); // Executes as user@example.com +``` -// Per-request user context (new in v2.1.0) -const userTable = db.table('connection', 'tableName', 'user@example.com'); -await userTable.findAll(); // Executes as user@example.com +**Pattern 3: Testing with Mock Factory** +```typescript +import { + MockAppSheetClientFactory, + SchemaManager, + MockDataProvider +} from '@techdivision/appsheet'; + +// Test setup with mock factory +const testData: MockDataProvider = { + getTables: () => new Map([ + ['extract_user', { rows: [...], keyField: 'id' }] + ]) +}; +const mockFactory = new MockAppSheetClientFactory(testData); +const db = new SchemaManager(mockFactory, schema); + +// Test operations without hitting real API +const table = db.table('worklog', 'users', 'test@example.com'); +const users = await table.findAll(); // Returns seeded test data ``` -**Pattern 3: Multi-Tenant MCP Server** (New in v2.1.0) +**Pattern 4: Multi-Tenant MCP Server** ```typescript -// MCP Server with per-request user context -const schema = SchemaLoader.fromYaml('./config/schema.yaml'); -const db = new SchemaManager(schema); +// Single SchemaManager instance for entire server +const clientFactory = new AppSheetClientFactory(); +const db = new SchemaManager(clientFactory, SchemaLoader.fromYaml('./schema.yaml')); + +// MCP tool handler with per-request user context +server.tool('list_worklogs', async (params, context) => { + // Extract user from MCP context + const userEmail = context.user?.email; -// Handler for MCP request with authenticated user -async function handleToolCall(toolName: string, params: any, userEmail: string) { - // Create user-specific table client on-the-fly + // Create user-specific table client (lightweight, on-demand) const table = db.table('worklog', 'worklogs', userEmail); - // All operations execute with user's permissions - const worklogs = await table.findAll(); - return worklogs; + // All operations execute with user's AppSheet permissions + return await table.findAll(); +}); +``` + +### Schema Introspection (v3.0.0) + +Access schema metadata directly without navigating nested structures: + +```typescript +// Get table definition +const tableDef = db.getTableDefinition('default', 'service_portfolio'); +// → { tableName: 'service_portfolio', keyField: 'id', fields: {...} } + +// Get field definition +const statusField = db.getFieldDefinition('default', 'service_portfolio', 'status'); +// → { type: 'Enum', allowedValues: ['Active', 'Inactive'], required: true } + +// Get allowed values for Enum field (shortcut) +const statusValues = db.getAllowedValues('default', 'service_portfolio', 'status'); +// → ['Active', 'Inactive', 'Pending'] + +// Use case: Generate Zod enum schema +const values = db.getAllowedValues('default', 'users', 'role'); +if (values) { + const roleEnum = z.enum(values as [string, ...string[]]); } + +// Use case: Populate UI dropdown +const options = db.getAllowedValues('default', 'users', 'status')?.map(v => ({ + label: v, + value: v +})); ``` ### Validation Examples @@ -267,72 +334,30 @@ await table.add([{ discount: 1.5 }]); // ❌ ValidationError: Field "discount" must be between 0.00 and 1.00 ``` -### Per-Request User Context (v2.1.0) +### Factory Injection Pattern (v3.0.0) -**Feature**: Execute operations with per-request user context in multi-tenant environments. +**Feature**: Dependency injection via factory interfaces enables easy testing and flexible instantiation. -**Use Cases**: -- Multi-tenant MCP servers where different authenticated users make requests -- Row-level security and permissions enforcement by AppSheet -- User-specific audit trails and tracking -- User-based data filtering and access control +**Key Interfaces**: +- `AppSheetClientFactoryInterface`: Creates client instances +- `DynamicTableFactoryInterface`: Creates table instances -**ConnectionManager Usage**: +**Production vs Test**: ```typescript -const manager = new ConnectionManager(); -manager.register({ - name: 'worklog', - appId: 'app-id', - applicationAccessKey: 'key', - runAsUserEmail: 'default@example.com' // Optional global default -}); - -// Get default client (uses global user or no user) -const defaultClient = manager.get('worklog'); +// Production: Use AppSheetClientFactory +const prodFactory = new AppSheetClientFactory(); +const prodDb = new SchemaManager(prodFactory, schema); -// Get user-specific client (creates new instance with user context) -const userClient = manager.get('worklog', 'user@example.com'); -await userClient.findAll('worklogs'); // Executes as user@example.com +// Testing: Use MockAppSheetClientFactory +const testFactory = new MockAppSheetClientFactory(mockData); +const testDb = new SchemaManager(testFactory, schema); ``` -**SchemaManager Usage**: -```typescript -const schema = SchemaLoader.fromYaml('./config/schema.yaml'); -const db = new SchemaManager(schema); - -// Get default table client -const table = db.table('worklog', 'worklogs'); - -// Get user-specific table client (creates new instance) -const userTable = db.table('worklog', 'worklogs', 'user@example.com'); -await userTable.findAll(); // Executes as user@example.com -``` - -**Important Notes**: -- User-specific clients are created on-the-fly (not cached) - this is a lightweight operation -- Parameter `runAsUserEmail` overrides any global `runAsUserEmail` from schema -- Omitting the parameter returns default client (backward compatible) -- Each call with `runAsUserEmail` creates a new client instance -- DynamicTable and AppSheetClient are lightweight, so on-the-fly creation is efficient - -**MCP Server Example**: -```typescript -// Single SchemaManager instance for entire server -const db = new SchemaManager(SchemaLoader.fromYaml('./schema.yaml')); - -// MCP tool handler with per-request user context -server.tool('list_worklogs', async (params, context) => { - // Extract user from MCP context (authentication handled by MCP framework) - const userEmail = context.user?.email; - - // Create user-specific table client - const table = db.table('worklog', 'worklogs', userEmail); - - // All operations execute with user's AppSheet permissions - const worklogs = await table.findAll(); - return worklogs; -}); -``` +**Benefits**: +- Easy unit testing without mocking complex dependencies +- No need to mock axios or network calls +- Test data can be pre-seeded via MockDataProvider +- Same code paths for production and test environments ### Error Handling @@ -376,44 +401,62 @@ Retry logic applies to network errors and 5xx server errors (max 3 attempts by d **Note**: The AppSheet API may return responses in either format. The AppSheetClient automatically normalizes both formats to the standard `{ Rows: [...], Warnings?: [...] }` structure for consistent handling. -## Breaking Changes (v2.0.0) +## Breaking Changes (v3.0.0) -**⚠️ IMPORTANT**: Version 2.0.0 introduces breaking changes. See MIGRATION.md for upgrade guide. +**⚠️ IMPORTANT**: Version 3.0.0 introduces breaking changes. See MIGRATION.md for upgrade guide. -### Removed Features -- ❌ Old generic types (`'string'`, `'number'`, `'boolean'`, `'date'`, `'array'`, `'object'`) are no longer supported -- ❌ Shorthand string format for field definitions (`"email": "string"`) is no longer supported -- ❌ `enum` property renamed to `allowedValues` +### v3.0.0 Breaking Changes -### New Requirements -- ✅ All fields must use full FieldDefinition object with `type` property -- ✅ Only AppSheet-specific types are supported (Text, Email, Number, etc.) -- ✅ Schema validation is stricter and more comprehensive +**AppSheetClient**: +- ❌ Old: `new AppSheetClient({ appId, applicationAccessKey, runAsUserEmail? })` +- ✅ New: `new AppSheetClient(connectionDef, runAsUserEmail)` +- ❌ `getConfig()` removed - use `getTable()` instead -### Migration Example -```yaml -# ❌ Old schema (v1.x) - NO LONGER WORKS -fields: - email: string - age: number - status: - type: string - enum: ["Active", "Inactive"] +**ConnectionManager**: +- ❌ Old: `new ConnectionManager()` + `register()` + `get(name, userEmail?)` +- ✅ New: `new ConnectionManager(clientFactory, schema)` + `get(name, userEmail)` +- ❌ `register()`, `remove()`, `clear()`, `ping()`, `healthCheck()` removed +- ✅ `list()` and `has()` added for introspection -# ✅ New schema (v2.0.0) -fields: - email: - type: Email - required: true - age: - type: Number - required: false - status: - type: Enum - required: true - allowedValues: ["Active", "Inactive"] +**SchemaManager**: +- ❌ Old: `new SchemaManager(schema)` + `table(conn, table, userEmail?)` +- ✅ New: `new SchemaManager(clientFactory, schema)` + `table(conn, table, userEmail)` +- ❌ `getConnectionManager()` and `reload()` removed +- ✅ `hasConnection()` and `hasTable()` added + +**MockAppSheetClient**: +- ❌ Old: `new MockAppSheetClient({ appId, applicationAccessKey })` +- ✅ New: `new MockAppSheetClient(connectionDef, runAsUserEmail, dataProvider?)` + +### v3.0.0 Migration Example +```typescript +// ❌ Old (v2.x) +const client = new AppSheetClient({ + appId: 'app-id', + applicationAccessKey: 'key', + runAsUserEmail: 'user@example.com' +}); +const db = new SchemaManager(schema); +const table = db.table('conn', 'tableName'); // optional user + +// ✅ New (v3.0.0) +const connectionDef = { appId: 'app-id', applicationAccessKey: 'key', tables: {...} }; +const client = new AppSheetClient(connectionDef, 'user@example.com'); + +const clientFactory = new AppSheetClientFactory(); +const db = new SchemaManager(clientFactory, schema); +const table = db.table('conn', 'tableName', 'user@example.com'); // required user ``` +## Breaking Changes (v2.0.0) + +### v2.0.0 Schema Changes +- ❌ Old generic types (`'string'`, `'number'`, etc.) no longer supported +- ❌ Shorthand string format (`"email": "string"`) no longer supported +- ❌ `enum` property renamed to `allowedValues` +- ✅ All fields must use full FieldDefinition with `type` property +- ✅ Only AppSheet-specific types supported (Text, Email, Number, etc.) + ## Documentation All public APIs use TSDoc comments with: @@ -449,38 +492,56 @@ docs/ ## Testing +### Factory-Based Testing (v3.0.0) + +Use `MockAppSheetClientFactory` for testing without hitting the real AppSheet API: + +```typescript +import { + MockAppSheetClientFactory, + SchemaManager, + MockDataProvider, + ConnectionDefinition +} from '@techdivision/appsheet'; + +// Define connection for direct client testing +const connectionDef: ConnectionDefinition = { + appId: 'test-app', + applicationAccessKey: 'test-key', + tables: { + users: { tableName: 'extract_user', keyField: 'id', fields: {...} } + } +}; + +// Option 1: Direct MockAppSheetClient usage +const mockClient = new MockAppSheetClient(connectionDef, 'test@example.com'); +await mockClient.addOne('extract_user', { id: '1', name: 'Test' }); +const users = await mockClient.findAll('extract_user'); + +// Option 2: Factory injection with SchemaManager (recommended) +const mockFactory = new MockAppSheetClientFactory(); +const db = new SchemaManager(mockFactory, schema); +const table = db.table('worklog', 'users', 'test@example.com'); +await table.add([{ id: '1', name: 'Test' }]); + +// Option 3: Pre-seeded test data via MockDataProvider +const testData: MockDataProvider = { + getTables: () => new Map([ + ['extract_user', { rows: [{ id: '1', name: 'Alice' }], keyField: 'id' }] + ]) +}; +const seededFactory = new MockAppSheetClientFactory(testData); +const seededDb = new SchemaManager(seededFactory, schema); +const table = seededDb.table('worklog', 'users', 'test@example.com'); +const users = await table.findAll(); // Returns pre-seeded data +``` + ### MockAppSheetClient -For testing purposes, use `MockAppSheetClient` (`src/client/MockAppSheetClient.ts`): - In-memory mock implementation of `AppSheetClientInterface` -- Implements the same interface as `AppSheetClient` for easy swapping in tests +- **v3.0.0 Constructor**: `new MockAppSheetClient(connectionDef, runAsUserEmail, dataProvider?)` - Stores data in memory without making API calls -- Useful for unit tests and local development - Fully tested with comprehensive test suite -```typescript -import { MockAppSheetClient, AppSheetClientInterface } from '@techdivision/appsheet'; - -// Direct usage -const mockClient = new MockAppSheetClient({ - appId: 'mock-app', - applicationAccessKey: 'mock-key' -}); -await mockClient.addOne('Users', { id: '1', name: 'Test' }); -const users = await mockClient.findAll('Users'); // Returns mock data - -// Using interface for polymorphism -function processUsers(client: AppSheetClientInterface) { - return client.findAll('Users'); -} - -// Works with both real and mock clients -const realClient = new AppSheetClient({ appId, applicationAccessKey }); -const mockClient = new MockAppSheetClient({ appId, applicationAccessKey }); - -await processUsers(realClient); // Uses real API -await processUsers(mockClient); // Uses in-memory data -``` - ### Test Configuration - Tests use Jest with ts-jest preset - Test files located in `tests/` directory (separate from `src/`) diff --git a/MIGRATION.md b/MIGRATION.md index c9ec440..8732022 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,4 +1,169 @@ -# Migration Guide: v1.x → v2.0.0 +# Migration Guide + +This guide helps you upgrade between major versions of the AppSheet library. + +--- + +# Migration: v2.x → v3.0.0 + +## Overview + +Version 3.0.0 introduces **breaking changes** for dependency injection and testing support. The main changes are: +- Factory injection pattern for SchemaManager and ConnectionManager +- `runAsUserEmail` is now **required** (not optional) +- New constructor signatures for all main classes +- New schema introspection methods + +## Quick Migration + +```typescript +// ❌ Old (v2.x) +import { SchemaLoader, SchemaManager, AppSheetClient } from '@techdivision/appsheet'; + +const client = new AppSheetClient({ + appId: 'app-id', + applicationAccessKey: 'key', + runAsUserEmail: 'user@example.com' // optional +}); + +const schema = SchemaLoader.fromYaml('./schema.yaml'); +const db = new SchemaManager(schema); +const table = db.table('conn', 'tableName'); // userEmail optional + +// ✅ New (v3.0.0) +import { + SchemaLoader, + SchemaManager, + AppSheetClient, + AppSheetClientFactory, + ConnectionDefinition +} from '@techdivision/appsheet'; + +// Direct client usage +const connectionDef: ConnectionDefinition = { + appId: 'app-id', + applicationAccessKey: 'key', + tables: { /* table definitions */ } +}; +const client = new AppSheetClient(connectionDef, 'user@example.com'); // required + +// Schema-based usage with factory injection +const schema = SchemaLoader.fromYaml('./schema.yaml'); +const clientFactory = new AppSheetClientFactory(); +const db = new SchemaManager(clientFactory, schema); +const table = db.table('conn', 'tableName', 'user@example.com'); // required +``` + +## Breaking Changes + +### 1. AppSheetClient Constructor + +```typescript +// ❌ Old (v2.x) +const client = new AppSheetClient({ + appId: 'app-id', + applicationAccessKey: 'key', + runAsUserEmail: 'user@example.com' // optional +}); + +// ✅ New (v3.0.0) +const connectionDef: ConnectionDefinition = { + appId: 'app-id', + applicationAccessKey: 'key', + tables: { + users: { tableName: 'Users', keyField: 'id', fields: {...} } + } +}; +const client = new AppSheetClient(connectionDef, 'user@example.com'); // required +``` + +### 2. SchemaManager Constructor + +```typescript +// ❌ Old (v2.x) +const db = new SchemaManager(schema); + +// ✅ New (v3.0.0) +const clientFactory = new AppSheetClientFactory(); +const db = new SchemaManager(clientFactory, schema); +``` + +### 3. SchemaManager.table() - runAsUserEmail Required + +```typescript +// ❌ Old (v2.x) +const table = db.table('conn', 'tableName'); // optional user +const table = db.table('conn', 'tableName', 'user@example.com'); + +// ✅ New (v3.0.0) +const table = db.table('conn', 'tableName', 'user@example.com'); // always required +``` + +### 4. ConnectionManager Constructor + +```typescript +// ❌ Old (v2.x) +const connMgr = new ConnectionManager(); +connMgr.register('name', client); +const client = connMgr.get('name', 'user@example.com'); + +// ✅ New (v3.0.0) +const clientFactory = new AppSheetClientFactory(); +const connMgr = new ConnectionManager(clientFactory, schema); +const client = connMgr.get('name', 'user@example.com'); // both required +``` + +### 5. Removed Methods + +| Removed | Alternative | +|---------|-------------| +| `AppSheetClient.getConfig()` | Use `getTable(tableName)` | +| `ConnectionManager.register()` | Pass schema to constructor | +| `ConnectionManager.remove()` | Create new instance | +| `ConnectionManager.clear()` | Create new instance | +| `ConnectionManager.ping()` | Removed | +| `ConnectionManager.healthCheck()` | Removed | +| `SchemaManager.getConnectionManager()` | Internal only | +| `SchemaManager.reload()` | Create new instance | + +## New Features in v3.0.0 + +### Schema Introspection Methods + +Access schema metadata directly: + +```typescript +// Get table definition +const tableDef = db.getTableDefinition('default', 'users'); +// → { tableName: 'Users', keyField: 'id', fields: {...} } + +// Get field definition +const fieldDef = db.getFieldDefinition('default', 'users', 'status'); +// → { type: 'Enum', required: true, allowedValues: [...] } + +// Get allowed values for Enum fields +const values = db.getAllowedValues('default', 'users', 'status'); +// → ['Active', 'Inactive', 'Pending'] +``` + +### Testing with Mock Factory + +```typescript +import { MockAppSheetClientFactory, SchemaManager } from '@techdivision/appsheet'; + +// Test setup - no real API calls +const mockFactory = new MockAppSheetClientFactory(); +const db = new SchemaManager(mockFactory, schema); + +// Test your code +const table = db.table('conn', 'users', 'test@example.com'); +await table.add([{ id: '1', name: 'Test' }]); +const users = await table.findAll(); // Returns in-memory data +``` + +--- + +# Migration: v1.x → v2.0.0 This guide helps you upgrade from version 1.x to 2.0.0 of the AppSheet library. diff --git a/README.md b/README.md index 632d76e..e7c1f58 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ A generic TypeScript library for AppSheet CRUD operations, designed for building ## Features -- 🚀 Full CRUD operations for AppSheet tables -- 📝 Runtime schema loading from YAML/JSON -- 🔧 CLI tool for schema generation and management -- 🔒 Type-safe API with TypeScript -- 🌐 Multi-instance support (multiple AppSheet apps) -- ✅ Schema-based validation -- 🔄 Connection management with health checks +- Full CRUD operations for AppSheet tables +- Runtime schema loading from YAML/JSON +- CLI tool for schema generation and management +- Type-safe API with TypeScript +- Multi-instance support (multiple AppSheet apps) +- Schema-based validation with 27 AppSheet field types +- Factory injection for dependency injection and testing (v3.0.0) +- Schema introspection methods (v3.0.0) ## Installation @@ -69,16 +70,21 @@ npx appsheet inspect \ This creates `config/appsheet-schema.yaml` with your table definitions. -### 2. Use in Your Code +### 2. Use in Your Code (v3.0.0) ```typescript -import { SchemaLoader, SchemaManager } from '@techdivision/appsheet'; +import { + SchemaLoader, + SchemaManager, + AppSheetClientFactory +} from '@techdivision/appsheet'; -// Load schema +// Load schema with factory injection (v3.0.0) const schema = SchemaLoader.fromYaml('./config/appsheet-schema.yaml'); -const db = new SchemaManager(schema); +const clientFactory = new AppSheetClientFactory(); +const db = new SchemaManager(clientFactory, schema); -// Use type-safe table clients +// Use type-safe table clients (runAsUserEmail required in v3.0.0) interface Worklog { id: string; date: string; @@ -86,7 +92,7 @@ interface Worklog { description: string; } -const worklogsTable = db.table('worklog', 'worklogs'); +const worklogsTable = db.table('worklog', 'worklogs', 'user@example.com'); // CRUD operations const worklogs = await worklogsTable.findAll(); @@ -98,6 +104,11 @@ await worklogsTable.add([ await worklogsTable.update([{ id: '123', hours: 7 }]); await worklogsTable.delete([{ id: '123' }]); + +// Schema introspection (v3.0.0) +const tableDef = db.getTableDefinition('worklog', 'worklogs'); +const fieldDef = db.getFieldDefinition('worklog', 'worklogs', 'status'); +const allowedValues = db.getAllowedValues('worklog', 'worklogs', 'status'); ``` ## CLI Commands @@ -116,18 +127,32 @@ npx appsheet add-table npx appsheet validate ``` -## Direct Client Usage +## Direct Client Usage (v3.0.0) For simple use cases without schema files: ```typescript -import { AppSheetClient } from '@techdivision/appsheet'; +import { AppSheetClient, ConnectionDefinition } from '@techdivision/appsheet'; -const client = new AppSheetClient({ +// v3.0.0: ConnectionDefinition with tables required +const connectionDef: ConnectionDefinition = { appId: process.env.APPSHEET_APP_ID!, applicationAccessKey: process.env.APPSHEET_ACCESS_KEY!, - runAsUserEmail: 'default@example.com', // Optional: run operations as specific user -}); + tables: { + users: { + tableName: 'Users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + name: { type: 'Text', required: true }, + email: { type: 'Email', required: true } + } + } + } +}; + +// v3.0.0: runAsUserEmail is required (second parameter) +const client = new AppSheetClient(connectionDef, 'user@example.com'); // CRUD operations const rows = await client.findAll('Users'); @@ -136,13 +161,6 @@ const user = await client.findOne('Users', '[Email] = "john@example.com"'); await client.addOne('Users', { name: 'John', email: 'john@example.com' }); await client.updateOne('Users', { id: '123', name: 'John Updated' }); await client.deleteOne('Users', { id: '123' }); - -// Override runAsUserEmail for specific operation -await client.add({ - tableName: 'Users', - rows: [{ name: 'Jane' }], - properties: { RunAsUserEmail: 'admin@example.com' } -}); ``` ## Multi-Instance Support @@ -166,8 +184,9 @@ connections: ``` ```typescript -const worklogTable = db.table('worklog', 'worklogs'); -const employeeTable = db.table('hr', 'employees'); +// v3.0.0: runAsUserEmail required +const worklogTable = db.table('worklog', 'worklogs', 'user@example.com'); +const employeeTable = db.table('hr', 'employees', 'user@example.com'); ``` ## Examples diff --git a/docs/SOSO-249/INTEGRATION_CONCEPT.md b/docs/SOSO-249/INTEGRATION_CONCEPT.md new file mode 100644 index 0000000..d5c30a5 --- /dev/null +++ b/docs/SOSO-249/INTEGRATION_CONCEPT.md @@ -0,0 +1,915 @@ +# v3.0.0: Client Factory Injection for DI and Testing + +## Overview + +Enable dependency injection (DI) and testing support by injecting a `AppSheetClientFactoryInterface` into `ConnectionManager`. This allows creating user-specific clients (real or mock) at runtime while maintaining full testability. + +This is a **breaking change** requiring a major version bump to v3.0.0. + +## Multi-Connection Support + +**Definition**: Multi-Connection means integrating multiple AppSheet applications within a single MCP server or application. + +**Example Use Case** (appsheet-schema.json): +```json +{ + "connections": { + "worklog": { + "appId": "${WORKLOG_APP_ID}", + "applicationAccessKey": "${WORKLOG_KEY}", + "tables": { + "worklogs": { "..." : "..." }, + "issues": { "..." : "..." } + } + }, + "hr": { + "appId": "${HR_APP_ID}", + "applicationAccessKey": "${HR_KEY}", + "tables": { + "employees": { "..." : "..." }, + "departments": { "..." : "..." } + } + }, + "inventory": { + "appId": "${INVENTORY_APP_ID}", + "applicationAccessKey": "${INVENTORY_KEY}", + "tables": { + "products": { "..." : "..." }, + "warehouses": { "..." : "..." } + } + } + } +} +``` + +**Usage in Service**: +```typescript +// Access different AppSheet apps via connectionName +const worklogs = await schemaManager.table('worklog', 'worklogs', userEmail).findAll(); +const employees = await schemaManager.table('hr', 'employees', userEmail).findAll(); +const products = await schemaManager.table('inventory', 'products', userEmail).findAll(); +``` + +Each connection represents a separate AppSheet application with its own `appId` and `applicationAccessKey`. The `connectionName` parameter identifies which app to use. + +## Problem Statement + +The current `SchemaManager` cannot be used with `MockAppSheetClient` for testing because: + +1. **SchemaManager constructor** only accepts `SchemaConfig`, not a pre-configured `ConnectionManager` +2. **ConnectionManager** always creates a new `AppSheetClient` internally, bypassing any injected mock +3. **User-specific client creation** hardcodes `new AppSheetClient()`, making it untestable + +This makes it impossible to inject a `MockAppSheetClient` for unit/integration testing while using the SchemaManager API with its automatic validation features. + +## Primary Use Case: TSyringe DI with Per-Request User Context + +```typescript +// Service uses SchemaManager with per-request user context +@injectable() +export class ServicePortfolioService { + constructor( + @inject("SchemaManager") private readonly schemaManager: SchemaManager + ) {} + + async listServices(userEmail: string, filters?: ServiceFilters) { + const table = this.schemaManager.table( + 'default', // connectionName + 'service_portfolio', // tableName + userEmail // ← Per-request user context (REQUIRED) + ); + return await table.findAll(); + } +} + +// DI Container setup +export function registerAppSheet(c: DependencyContainer): void { + // Register client factory (swap for MockAppSheetClientFactory in tests) + c.register(TOKENS.AppSheetClientFactory, { + useClass: AppSheetClientFactory + }); + + // Register ConnectionManager with factory and schema + c.register(TOKENS.ConnectionManager, { + useFactory: (container) => { + const clientFactory = container.resolve(TOKENS.AppSheetClientFactory); + const schema = SchemaLoader.fromJson('config/appsheet-schema.json'); + return new ConnectionManager(clientFactory, schema); + } + }); + + // Register DynamicTableFactory + c.register(TOKENS.DynamicTableFactory, { + useFactory: (container) => { + const connectionManager = container.resolve(TOKENS.ConnectionManager); + return new DynamicTableFactory(connectionManager); + } + }); + + // Register SchemaManager + c.register(TOKENS.SchemaManager, { + useFactory: (container) => { + const connectionManager = container.resolve(TOKENS.ConnectionManager); + const tableFactory = container.resolve(TOKENS.DynamicTableFactory); + return new SchemaManager(connectionManager, tableFactory); + } + }); +} + +// Test setup: Swap client factory for mock +container.register(TOKENS.AppSheetClientFactory, { + useClass: MockAppSheetClientFactory +}); +``` + +## Breaking Changes in v3.0.0 + +### 1. NEW: AppSheetClientFactoryInterface + +Factory interface for instantiating clients with user context. Accepts `ConnectionDefinition` directly from schema. + +```typescript +export interface AppSheetClientFactoryInterface { + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface; +} +``` + +### 2. NEW: AppSheetClientFactory (Real Implementation) + +```typescript +export class AppSheetClientFactory implements AppSheetClientFactoryInterface { + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface { + return new AppSheetClient(connectionDef, runAsUserEmail); + } +} +``` + +### 3. NEW: MockAppSheetClientFactory (Test Implementation) + +```typescript +export class MockAppSheetClientFactory implements AppSheetClientFactoryInterface { + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface { + return new MockAppSheetClient(connectionDef, runAsUserEmail); + } +} +``` + +### 4. CHANGED: AppSheetClientInterface - New getTable() Method + +**Before (v2.x):** +```typescript +interface AppSheetClientInterface { + findAll(tableName: string): Promise; + find(options: FindOptions): Promise; + add(tableName: string, rows: T[]): Promise; + // ... other CRUD methods + getConfig(): AppSheetClientConfig; +} +``` + +**After (v3.0.0):** +```typescript +interface AppSheetClientInterface { + findAll(tableName: string): Promise; + find(options: FindOptions): Promise; + add(tableName: string, rows: T[]): Promise; + // ... other CRUD methods + getTable(tableName: string): TableDefinition; // NEW +} +``` + +The interface now includes `getTable()` for accessing table definitions. + +### 5. CHANGED: AppSheetClient - Accepts Full ConnectionDefinition + +**Before (v2.x):** +```typescript +constructor(config: AppSheetClientConfig); +``` + +**After (v3.0.0):** +```typescript +constructor(connectionDef: ConnectionDefinition, runAsUserEmail: string); + +// NEW: Get table definition +getTable(tableName: string): TableDefinition; +``` + +The client now knows its complete configuration including all table definitions. + +### 6. NEW: DynamicTableFactoryInterface + +Factory interface for creating DynamicTable instances. + +```typescript +export interface DynamicTableFactoryInterface { + create(connectionName: string, tableName: string, runAsUserEmail: string): DynamicTable; +} +``` + +### 7. NEW: DynamicTableFactory (Real Implementation) + +```typescript +export class DynamicTableFactory implements DynamicTableFactoryInterface { + constructor(private connectionManager: ConnectionManager) {} + + create(connectionName: string, tableName: string, runAsUserEmail: string): DynamicTable { + const client = this.connectionManager.get(connectionName, runAsUserEmail); + return new DynamicTable(client, client.getTable(tableName)); + } +} +``` + +### 8. ConnectionManager - Complete Redesign + +**Before (v2.x):** +```typescript +class ConnectionManager { + constructor(); + register(config: ConnectionConfig): void; + get(name: string, runAsUserEmail?: string): AppSheetClient; + has(name: string): boolean; + remove(name: string): boolean; + list(): string[]; + clear(): void; + ping(name: string): Promise; + healthCheck(): Promise>; +} +``` + +**After (v3.0.0):** +```typescript +class ConnectionManager { + constructor( + clientFactory: AppSheetClientFactoryInterface, + schema: SchemaConfig + ); + get(connectionName: string, runAsUserEmail: string): AppSheetClientInterface; +} +``` + +**Removed:** +- `register()` - Schema is injected via constructor +- `has()`, `remove()`, `list()`, `clear()` - No connection pool anymore +- `ping()`, `healthCheck()` - Can be done directly on client if needed + +**Changed:** +- `get()` now requires both `connectionName` and `runAsUserEmail` (both required) + +### 9. SchemaManager Constructor - ConnectionManager + TableFactory + +**Before (v2.x):** +```typescript +constructor(schema: SchemaConfig); +``` + +**After (v3.0.0):** +```typescript +constructor(connectionManager: ConnectionManager, tableFactory: DynamicTableFactoryInterface); +``` + +**Note:** +- Schema is accessed via ConnectionManager +- Table creation is delegated to DynamicTableFactory +- The `initialize()` method is **REMOVED** - DI handles all initialization + +### 10. SchemaManager.table() - Delegates to TableFactory + +**Before (v2.x):** +```typescript +table(connectionName: string, tableName: string, runAsUserEmail?: string): DynamicTable; +``` + +**After (v3.0.0):** +```typescript +table(connectionName: string, tableName: string, runAsUserEmail: string): DynamicTable; +// Implementation: return this.tableFactory.create(connectionName, tableName, runAsUserEmail); +``` + +**Changed:** +- `runAsUserEmail` is now required (was optional) +- Delegates to `DynamicTableFactory` instead of creating table internally + +### 11. SchemaConfig - Structure Unchanged + +The schema structure remains the same with `connections` hierarchy: + +```json +{ + "connections": { + "worklog": { + "appId": "${APP_ID}", + "applicationAccessKey": "${KEY}", + "tables": { + "worklogs": { + "tableName": "extract_worklog", + "keyField": "id", + "fields": {} + } + } + } + } +} +``` + +### 12. ConnectionConfig Type - Removed + +The `ConnectionConfig` type is no longer needed and will be removed. + +## Schema Validation (Unchanged) + +The schema-based validation remains **unchanged** in v3.0.0. Validation happens in `DynamicTable`, not in the factories or managers. + +### Validation Flow + +``` +┌─────────────────┐ ┌───────────────────┐ ┌─────────────────┐ +│ SchemaManager │────▶│ DynamicTableFactory│────▶│ DynamicTable │ +│ .table() │ │ .create() │ │ │ +└─────────────────┘ └───────────────────┘ └────────┬────────┘ + │ + ▼ + ┌─────────────────┐ + │ TableDefinition │ + │ (from client) │ + └────────┬────────┘ + │ + ▼ + ┌────────────────────────┐ + │ validateRows() │ + │ - Required fields │ + │ - Type validation │ + │ - Enum validation │ + └────────────────────────┘ +``` + +### Where Validation Happens + +**DynamicTable.validateRows()** performs all schema-based validation: + +```typescript +// DynamicTable (unchanged in v3.0.0) +export class DynamicTable { + constructor( + private client: AppSheetClientInterface, // Changed: Interface instead of concrete class + private definition: TableDefinition // Unchanged: Still receives TableDefinition + ) {} + + async add(rows: Partial[]): Promise { + this.validateRows(rows); // ← Validation happens here + // ... API call + } + + async update(rows: Partial[]): Promise { + this.validateRows(rows, false); // ← Validation happens here (skip required check) + // ... API call + } + + private validateRows(rows: Partial[], checkRequired = true): void { + for (const row of rows) { + for (const [fieldName, fieldDef] of Object.entries(this.definition.fields)) { + // 1. Required field validation + if (checkRequired && fieldDef.required) { + AppSheetTypeValidator.validateRequired(fieldName, ...); + } + + // 2. Type validation (Email, URL, Phone, Date, Number, etc.) + AppSheetTypeValidator.validate(fieldName, fieldDef.type, value, ...); + + // 3. Enum/EnumList validation + if (fieldDef.allowedValues) { + AppSheetTypeValidator.validateEnum(fieldName, fieldDef.type, fieldDef.allowedValues, value, ...); + } + } + } + } +} +``` + +### How TableDefinition Flows in v3.0.0 + +```typescript +// 1. Schema loaded at startup +const schema = SchemaLoader.fromJson('config/appsheet-schema.json'); +// schema.connections['worklog'].tables['worklogs'] = TableDefinition + +// 2. ConnectionManager receives schema +const connectionManager = new ConnectionManager(clientFactory, schema); + +// 3. Factory creates client with full ConnectionDefinition (includes tables) +const client = connectionManager.get('worklog', 'user@example.com'); +// client has: connectionDef.tables['worklogs'] = TableDefinition + +// 4. DynamicTableFactory gets TableDefinition from client +const table = new DynamicTable(client, client.getTable('worklogs')); +// ↑ Returns TableDefinition + +// 5. DynamicTable uses TableDefinition for validation +await table.add([{ ... }]); // validateRows() uses this.definition +``` + +### What Changes, What Stays + +| Component | v2.x | v3.0.0 | Validation Role | +|-----------|------|--------|-----------------| +| SchemaManager | Creates DynamicTable | Delegates to factory | None | +| ConnectionManager | Creates clients | Delegates to factory | None | +| AppSheetClient | No table knowledge | Has `getTable()` | None (just provides TableDefinition) | +| **DynamicTable** | **Validates rows** | **Validates rows** | **All validation happens here** | +| AppSheetTypeValidator | Validation logic | Validation logic | Unchanged | + +**Key Point**: The validation logic in `DynamicTable` and `AppSheetTypeValidator` remains **completely unchanged**. Only the way `TableDefinition` is passed to `DynamicTable` changes (via `client.getTable()` instead of direct schema lookup). + +## Proposed Solution + +### AppSheetClientFactoryInterface + +```typescript +/** + * Factory interface for instantiating AppSheet clients with user context. + * Accepts ConnectionDefinition directly from schema. + * Enables DI and testability by allowing mock factories in tests. + */ +export interface AppSheetClientFactoryInterface { + /** + * Create a new client instance with user context. + * + * @param connectionDef - Connection definition from schema (appId, applicationAccessKey, tables, etc.) + * @param runAsUserEmail - Email of the user to execute operations as + * @returns A new client instance configured for the user + */ + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface; +} +``` + +### AppSheetClientFactory (Real) + +```typescript +/** + * Factory for creating real AppSheet clients. + * Used in production environments. + */ +export class AppSheetClientFactory implements AppSheetClientFactoryInterface { + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface { + return new AppSheetClient(connectionDef, runAsUserEmail); + } +} +``` + +### MockAppSheetClientFactory (Test) + +```typescript +/** + * Factory for creating mock AppSheet clients. + * Used in test environments. + */ +export class MockAppSheetClientFactory implements AppSheetClientFactoryInterface { + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface { + return new MockAppSheetClient(connectionDef, runAsUserEmail); + } +} +``` + +### AppSheetClient v3.0.0 + +```typescript +/** + * AppSheet API client with full connection configuration. + * Knows its credentials, settings AND table definitions. + */ +export class AppSheetClient implements AppSheetClientInterface { + constructor( + private connectionDef: ConnectionDefinition, + private runAsUserEmail: string + ) {} + + /** + * Get a table definition by name. + * + * @param tableName - Name of the table + * @returns The table definition + * @throws {Error} If table not found + */ + getTable(tableName: string): TableDefinition { + const tableDef = this.connectionDef.tables[tableName]; + if (!tableDef) { + const available = Object.keys(this.connectionDef.tables).join(', ') || 'none'; + throw new Error(`Table "${tableName}" not found. Available tables: ${available}`); + } + return tableDef; + } + + // API methods use this.connectionDef.appId, this.connectionDef.applicationAccessKey, etc. + // ... findAll, find, add, update, delete methods unchanged +} +``` + +### ConnectionManager v3.0.0 + +```typescript +/** + * Manages AppSheet client creation and initialization. + * Resolves connection configuration from schema and delegates client creation to factory. + * Supports multiple connections (AppSheet apps) via schema configuration. + * + * Responsibilities: + * - Config resolution from schema (connectionName → ConnectionDefinition) + * - Delegation to factory for client instantiation with user context + */ +export class ConnectionManager { + /** + * Create a new ConnectionManager. + * + * @param clientFactory - Factory for instantiating clients with user context (injected via DI) + * @param schema - Schema configuration containing connection configs (injected via DI) + */ + constructor( + private clientFactory: AppSheetClientFactoryInterface, + private schema: SchemaConfig + ) {} + + /** + * Get a user-specific client for a connection. + * + * Resolves the connection definition from schema and delegates to the factory + * for client creation with user context. + * + * @param connectionName - Name of the connection (AppSheet app) as defined in schema + * @param runAsUserEmail - Email of the user to execute operations as (required) + * @returns A new client instance configured for the user + * @throws {Error} If connection not found in schema + */ + get(connectionName: string, runAsUserEmail: string): AppSheetClientInterface { + const connDef = this.schema.connections[connectionName]; + if (!connDef) { + const available = Object.keys(this.schema.connections).join(', ') || 'none'; + throw new Error( + `Connection "${connectionName}" not found. Available connections: ${available}` + ); + } + + return this.clientFactory.create(connDef, runAsUserEmail); + } + + /** + * Get the schema configuration. + */ + getSchema(): SchemaConfig { + return this.schema; + } +} +``` + +### DynamicTableFactory v3.0.0 + +```typescript +/** + * Factory for creating DynamicTable instances. + * Delegates client creation to ConnectionManager and table lookup to client. + */ +export class DynamicTableFactory implements DynamicTableFactoryInterface { + /** + * Create a new DynamicTableFactory. + * + * @param connectionManager - ConnectionManager for client creation (injected via DI) + */ + constructor(private connectionManager: ConnectionManager) {} + + /** + * Create a DynamicTable for a specific user. + * + * @param connectionName - Name of the connection (AppSheet app) + * @param tableName - The name of the table as defined in the schema + * @param runAsUserEmail - Email of the user to execute operations as (required) + */ + create>( + connectionName: string, + tableName: string, + runAsUserEmail: string + ): DynamicTable { + const client = this.connectionManager.get(connectionName, runAsUserEmail); + return new DynamicTable(client, client.getTable(tableName)); + } +} +``` + +### SchemaManager v3.0.0 + +```typescript +/** + * High-level API for schema-based AppSheet operations. + * Delegates table creation to DynamicTableFactory. + */ +export class SchemaManager { + /** + * Create a new SchemaManager. + * + * @param connectionManager - ConnectionManager for schema access (injected via DI) + * @param tableFactory - Factory for creating DynamicTable instances (injected via DI) + */ + constructor( + private connectionManager: ConnectionManager, + private tableFactory: DynamicTableFactoryInterface + ) {} + + // initialize() method REMOVED - DI handles all initialization + + /** + * Get a type-safe table client for a specific user. + * + * @param connectionName - Name of the connection (AppSheet app) + * @param tableName - The name of the table as defined in the schema + * @param runAsUserEmail - Email of the user to execute operations as (required) + */ + table>( + connectionName: string, + tableName: string, + runAsUserEmail: string + ): DynamicTable { + return this.tableFactory.create(connectionName, tableName, runAsUserEmail); + } + + /** + * Get all available connection names. + */ + getConnections(): string[] { + return Object.keys(this.connectionManager.getSchema().connections); + } + + /** + * Get all available table names for a connection. + */ + getTables(connectionName: string): string[] { + const schema = this.connectionManager.getSchema(); + const connDef = schema.connections[connectionName]; + if (!connDef) { + throw new Error(`Connection "${connectionName}" not found`); + } + return Object.keys(connDef.tables); + } + + /** + * Get the current schema configuration. + */ + getSchema(): SchemaConfig { + return this.connectionManager.getSchema(); + } +} +``` + +## Migration Guide (v2.x → v3.0.0) + +### Schema File - No Changes Required + +The schema structure remains the same: + +```json +{ + "connections": { + "worklog": { + "appId": "${APP_ID}", + "applicationAccessKey": "${KEY}", + "tables": { + "worklogs": { + "tableName": "extract_worklog", + "keyField": "id", + "fields": { + "id": { "type": "Text", "required": true } + } + } + } + } + } +} +``` + +### DI Container Setup + +**Before (v2.x):** +```typescript +const schema = SchemaLoader.fromJson('./appsheet-schema.json'); +const db = new SchemaManager(schema); +const table = db.table('worklog', 'worklogs', 'user@example.com'); +``` + +**After (v3.0.0):** +```typescript +// Setup (once at startup) +const clientFactory = new AppSheetClientFactory(); +const schema = SchemaLoader.fromJson('./appsheet-schema.json'); +const connectionManager = new ConnectionManager(clientFactory, schema); +const tableFactory = new DynamicTableFactory(connectionManager); +const db = new SchemaManager(connectionManager, tableFactory); + +// Usage (per request) - runAsUserEmail is now REQUIRED +const table = db.table('worklog', 'worklogs', 'user@example.com'); +``` + +### TSyringe DI Container Example + +```typescript +export function registerAppSheet(c: DependencyContainer): void { + // Register client factory (swap for MockAppSheetClientFactory in tests) + c.register(TOKENS.AppSheetClientFactory, { useClass: AppSheetClientFactory }); + + // Register ConnectionManager + c.register(TOKENS.ConnectionManager, { + useFactory: (container) => { + const clientFactory = container.resolve(TOKENS.AppSheetClientFactory); + const schema = SchemaLoader.fromJson('config/appsheet-schema.json'); + return new ConnectionManager(clientFactory, schema); + } + }); + + // Register DynamicTableFactory + c.register(TOKENS.DynamicTableFactory, { + useFactory: (container) => { + const connectionManager = container.resolve(TOKENS.ConnectionManager); + return new DynamicTableFactory(connectionManager); + } + }); + + // Register SchemaManager + c.register(TOKENS.SchemaManager, { + useFactory: (container) => { + const connectionManager = container.resolve(TOKENS.ConnectionManager); + const tableFactory = container.resolve(TOKENS.DynamicTableFactory); + return new SchemaManager(connectionManager, tableFactory); + } + }); +} +``` + +### Test Setup + +**Before (not possible with SchemaManager):** +```typescript +// Could only test with direct MockAppSheetClient, not SchemaManager +const mockClient = new MockAppSheetClient({ appId: 'mock', applicationAccessKey: 'mock' }); +``` + +**After (full testability):** +```typescript +// Now SchemaManager is fully testable with mocks! +const mockFactory = new MockAppSheetClientFactory(); +const schema = SchemaLoader.fromJson('./appsheet-schema.json'); +const connectionManager = new ConnectionManager(mockFactory, schema); +const tableFactory = new DynamicTableFactory(connectionManager); +const db = new SchemaManager(connectionManager, tableFactory); + +// All operations use MockAppSheetClient +const table = db.table('worklog', 'users', 'test@example.com'); +const users = await table.findAll(); // Uses mock! +``` + +## Implementation Plan + +### Phase 1: AppSheetClient Changes + +1. Add `getTable()` to `AppSheetClientInterface` in `src/types/client.ts` +2. Change `AppSheetClient` constructor to `(connectionDef: ConnectionDefinition, runAsUserEmail: string)` +3. Implement `getTable()` method in `AppSheetClient` +4. Change `MockAppSheetClient` constructor to `(connectionDef: ConnectionDefinition, runAsUserEmail: string)` +5. Implement `getTable()` method in `MockAppSheetClient` +6. Update tests for both clients + +### Phase 2: New Factory Interfaces and Classes + +1. Create `AppSheetClientFactoryInterface` in `src/types/client.ts` +2. Create `AppSheetClientFactory` in `src/client/AppSheetClientFactory.ts` +3. Create `MockAppSheetClientFactory` in `src/client/MockAppSheetClientFactory.ts` +4. Create `DynamicTableFactoryInterface` in `src/types/client.ts` +5. Create `DynamicTableFactory` in `src/client/DynamicTableFactory.ts` +6. Add unit tests for all factories + +### Phase 3: ConnectionManager Redesign + +1. Replace constructor with `(clientFactory, schema)` parameters +2. Replace `get()` with `get(connectionName: string, runAsUserEmail: string)` +3. Add `getSchema()` method +4. Remove all other methods (`register`, `has`, `remove`, `list`, `clear`, `ping`, `healthCheck`) +5. Update unit tests + +### Phase 4: SchemaManager Changes + +1. Change constructor to accept `(connectionManager, tableFactory)` +2. **Remove `initialize()` method** - DI handles everything +3. Delegate `table()` to `tableFactory.create()` +4. Make `runAsUserEmail` on `table()` **required** (was optional) +5. Update unit tests + +### Phase 5: Cleanup + +1. Remove `ConnectionConfig` type +2. Update exports in `src/index.ts` + +### Phase 6: Documentation + +1. Update CLAUDE.md with new APIs +2. Create MIGRATION.md for v2.x → v3.0.0 +3. Update README with DI/testing examples + +### Phase 7: Release + +1. Update package.json version to 3.0.0 +2. Update CHANGELOG.md +3. Tag release + +## Files to Modify + +### Source Files + +1. `src/types/client.ts` - Add `AppSheetClientFactoryInterface`, `DynamicTableFactoryInterface`, add `getTable()` to `AppSheetClientInterface` +2. `src/client/AppSheetClient.ts` - **CHANGED** New constructor `(connectionDef, runAsUserEmail)`, add `getTable()` method +3. `src/client/MockAppSheetClient.ts` - **CHANGED** New constructor `(connectionDef, runAsUserEmail)`, add `getTable()` method +4. `src/client/AppSheetClientFactory.ts` - **NEW** Real client factory implementation +5. `src/client/MockAppSheetClientFactory.ts` - **NEW** Mock client factory implementation +6. `src/client/DynamicTableFactory.ts` - **NEW** Table factory implementation +7. `src/utils/ConnectionManager.ts` - Complete redesign (factory + schema injection) +8. `src/utils/SchemaManager.ts` - Constructor takes (connectionManager, tableFactory) +9. `src/types/index.ts` - Remove `ConnectionConfig`, export new types +10. `src/index.ts` - Export new classes + +### Test Files + +11. `tests/client/AppSheetClient.test.ts` - Update tests for new constructor and `getTable()` method +12. `tests/client/MockAppSheetClient.test.ts` - Update tests for new constructor and `getTable()` method +13. `tests/client/AppSheetClientFactory.test.ts` - **NEW** Client factory tests +14. `tests/client/MockAppSheetClientFactory.test.ts` - **NEW** Mock client factory tests +15. `tests/client/DynamicTableFactory.test.ts` - **NEW** Table factory tests +16. `tests/utils/ConnectionManager.test.ts` - Update tests +17. `tests/utils/SchemaManager.test.ts` - Update tests + +### Documentation + +18. `CLAUDE.md` - Document new APIs +19. `MIGRATION.md` - Create v2.x → v3.0.0 migration guide +20. `package.json` - Bump to 3.0.0 +21. `CHANGELOG.md` - Document breaking changes + +## Schema Introspection Methods (Issue #7) + +As part of v3.0.0, we also implemented convenient schema introspection methods: + +### SchemaManager Methods + +```typescript +// Get table definition +const tableDef = db.getTableDefinition('default', 'users'); +// → { tableName: 'Users', keyField: 'id', fields: {...} } + +// Get field definition +const fieldDef = db.getFieldDefinition('default', 'users', 'status'); +// → { type: 'Enum', required: true, allowedValues: [...] } + +// Get allowed values for Enum fields +const values = db.getAllowedValues('default', 'users', 'status'); +// → ['Active', 'Inactive', 'Pending'] +``` + +### Use Cases + +1. **Zod Schema Generation**: Generate Zod enums from schema `allowedValues` +2. **OpenAPI Spec Generation**: Generate enum constraints for API documentation +3. **UI Dropdowns**: Populate select options from schema +4. **Validation**: Check if a value is valid for an Enum field +5. **Type Generation**: Generate TypeScript types from schema + +### Implementation + +```typescript +// In SchemaManager +getTableDefinition(connectionName: string, tableName: string): TableDefinition | undefined { + const connDef = this.schema.connections[connectionName]; + return connDef?.tables[tableName]; +} + +getFieldDefinition(connectionName: string, tableName: string, fieldName: string): FieldDefinition | undefined { + return this.getTableDefinition(connectionName, tableName)?.fields[fieldName]; +} + +getAllowedValues(connectionName: string, tableName: string, fieldName: string): string[] | undefined { + return this.getFieldDefinition(connectionName, tableName, fieldName)?.allowedValues; +} +``` + +## Implementation Status + +| Phase | Description | Status | +|-------|-------------|--------| +| Phase 1 | AppSheetClient Changes | ✅ Completed | +| Phase 2 | Factory Interfaces and Classes | ✅ Completed | +| Phase 3 | ConnectionManager Redesign | ✅ Completed | +| Phase 4 | SchemaManager Changes | ✅ Completed | +| Phase 5 | Cleanup | ✅ Completed | +| Phase 6 | Documentation | ✅ Completed | +| Phase 7 | Release | 🔄 Pending PR Merge | + +**Test Coverage**: 221 tests across 8 test suites + +## Related Issues + +- GitHub Issue: https://github.com/techdivision/appsheet/issues/6 (DI Support) +- GitHub Issue: https://github.com/techdivision/appsheet/issues/7 (Schema Introspection) +- JIRA Ticket: SOSO-249 +- Extends: SOSO-248 (Per-request user context - now works with DI!) diff --git a/package-lock.json b/package-lock.json index 2293c87..bf4ac35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@techdivision/appsheet", - "version": "2.1.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@techdivision/appsheet", - "version": "2.1.0", + "version": "3.0.0", "license": "MIT", "dependencies": { "@types/uuid": "^10.0.0", @@ -66,6 +66,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1435,6 +1436,7 @@ "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1525,6 +1527,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -1694,6 +1697,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2050,6 +2054,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -2723,6 +2728,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3792,6 +3798,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5911,6 +5918,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index c3c20c8..37cb85a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@techdivision/appsheet", - "version": "2.1.0", + "version": "3.0.0", "description": "Generic TypeScript library for AppSheet CRUD operations", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 52dd315..5d1d33f 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import { AppSheetClient } from '../client'; import { SchemaInspector } from './SchemaInspector'; import { SchemaLoader } from '../utils'; -import { SchemaConfig } from '../types'; +import { SchemaConfig, ConnectionDefinition } from '../types'; /** * Create CLI program with all commands @@ -59,12 +59,18 @@ export function createCLI(): Command { .option('--auto-discover', 'Attempt to automatically discover all tables', false) .action(async (options) => { try { - const client = new AppSheetClient({ + // Create a minimal connection definition for inspection + // (tables are not known yet - we're discovering them) + const connectionDef: ConnectionDefinition = { appId: options.appId, applicationAccessKey: options.accessKey, - runAsUserEmail: options.runAsUserEmail, - }); + tables: {}, // Empty - schema is being generated + }; + + // runAsUserEmail is required in v3.0.0 - use provided or default + const runAsUserEmail = options.runAsUserEmail || 'cli@appsheet.local'; + const client = new AppSheetClient(connectionDef, runAsUserEmail); const inspector = new SchemaInspector(client); let tableNames: string[]; @@ -153,10 +159,9 @@ export function createCLI(): Command { // Create client from existing connection config const connDef = schema.connections[connection]; - const client = new AppSheetClient({ - appId: connDef.appId, - applicationAccessKey: connDef.applicationAccessKey, - }); + // runAsUserEmail is required in v3.0.0 - use schema config or default + const runAsUserEmail = connDef.runAsUserEmail || 'cli@appsheet.local'; + const client = new AppSheetClient(connDef, runAsUserEmail); const inspector = new SchemaInspector(client); diff --git a/src/client/AppSheetClient.ts b/src/client/AppSheetClient.ts index 965fcdb..2181c7d 100644 --- a/src/client/AppSheetClient.ts +++ b/src/client/AppSheetClient.ts @@ -6,8 +6,9 @@ import axios, { AxiosInstance, AxiosError } from 'axios'; import { - AppSheetConfig, AppSheetClientInterface, + ConnectionDefinition, + TableDefinition, RequestProperties, AddOptions, FindOptions, @@ -32,67 +33,76 @@ import { * This is the main client class that provides methods for creating, reading, * updating, and deleting data from AppSheet tables via the AppSheet API v2. * + * In v3.0.0, the client accepts a full ConnectionDefinition (including table schemas) + * and a required runAsUserEmail parameter for per-request user context. + * * @category Client * * @example * ```typescript - * const client = new AppSheetClient({ + * // v3.0.0 usage with ConnectionDefinition + * const connectionDef: ConnectionDefinition = { * appId: 'your-app-id', * applicationAccessKey: 'your-access-key', - * runAsUserEmail: 'default@example.com' // Global default - * }); + * tables: { + * users: { + * tableName: 'extract_user', + * keyField: 'id', + * fields: { ... } + * } + * } + * }; * - * // Find all rows (runs as default@example.com) - * const users = await client.findAll('Users'); + * const client = new AppSheetClient(connectionDef, 'user@example.com'); * - * // Add a row with different user (per-operation override) - * await client.addOne('Users', { name: 'John', email: 'john@example.com' }); + * // Find all rows + * const users = await client.findAll('extract_user'); * - * // Override runAsUserEmail for specific operation - * await client.add({ - * tableName: 'Users', - * rows: [{ name: 'Jane' }], - * properties: { RunAsUserEmail: 'admin@example.com' } - * }); + * // Get table definition + * const tableDef = client.getTable('users'); + * console.log(tableDef.tableName); // 'extract_user' * ``` */ export class AppSheetClient implements AppSheetClientInterface { private readonly axios: AxiosInstance; - private readonly config: Required> & { runAsUserEmail?: string }; + private readonly connectionDef: ConnectionDefinition; + private readonly runAsUserEmail: string; + private readonly retryAttempts: number; /** * Creates a new AppSheet API client instance. * - * @param config - Configuration for the AppSheet client - * @throws {ValidationError} If required configuration fields are missing + * @param connectionDef - Full connection definition including app credentials and table schemas + * @param runAsUserEmail - Email of the user to execute all operations as (required) * * @example * ```typescript - * const client = new AppSheetClient({ + * const connectionDef: ConnectionDefinition = { * appId: process.env.APPSHEET_APP_ID!, * applicationAccessKey: process.env.APPSHEET_ACCESS_KEY!, * timeout: 60000, // Optional: 60 seconds - * retryAttempts: 5, // Optional: retry 5 times - * runAsUserEmail: 'user@example.com' // Optional: run all operations as this user - * }); + * tables: { ... } + * }; + * + * const client = new AppSheetClient(connectionDef, 'user@example.com'); * ``` */ - constructor(config: AppSheetConfig) { + constructor(connectionDef: ConnectionDefinition, runAsUserEmail: string) { + this.connectionDef = connectionDef; + this.runAsUserEmail = runAsUserEmail; + this.retryAttempts = 3; // Default retry attempts + // Apply defaults - this.config = { - baseUrl: 'https://api.appsheet.com/api/v2', - timeout: 30000, - retryAttempts: 3, - ...config, - }; + const baseUrl = connectionDef.baseUrl || 'https://api.appsheet.com/api/v2'; + const timeout = connectionDef.timeout || 30000; // Create axios instance this.axios = axios.create({ - baseURL: this.config.baseUrl, - timeout: this.config.timeout, + baseURL: baseUrl, + timeout: timeout, headers: { 'Content-Type': 'application/json', - ApplicationAccessKey: this.config.applicationAccessKey, + ApplicationAccessKey: connectionDef.applicationAccessKey, }, }); } @@ -120,7 +130,7 @@ export class AppSheetClient implements AppSheetClientInterface { * ``` */ async add>(options: AddOptions): Promise> { - const url = `/apps/${this.config.appId}/tables/${options.tableName}/Action`; + const url = `/apps/${this.connectionDef.appId}/tables/${options.tableName}/Action`; const payload = { Action: 'Add', @@ -159,7 +169,7 @@ export class AppSheetClient implements AppSheetClientInterface { * ``` */ async find>(options: FindOptions): Promise> { - const url = `/apps/${this.config.appId}/tables/${options.tableName}/Action`; + const url = `/apps/${this.connectionDef.appId}/tables/${options.tableName}/Action`; const properties = this.mergeProperties(options.properties); if (options.selector) { @@ -205,7 +215,7 @@ export class AppSheetClient implements AppSheetClientInterface { * ``` */ async update>(options: UpdateOptions): Promise> { - const url = `/apps/${this.config.appId}/tables/${options.tableName}/Action`; + const url = `/apps/${this.connectionDef.appId}/tables/${options.tableName}/Action`; const payload = { Action: 'Edit', @@ -246,7 +256,7 @@ export class AppSheetClient implements AppSheetClientInterface { * ``` */ async delete>(options: DeleteOptions): Promise { - const url = `/apps/${this.config.appId}/tables/${options.tableName}/Action`; + const url = `/apps/${this.connectionDef.appId}/tables/${options.tableName}/Action`; const payload = { Action: 'Delete', @@ -376,12 +386,9 @@ export class AppSheetClient implements AppSheetClientInterface { * Per-operation properties take precedence over global config. */ private mergeProperties(operationProperties?: RequestProperties): RequestProperties { - const properties: RequestProperties = {}; - - // Add global runAsUserEmail if configured - if (this.config.runAsUserEmail) { - properties.RunAsUserEmail = this.config.runAsUserEmail; - } + const properties: RequestProperties = { + RunAsUserEmail: this.runAsUserEmail, + }; // Merge with operation-specific properties (takes precedence) if (operationProperties) { @@ -417,7 +424,7 @@ export class AppSheetClient implements AppSheetClientInterface { // Retry on network errors or 5xx server errors if ( - attempt < this.config.retryAttempts && + attempt < this.retryAttempts && (this.isRetryableError(axiosError) || this.isServerError(axiosError)) ) { // Exponential backoff @@ -487,13 +494,28 @@ export class AppSheetClient implements AppSheetClientInterface { } /** - * Get the current client configuration. + * Get a table definition by name. + * + * Returns the TableDefinition for the specified table from the client's + * ConnectionDefinition. Used by DynamicTableFactory to create DynamicTable instances. * - * Returns a readonly copy of the configuration with all defaults applied. + * @param tableName - The schema name of the table (not the AppSheet table name) + * @returns The TableDefinition for the specified table + * @throws {Error} If the table doesn't exist in the connection * - * @returns The client configuration + * @example + * ```typescript + * const tableDef = client.getTable('users'); + * console.log(tableDef.tableName); // 'extract_user' + * console.log(tableDef.keyField); // 'id' + * ``` */ - getConfig(): Readonly> & { runAsUserEmail?: string }> { - return { ...this.config }; + getTable(tableName: string): TableDefinition { + const tableDef = this.connectionDef.tables[tableName]; + if (!tableDef) { + const available = Object.keys(this.connectionDef.tables).join(', ') || 'none'; + throw new Error(`Table "${tableName}" not found. Available tables: ${available}`); + } + return tableDef; } } diff --git a/src/client/AppSheetClientFactory.ts b/src/client/AppSheetClientFactory.ts new file mode 100644 index 0000000..69c1694 --- /dev/null +++ b/src/client/AppSheetClientFactory.ts @@ -0,0 +1,52 @@ +/** + * AppSheetClientFactory - Factory for creating real AppSheetClient instances + * + * Implements AppSheetClientFactoryInterface to enable dependency injection + * in ConnectionManager and other components that need to create clients. + * + * @module client + * @category Client + */ + +import { AppSheetClientFactoryInterface, AppSheetClientInterface, ConnectionDefinition } from '../types'; +import { AppSheetClient } from './AppSheetClient'; + +/** + * Factory for creating real AppSheetClient instances. + * + * This factory creates actual AppSheetClient instances that make real + * HTTP requests to the AppSheet API. Use this factory in production. + * + * @category Client + * + * @example + * ```typescript + * // Create factory + * const factory = new AppSheetClientFactory(); + * + * // Use factory to create clients + * const connectionDef: ConnectionDefinition = { + * appId: 'your-app-id', + * applicationAccessKey: 'your-key', + * tables: { ... } + * }; + * + * const client = factory.create(connectionDef, 'user@example.com'); + * const users = await client.findAll('extract_user'); + * + * // Inject factory into ConnectionManager + * const connectionManager = new ConnectionManager(factory, schema); + * ``` + */ +export class AppSheetClientFactory implements AppSheetClientFactoryInterface { + /** + * Create a new AppSheetClient instance. + * + * @param connectionDef - Full connection definition including app credentials and table schemas + * @param runAsUserEmail - Email of the user to execute all operations as + * @returns A new AppSheetClient instance + */ + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface { + return new AppSheetClient(connectionDef, runAsUserEmail); + } +} diff --git a/src/client/DynamicTable.ts b/src/client/DynamicTable.ts index 681330e..24a4855 100644 --- a/src/client/DynamicTable.ts +++ b/src/client/DynamicTable.ts @@ -4,8 +4,7 @@ * @category Client */ -import { AppSheetClient } from './AppSheetClient'; -import { TableDefinition } from '../types'; +import { AppSheetClientInterface, TableDefinition } from '../types'; import { AppSheetTypeValidator } from '../utils/validators'; /** @@ -15,6 +14,9 @@ import { AppSheetTypeValidator } from '../utils/validators'; * based on the table's schema definition. Validates field types, required * fields, and enum values at runtime. * + * In v3.0.0, accepts AppSheetClientInterface instead of concrete AppSheetClient, + * enabling dependency injection and use with MockAppSheetClient for testing. + * * @template T - The TypeScript type for rows in this table * @category Client * @@ -31,9 +33,9 @@ import { AppSheetTypeValidator } from '../utils/validators'; * await table.delete([{ id: '1' }]); * ``` */ -export class DynamicTable> { +export class DynamicTable = Record> { constructor( - private client: AppSheetClient, + private client: AppSheetClientInterface, private definition: TableDefinition ) {} diff --git a/src/client/DynamicTableFactory.ts b/src/client/DynamicTableFactory.ts new file mode 100644 index 0000000..9506b88 --- /dev/null +++ b/src/client/DynamicTableFactory.ts @@ -0,0 +1,90 @@ +/** + * DynamicTableFactory - Factory for creating DynamicTable instances + * + * Implements DynamicTableFactoryInterface to enable dependency injection + * in SchemaManager and flexible table instantiation strategies. + * + * @module client + * @category Client + */ + +import { DynamicTableFactoryInterface, AppSheetClientFactoryInterface, SchemaConfig } from '../types'; +import { DynamicTable } from './DynamicTable'; + +/** + * Factory for creating DynamicTable instances. + * + * This factory creates DynamicTable instances by: + * 1. Looking up the connection definition from the schema + * 2. Creating a client using the injected client factory + * 3. Getting the table definition from the connection + * 4. Creating a DynamicTable with the client and table definition + * + * @category Client + * + * @example + * ```typescript + * // Create factory with client factory and schema + * const clientFactory = new AppSheetClientFactory(); + * const tableFactory = new DynamicTableFactory(clientFactory, schema); + * + * // Create table instances + * const usersTable = tableFactory.create('worklog', 'users', 'user@example.com'); + * const users = await usersTable.findAll(); + * + * // For testing, use MockAppSheetClientFactory + * const mockClientFactory = new MockAppSheetClientFactory(testData); + * const testTableFactory = new DynamicTableFactory(mockClientFactory, schema); + * const testTable = testTableFactory.create('worklog', 'users', 'test@example.com'); + * ``` + */ +export class DynamicTableFactory implements DynamicTableFactoryInterface { + /** + * Creates a new DynamicTableFactory. + * + * @param clientFactory - Factory to create AppSheetClient instances + * @param schema - Schema configuration with connection definitions + */ + constructor( + private readonly clientFactory: AppSheetClientFactoryInterface, + private readonly schema: SchemaConfig + ) {} + + /** + * Create a DynamicTable instance for a specific connection and table. + * + * @template T - The TypeScript type for rows in this table + * @param connectionName - Name of the connection in the schema + * @param tableName - Schema name of the table (not the AppSheet table name) + * @param runAsUserEmail - Email of the user to execute all operations as + * @returns A new DynamicTable instance configured for the specified table + * @throws {Error} If the connection or table doesn't exist in the schema + * + * @example + * ```typescript + * const table = tableFactory.create('worklog', 'worklogs', 'user@example.com'); + * const worklogs = await table.findAll(); + * ``` + */ + create = Record>( + connectionName: string, + tableName: string, + runAsUserEmail: string + ): DynamicTable { + // Get connection definition from schema + 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}`); + } + + // Create client using factory + const client = this.clientFactory.create(connectionDef, runAsUserEmail); + + // Get table definition (will throw if not found) + const tableDef = client.getTable(tableName); + + // Create and return DynamicTable + return new DynamicTable(client, tableDef); + } +} diff --git a/src/client/MockAppSheetClient.ts b/src/client/MockAppSheetClient.ts index d25245e..d50ca06 100644 --- a/src/client/MockAppSheetClient.ts +++ b/src/client/MockAppSheetClient.ts @@ -10,8 +10,9 @@ import { v4 as uuidv4 } from 'uuid'; import { - AppSheetConfig, AppSheetClientInterface, + ConnectionDefinition, + TableDefinition, AddOptions, FindOptions, UpdateOptions, @@ -33,78 +34,68 @@ import { createDefaultMockData } from './__mocks__/mockData'; * Implements the same interface as AppSheetClient but uses an in-memory database. * Useful for unit and integration tests without hitting the real AppSheet API. * + * In v3.0.0, accepts a ConnectionDefinition (including table schemas) and runAsUserEmail. + * * @category Client * * @example * ```typescript - * // Option 1: Use default mock data (example data for testing) - * const client = new MockAppSheetClient({ + * const connectionDef: ConnectionDefinition = { * appId: 'mock-app', - * applicationAccessKey: 'mock-key' - * }); - * client.seedDatabase(); // Load default example data - * - * // Option 2: Use project-specific mock data (recommended) - * class MyProjectMockData implements MockDataProvider { - * getTables(): Map { - * const tables = new Map(); - * tables.set('users', { - * rows: [{ id: '1', name: 'John' }], - * keyField: 'id' - * }); - * return tables; + * applicationAccessKey: 'mock-key', + * tables: { + * users: { + * tableName: 'extract_user', + * keyField: 'id', + * fields: { id: { type: 'Text', required: true } } + * } * } - * } + * }; * - * const mockData = new MyProjectMockData(); - * const client = new MockAppSheetClient({ - * appId: 'mock-app', - * applicationAccessKey: 'mock-key' - * }, mockData); // Tables are automatically seeded + * // Create mock client + * const client = new MockAppSheetClient(connectionDef, 'user@example.com'); + * client.seedDatabase(); // Load default example data * * // Use like real client - * const users = await client.findAll('users'); - * const user = await client.addOne('users', { id: '2', name: 'Jane' }); + * const users = await client.findAll('extract_user'); + * const user = await client.addOne('extract_user', { id: '2', name: 'Jane' }); + * + * // Get table definition + * const tableDef = client.getTable('users'); * ``` */ export class MockAppSheetClient implements AppSheetClientInterface { - private readonly config: Required> & { - runAsUserEmail?: string; - }; + private readonly connectionDef: ConnectionDefinition; + private readonly runAsUserEmail: string; private readonly database: MockDatabase; /** * Creates a new Mock AppSheet client instance. * - * @param config - Configuration for the mock client (only appId and applicationAccessKey are used) + * @param connectionDef - Full connection definition including app credentials and table schemas + * @param runAsUserEmail - Email of the user to execute all operations as (required) * @param dataProvider - Optional project-specific mock data provider. If provided, tables are automatically seeded. * * @example * ```typescript - * // Without data provider (manual seeding) - * const client = new MockAppSheetClient({ + * const connectionDef: ConnectionDefinition = { * appId: 'mock-app', * applicationAccessKey: 'mock-key', - * runAsUserEmail: 'test@example.com' - * }); + * tables: { ... } + * }; + * + * // Without data provider (manual seeding) + * const client = new MockAppSheetClient(connectionDef, 'test@example.com'); * client.seedDatabase(); // Load default example data * * // With data provider (automatic seeding) * const mockData = new MyProjectMockData(); - * const client = new MockAppSheetClient({ - * appId: 'mock-app', - * applicationAccessKey: 'mock-key' - * }, mockData); // Tables automatically seeded + * const client = new MockAppSheetClient(connectionDef, 'test@example.com', mockData); * ``` */ - constructor(config: AppSheetConfig, dataProvider?: MockDataProvider) { - this.config = { - baseUrl: 'https://api.appsheet.com/api/v2', - timeout: 30000, - retryAttempts: 3, - ...config, - }; - + constructor(connectionDef: ConnectionDefinition, runAsUserEmail: string, dataProvider?: MockDataProvider) { + this.connectionDef = connectionDef; + this.runAsUserEmail = runAsUserEmail; this.database = new MockDatabase(); // Auto-seed from data provider if provided @@ -174,7 +165,7 @@ export class MockAppSheetClient implements AppSheetClientInterface { [keyField]: (row as any)[keyField] || uuidv4(), created_at: new Date().toISOString(), created_by: - options.properties?.RunAsUserEmail || this.config.runAsUserEmail || 'mock@example.com', + options.properties?.RunAsUserEmail || this.runAsUserEmail, } as T; const created = this.database.insert(options.tableName, rowWithId, keyField); @@ -224,7 +215,7 @@ export class MockAppSheetClient implements AppSheetClientInterface { ...row, modified_at: new Date().toISOString(), modified_by: - options.properties?.RunAsUserEmail || this.config.runAsUserEmail || 'mock@example.com', + options.properties?.RunAsUserEmail || this.runAsUserEmail, } as Partial); if (!updated) { @@ -316,12 +307,22 @@ export class MockAppSheetClient implements AppSheetClientInterface { } /** - * Get the current client configuration. + * Get a table definition by name. + * + * Returns the TableDefinition for the specified table from the client's + * ConnectionDefinition. Used by DynamicTableFactory to create DynamicTable instances. + * + * @param tableName - The schema name of the table (not the AppSheet table name) + * @returns The TableDefinition for the specified table + * @throws {Error} If the table doesn't exist in the connection */ - getConfig(): Readonly< - Required> & { runAsUserEmail?: string } - > { - return { ...this.config }; + getTable(tableName: string): TableDefinition { + const tableDef = this.connectionDef.tables[tableName]; + if (!tableDef) { + const available = Object.keys(this.connectionDef.tables).join(', ') || 'none'; + throw new Error(`Table "${tableName}" not found. Available tables: ${available}`); + } + return tableDef; } /** diff --git a/src/client/MockAppSheetClientFactory.ts b/src/client/MockAppSheetClientFactory.ts new file mode 100644 index 0000000..8b41dd5 --- /dev/null +++ b/src/client/MockAppSheetClientFactory.ts @@ -0,0 +1,68 @@ +/** + * MockAppSheetClientFactory - Factory for creating MockAppSheetClient instances + * + * Implements AppSheetClientFactoryInterface to enable easy testing + * by substituting real clients with mock implementations. + * + * @module client + * @category Client + */ + +import { + AppSheetClientFactoryInterface, + AppSheetClientInterface, + ConnectionDefinition, + MockDataProvider, +} from '../types'; +import { MockAppSheetClient } from './MockAppSheetClient'; + +/** + * Factory for creating MockAppSheetClient instances. + * + * This factory creates mock client instances that use in-memory storage + * instead of making real API calls. Use this factory for testing. + * + * Can be configured with an optional MockDataProvider to automatically + * seed all created clients with test data. + * + * @category Client + * + * @example + * ```typescript + * // Basic usage - empty mock database + * const factory = new MockAppSheetClientFactory(); + * const client = factory.create(connectionDef, 'user@example.com'); + * + * // With data provider - auto-seeds all clients + * const mockData = new MyProjectMockData(); + * const factory = new MockAppSheetClientFactory(mockData); + * const client = factory.create(connectionDef, 'user@example.com'); + * // client already has test data from mockData + * + * // Use in tests with ConnectionManager + * const mockFactory = new MockAppSheetClientFactory(testData); + * const connectionManager = new ConnectionManager(mockFactory, schema); + * ``` + */ +export class MockAppSheetClientFactory implements AppSheetClientFactoryInterface { + /** + * Creates a new MockAppSheetClientFactory. + * + * @param dataProvider - Optional data provider to automatically seed all created clients + */ + constructor(private readonly dataProvider?: MockDataProvider) {} + + /** + * Create a new MockAppSheetClient instance. + * + * If a dataProvider was supplied to the factory constructor, the created + * client will be automatically seeded with the provider's test data. + * + * @param connectionDef - Full connection definition including app credentials and table schemas + * @param runAsUserEmail - Email of the user to execute all operations as + * @returns A new MockAppSheetClient instance + */ + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface { + return new MockAppSheetClient(connectionDef, runAsUserEmail, this.dataProvider); + } +} diff --git a/src/client/index.ts b/src/client/index.ts index 11d9521..fa03ce4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,3 +5,8 @@ export * from './AppSheetClient'; export * from './MockAppSheetClient'; export * from './DynamicTable'; + +// Factory implementations (v3.0.0) +export * from './AppSheetClientFactory'; +export * from './MockAppSheetClientFactory'; +export * from './DynamicTableFactory'; diff --git a/src/types/client.ts b/src/types/client.ts index a298426..d34f506 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -4,7 +4,6 @@ * @category Types */ -import { AppSheetConfig } from './config'; import { AddOptions, FindOptions, @@ -17,6 +16,7 @@ import { UpdateResponse, DeleteResponse, } from './responses'; +import { TableDefinition } from './schema'; /** * Interface for AppSheet client implementations. @@ -131,9 +131,22 @@ export interface AppSheetClientInterface { deleteOne = Record>(tableName: string, row: T): Promise; /** - * Get the current client configuration. + * Get a table definition by name. * - * @returns Readonly copy of the client configuration + * Returns the TableDefinition for the specified table from the client's + * ConnectionDefinition. Used by DynamicTableFactory to create DynamicTable instances. + * + * @param tableName - The schema name of the table (not the AppSheet table name) + * @returns The TableDefinition for the specified table + * @throws {Error} If the table doesn't exist in the connection + * + * @example + * ```typescript + * const client = new AppSheetClient(connectionDef, 'user@example.com'); + * const tableDef = client.getTable('users'); + * console.log(tableDef.tableName); // 'extract_user' + * console.log(tableDef.keyField); // 'id' + * ``` */ - getConfig(): Readonly> & { runAsUserEmail?: string }>; + getTable(tableName: string): TableDefinition; } diff --git a/src/types/config.ts b/src/types/config.ts index fd98e15..0ae9d5a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -7,7 +7,8 @@ /** * AppSheet client configuration. * - * Configuration options for creating an AppSheet API client. + * @deprecated Since v3.0.0 - Use {@link ConnectionDefinition} instead. + * The AppSheetClient constructor now takes (ConnectionDefinition, runAsUserEmail). * * @category Types */ @@ -33,6 +34,11 @@ export interface AppSheetConfig { /** * Connection configuration for ConnectionManager + * + * @deprecated Since v3.0.0 - ConnectionManager now uses factory injection. + * Use ConnectionManager(clientFactory, schema) constructor instead. + * + * @category Types */ export interface ConnectionConfig extends AppSheetConfig { /** Unique name for this connection */ diff --git a/src/types/factories.ts b/src/types/factories.ts new file mode 100644 index 0000000..174265a --- /dev/null +++ b/src/types/factories.ts @@ -0,0 +1,84 @@ +/** + * Factory interfaces for Dependency Injection support (v3.0.0) + * + * Provides factory interfaces that enable: + * - Dependency injection in ConnectionManager and SchemaManager + * - Easy testing with MockAppSheetClient + * - Flexible client instantiation strategies + * + * @module types + * @category Types + */ + +import { AppSheetClientInterface } from './client'; +import { ConnectionDefinition } from './schema'; +import { DynamicTable } from '../client/DynamicTable'; + +/** + * Factory interface for creating AppSheetClient instances. + * + * Implementations of this interface are responsible for instantiating + * AppSheetClient (or compatible) instances. This enables: + * - Dependency injection in ConnectionManager + * - Testing with MockAppSheetClient via MockAppSheetClientFactory + * - Custom client creation strategies + * + * @category Types + * + * @example + * ```typescript + * // Production usage with real client + * const factory = new AppSheetClientFactory(); + * const client = factory.create(connectionDef, 'user@example.com'); + * + * // Testing with mock client + * const mockFactory = new MockAppSheetClientFactory(); + * const mockClient = mockFactory.create(connectionDef, 'user@example.com'); + * ``` + */ +export interface AppSheetClientFactoryInterface { + /** + * Create a new AppSheetClient instance. + * + * @param connectionDef - Full connection definition including app credentials and table schemas + * @param runAsUserEmail - Email of the user to execute all operations as + * @returns A new AppSheetClientInterface instance + */ + create(connectionDef: ConnectionDefinition, runAsUserEmail: string): AppSheetClientInterface; +} + +/** + * Factory interface for creating DynamicTable instances. + * + * This factory abstracts the creation of DynamicTable instances, + * enabling dependency injection in SchemaManager and flexible + * table instantiation strategies. + * + * @category Types + * + * @example + * ```typescript + * // Factory creates tables using ConnectionManager + * const tableFactory = new DynamicTableFactory(connectionManager); + * const table = tableFactory.create('worklog', 'users', 'user@example.com'); + * + * // All operations execute as the specified user + * const users = await table.findAll(); + * ``` + */ +export interface DynamicTableFactoryInterface { + /** + * Create a DynamicTable instance for a specific connection and table. + * + * @template T - The TypeScript type for rows in this table + * @param connectionName - Name of the connection in the schema + * @param tableName - Schema name of the table (not the AppSheet table name) + * @param runAsUserEmail - Email of the user to execute all operations as + * @returns A new DynamicTable instance configured for the specified table + */ + create = Record>( + connectionName: string, + tableName: string, + runAsUserEmail: string + ): DynamicTable; +} diff --git a/src/types/index.ts b/src/types/index.ts index 3c7bbf9..96a617b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,3 +22,6 @@ export * from './errors'; // Mock types export * from './mock'; + +// Factory interfaces (v3.0.0) +export * from './factories'; diff --git a/src/utils/ConnectionManager.ts b/src/utils/ConnectionManager.ts index 596a1a0..cb492d8 100644 --- a/src/utils/ConnectionManager.ts +++ b/src/utils/ConnectionManager.ts @@ -1,177 +1,100 @@ /** - * Connection Manager for handling multiple AppSheet connections + * Connection Manager v3.0.0 - Simplified with Factory Injection + * + * The ConnectionManager creates AppSheetClient instances on-demand using + * the injected factory and schema. Each call to get() creates a new client + * configured for the specified user. + * * @module utils * @category Connection Management */ -import { AppSheetClient } from '../client'; -import { ConnectionConfig } from '../types'; +import { AppSheetClientFactoryInterface, AppSheetClientInterface, SchemaConfig } from '../types'; /** - * Manages multiple AppSheet client connections. + * Manages AppSheet client instances using factory injection. * - * Allows registering and retrieving multiple AppSheet app connections - * by name, useful for projects that need to interact with multiple - * AppSheet applications. + * In v3.0.0, ConnectionManager is simplified to focus solely on creating + * user-specific client instances. The factory pattern enables: + * - Easy testing by injecting MockAppSheetClientFactory + * - On-demand client creation (no connection pooling) + * - Per-request user context (runAsUserEmail is required) * * @category Connection Management * * @example * ```typescript - * const manager = new ConnectionManager(); + * // Production setup + * const clientFactory = new AppSheetClientFactory(); + * const schema = SchemaLoader.fromYaml('./config/schema.yaml'); + * const connectionManager = new ConnectionManager(clientFactory, schema); * - * // Register connections - * manager.register({ - * name: 'worklog', - * appId: 'worklog-app-id', - * applicationAccessKey: 'key-1' - * }); + * // Get client for specific user + * const client = connectionManager.get('worklog', 'user@example.com'); + * const worklogs = await client.findAll('extract_worklog'); * - * manager.register({ - * name: 'hr', - * appId: 'hr-app-id', - * applicationAccessKey: 'key-2' - * }); - * - * // Use connections - * const worklogClient = manager.get('worklog'); - * const hrClient = manager.get('hr'); + * // Testing setup with mock factory + * const mockFactory = new MockAppSheetClientFactory(testData); + * const testConnectionManager = new ConnectionManager(mockFactory, schema); + * const testClient = testConnectionManager.get('worklog', 'test@example.com'); * ``` */ export class ConnectionManager { - private connections = new Map(); - /** - * Register a new AppSheet connection. + * Creates a new ConnectionManager. * - * Adds a new connection to the manager that can be retrieved later by name. - * Each connection must have a unique name within the manager. - * - * @param config - Connection configuration including name, appId, access key, and optional settings - * @throws {Error} If a connection with the same name already exists + * @param clientFactory - Factory to create AppSheetClient instances + * @param schema - Schema configuration containing connection definitions * * @example * ```typescript - * manager.register({ - * name: 'worklog', - * appId: 'app-123', - * applicationAccessKey: 'key-xyz', - * runAsUserEmail: 'user@example.com', - * timeout: 60000 - * }); + * const factory = new AppSheetClientFactory(); + * const schema = SchemaLoader.fromYaml('./schema.yaml'); + * const manager = new ConnectionManager(factory, schema); * ``` */ - register(config: ConnectionConfig): void { - if (this.connections.has(config.name)) { - throw new Error(`Connection "${config.name}" is already registered`); - } - - const client = new AppSheetClient({ - appId: config.appId, - applicationAccessKey: config.applicationAccessKey, - baseUrl: config.baseUrl, - timeout: config.timeout, - retryAttempts: config.retryAttempts, - runAsUserEmail: config.runAsUserEmail, - }); - - this.connections.set(config.name, client); - } + constructor( + private readonly clientFactory: AppSheetClientFactoryInterface, + private readonly schema: SchemaConfig + ) {} /** - * Get a registered client by name, optionally for a specific user. - * - * When runAsUserEmail is provided, creates a new AppSheetClient instance - * configured for that user. The user-specific client is created on-the-fly - * and not cached (lightweight operation). + * Get a client for a specific connection and user. * - * When runAsUserEmail is not provided, returns the default registered client. + * Creates a new AppSheetClient instance configured for the specified + * connection and user. Each call creates a fresh client instance. * - * @param name - The unique name of the connection to retrieve - * @param runAsUserEmail - Optional: Email of the user to execute operations as - * @returns The AppSheetClient instance for the specified connection - * @throws {Error} If no connection with the given name exists + * @param connectionName - Name of the connection in the schema + * @param runAsUserEmail - Email of the user to execute all operations as (required) + * @returns A new AppSheetClientInterface instance + * @throws {Error} If the connection doesn't exist in the schema * * @example * ```typescript - * // Default behavior (existing code, backward compatible) - * const client = manager.get('worklog'); - * const records = await client.findAll('worklogs'); + * // Get client for user + * const client = manager.get('worklog', 'user@example.com'); * - * // User-specific behavior (new) - * const userClient = manager.get('worklog', 'user@example.com'); - * const userRecords = await userClient.findAll('worklogs'); + * // All operations execute as the specified user + * const worklogs = await client.findAll('extract_worklog'); * ``` */ - get(name: string, runAsUserEmail?: string): AppSheetClient { - const baseClient = this.connections.get(name); - if (!baseClient) { - const available = [...this.connections.keys()].join(', ') || 'none'; + get(connectionName: string, runAsUserEmail: string): AppSheetClientInterface { + const connectionDef = this.schema.connections[connectionName]; + if (!connectionDef) { + const available = Object.keys(this.schema.connections).join(', ') || 'none'; throw new Error( - `Connection "${name}" not found. Available connections: ${available}` + `Connection "${connectionName}" not found. Available connections: ${available}` ); } - // No user specified - return default client (backward compatible) - if (!runAsUserEmail) { - return baseClient; - } - - // User specified - create user-specific client on-the-fly - const config = baseClient.getConfig(); - return new AppSheetClient({ - appId: config.appId, - applicationAccessKey: config.applicationAccessKey, - baseUrl: config.baseUrl, - timeout: config.timeout, - retryAttempts: config.retryAttempts, - runAsUserEmail, // Override with user-specific email - }); - } - - /** - * Check if a connection exists. - * - * Checks whether a connection with the given name has been registered. - * This is useful to avoid errors when attempting to access connections. - * - * @param name - The connection name to check - * @returns `true` if the connection exists, `false` otherwise - * - * @example - * ```typescript - * if (manager.has('worklog')) { - * const client = manager.get('worklog'); - * } - * ``` - */ - has(name: string): boolean { - return this.connections.has(name); - } - - /** - * Remove a connection. - * - * Removes a registered connection from the manager. - * The connection cannot be retrieved after removal. - * - * @param name - The name of the connection to remove - * @returns `true` if the connection was removed, `false` if it didn't exist - * - * @example - * ```typescript - * manager.remove('old-connection'); - * ``` - */ - remove(name: string): boolean { - return this.connections.delete(name); + return this.clientFactory.create(connectionDef, runAsUserEmail); } /** - * Get all registered connection names. + * Get list of available connection names. * - * Returns an array of all connection names currently registered - * in the manager. Useful for iterating over all connections. + * Returns the names of all connections defined in the schema. + * Useful for debugging and introspection. * * @returns Array of connection names * @@ -179,90 +102,26 @@ export class ConnectionManager { * ```typescript * const names = manager.list(); * console.log('Available connections:', names); - * // Output: ['worklog', 'hr', 'inventory'] * ``` */ list(): string[] { - return [...this.connections.keys()]; - } - - /** - * Remove all connections. - * - * Clears all registered connections from the manager. - * After calling this method, the manager will have no connections. - * - * @example - * ```typescript - * manager.clear(); - * console.log(manager.list()); // Output: [] - * ``` - */ - clear(): void { - this.connections.clear(); + return Object.keys(this.schema.connections); } /** - * Test a connection by performing a simple query. - * - * Attempts to execute a minimal query to verify that the connection - * is working correctly. This is useful for health checks and diagnostics. + * Check if a connection exists in the schema. * - * @param name - The name of the connection to test - * @returns Promise resolving to `true` if connection is healthy, `false` otherwise - * - * @example - * ```typescript - * const isHealthy = await manager.ping('worklog'); - * if (!isHealthy) { - * console.error('Worklog connection is down'); - * } - * ``` - */ - async ping(name: string): Promise { - try { - const client = this.get(name); - // Attempt a minimal query - await client.find({ - tableName: '_system', - selector: '1=0', - }); - return true; - } catch (error) { - return false; - } - } - - /** - * Test all registered connections. - * - * Performs a health check on all registered connections concurrently. - * Returns a record mapping connection names to their health status. - * - * @returns Promise resolving to an object with connection names as keys and health status as values + * @param connectionName - The connection name to check + * @returns `true` if the connection exists, `false` otherwise * * @example * ```typescript - * const health = await manager.healthCheck(); - * console.log(health); - * // Output: { worklog: true, hr: true, inventory: false } - * - * // Check specific connection - * if (!health.inventory) { - * console.error('Inventory connection failed'); + * if (manager.has('worklog')) { + * const client = manager.get('worklog', 'user@example.com'); * } * ``` */ - async healthCheck(): Promise> { - const results: Record = {}; - const names = this.list(); - - await Promise.all( - names.map(async (name) => { - results[name] = await this.ping(name); - }) - ); - - return results; + has(connectionName: string): boolean { + return connectionName in this.schema.connections; } } diff --git a/src/utils/SchemaManager.ts b/src/utils/SchemaManager.ts index a6fbbf9..b6151ad 100644 --- a/src/utils/SchemaManager.ts +++ b/src/utils/SchemaManager.ts @@ -1,44 +1,70 @@ /** - * Schema Manager for managing connections and tables from schema + * Schema Manager v3.0.0 - Factory-Based Table Creation + * + * The SchemaManager uses injected factories to create table clients on-demand. + * This enables dependency injection and easy testing by swapping real factories + * with mock implementations. + * * @module utils * @category Schema Management */ -import { SchemaConfig, ValidationError } from '../types'; -import { ConnectionManager } from './ConnectionManager'; +import { + SchemaConfig, + ValidationError, + AppSheetClientFactoryInterface, + DynamicTableFactoryInterface, +} from '../types'; import { SchemaLoader } from './SchemaLoader'; -import { DynamicTable } from '../client/DynamicTable'; +import { DynamicTable, DynamicTableFactory } from '../client'; /** - * Manages connections and tables based on schema configuration. + * Manages schema-based table access using factory injection. * - * Central management class that initializes connections and provides - * type-safe table clients based on a loaded schema configuration. + * In v3.0.0, SchemaManager is simplified to focus on schema validation and + * providing a clean API for accessing tables. Table clients are created + * on-demand using the injected DynamicTableFactory. * * @category Schema Management * * @example * ```typescript - * // Load schema - * const schema = SchemaLoader.fromYaml('./config/appsheet-schema.yaml'); - * - * // Create manager - * const db = new SchemaManager(schema); + * // Production setup + * const clientFactory = new AppSheetClientFactory(); + * const schema = SchemaLoader.fromYaml('./config/schema.yaml'); + * const db = new SchemaManager(clientFactory, schema); * - * // Get table clients - * const worklogsTable = db.table('worklog', 'worklogs'); - * const usersTable = db.table('hr', 'users'); + * // Get table client for specific user + * const worklogsTable = db.table('worklog', 'worklogs', 'user@example.com'); + * const entries = await worklogsTable.findAll(); * - * // Use table clients - * const worklogs = await worklogsTable.findAll(); - * await worklogsTable.add([{ ... }]); + * // Testing setup with mock factory + * const mockFactory = new MockAppSheetClientFactory(testData); + * const testDb = new SchemaManager(mockFactory, schema); + * const testTable = testDb.table('worklog', 'worklogs', 'test@example.com'); * ``` */ export class SchemaManager { - private schema: SchemaConfig; - private connectionManager: ConnectionManager; + private readonly tableFactory: DynamicTableFactoryInterface; - constructor(schema: SchemaConfig) { + /** + * Creates a new SchemaManager. + * + * @param clientFactory - Factory to create AppSheetClient instances + * @param schema - Schema configuration containing connection and table definitions + * @throws {ValidationError} If the schema is invalid + * + * @example + * ```typescript + * const factory = new AppSheetClientFactory(); + * const schema = SchemaLoader.fromYaml('./schema.yaml'); + * const db = new SchemaManager(factory, schema); + * ``` + */ + constructor( + clientFactory: AppSheetClientFactoryInterface, + private readonly schema: SchemaConfig + ) { // Validate schema const validation = SchemaLoader.validate(schema); if (!validation.valid) { @@ -48,47 +74,23 @@ export class SchemaManager { ); } - this.schema = schema; - this.connectionManager = new ConnectionManager(); - this.initialize(); - } - - /** - * Initialize all connections from schema. - * - * Registers all connections defined in the schema with the ConnectionManager. - * Table clients are created on-the-fly when requested via table() method. - */ - private initialize(): void { - for (const [connName, connDef] of Object.entries(this.schema.connections)) { - // Register connection - this.connectionManager.register({ - name: connName, - appId: connDef.appId, - applicationAccessKey: connDef.applicationAccessKey, - baseUrl: connDef.baseUrl, - timeout: connDef.timeout, - runAsUserEmail: connDef.runAsUserEmail, - }); - } + // Create table factory using injected client factory + this.tableFactory = new DynamicTableFactory(clientFactory, schema); } /** - * Get a type-safe table client, optionally for a specific user. + * Get a type-safe table client for a specific user. * - * Returns a DynamicTable instance for the specified table in the given connection. + * Creates a DynamicTable instance for the specified table in the given connection. * The table client provides CRUD operations with runtime validation based on the schema. * - * When runAsUserEmail is provided, creates a user-specific client that will execute - * all operations as that user. The client is created on-the-fly and not cached (lightweight operation). - * - * When runAsUserEmail is not provided, uses the default client from the connection - * (which may have a default runAsUserEmail configured in the schema). + * Each call creates a new client instance (lightweight operation). + * The runAsUserEmail parameter is required in v3.0.0 to ensure explicit user context. * * @template T - Type interface for the table rows * @param connectionName - The name of the connection containing the table * @param tableName - The name of the table as defined in the schema - * @param runAsUserEmail - Optional: Email of the user to execute operations as + * @param runAsUserEmail - Email of the user to execute all operations as (required) * @returns A DynamicTable instance for performing operations on the table * @throws {Error} If the connection or table doesn't exist * @@ -101,41 +103,18 @@ export class SchemaManager { * description: string; * } * - * // Default behavior (existing code, backward compatible) - * const worklogsTable = db.table('worklog', 'worklogs'); - * const entries = await worklogsTable.findAll(); - * await worklogsTable.add([{ id: '1', date: '2025-10-29', hours: 8, description: 'Work' }]); - * - * // User-specific behavior (new) - * const userWorklogsTable = db.table('worklog', 'worklogs', 'user@example.com'); - * const userEntries = await userWorklogsTable.findAll(); + * // Get table client for specific user + * const table = db.table('worklog', 'worklogs', 'user@example.com'); + * const entries = await table.findAll(); + * await table.add([{ id: '1', date: '2025-10-29', hours: 8, description: 'Work' }]); * ``` */ - table>(connectionName: string, tableName: string, runAsUserEmail?: string): DynamicTable { - // Check if connection exists in schema - const connDef = this.schema.connections[connectionName]; - if (!connDef) { - const available = Object.keys(this.schema.connections).join(', ') || 'none'; - throw new Error( - `Connection "${connectionName}" not found. Available connections: ${available}` - ); - } - - // Check if table exists in connection - const tableDef = connDef.tables[tableName]; - if (!tableDef) { - const available = Object.keys(connDef.tables).join(', '); - throw new Error( - `Table "${tableName}" not found in connection "${connectionName}". ` + - `Available tables: ${available}` - ); - } - - // Get client (with optional user context) - const client = this.connectionManager.get(connectionName, runAsUserEmail); - - // Create and return table client on-the-fly - return new DynamicTable(client, tableDef); + table = Record>( + connectionName: string, + tableName: string, + runAsUserEmail: string + ): DynamicTable { + return this.tableFactory.create(connectionName, tableName, runAsUserEmail); } /** @@ -186,47 +165,39 @@ export class SchemaManager { } /** - * Get the underlying connection manager. - * - * Provides access to the internal ConnectionManager instance for advanced use cases, - * such as direct client access or connection management. + * Check if a connection exists in the schema. * - * @returns The ConnectionManager instance + * @param connectionName - The connection name to check + * @returns `true` if the connection exists, `false` otherwise * * @example * ```typescript - * const connManager = db.getConnectionManager(); - * const health = await connManager.healthCheck(); - * console.log('Connection health:', health); + * if (db.hasConnection('worklog')) { + * const table = db.table('worklog', 'worklogs', 'user@example.com'); + * } * ``` */ - getConnectionManager(): ConnectionManager { - return this.connectionManager; + hasConnection(connectionName: string): boolean { + return connectionName in this.schema.connections; } /** - * Reload schema configuration. - * - * Clears all existing connections, then reinitializes them with the new schema. - * Useful for hot-reloading configuration changes. Table clients are created - * on-the-fly when requested via table() method. + * Check if a table exists in a connection. * - * @param schema - The new schema configuration to load + * @param connectionName - The connection name + * @param tableName - The table name to check + * @returns `true` if the table exists in the connection, `false` otherwise * * @example * ```typescript - * // Load updated schema - * const newSchema = SchemaLoader.fromYaml('./config/appsheet-schema.yaml'); - * db.reload(newSchema); - * - * // All table clients now use the new configuration - * const table = db.table('worklog', 'worklogs'); + * if (db.hasTable('worklog', 'worklogs')) { + * const table = db.table('worklog', 'worklogs', 'user@example.com'); + * } * ``` */ - reload(schema: SchemaConfig): void { - this.connectionManager.clear(); - this.schema = schema; - this.initialize(); + hasTable(connectionName: string, tableName: string): boolean { + const connDef = this.schema.connections[connectionName]; + return connDef ? tableName in connDef.tables : false; } /** @@ -246,4 +217,112 @@ export class SchemaManager { getSchema(): SchemaConfig { return this.schema; } + + /** + * Get a table definition from the schema. + * + * Returns the complete table definition including tableName, keyField, and all field definitions. + * Returns `undefined` if the connection or table doesn't exist. + * + * @param connectionName - The name of the connection containing the table + * @param tableName - The name of the table as defined in the schema + * @returns The TableDefinition or `undefined` if not found + * + * @example + * ```typescript + * const tableDef = db.getTableDefinition('worklog', 'worklogs'); + * if (tableDef) { + * console.log('Table name:', tableDef.tableName); + * console.log('Key field:', tableDef.keyField); + * console.log('Fields:', Object.keys(tableDef.fields)); + * } + * ``` + */ + getTableDefinition( + connectionName: string, + tableName: string + ): import('../types').TableDefinition | undefined { + const connDef = this.schema.connections[connectionName]; + if (!connDef) { + return undefined; + } + return connDef.tables[tableName]; + } + + /** + * Get a field definition from the schema. + * + * Returns the complete field definition including type, required status, allowedValues, etc. + * Returns `undefined` if the connection, table, or field doesn't exist. + * + * @param connectionName - The name of the connection containing the table + * @param tableName - The name of the table as defined in the schema + * @param fieldName - The name of the field + * @returns The FieldDefinition or `undefined` if not found + * + * @example + * ```typescript + * const statusField = db.getFieldDefinition('worklog', 'worklogs', 'status'); + * if (statusField) { + * console.log('Type:', statusField.type); + * console.log('Required:', statusField.required); + * if (statusField.allowedValues) { + * console.log('Allowed values:', statusField.allowedValues); + * } + * } + * ``` + */ + getFieldDefinition( + connectionName: string, + tableName: string, + fieldName: string + ): import('../types').FieldDefinition | undefined { + const tableDef = this.getTableDefinition(connectionName, tableName); + if (!tableDef) { + return undefined; + } + return tableDef.fields[fieldName]; + } + + /** + * Get allowed values for an Enum or EnumList field. + * + * Convenience method to quickly retrieve the allowed values for enum fields. + * Returns `undefined` if the field doesn't exist or has no allowedValues defined. + * + * @param connectionName - The name of the connection containing the table + * @param tableName - The name of the table as defined in the schema + * @param fieldName - The name of the Enum/EnumList field + * @returns Array of allowed values or `undefined` if not found/not an enum field + * + * @example + * ```typescript + * const statusValues = db.getAllowedValues('worklog', 'worklogs', 'status'); + * if (statusValues) { + * console.log('Valid status values:', statusValues); + * // Output: ['Pending', 'In Progress', 'Completed'] + * } + * + * // Use case: Generate Zod enum schema + * const values = db.getAllowedValues('default', 'service_portfolio', 'status'); + * const statusEnum = z.enum(values as [string, ...string[]]); + * + * // Use case: Populate UI dropdown + * const options = db.getAllowedValues('default', 'users', 'role')?.map(v => ({ + * label: v, + * value: v + * })); + * ``` + */ + getAllowedValues( + connectionName: string, + tableName: string, + fieldName: string + ): string[] | undefined { + const fieldDef = this.getFieldDefinition(connectionName, tableName, fieldName); + if (!fieldDef) { + return undefined; + } + return fieldDef.allowedValues; + } } diff --git a/tests/cli/SchemaInspector.test.ts b/tests/cli/SchemaInspector.test.ts index dd9f351..5edfcf0 100644 --- a/tests/cli/SchemaInspector.test.ts +++ b/tests/cli/SchemaInspector.test.ts @@ -4,6 +4,7 @@ import { SchemaInspector } from '../../src/cli/SchemaInspector'; import { AppSheetClient } from '../../src/client/AppSheetClient'; +import { ConnectionDefinition } from '../../src/types'; // Mock AppSheetClient jest.mock('../../src/client/AppSheetClient'); @@ -12,11 +13,25 @@ describe('SchemaInspector', () => { let mockClient: jest.Mocked; let inspector: SchemaInspector; + const mockConnectionDef: ConnectionDefinition = { + appId: 'test-app-id', + applicationAccessKey: 'test-key', + tables: { + users: { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + }, + }, + }, + }; + beforeEach(() => { - mockClient = new AppSheetClient({ - appId: 'test', - applicationAccessKey: 'test', - }) as jest.Mocked; + mockClient = new AppSheetClient( + mockConnectionDef, + 'test@example.com' + ) as jest.Mocked; inspector = new SchemaInspector(mockClient); }); diff --git a/tests/client/AppSheetClient.test.ts b/tests/client/AppSheetClient.test.ts index 4000bf3..dfbbf9f 100644 --- a/tests/client/AppSheetClient.test.ts +++ b/tests/client/AppSheetClient.test.ts @@ -1,5 +1,5 @@ /** - * Test Suite: AppSheetClient - runAsUserEmail Functionality + * Test Suite: AppSheetClient v3.0.0 - runAsUserEmail Functionality * * Integration test suite for the AppSheetClient class that verifies the * runAsUserEmail feature works correctly with the real HTTP client (mocked axios). @@ -9,17 +9,19 @@ * testing how the runAsUserEmail configuration is propagated to API requests. * * Test areas: - * - Global runAsUserEmail configuration (applied to all operations) + * - v3.0.0 constructor (ConnectionDefinition, runAsUserEmail) + * - runAsUserEmail propagation to all operations * - Per-operation runAsUserEmail override (operation-specific user context) * - Property merging (combining runAsUserEmail with other request properties) * - Convenience methods (simplified API with runAsUserEmail support) - * - Configuration retrieval (getConfig() method) + * - getTable() method (v3.0.0) * * @module tests/client */ import axios from 'axios'; import { AppSheetClient } from '../../src/client/AppSheetClient'; +import { ConnectionDefinition } from '../../src/types'; /** * Mock axios module to intercept HTTP requests without hitting real API. @@ -29,7 +31,7 @@ jest.mock('axios'); const mockedAxios = axios as jest.Mocked; /** - * Test Suite: AppSheetClient - runAsUserEmail Feature + * Test Suite: AppSheetClient - runAsUserEmail Feature (v3.0.0) * * Tests the real AppSheetClient implementation (not the mock) by verifying * HTTP request payloads contain correct runAsUserEmail values in the @@ -37,12 +39,13 @@ const mockedAxios = axios as jest.Mocked; */ describe('AppSheetClient - runAsUserEmail', () => { /** - * Base configuration for creating AppSheetClient instances in tests. - * Minimal config with only required fields. + * Base ConnectionDefinition for creating AppSheetClient instances in tests. + * v3.0.0 requires ConnectionDefinition with tables property. */ - const mockConfig = { + const mockConnectionDef: ConnectionDefinition = { appId: 'test-app-id', applicationAccessKey: 'test-key', + tables: {}, }; /** @@ -92,11 +95,8 @@ describe('AppSheetClient - runAsUserEmail', () => { * * Use case: Setting up audit trail / user context for all operations */ - it('should include RunAsUserEmail in Properties when globally configured', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'global@example.com', - }); + it('should include RunAsUserEmail in Properties when configured', async () => { + const client = new AppSheetClient(mockConnectionDef, 'global@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [] }, @@ -115,20 +115,13 @@ describe('AppSheetClient - runAsUserEmail', () => { }); /** - * Test: No runAsUserEmail When Not Configured - * - * Verifies that when runAsUserEmail is not configured, the HTTP request - * Properties field remains empty, avoiding unnecessary API parameters. - * - * Expected behavior: - * - Properties object exists but is empty - * - No RunAsUserEmail field is included - * - API request is minimal and clean + * Test: runAsUserEmail is always required in v3.0.0 * - * Use case: Operations not requiring user context + * In v3.0.0, runAsUserEmail is a required constructor parameter. + * This test verifies it's always included in requests. */ - it('should not include RunAsUserEmail when not configured', async () => { - const client = new AppSheetClient(mockConfig); + it('should always include RunAsUserEmail (required in v3.0.0)', async () => { + const client = new AppSheetClient(mockConnectionDef, 'required@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [] }, @@ -139,7 +132,9 @@ describe('AppSheetClient - runAsUserEmail', () => { expect(mockAxiosInstance.post).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - Properties: {}, + Properties: expect.objectContaining({ + RunAsUserEmail: 'required@example.com', + }), }) ); }); @@ -165,10 +160,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * Use case: Audit trail showing which admin user created new records */ it('should apply global runAsUserEmail to add operations', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'admin@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'admin@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [{ id: '123' }] }, @@ -212,10 +204,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * Note: AppSheet API uses 'Edit' action name for update operations */ it('should apply global runAsUserEmail to update operations', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'editor@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'editor@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [{ id: '123', name: 'Updated' }] }, @@ -260,10 +249,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * Important: Delete operations typically return empty Rows array */ it('should apply global runAsUserEmail to delete operations', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'deleter@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'deleter@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [] }, @@ -324,10 +310,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * or audit trail purposes. */ it('should allow per-operation override of global runAsUserEmail', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'global@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'global@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [{ id: '123' }] }, @@ -374,10 +357,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * like localization settings, timezone, or custom AppSheet properties. */ it('should merge per-operation properties with global runAsUserEmail', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'global@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'global@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [] }, @@ -430,10 +410,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * rows in Find operations using AppSheet's expression syntax. */ it('should handle selector and runAsUserEmail together in find operations', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'finder@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'finder@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [] }, @@ -500,10 +477,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * - Full API: client.find({ tableName: 'Users' }) */ it('should apply runAsUserEmail to findAll convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'reader@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'reader@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [{ id: '1' }, { id: '2' }] }, @@ -548,10 +522,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * - Full API: client.find({ tableName: 'Users', selector: '[Email] = "john@example.com"' }).then(r => r[0] || null) */ it('should apply runAsUserEmail to findOne convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'reader@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'reader@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [{ id: '123', name: 'John' }] }, @@ -596,10 +567,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * - Full API: client.add({ tableName: 'Users', rows: [{ name: 'Jane' }] }).then(r => r[0]) */ it('should apply runAsUserEmail to addOne convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'creator@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'creator@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [{ id: '123', name: 'Jane' }] }, @@ -643,10 +611,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * - Full API: client.update({ tableName: 'Users', rows: [{ id: '123', name: 'Updated' }] }).then(r => r[0]) */ it('should apply runAsUserEmail to updateOne convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'updater@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'updater@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [{ id: '123', name: 'Updated' }] }, @@ -690,10 +655,7 @@ describe('AppSheetClient - runAsUserEmail', () => { * - Full API: client.delete({ tableName: 'Users', rows: [{ id: '123' }] }).then(r => r.success) */ it('should apply runAsUserEmail to deleteOne convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'deleter@example.com', - }); + const client = new AppSheetClient(mockConnectionDef, 'deleter@example.com'); mockAxiosInstance.post.mockResolvedValue({ data: { Rows: [] }, @@ -713,82 +675,92 @@ describe('AppSheetClient - runAsUserEmail', () => { }); /** - * Test Suite: Configuration Retrieval + * Test Suite: getTable() Method (v3.0.0) * - * Verifies that the getConfig() method correctly returns the client's - * configuration including the runAsUserEmail setting if configured. - * - * This allows users to: - * - Inspect the current configuration at runtime - * - Verify runAsUserEmail is set correctly - * - Debug configuration issues - * - Clone or modify configuration for new client instances + * Verifies that the getTable() method correctly returns TableDefinitions + * from the ConnectionDefinition for use with DynamicTableFactory. */ - describe('Configuration retrieval', () => { + describe('getTable() method', () => { /** - * Test: getConfig() Includes runAsUserEmail When Configured - * - * Verifies that the getConfig() method returns the client's configuration - * including the runAsUserEmail setting when it is configured. - * - * Test approach: - * 1. Create client with runAsUserEmail='test@example.com' - * 2. Call getConfig() method - * 3. Verify returned config object includes runAsUserEmail property - * 4. Verify runAsUserEmail value matches configured value - * - * Expected behavior: - * - getConfig() returns an object with all client configuration - * - config.runAsUserEmail equals 'test@example.com' - * - Configuration is readable at runtime - * - Other config fields (appId, applicationAccessKey) are also present - * - * Use case: Inspecting configuration at runtime for debugging, logging, - * or creating new client instances with modified configuration. - * - * Note: The returned config is readonly to prevent accidental modification. + * ConnectionDefinition with tables for testing getTable() */ - it('should include runAsUserEmail in getConfig() result', () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'test@example.com', + const connDefWithTables: ConnectionDefinition = { + appId: 'test-app-id', + applicationAccessKey: 'test-key', + tables: { + users: { + tableName: 'extract_user', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + }, + }, + products: { + tableName: 'extract_product', + keyField: 'product_id', + fields: { + product_id: { type: 'Text', required: true }, + name: { type: 'Text', required: true }, + }, + }, + }, + }; + + /** + * Test: getTable() returns TableDefinition for existing table + */ + it('should return TableDefinition for existing table', () => { + const client = new AppSheetClient(connDefWithTables, 'test@example.com'); + + const tableDef = client.getTable('users'); + + expect(tableDef).toEqual({ + tableName: 'extract_user', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + }, }); + }); + + /** + * Test: getTable() returns correct TableDefinition for different tables + */ + it('should return correct TableDefinition for different tables', () => { + const client = new AppSheetClient(connDefWithTables, 'test@example.com'); + + const usersDef = client.getTable('users'); + const productsDef = client.getTable('products'); - const config = client.getConfig(); + expect(usersDef.tableName).toBe('extract_user'); + expect(usersDef.keyField).toBe('id'); - expect(config.runAsUserEmail).toBe('test@example.com'); + expect(productsDef.tableName).toBe('extract_product'); + expect(productsDef.keyField).toBe('product_id'); }); /** - * Test: getConfig() Returns Undefined runAsUserEmail When Not Set - * - * Verifies that when runAsUserEmail is not configured during client - * initialization, the getConfig() method returns undefined for this - * property rather than a default value or empty string. - * - * Test approach: - * 1. Create client without runAsUserEmail configuration - * 2. Call getConfig() method - * 3. Verify config.runAsUserEmail is undefined - * - * Expected behavior: - * - getConfig() returns an object with all required configuration - * - config.runAsUserEmail is undefined (not null, not empty string) - * - Other config fields (appId, applicationAccessKey) are present - * - Client functions normally without runAsUserEmail - * - * Use case: Clients that don't require user context can omit - * runAsUserEmail, and this is reflected in getConfig() output. - * - * Important: Undefined means the feature is not configured, which is - * different from an empty string or null value. + * Test: getTable() throws Error for non-existent table */ - it('should have undefined runAsUserEmail in getConfig() when not set', () => { - const client = new AppSheetClient(mockConfig); + it('should throw Error for non-existent table', () => { + const client = new AppSheetClient(connDefWithTables, 'test@example.com'); - const config = client.getConfig(); + expect(() => client.getTable('nonexistent')).toThrow( + 'Table "nonexistent" not found. Available tables: users, products' + ); + }); + + /** + * Test: getTable() handles empty tables object + */ + it('should handle empty tables object gracefully', () => { + const client = new AppSheetClient(mockConnectionDef, 'test@example.com'); - expect(config.runAsUserEmail).toBeUndefined(); + expect(() => client.getTable('anything')).toThrow( + 'Table "anything" not found. Available tables: none' + ); }); }); }); diff --git a/tests/client/AppSheetClient.v3.test.ts b/tests/client/AppSheetClient.v3.test.ts new file mode 100644 index 0000000..97e4f5f --- /dev/null +++ b/tests/client/AppSheetClient.v3.test.ts @@ -0,0 +1,326 @@ +/** + * Test Suite: AppSheetClient v3.0.0 - New Constructor and getTable() Method + * + * Tests for the v3.0.0 breaking changes: + * - New constructor signature: (connectionDef: ConnectionDefinition, runAsUserEmail: string) + * - New getTable() method for accessing table definitions + * + * @module tests/client + */ + +import axios from 'axios'; +import { AppSheetClient } from '../../src/client/AppSheetClient'; +import { ConnectionDefinition } from '../../src/types'; + +/** + * Mock axios module to intercept HTTP requests without hitting real API. + */ +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('AppSheetClient v3.0.0', () => { + /** + * Sample ConnectionDefinition for testing. + * Contains full configuration including tables. + */ + const mockConnectionDef: ConnectionDefinition = { + appId: 'test-app-id', + applicationAccessKey: 'test-key', + tables: { + users: { + tableName: 'extract_user', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + name: { type: 'Name', required: false }, + }, + }, + worklogs: { + tableName: 'extract_worklog', + keyField: 'worklog_id', + fields: { + worklog_id: { type: 'Text', required: true }, + date: { type: 'Date', required: true }, + hours: { type: 'Number', required: true }, + }, + }, + }, + }; + + const mockRunAsUserEmail = 'user@example.com'; + + /** + * Mocked axios instance that captures HTTP POST requests. + */ + const mockAxiosInstance = { + post: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.create.mockReturnValue(mockAxiosInstance as any); + }); + + describe('Constructor', () => { + /** + * Test: Constructor accepts ConnectionDefinition and runAsUserEmail + */ + it('should accept ConnectionDefinition and runAsUserEmail', () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + expect(client).toBeInstanceOf(AppSheetClient); + }); + + /** + * Test: Constructor uses appId from ConnectionDefinition for API calls + */ + it('should use appId from ConnectionDefinition for API calls', async () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [] }, + }); + + await client.findAll('extract_user'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.stringContaining('test-app-id'), + expect.any(Object) + ); + }); + + /** + * Test: Constructor uses runAsUserEmail for all operations + */ + it('should use runAsUserEmail for all operations', async () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [] }, + }); + + await client.findAll('extract_user'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: expect.objectContaining({ + RunAsUserEmail: 'user@example.com', + }), + }) + ); + }); + + /** + * Test: Constructor applies optional settings from ConnectionDefinition + */ + it('should apply optional settings from ConnectionDefinition', () => { + const connDefWithOptions: ConnectionDefinition = { + ...mockConnectionDef, + baseUrl: 'https://custom.api.com', + timeout: 60000, + }; + + // Create client to trigger axios.create + new AppSheetClient(connDefWithOptions, mockRunAsUserEmail); + + // Verify axios was created with custom settings + expect(mockedAxios.create).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: 'https://custom.api.com', + timeout: 60000, + }) + ); + }); + }); + + describe('getTable() method', () => { + /** + * Test: getTable() returns TableDefinition for existing table + */ + it('should return TableDefinition for existing table', () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + const tableDef = client.getTable('users'); + + expect(tableDef).toEqual({ + tableName: 'extract_user', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + name: { type: 'Name', required: false }, + }, + }); + }); + + /** + * Test: getTable() returns correct TableDefinition for different tables + */ + it('should return correct TableDefinition for different tables', () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + const usersDef = client.getTable('users'); + const worklogsDef = client.getTable('worklogs'); + + expect(usersDef.tableName).toBe('extract_user'); + expect(usersDef.keyField).toBe('id'); + + expect(worklogsDef.tableName).toBe('extract_worklog'); + expect(worklogsDef.keyField).toBe('worklog_id'); + }); + + /** + * Test: getTable() throws Error for non-existent table + */ + it('should throw Error for non-existent table', () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + expect(() => client.getTable('nonexistent')).toThrow( + 'Table "nonexistent" not found. Available tables: users, worklogs' + ); + }); + + /** + * Test: getTable() error message lists available tables + */ + it('should list available tables in error message', () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + expect(() => client.getTable('invalid')).toThrow(/Available tables:/); + expect(() => client.getTable('invalid')).toThrow(/users/); + expect(() => client.getTable('invalid')).toThrow(/worklogs/); + }); + + /** + * Test: getTable() handles empty tables object + */ + it('should handle empty tables object gracefully', () => { + const emptyConnDef: ConnectionDefinition = { + appId: 'test-app', + applicationAccessKey: 'test-key', + tables: {}, + }; + + const client = new AppSheetClient(emptyConnDef, mockRunAsUserEmail); + + expect(() => client.getTable('anything')).toThrow( + 'Table "anything" not found. Available tables: none' + ); + }); + }); + + describe('CRUD operations with new constructor', () => { + /** + * Test: findAll still works with new constructor + */ + it('should perform findAll with ConnectionDefinition', async () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '1', email: 'test@example.com' }] }, + }); + + const result = await client.findAll('extract_user'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ id: '1', email: 'test@example.com' }); + }); + + /** + * Test: add still works with new constructor + */ + it('should perform add with ConnectionDefinition', async () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '1', email: 'new@example.com' }] }, + }); + + const result = await client.add({ + tableName: 'extract_user', + rows: [{ email: 'new@example.com' }], + }); + + expect(result.rows).toHaveLength(1); + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Action: 'Add', + Properties: expect.objectContaining({ + RunAsUserEmail: 'user@example.com', + }), + }) + ); + }); + + /** + * Test: update still works with new constructor + */ + it('should perform update with ConnectionDefinition', async () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '1', email: 'updated@example.com' }] }, + }); + + const result = await client.update({ + tableName: 'extract_user', + rows: [{ id: '1', email: 'updated@example.com' }], + }); + + expect(result.rows).toHaveLength(1); + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Action: 'Edit', + }) + ); + }); + + /** + * Test: delete still works with new constructor + */ + it('should perform delete with ConnectionDefinition', async () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [] }, + }); + + const result = await client.delete({ + tableName: 'extract_user', + rows: [{ id: '1' }], + }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(1); + }); + }); + + describe('AppSheetClientInterface compliance', () => { + /** + * Test: Client implements all required interface methods + */ + it('should implement all AppSheetClientInterface methods', () => { + const client = new AppSheetClient(mockConnectionDef, mockRunAsUserEmail); + + // Core CRUD methods + expect(typeof client.add).toBe('function'); + expect(typeof client.find).toBe('function'); + expect(typeof client.update).toBe('function'); + expect(typeof client.delete).toBe('function'); + + // Convenience methods + expect(typeof client.findAll).toBe('function'); + expect(typeof client.findOne).toBe('function'); + expect(typeof client.addOne).toBe('function'); + expect(typeof client.updateOne).toBe('function'); + expect(typeof client.deleteOne).toBe('function'); + + // New v3.0.0 method + expect(typeof client.getTable).toBe('function'); + }); + }); +}); diff --git a/tests/client/DynamicTable.test.ts b/tests/client/DynamicTable.test.ts index 085e79f..5a1295e 100644 --- a/tests/client/DynamicTable.test.ts +++ b/tests/client/DynamicTable.test.ts @@ -1,27 +1,38 @@ /** - * Tests for DynamicTable with AppSheet field types + * Tests for DynamicTable with AppSheet field types (v3.0.0) */ import { DynamicTable } from '../../src/client/DynamicTable'; -import { AppSheetClient } from '../../src/client/AppSheetClient'; -import { TableDefinition, ValidationError } from '../../src/types'; +import { AppSheetClientInterface, TableDefinition, ValidationError } from '../../src/types'; -// Mock AppSheetClient -jest.mock('../../src/client/AppSheetClient'); +/** + * Create a mock client that implements AppSheetClientInterface + */ +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: 'id', + fields: { id: { type: 'Text', required: true } }, + }), + }; +} describe('DynamicTable - AppSheet Field Types', () => { - let mockClient: jest.Mocked; + let mockClient: jest.Mocked; let tableDef: TableDefinition; beforeEach(() => { - mockClient = new AppSheetClient({ - appId: 'test', - applicationAccessKey: 'test', - }) as jest.Mocked; - - // Mock successful responses - mockClient.add.mockResolvedValue({ rows: [], warnings: [] }); - mockClient.update.mockResolvedValue({ rows: [], warnings: [] }); + mockClient = createMockClient(); }); describe('Email field validation', () => { diff --git a/tests/client/MockAppSheetClient.test.ts b/tests/client/MockAppSheetClient.test.ts index 8263283..5d42198 100644 --- a/tests/client/MockAppSheetClient.test.ts +++ b/tests/client/MockAppSheetClient.test.ts @@ -1,5 +1,5 @@ /** - * Test Suite: MockAppSheetClient + * Test Suite: MockAppSheetClient v3.0.0 * * Comprehensive test suite for the MockAppSheetClient class, which provides an in-memory * mock implementation of the AppSheetClientInterface for testing purposes. @@ -8,7 +8,8 @@ * - Database management (initialization, seeding, clearing) * - CRUD operations (Create, Read, Update, Delete) * - Convenience methods (simplified API wrappers) - * - Configuration handling (including runAsUserEmail) + * - v3.0.0 constructor (ConnectionDefinition, runAsUserEmail) + * - v3.0.0 getTable() method * - Interface compliance with AppSheetClientInterface * * @module tests/client @@ -18,7 +19,7 @@ jest.mock('uuid'); import { MockAppSheetClient } from '../../src/client/MockAppSheetClient'; -import { ValidationError, NotFoundError } from '../../src/types'; +import { ValidationError, NotFoundError, ConnectionDefinition } from '../../src/types'; /** * Test data interface representing a User entity. @@ -43,6 +44,36 @@ interface Product { name: string; } +/** + * Sample ConnectionDefinition for v3.0.0 testing. + */ +const mockConnectionDef: ConnectionDefinition = { + appId: 'mock-app', + applicationAccessKey: 'mock-key', + tables: { + users: { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + name: { type: 'Name', required: true }, + email: { type: 'Email', required: false }, + status: { type: 'Enum', required: false, allowedValues: ['active', 'inactive'] }, + }, + }, + products: { + tableName: 'products', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + name: { type: 'Text', required: true }, + }, + }, + }, +}; + +const mockRunAsUserEmail = 'mock@example.com'; + /** * Test Suite: MockAppSheetClient Core Functionality * @@ -57,10 +88,7 @@ describe('MockAppSheetClient', () => { * Ensures test isolation by providing a clean database state. */ beforeEach(() => { - client = new MockAppSheetClient({ - appId: 'mock-app', - applicationAccessKey: 'mock-key', - }); + client = new MockAppSheetClient(mockConnectionDef, mockRunAsUserEmail); }); /** @@ -245,24 +273,20 @@ describe('MockAppSheetClient', () => { }); /** - * Test: Use Global runAsUserEmail from Configuration + * Test: Use Global runAsUserEmail from Constructor * - * Verifies that when runAsUserEmail is configured globally on the client, + * Verifies that when runAsUserEmail is provided in constructor, * it is automatically applied to the created_by field for all add operations. * * Expected behavior: - * - created_by field is set to the configured runAsUserEmail + * - created_by field is set to the constructor runAsUserEmail * - Global setting applies to all operations unless overridden * * Use case: Testing audit trails and permission contexts where all * operations should be attributed to a specific user */ - it('should use runAsUserEmail from config', async () => { - const clientWithUser = new MockAppSheetClient({ - appId: 'mock-app', - applicationAccessKey: 'mock-key', - runAsUserEmail: 'admin@example.com', - }); + it('should use runAsUserEmail from constructor', async () => { + const clientWithUser = new MockAppSheetClient(mockConnectionDef, 'admin@example.com'); const result = await clientWithUser.add({ tableName: 'users', @@ -862,55 +886,88 @@ describe('MockAppSheetClient', () => { }); /** - * Test Suite: Configuration Management + * Test Suite: getTable() Method (v3.0.0) * - * Verifies that the client properly stores and retrieves configuration - * values including default values and optional parameters. + * Verifies the getTable() method which retrieves TableDefinitions + * from the ConnectionDefinition for use with DynamicTableFactory. */ - describe('Configuration', () => { + describe('getTable() method', () => { /** - * Test: Retrieve Default Configuration - * - * Verifies that getConfig() returns all configuration values including - * defaults that were applied during client initialization. + * Test: Get TableDefinition for existing table * - * Expected behavior: - * - Provided values are returned (appId, applicationAccessKey) - * - Default values are returned (baseUrl, timeout, retryAttempts) - * - Configuration is read-only (via Readonly type) + * Verifies that getTable() returns the correct TableDefinition + * from the ConnectionDefinition. + */ + it('should return TableDefinition for existing table', () => { + const tableDef = client.getTable('users'); + expect(tableDef).toEqual({ + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + name: { type: 'Name', required: true }, + email: { type: 'Email', required: false }, + status: { type: 'Enum', required: false, allowedValues: ['active', 'inactive'] }, + }, + }); + }); + + /** + * Test: Get different TableDefinitions * - * Use case: Debugging client configuration in tests + * Verifies that getTable() returns correct TableDefinition for different tables. */ - it('should return configuration', () => { - const config = client.getConfig(); - expect(config.appId).toBe('mock-app'); - expect(config.applicationAccessKey).toBe('mock-key'); - expect(config.baseUrl).toBe('https://api.appsheet.com/api/v2'); - expect(config.timeout).toBe(30000); - expect(config.retryAttempts).toBe(3); + it('should return correct TableDefinition for different tables', () => { + const usersDef = client.getTable('users'); + const productsDef = client.getTable('products'); + + expect(usersDef.tableName).toBe('users'); + expect(usersDef.keyField).toBe('id'); + + expect(productsDef.tableName).toBe('products'); + expect(productsDef.keyField).toBe('id'); }); /** - * Test: Configuration Includes Optional runAsUserEmail + * Test: Throw Error for non-existent table * - * Verifies that when runAsUserEmail is provided during client initialization, - * it is included in the returned configuration. + * Verifies that getTable() throws an error when the requested + * table doesn't exist in the ConnectionDefinition. + */ + it('should throw Error for non-existent table', () => { + expect(() => client.getTable('nonexistent')).toThrow( + 'Table "nonexistent" not found. Available tables: users, products' + ); + }); + + /** + * Test: Error message lists available tables * - * Expected behavior: - * - runAsUserEmail is present in config when provided - * - Value matches what was provided during initialization + * Verifies that the error message includes a list of available tables. + */ + it('should list available tables in error message', () => { + expect(() => client.getTable('invalid')).toThrow(/Available tables:/); + expect(() => client.getTable('invalid')).toThrow(/users/); + expect(() => client.getTable('invalid')).toThrow(/products/); + }); + + /** + * Test: Handle empty tables object * - * Use case: Verifying user context configuration + * Verifies that getTable() handles ConnectionDefinition with empty tables. */ - it('should include runAsUserEmail in config if provided', () => { - const clientWithUser = new MockAppSheetClient({ - appId: 'mock-app', - applicationAccessKey: 'mock-key', - runAsUserEmail: 'admin@example.com', - }); + it('should handle empty tables object gracefully', () => { + const emptyConnDef: ConnectionDefinition = { + appId: 'test-app', + applicationAccessKey: 'test-key', + tables: {}, + }; + + const emptyClient = new MockAppSheetClient(emptyConnDef, 'test@example.com'); - const config = clientWithUser.getConfig(); - expect(config.runAsUserEmail).toBe('admin@example.com'); + expect(() => emptyClient.getTable('anything')).toThrow( + 'Table "anything" not found. Available tables: none' + ); }); }); @@ -944,7 +1001,7 @@ describe('MockAppSheetClient', () => { // Type check: This ensures MockAppSheetClient implements AppSheetClientInterface const clientInterface: import('../../src/types').AppSheetClientInterface = client; - // Check all required methods exist + // Check all required methods exist (v3.0.0) expect(typeof clientInterface.add).toBe('function'); expect(typeof clientInterface.find).toBe('function'); expect(typeof clientInterface.update).toBe('function'); @@ -954,7 +1011,7 @@ describe('MockAppSheetClient', () => { expect(typeof clientInterface.addOne).toBe('function'); expect(typeof clientInterface.updateOne).toBe('function'); expect(typeof clientInterface.deleteOne).toBe('function'); - expect(typeof clientInterface.getConfig).toBe('function'); + expect(typeof clientInterface.getTable).toBe('function'); // v3.0.0 }); }); }); diff --git a/tests/client/factories.test.ts b/tests/client/factories.test.ts new file mode 100644 index 0000000..a10428c --- /dev/null +++ b/tests/client/factories.test.ts @@ -0,0 +1,331 @@ +/** + * Test Suite: Factory Implementations (v3.0.0) + * + * Tests for: + * - AppSheetClientFactory + * - MockAppSheetClientFactory + * - DynamicTableFactory + * + * @module tests/client + */ + +import axios from 'axios'; +import { + AppSheetClientFactory, + MockAppSheetClientFactory, + DynamicTableFactory, + AppSheetClient, + MockAppSheetClient, + DynamicTable, +} from '../../src/client'; +import { + ConnectionDefinition, + SchemaConfig, + MockDataProvider, +} from '../../src/types'; + +// Mock axios for AppSheetClientFactory tests +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('AppSheetClientFactory', () => { + const mockConnectionDef: ConnectionDefinition = { + appId: 'test-app-id', + applicationAccessKey: 'test-key', + tables: { + users: { + tableName: 'extract_user', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + }, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.create.mockReturnValue({ post: jest.fn() } as any); + }); + + it('should create an AppSheetClient instance', () => { + const factory = new AppSheetClientFactory(); + const client = factory.create(mockConnectionDef, 'user@example.com'); + + expect(client).toBeInstanceOf(AppSheetClient); + }); + + it('should create client with correct configuration', () => { + const factory = new AppSheetClientFactory(); + const client = factory.create(mockConnectionDef, 'user@example.com'); + + // Verify client can access table definitions + const tableDef = client.getTable('users'); + expect(tableDef.tableName).toBe('extract_user'); + }); + + it('should implement AppSheetClientFactoryInterface', () => { + const factory = new AppSheetClientFactory(); + expect(typeof factory.create).toBe('function'); + }); +}); + +describe('MockAppSheetClientFactory', () => { + const mockConnectionDef: ConnectionDefinition = { + appId: 'test-app-id', + applicationAccessKey: 'test-key', + tables: { + users: { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + name: { type: 'Text', required: true }, + }, + }, + }, + }; + + it('should create a MockAppSheetClient instance', () => { + const factory = new MockAppSheetClientFactory(); + const client = factory.create(mockConnectionDef, 'user@example.com'); + + expect(client).toBeInstanceOf(MockAppSheetClient); + }); + + it('should create client that implements AppSheetClientInterface', async () => { + const factory = new MockAppSheetClientFactory(); + const client = factory.create(mockConnectionDef, 'user@example.com'); + + // Test interface methods + expect(typeof client.findAll).toBe('function'); + expect(typeof client.add).toBe('function'); + expect(typeof client.update).toBe('function'); + expect(typeof client.delete).toBe('function'); + expect(typeof client.getTable).toBe('function'); + + // Test basic operation + const users = await client.findAll('users'); + expect(users).toEqual([]); + }); + + it('should accept optional MockDataProvider', () => { + const mockData: MockDataProvider = { + getTables: () => new Map([ + ['users', { rows: [{ id: '1', name: 'Test User' }], keyField: 'id' }], + ]), + }; + + const factory = new MockAppSheetClientFactory(mockData); + const client = factory.create(mockConnectionDef, 'user@example.com'); + + // MockAppSheetClient should be seeded with data + expect(client).toBeInstanceOf(MockAppSheetClient); + }); + + it('should pass dataProvider to created clients', async () => { + const mockData: MockDataProvider = { + getTables: () => new Map([ + ['users', { rows: [{ id: '1', name: 'Test User' }], keyField: 'id' }], + ]), + }; + + const factory = new MockAppSheetClientFactory(mockData); + const client = factory.create(mockConnectionDef, 'user@example.com'); + + // Client should have data from provider + const users = await client.findAll('users'); + expect(users).toHaveLength(1); + expect(users[0]).toMatchObject({ id: '1', name: 'Test User' }); + }); +}); + +describe('DynamicTableFactory', () => { + const mockSchema: SchemaConfig = { + connections: { + worklog: { + appId: 'worklog-app-id', + applicationAccessKey: 'worklog-key', + tables: { + users: { + tableName: 'extract_user', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + }, + }, + worklogs: { + tableName: 'extract_worklog', + keyField: 'worklog_id', + fields: { + worklog_id: { type: 'Text', required: true }, + date: { type: 'Date', required: true }, + }, + }, + }, + }, + otherApp: { + appId: 'other-app-id', + applicationAccessKey: 'other-key', + tables: { + products: { + tableName: 'products', + keyField: 'product_id', + fields: { + product_id: { type: 'Text', required: true }, + }, + }, + }, + }, + }, + }; + + it('should create DynamicTable using MockAppSheetClientFactory', () => { + const clientFactory = new MockAppSheetClientFactory(); + const tableFactory = new DynamicTableFactory(clientFactory, mockSchema); + + const table = tableFactory.create('worklog', 'users', 'user@example.com'); + + expect(table).toBeInstanceOf(DynamicTable); + }); + + it('should create table with correct table definition', () => { + const clientFactory = new MockAppSheetClientFactory(); + const tableFactory = new DynamicTableFactory(clientFactory, mockSchema); + + const table = tableFactory.create('worklog', 'users', 'user@example.com'); + + expect(table.getTableName()).toBe('extract_user'); + expect(table.getKeyField()).toBe('id'); + }); + + it('should create tables for different connections', () => { + const clientFactory = new MockAppSheetClientFactory(); + const tableFactory = new DynamicTableFactory(clientFactory, mockSchema); + + const usersTable = tableFactory.create('worklog', 'users', 'user@example.com'); + const productsTable = tableFactory.create('otherApp', 'products', 'user@example.com'); + + expect(usersTable.getTableName()).toBe('extract_user'); + expect(productsTable.getTableName()).toBe('products'); + }); + + it('should throw error for non-existent connection', () => { + const clientFactory = new MockAppSheetClientFactory(); + const tableFactory = new DynamicTableFactory(clientFactory, mockSchema); + + expect(() => tableFactory.create('nonexistent', 'users', 'user@example.com')).toThrow( + 'Connection "nonexistent" not found. Available connections: worklog, otherApp' + ); + }); + + it('should throw error for non-existent table', () => { + const clientFactory = new MockAppSheetClientFactory(); + const tableFactory = new DynamicTableFactory(clientFactory, mockSchema); + + expect(() => tableFactory.create('worklog', 'nonexistent', 'user@example.com')).toThrow( + 'Table "nonexistent" not found. Available tables: users, worklogs' + ); + }); + + it('should create functional tables for CRUD operations', async () => { + const clientFactory = new MockAppSheetClientFactory(); + const tableFactory = new DynamicTableFactory(clientFactory, mockSchema); + + interface User { + id: string; + email: string; + } + + const table = tableFactory.create('worklog', 'users', 'user@example.com'); + + // Add + const added = await table.add([{ id: '1', email: 'test@example.com' }]); + expect(added).toHaveLength(1); + + // Find + const found = await table.findAll(); + expect(found).toHaveLength(1); + + // Update + const updated = await table.update([{ id: '1', email: 'updated@example.com' }]); + expect(updated[0].email).toBe('updated@example.com'); + + // Delete + const deleted = await table.delete([{ id: '1' }]); + expect(deleted).toBe(true); + + // Verify deleted + const afterDelete = await table.findAll(); + expect(afterDelete).toHaveLength(0); + }); + + it('should implement DynamicTableFactoryInterface', () => { + const clientFactory = new MockAppSheetClientFactory(); + const tableFactory = new DynamicTableFactory(clientFactory, mockSchema); + + expect(typeof tableFactory.create).toBe('function'); + }); +}); + +describe('Factory Integration', () => { + const schema: SchemaConfig = { + connections: { + myApp: { + appId: 'my-app-id', + applicationAccessKey: 'my-key', + tables: { + users: { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + name: { type: 'Text', required: true }, + }, + }, + }, + }, + }, + }; + + it('should enable easy testing by swapping client factory', async () => { + // Production code would use AppSheetClientFactory + // For testing, use MockAppSheetClientFactory + const mockClientFactory = new MockAppSheetClientFactory(); + const tableFactory = new DynamicTableFactory(mockClientFactory, schema); + + const table = tableFactory.create('myApp', 'users', 'test@example.com'); + + // Add test data + await table.add([{ id: '1', name: 'Test User' }]); + + // Verify + const users = await table.findAll(); + expect(users).toHaveLength(1); + expect(users[0].name).toBe('Test User'); + }); + + it('should support pre-seeded mock data via factory', async () => { + const testData: MockDataProvider = { + getTables: () => new Map([ + ['users', { + rows: [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ], + keyField: 'id', + }], + ]), + }; + + const mockClientFactory = new MockAppSheetClientFactory(testData); + const tableFactory = new DynamicTableFactory(mockClientFactory, schema); + + const table = tableFactory.create('myApp', 'users', 'test@example.com'); + + // Table should already have data + const users = await table.findAll(); + expect(users).toHaveLength(2); + }); +}); diff --git a/tests/utils/ConnectionManager.test.ts b/tests/utils/ConnectionManager.test.ts index e2dedaa..64fa954 100644 --- a/tests/utils/ConnectionManager.test.ts +++ b/tests/utils/ConnectionManager.test.ts @@ -1,476 +1,319 @@ /** - * Test Suite: ConnectionManager - User-Specific Client Creation + * Test Suite: ConnectionManager v3.0.0 - Factory Injection Pattern * - * Test suite for ConnectionManager class that verifies the new per-request - * user context feature (runAsUserEmail parameter on get() method). - * - * Test areas: - * - Default client retrieval (backward compatible) - * - User-specific client creation (new feature) - * - Client configuration verification - * - Error handling for invalid connections - * - Multiple connection management + * Tests for ConnectionManager with injected client factory and schema. + * The v3.0.0 API simplifies ConnectionManager to: + * - Accept factory and schema in constructor + * - Create clients on-demand via get(connectionName, runAsUserEmail) + * - Provide list() and has() for introspection * * @module tests/utils */ import { ConnectionManager } from '../../src/utils/ConnectionManager'; -import { AppSheetClient } from '../../src/client/AppSheetClient'; - -/** - * Mock axios module to prevent actual HTTP requests during tests. - * ConnectionManager creates AppSheetClient instances which use axios internally. - */ -jest.mock('axios'); - -/** - * Test Suite: ConnectionManager - runAsUserEmail Feature - * - * Tests the ConnectionManager's ability to create user-specific clients - * on-the-fly when the optional runAsUserEmail parameter is provided. - */ -describe('ConnectionManager - runAsUserEmail', () => { +import { + MockAppSheetClientFactory, + MockAppSheetClient, +} from '../../src/client'; +import { + SchemaConfig, + AppSheetClientFactoryInterface, + MockDataProvider, +} from '../../src/types'; + +describe('ConnectionManager v3.0.0', () => { /** - * Base configuration for creating test connections. - * Uses minimal config with only required fields. + * Test schema with multiple connections. */ - const baseConfig = { - appId: 'test-app-id', - applicationAccessKey: 'test-key', + const testSchema: SchemaConfig = { + connections: { + worklog: { + appId: 'worklog-app-id', + applicationAccessKey: 'worklog-key', + tables: { + users: { + tableName: 'extract_user', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + }, + }, + worklogs: { + tableName: 'extract_worklog', + keyField: 'worklog_id', + fields: { + worklog_id: { type: 'Text', required: true }, + date: { type: 'Date', required: true }, + }, + }, + }, + }, + inventory: { + appId: 'inventory-app-id', + applicationAccessKey: 'inventory-key', + tables: { + products: { + tableName: 'products', + keyField: 'product_id', + fields: { + product_id: { type: 'Text', required: true }, + name: { type: 'Text', required: true }, + }, + }, + }, + }, + }, }; - let manager: ConnectionManager; - /** - * Setup: Create a fresh ConnectionManager instance before each test. - * Ensures test isolation and clean state. + * Empty schema for edge case testing. */ - beforeEach(() => { - manager = new ConnectionManager(); - }); + const emptySchema: SchemaConfig = { + connections: {}, + }; - /** - * Test Suite: Default Client Retrieval (Backward Compatible) - * - * Verifies that when no runAsUserEmail parameter is provided, - * the get() method returns the default registered client. - * - * This ensures 100% backward compatibility with existing code - * that doesn't use the user-specific feature. - */ - describe('Default client retrieval (backward compatible)', () => { - /** - * Test: Return Default Client Without User Parameter - * - * Verifies that calling get() without runAsUserEmail parameter - * returns the same client instance that was registered. - * - * Test approach: - * 1. Register a connection with default config - * 2. Call get() without runAsUserEmail parameter - * 3. Verify returned client is an AppSheetClient instance - * - * Expected behavior: - * - Returns a valid AppSheetClient - * - Uses default configuration from registration - * - No user-specific configuration applied - */ - it('should return default client when no runAsUserEmail is provided', () => { - manager.register({ - name: 'test-conn', - ...baseConfig, - }); - - const client = manager.get('test-conn'); - - expect(client).toBeInstanceOf(AppSheetClient); - expect(client.getConfig().appId).toBe(baseConfig.appId); - expect(client.getConfig().applicationAccessKey).toBe(baseConfig.applicationAccessKey); - }); + describe('Constructor', () => { + it('should accept client factory and schema', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); - /** - * Test: Return Default Client With Global runAsUserEmail - * - * Verifies that when a connection is registered with a global - * runAsUserEmail, calling get() without parameter returns a client - * configured with that global user. - * - * Test approach: - * 1. Register connection with global runAsUserEmail - * 2. Call get() without runAsUserEmail parameter - * 3. Verify client has global runAsUserEmail configured - * - * Expected behavior: - * - Returns client with global runAsUserEmail from registration - * - Global runAsUserEmail is set in client config - */ - it('should return default client with global runAsUserEmail when configured', () => { - const globalEmail = 'global@example.com'; - manager.register({ - name: 'test-conn', - ...baseConfig, - runAsUserEmail: globalEmail, - }); - - const client = manager.get('test-conn'); - - expect(client).toBeInstanceOf(AppSheetClient); - expect(client.getConfig().runAsUserEmail).toBe(globalEmail); + expect(manager).toBeInstanceOf(ConnectionManager); }); - /** - * Test: Return Same Instance on Multiple Calls - * - * Verifies that calling get() multiple times without runAsUserEmail - * returns the same client instance (cached behavior). - * - * Test approach: - * 1. Register a connection - * 2. Call get() twice without runAsUserEmail - * 3. Verify both calls return the exact same instance - * - * Expected behavior: - * - Multiple calls return same instance (reference equality) - * - Default client is cached and reused - */ - it('should return same instance on multiple calls without runAsUserEmail', () => { - manager.register({ - name: 'test-conn', - ...baseConfig, - }); - - const client1 = manager.get('test-conn'); - const client2 = manager.get('test-conn'); - - expect(client1).toBe(client2); // Reference equality + it('should work with empty schema', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, emptySchema); + + expect(manager.list()).toEqual([]); }); }); - /** - * Test Suite: User-Specific Client Creation - * - * Verifies the new feature where providing runAsUserEmail parameter - * creates a new client instance with user-specific configuration. - * - * This is the core feature for per-request user context support - * in multi-tenant MCP servers. - */ - describe('User-specific client creation', () => { - /** - * Test: Create User-Specific Client On-The-Fly - * - * Verifies that providing runAsUserEmail creates a new client - * with user-specific configuration. - * - * Test approach: - * 1. Register connection without global user - * 2. Call get() with runAsUserEmail parameter - * 3. Verify new client has user-specific runAsUserEmail - * - * Expected behavior: - * - Returns new AppSheetClient instance - * - Client has user-specific runAsUserEmail configured - * - Base config (appId, key) inherited from registered connection - */ - it('should create user-specific client when runAsUserEmail is provided', () => { - manager.register({ - name: 'test-conn', - ...baseConfig, - }); - - const userEmail = 'user@example.com'; - const client = manager.get('test-conn', userEmail); - - expect(client).toBeInstanceOf(AppSheetClient); - expect(client.getConfig().runAsUserEmail).toBe(userEmail); - expect(client.getConfig().appId).toBe(baseConfig.appId); - expect(client.getConfig().applicationAccessKey).toBe(baseConfig.applicationAccessKey); + describe('get()', () => { + it('should create client using injected factory', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + const client = manager.get('worklog', 'user@example.com'); + + expect(client).toBeInstanceOf(MockAppSheetClient); }); - /** - * Test: User-Specific Client Overrides Global Config - * - * Verifies that when connection has global runAsUserEmail configured, - * providing a different runAsUserEmail parameter creates a client - * with the parameter value (override behavior). - * - * Test approach: - * 1. Register connection with global runAsUserEmail - * 2. Call get() with different runAsUserEmail - * 3. Verify new client uses parameter value, not global value - * - * Expected behavior: - * - Parameter runAsUserEmail overrides global config - * - New client uses provided user email - * - Global config remains unchanged - */ - it('should override global runAsUserEmail with parameter value', () => { - const globalEmail = 'global@example.com'; - const userEmail = 'user@example.com'; - - manager.register({ - name: 'test-conn', - ...baseConfig, - runAsUserEmail: globalEmail, - }); - - const client = manager.get('test-conn', userEmail); - - expect(client.getConfig().runAsUserEmail).toBe(userEmail); - expect(client.getConfig().runAsUserEmail).not.toBe(globalEmail); + it('should pass connection definition and user email to factory', () => { + const factory = new MockAppSheetClientFactory(); + const createSpy = jest.spyOn(factory, 'create'); + const manager = new ConnectionManager(factory, testSchema); + + manager.get('worklog', 'user@example.com'); + + expect(createSpy).toHaveBeenCalledWith( + testSchema.connections.worklog, + 'user@example.com' + ); }); - /** - * Test: Create New Instance on Each Call with User - * - * Verifies that each call to get() with runAsUserEmail creates - * a new client instance (no caching for user-specific clients). - * - * Test approach: - * 1. Register connection - * 2. Call get() twice with same runAsUserEmail - * 3. Verify different instances are returned - * - * Expected behavior: - * - Each call creates new instance (no reference equality) - * - User-specific clients are not cached - * - Lightweight operation (AppSheetClient is lightweight) - */ - it('should create new instance on each call with runAsUserEmail', () => { - manager.register({ - name: 'test-conn', - ...baseConfig, - }); - - const userEmail = 'user@example.com'; - const client1 = manager.get('test-conn', userEmail); - const client2 = manager.get('test-conn', userEmail); - - expect(client1).not.toBe(client2); // Different instances - expect(client1.getConfig().runAsUserEmail).toBe(userEmail); - expect(client2.getConfig().runAsUserEmail).toBe(userEmail); + it('should create client with correct table definitions', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + const client = manager.get('worklog', 'user@example.com'); + + const tableDef = client.getTable('users'); + expect(tableDef.tableName).toBe('extract_user'); + expect(tableDef.keyField).toBe('id'); }); - /** - * Test: Create Clients for Different Users - * - * Verifies that calling get() with different runAsUserEmail values - * creates separate clients with correct user configurations. - * - * Test approach: - * 1. Register connection - * 2. Call get() with different user emails - * 3. Verify each client has correct user email - * - * Expected behavior: - * - Different clients for different users - * - Each client has correct runAsUserEmail - * - All clients share base configuration - */ - it('should create separate clients for different users', () => { - manager.register({ - name: 'test-conn', - ...baseConfig, - }); - - const user1Email = 'user1@example.com'; - const user2Email = 'user2@example.com'; - - const client1 = manager.get('test-conn', user1Email); - const client2 = manager.get('test-conn', user2Email); + it('should create new client on each call (no caching)', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + const client1 = manager.get('worklog', 'user@example.com'); + const client2 = manager.get('worklog', 'user@example.com'); expect(client1).not.toBe(client2); - expect(client1.getConfig().runAsUserEmail).toBe(user1Email); - expect(client2.getConfig().runAsUserEmail).toBe(user2Email); - expect(client1.getConfig().appId).toBe(baseConfig.appId); - expect(client2.getConfig().appId).toBe(baseConfig.appId); }); - /** - * Test: User-Specific Client Inherits All Config - * - * Verifies that user-specific client inherits all configuration - * options (baseUrl, timeout, retryAttempts) from registered connection. - * - * Test approach: - * 1. Register connection with custom timeout and retryAttempts - * 2. Call get() with runAsUserEmail - * 3. Verify new client has all custom config options - * - * Expected behavior: - * - User-specific client inherits all config options - * - Only runAsUserEmail is overridden - * - Custom settings (timeout, retryAttempts) preserved - */ - it('should inherit all config options in user-specific client', () => { - const customConfig = { - name: 'test-conn', - ...baseConfig, - timeout: 60000, - retryAttempts: 5, - baseUrl: 'https://custom-api.appsheet.com', - }; + it('should create clients for different users', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + const client1 = manager.get('worklog', 'user1@example.com'); + const client2 = manager.get('worklog', 'user2@example.com'); + + expect(client1).not.toBe(client2); + }); - manager.register(customConfig); + it('should create clients for different connections', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); - const userEmail = 'user@example.com'; - const client = manager.get('test-conn', userEmail); + const worklogClient = manager.get('worklog', 'user@example.com'); + const inventoryClient = manager.get('inventory', 'user@example.com'); - const config = client.getConfig(); - expect(config.runAsUserEmail).toBe(userEmail); - expect(config.timeout).toBe(customConfig.timeout); - expect(config.retryAttempts).toBe(customConfig.retryAttempts); - expect(config.baseUrl).toBe(customConfig.baseUrl); + expect(worklogClient).not.toBe(inventoryClient); + expect(worklogClient.getTable('users').tableName).toBe('extract_user'); + expect(inventoryClient.getTable('products').tableName).toBe('products'); }); - }); - /** - * Test Suite: Error Handling - * - * Verifies that ConnectionManager handles errors correctly when - * requesting non-existent connections or using invalid parameters. - */ - describe('Error handling', () => { - /** - * Test: Throw Error for Non-Existent Connection - * - * Verifies that get() throws descriptive error when requesting - * a connection that hasn't been registered. - * - * Test approach: - * 1. Register some connections - * 2. Call get() with non-existent connection name - * 3. Verify error message lists available connections - * - * Expected behavior: - * - Throws Error with descriptive message - * - Error message includes connection name - * - Error message lists available connections - */ - it('should throw error when connection not found', () => { - manager.register({ - name: 'conn1', - ...baseConfig, - }); - - expect(() => manager.get('non-existent')).toThrow( - 'Connection "non-existent" not found. Available connections: conn1' + it('should throw error for non-existent connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + expect(() => manager.get('nonexistent', 'user@example.com')).toThrow( + 'Connection "nonexistent" not found. Available connections: worklog, inventory' ); }); - /** - * Test: Throw Error When No Connections Registered - * - * Verifies error message when get() is called but no connections - * have been registered yet. - * - * Expected behavior: - * - Throws Error indicating no connections available - * - Message shows "none" as available connections - */ - it('should throw error with "none" when no connections registered', () => { - expect(() => manager.get('any')).toThrow( + it('should throw error with "none" when schema has no connections', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, emptySchema); + + expect(() => manager.get('any', 'user@example.com')).toThrow( 'Connection "any" not found. Available connections: none' ); }); + }); + + describe('list()', () => { + it('should return all connection names', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + const names = manager.list(); + + expect(names).toEqual(['worklog', 'inventory']); + }); - /** - * Test: Error for Non-Existent Connection with User Parameter - * - * Verifies that error handling works correctly even when - * runAsUserEmail parameter is provided. - * - * Expected behavior: - * - Throws same error regardless of user parameter - * - Connection validation happens before user-specific client creation - */ - it('should throw error for non-existent connection even with runAsUserEmail', () => { - manager.register({ - name: 'conn1', - ...baseConfig, - }); - - expect(() => manager.get('non-existent', 'user@example.com')).toThrow( - 'Connection "non-existent" not found' + it('should return empty array for empty schema', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, emptySchema); + + expect(manager.list()).toEqual([]); + }); + }); + + describe('has()', () => { + it('should return true for existing connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + expect(manager.has('worklog')).toBe(true); + expect(manager.has('inventory')).toBe(true); + }); + + it('should return false for non-existing connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + expect(manager.has('nonexistent')).toBe(false); + }); + + it('should return false for empty schema', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, emptySchema); + + expect(manager.has('any')).toBe(false); + }); + }); + + describe('Factory injection patterns', () => { + it('should enable testing with MockAppSheetClientFactory', async () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + const client = manager.get('worklog', 'test@example.com'); + + // Add data using addOne convenience method + await client.addOne('users', { id: '1', email: 'test@example.com' }); + + // Verify data + const users = await client.findAll('users'); + expect(users).toHaveLength(1); + expect(users[0].email).toBe('test@example.com'); + }); + + it('should support pre-seeded mock data via factory', async () => { + const testData: MockDataProvider = { + getTables: () => + new Map([ + [ + 'users', + { + rows: [ + { id: '1', email: 'alice@example.com' }, + { id: '2', email: 'bob@example.com' }, + ], + keyField: 'id', + }, + ], + ]), + }; + + const factory = new MockAppSheetClientFactory(testData); + const manager = new ConnectionManager(factory, testSchema); + + const client = manager.get('worklog', 'test@example.com'); + const users = await client.findAll('users'); + + expect(users).toHaveLength(2); + expect(users[0].email).toBe('alice@example.com'); + }); + + it('should work with custom factory implementations', () => { + // Create a spy factory to verify calls + const mockClient = new MockAppSheetClient( + testSchema.connections.worklog, + 'custom@example.com' ); + + const customFactory: AppSheetClientFactoryInterface = { + create: jest.fn().mockReturnValue(mockClient), + }; + + const manager = new ConnectionManager(customFactory, testSchema); + const client = manager.get('worklog', 'custom@example.com'); + + expect(customFactory.create).toHaveBeenCalledTimes(1); + expect(client).toBe(mockClient); }); }); - /** - * Test Suite: Multiple Connection Management - * - * Verifies that ConnectionManager correctly handles multiple - * registered connections with user-specific client creation. - */ - describe('Multiple connection management', () => { - /** - * Test: Manage Multiple Connections with User Clients - * - * Verifies that user-specific clients can be created for - * different connections independently. - * - * Test approach: - * 1. Register multiple connections - * 2. Create user-specific clients for different connections - * 3. Verify each client has correct connection config and user - * - * Expected behavior: - * - Each connection creates independent clients - * - User email applied to correct connection - * - No cross-connection configuration leakage - */ - it('should manage user-specific clients for multiple connections', () => { - manager.register({ - name: 'conn1', - appId: 'app-1', - applicationAccessKey: 'key-1', - }); - - manager.register({ - name: 'conn2', - appId: 'app-2', - applicationAccessKey: 'key-2', - }); - - const userEmail = 'user@example.com'; - const client1 = manager.get('conn1', userEmail); - const client2 = manager.get('conn2', userEmail); - - expect(client1.getConfig().appId).toBe('app-1'); - expect(client1.getConfig().runAsUserEmail).toBe(userEmail); - expect(client2.getConfig().appId).toBe('app-2'); - expect(client2.getConfig().runAsUserEmail).toBe(userEmail); + describe('Multi-tenant scenarios', () => { + it('should create isolated clients for concurrent users', async () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + // Simulate two users making concurrent requests + const user1Client = manager.get('worklog', 'user1@example.com'); + const user2Client = manager.get('worklog', 'user2@example.com'); + + // Each user adds their own data + await user1Client.addOne('users', { id: '1', email: 'user1@example.com' }); + await user2Client.addOne('users', { id: '2', email: 'user2@example.com' }); + + // Note: In mock mode, clients share data store + // In production with AppSheet API, each user would have their own permissions + expect(user1Client).not.toBe(user2Client); }); - /** - * Test: Mix Default and User-Specific Clients - * - * Verifies that default and user-specific clients can be - * retrieved from the same connection without conflicts. - * - * Test approach: - * 1. Register connection - * 2. Get default client (no user) - * 3. Get user-specific client (with user) - * 4. Verify both clients work independently - * - * Expected behavior: - * - Default client has no user or global user - * - User-specific client has correct user - * - Both clients are valid and independent - */ - it('should support mixing default and user-specific clients', () => { - manager.register({ - name: 'test-conn', - ...baseConfig, - }); - - const defaultClient = manager.get('test-conn'); - const userClient = manager.get('test-conn', 'user@example.com'); - - expect(defaultClient.getConfig().runAsUserEmail).toBeUndefined(); - expect(userClient.getConfig().runAsUserEmail).toBe('user@example.com'); - expect(defaultClient).not.toBe(userClient); + it('should support same user accessing multiple connections', async () => { + const factory = new MockAppSheetClientFactory(); + const manager = new ConnectionManager(factory, testSchema); + + const userEmail = 'admin@example.com'; + + const worklogClient = manager.get('worklog', userEmail); + const inventoryClient = manager.get('inventory', userEmail); + + // User can access different apps + await worklogClient.addOne('users', { id: '1', email: userEmail }); + await inventoryClient.addOne('products', { product_id: 'P1', name: 'Widget' }); + + const users = await worklogClient.findAll('users'); + const products = await inventoryClient.findAll('products'); + + expect(users).toHaveLength(1); + expect(products).toHaveLength(1); }); }); }); diff --git a/tests/utils/SchemaManager.test.ts b/tests/utils/SchemaManager.test.ts index c3b0bd1..25671b4 100644 --- a/tests/utils/SchemaManager.test.ts +++ b/tests/utils/SchemaManager.test.ts @@ -1,36 +1,20 @@ /** - * Test Suite: SchemaManager - User-Specific Table Client Creation + * Test Suite: SchemaManager v3.0.0 - Factory-Based Table Creation * - * Test suite for SchemaManager class that verifies the new per-request - * user context feature (runAsUserEmail parameter on table() method). - * - * Test areas: - * - Default table client retrieval (backward compatible) - * - User-specific table client creation (new feature) - * - On-the-fly client creation (no caching) - * - Error handling for invalid connections/tables - * - Multi-connection schema management + * Tests for SchemaManager with injected client factory and schema. + * The v3.0.0 API: + * - Accept clientFactory and schema in constructor + * - Create table clients on-demand via table(connection, table, userEmail) + * - runAsUserEmail is required (no default/optional user) * * @module tests/utils */ import { SchemaManager } from '../../src/utils/SchemaManager'; -import { SchemaConfig } from '../../src/types'; -import { DynamicTable } from '../../src/client/DynamicTable'; - -/** - * Mock axios module to prevent actual HTTP requests during tests. - * SchemaManager creates AppSheetClient instances which use axios internally. - */ -jest.mock('axios'); +import { SchemaConfig, MockDataProvider } from '../../src/types'; +import { DynamicTable, MockAppSheetClientFactory } from '../../src/client'; -/** - * Test Suite: SchemaManager - runAsUserEmail Feature - * - * Tests the SchemaManager's ability to create user-specific table clients - * on-the-fly when the optional runAsUserEmail parameter is provided. - */ -describe('SchemaManager - runAsUserEmail', () => { +describe('SchemaManager v3.0.0', () => { /** * Base schema configuration for testing. * Includes two connections with different tables. @@ -48,6 +32,16 @@ describe('SchemaManager - runAsUserEmail', () => { id: { type: 'Text', required: true }, email: { type: 'Email', required: true }, name: { type: 'Text', required: false }, + status: { + type: 'Enum', + required: true, + allowedValues: ['Active', 'Inactive', 'Pending'], + }, + tags: { + type: 'EnumList', + required: false, + allowedValues: ['Admin', 'User', 'Guest'], + }, }, }, worklogs: { @@ -78,504 +72,475 @@ describe('SchemaManager - runAsUserEmail', () => { }, }; - let manager: SchemaManager; + describe('Constructor', () => { + it('should accept client factory and schema', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); - /** - * Setup: Create a fresh SchemaManager instance before each test. - * Ensures test isolation and clean state. - */ - beforeEach(() => { - manager = new SchemaManager(baseSchema); - }); - - /** - * Test Suite: Default Table Client Retrieval (Backward Compatible) - * - * Verifies that when no runAsUserEmail parameter is provided, - * the table() method returns a table client with default configuration. - * - * This ensures 100% backward compatibility with existing code - * that doesn't use the user-specific feature. - */ - describe('Default table client retrieval (backward compatible)', () => { - /** - * Test: Return Table Client Without User Parameter - * - * Verifies that calling table() without runAsUserEmail parameter - * returns a valid DynamicTable instance. - * - * Test approach: - * 1. Call table() without runAsUserEmail parameter - * 2. Verify returned object is DynamicTable instance - * 3. Verify table definition matches schema - * - * Expected behavior: - * - Returns a valid DynamicTable - * - Uses default configuration from schema - * - No user-specific configuration applied - */ - it('should return table client when no runAsUserEmail is provided', () => { - const table = manager.table('test-conn', 'users'); - - expect(table).toBeInstanceOf(DynamicTable); - expect(table.getTableName()).toBe('extract_user'); - expect(table.getKeyField()).toBe('id'); + expect(manager).toBeInstanceOf(SchemaManager); }); - /** - * Test: Return Table Client With Global runAsUserEmail - * - * Verifies that when a connection has global runAsUserEmail configured, - * calling table() without parameter returns a client with global user. - * - * Test approach: - * 1. Create schema with global runAsUserEmail - * 2. Call table() without runAsUserEmail parameter - * 3. Verify underlying client has global user configured - * - * Expected behavior: - * - Returns table client with global user from schema - * - Global runAsUserEmail propagates to operations - */ - it('should return table client with global runAsUserEmail when configured', () => { - const schemaWithGlobalUser: SchemaConfig = { + it('should validate schema on construction', () => { + const factory = new MockAppSheetClientFactory(); + const invalidSchema: SchemaConfig = { connections: { - 'test-conn': { - ...baseSchema.connections['test-conn'], - runAsUserEmail: 'global@example.com', + invalid: { + appId: '', // Invalid: empty appId + applicationAccessKey: 'key', + tables: {}, }, }, }; - const managerWithGlobal = new SchemaManager(schemaWithGlobalUser); - const table = managerWithGlobal.table('test-conn', 'users'); - - expect(table).toBeInstanceOf(DynamicTable); - expect(table.getTableName()).toBe('extract_user'); + expect(() => new SchemaManager(factory, invalidSchema)).toThrow( + /Invalid schema/ + ); }); - /** - * Test: Access Multiple Tables Without User - * - * Verifies that multiple table clients can be retrieved - * from the same connection without user parameter. - * - * Test approach: - * 1. Get two different table clients from same connection - * 2. Verify both are valid DynamicTable instances - * 3. Verify each has correct table definition - * - * Expected behavior: - * - Each table returns correct DynamicTable instance - * - Table definitions match schema configuration - * - No interference between tables - */ - it('should access multiple tables without user parameter', () => { - const usersTable = manager.table('test-conn', 'users'); - const worklogsTable = manager.table('test-conn', 'worklogs'); - - expect(usersTable).toBeInstanceOf(DynamicTable); - expect(worklogsTable).toBeInstanceOf(DynamicTable); - expect(usersTable.getTableName()).toBe('extract_user'); - expect(worklogsTable.getTableName()).toBe('extract_worklog'); + it('should work with MockAppSheetClientFactory', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(manager.getConnections()).toEqual(['test-conn', 'hr-conn']); }); }); - /** - * Test Suite: User-Specific Table Client Creation - * - * Verifies the new feature where providing runAsUserEmail parameter - * creates a table client with user-specific configuration. - * - * This is the core feature for per-request user context support - * in multi-tenant MCP servers. - */ - describe('User-specific table client creation', () => { - /** - * Test: Create User-Specific Table Client On-The-Fly - * - * Verifies that providing runAsUserEmail creates a table client - * with user-specific underlying AppSheetClient. - * - * Test approach: - * 1. Call table() with runAsUserEmail parameter - * 2. Verify returned object is DynamicTable instance - * 3. Verify table definition matches schema - * - * Expected behavior: - * - Returns new DynamicTable instance - * - Underlying client has user-specific runAsUserEmail - * - Table definition inherited from schema - */ - it('should create user-specific table client when runAsUserEmail is provided', () => { - const userEmail = 'user@example.com'; - const table = manager.table('test-conn', 'users', userEmail); + describe('table()', () => { + it('should create DynamicTable using injected factory', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const table = manager.table('test-conn', 'users', 'user@example.com'); expect(table).toBeInstanceOf(DynamicTable); - expect(table.getTableName()).toBe('extract_user'); - expect(table.getKeyField()).toBe('id'); }); - /** - * Test: User-Specific Client Overrides Global Config - * - * Verifies that when connection has global runAsUserEmail configured, - * providing a different runAsUserEmail parameter creates a client - * with the parameter value (override behavior). - * - * Test approach: - * 1. Create schema with global runAsUserEmail - * 2. Call table() with different runAsUserEmail - * 3. Verify table client uses parameter value - * - * Expected behavior: - * - Parameter runAsUserEmail overrides global config - * - Table client uses provided user email - * - Global config remains unchanged for default calls - */ - it('should override global runAsUserEmail with parameter value', () => { - const schemaWithGlobalUser: SchemaConfig = { - connections: { - 'test-conn': { - ...baseSchema.connections['test-conn'], - runAsUserEmail: 'global@example.com', - }, - }, - }; + it('should create table with correct definition', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); - const managerWithGlobal = new SchemaManager(schemaWithGlobalUser); - const userEmail = 'user@example.com'; - const table = managerWithGlobal.table('test-conn', 'users', userEmail); + const table = manager.table('test-conn', 'users', 'user@example.com'); - expect(table).toBeInstanceOf(DynamicTable); expect(table.getTableName()).toBe('extract_user'); + expect(table.getKeyField()).toBe('id'); }); - /** - * Test: Create New Instance on Each Call with User - * - * Verifies that each call to table() with runAsUserEmail creates - * a new table client instance (no caching). - * - * Test approach: - * 1. Call table() twice with same parameters - * 2. Verify different instances are returned - * - * Expected behavior: - * - Each call creates new instance (no reference equality) - * - User-specific table clients are not cached - * - Lightweight operation (DynamicTable is lightweight) - */ - it('should create new instance on each call with runAsUserEmail', () => { - const userEmail = 'user@example.com'; - const table1 = manager.table('test-conn', 'users', userEmail); - const table2 = manager.table('test-conn', 'users', userEmail); - - expect(table1).not.toBe(table2); // Different instances - expect(table1.getTableName()).toBe('extract_user'); - expect(table2.getTableName()).toBe('extract_user'); + it('should create new instance on each call (no caching)', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const table1 = manager.table('test-conn', 'users', 'user@example.com'); + const table2 = manager.table('test-conn', 'users', 'user@example.com'); + + expect(table1).not.toBe(table2); }); - /** - * Test: Create Table Clients for Different Users - * - * Verifies that calling table() with different runAsUserEmail values - * creates separate table clients with correct user configurations. - * - * Test approach: - * 1. Call table() with different user emails - * 2. Verify each client is separate instance - * 3. Verify table definitions are identical (from schema) - * - * Expected behavior: - * - Different instances for different users - * - Each instance has correct user email in underlying client - * - All instances share same table definition - */ - it('should create separate table clients for different users', () => { - const user1Email = 'user1@example.com'; - const user2Email = 'user2@example.com'; - - const table1 = manager.table('test-conn', 'users', user1Email); - const table2 = manager.table('test-conn', 'users', user2Email); + it('should create separate tables for different users', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const table1 = manager.table('test-conn', 'users', 'user1@example.com'); + const table2 = manager.table('test-conn', 'users', 'user2@example.com'); expect(table1).not.toBe(table2); expect(table1.getTableName()).toBe('extract_user'); expect(table2.getTableName()).toBe('extract_user'); - expect(table1.getKeyField()).toBe('id'); - expect(table2.getKeyField()).toBe('id'); - }); - - /** - * Test: User-Specific Client for Multiple Tables - * - * Verifies that user-specific clients can be created for - * different tables in the same connection. - * - * Test approach: - * 1. Create user-specific clients for different tables - * 2. Verify each has correct table definition - * 3. Verify all are separate instances - * - * Expected behavior: - * - Each table returns correct DynamicTable instance - * - User email propagates to all table clients - * - Table definitions match schema configuration - */ - it('should create user-specific clients for multiple tables', () => { - const userEmail = 'user@example.com'; - const usersTable = manager.table('test-conn', 'users', userEmail); - const worklogsTable = manager.table('test-conn', 'worklogs', userEmail); + }); + + it('should create tables from different connections', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); - expect(usersTable).not.toBe(worklogsTable); + const usersTable = manager.table('test-conn', 'users', 'user@example.com'); + const employeesTable = manager.table('hr-conn', 'employees', 'user@example.com'); + + expect(usersTable).not.toBe(employeesTable); expect(usersTable.getTableName()).toBe('extract_user'); - expect(worklogsTable.getTableName()).toBe('extract_worklog'); + expect(employeesTable.getTableName()).toBe('extract_employee'); }); - }); - /** - * Test Suite: Error Handling - * - * Verifies that SchemaManager handles errors correctly when - * requesting non-existent connections or tables. - */ - describe('Error handling', () => { - /** - * Test: Throw Error for Non-Existent Connection - * - * Verifies that table() throws descriptive error when requesting - * a connection that doesn't exist in schema. - * - * Test approach: - * 1. Call table() with non-existent connection name - * 2. Verify error message lists available connections - * - * Expected behavior: - * - Throws Error with descriptive message - * - Error message includes connection name - * - Error message lists available connections - */ - it('should throw error when connection not found', () => { - expect(() => manager.table('non-existent', 'users')).toThrow( - 'Connection "non-existent" not found. Available connections: test-conn, hr-conn' + it('should throw error for non-existent connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(() => manager.table('nonexistent', 'users', 'user@example.com')).toThrow( + 'Connection "nonexistent" not found. Available connections: test-conn, hr-conn' ); }); - /** - * Test: Throw Error for Non-Existent Table - * - * Verifies that table() throws descriptive error when requesting - * a table that doesn't exist in the connection. - * - * Test approach: - * 1. Call table() with valid connection but non-existent table - * 2. Verify error message lists available tables - * - * Expected behavior: - * - Throws Error with descriptive message - * - Error message includes table and connection names - * - Error message lists available tables for that connection - */ - it('should throw error when table not found in connection', () => { - expect(() => manager.table('test-conn', 'non-existent')).toThrow( - 'Table "non-existent" not found in connection "test-conn". ' + - 'Available tables: users, worklogs' + it('should throw error for non-existent table', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(() => manager.table('test-conn', 'nonexistent', 'user@example.com')).toThrow( + 'Table "nonexistent" not found. Available tables: users, worklogs' ); }); + }); - /** - * Test: Error Handling with User Parameter - * - * Verifies that error handling works correctly even when - * runAsUserEmail parameter is provided. - * - * Expected behavior: - * - Throws same errors regardless of user parameter - * - Connection/table validation happens before user-specific client creation - */ - it('should throw error for invalid connection even with runAsUserEmail', () => { - expect(() => manager.table('non-existent', 'users', 'user@example.com')).toThrow( - 'Connection "non-existent" not found' - ); + describe('getConnections()', () => { + it('should return all connection names', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(manager.getConnections()).toEqual(['test-conn', 'hr-conn']); }); + }); - it('should throw error for invalid table even with runAsUserEmail', () => { - expect(() => manager.table('test-conn', 'non-existent', 'user@example.com')).toThrow( - 'Table "non-existent" not found in connection "test-conn"' + describe('getTables()', () => { + it('should return all tables for a connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(manager.getTables('test-conn')).toEqual(['users', 'worklogs']); + expect(manager.getTables('hr-conn')).toEqual(['employees']); + }); + + it('should throw error for non-existent connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(() => manager.getTables('nonexistent')).toThrow( + 'Connection "nonexistent" not found. Available connections: test-conn, hr-conn' ); }); }); - /** - * Test Suite: Multi-Connection Schema Management - * - * Verifies that SchemaManager correctly handles multiple - * connections with user-specific client creation. - */ - describe('Multi-connection schema management', () => { - /** - * Test: Manage User-Specific Clients Across Connections - * - * Verifies that user-specific table clients can be created - * for tables in different connections independently. - * - * Test approach: - * 1. Create user-specific clients for tables in different connections - * 2. Verify each client has correct table definition - * 3. Verify no cross-connection configuration leakage - * - * Expected behavior: - * - Each connection creates independent table clients - * - User email applied to correct connection - * - Table definitions match respective schemas - */ - it('should manage user-specific clients across multiple connections', () => { - const userEmail = 'user@example.com'; - const usersTable = manager.table('test-conn', 'users', userEmail); - const employeesTable = manager.table('hr-conn', 'employees', userEmail); + describe('hasConnection()', () => { + it('should return true for existing connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); - expect(usersTable).toBeInstanceOf(DynamicTable); - expect(employeesTable).toBeInstanceOf(DynamicTable); - expect(usersTable.getTableName()).toBe('extract_user'); - expect(employeesTable.getTableName()).toBe('extract_employee'); + expect(manager.hasConnection('test-conn')).toBe(true); + expect(manager.hasConnection('hr-conn')).toBe(true); }); - /** - * Test: Mix Default and User-Specific Clients - * - * Verifies that default and user-specific table clients can be - * retrieved from the same connection without conflicts. - * - * Test approach: - * 1. Get default table client (no user) - * 2. Get user-specific table client (with user) - * 3. Verify both clients work independently - * - * Expected behavior: - * - Default client uses default/global user config - * - User-specific client has correct user - * - Both clients are valid and independent - */ - it('should support mixing default and user-specific table clients', () => { - const defaultTable = manager.table('test-conn', 'users'); - const userTable = manager.table('test-conn', 'users', 'user@example.com'); - - expect(defaultTable).toBeInstanceOf(DynamicTable); - expect(userTable).toBeInstanceOf(DynamicTable); - expect(defaultTable).not.toBe(userTable); - expect(defaultTable.getTableName()).toBe('extract_user'); - expect(userTable.getTableName()).toBe('extract_user'); + it('should return false for non-existing connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(manager.hasConnection('nonexistent')).toBe(false); }); }); - /** - * Test Suite: Schema Query Methods - * - * Verifies that schema query methods (getConnections, getTables) - * work correctly after removing table client caching. - */ - describe('Schema query methods', () => { - /** - * Test: Get All Connections - * - * Verifies that getConnections() returns all connection names - * from schema configuration. - * - * Expected behavior: - * - Returns array of connection names - * - Names match schema configuration - */ - it('should return all connection names', () => { - const connections = manager.getConnections(); + describe('hasTable()', () => { + it('should return true for existing table in connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); - expect(connections).toEqual(['test-conn', 'hr-conn']); + expect(manager.hasTable('test-conn', 'users')).toBe(true); + expect(manager.hasTable('test-conn', 'worklogs')).toBe(true); + expect(manager.hasTable('hr-conn', 'employees')).toBe(true); }); - /** - * Test: Get Tables for Connection - * - * Verifies that getTables() returns all table names - * for a specific connection. - * - * Expected behavior: - * - Returns array of table names - * - Names match schema configuration for that connection - */ - it('should return all tables for a connection', () => { - const tables = manager.getTables('test-conn'); - - expect(tables).toEqual(['users', 'worklogs']); - }); - - /** - * Test: getTables() Error Handling - * - * Verifies that getTables() throws error for non-existent connection. - * - * Expected behavior: - * - Throws Error with descriptive message - * - Error message lists available connections - */ - it('should throw error when getting tables for non-existent connection', () => { - expect(() => manager.getTables('non-existent')).toThrow( - 'Connection "non-existent" not found. Available connections: test-conn, hr-conn' - ); + it('should return false for non-existing table', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(manager.hasTable('test-conn', 'nonexistent')).toBe(false); + }); + + it('should return false for non-existing connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(manager.hasTable('nonexistent', 'users')).toBe(false); }); }); - /** - * Test Suite: Schema Reload - * - * Verifies that reload() method works correctly without table caching. - */ - describe('Schema reload', () => { - /** - * Test: Reload Schema Configuration - * - * Verifies that reload() updates connections and table clients - * reflect new schema configuration. - * - * Test approach: - * 1. Create manager with initial schema - * 2. Reload with new schema - * 3. Verify new connections/tables available - * 4. Verify old connections/tables unavailable - * - * Expected behavior: - * - New schema replaces old configuration - * - Table clients reflect new schema - * - Old configuration no longer accessible - */ - it('should reload schema and create clients with new configuration', () => { - const newSchema: SchemaConfig = { - connections: { - 'new-conn': { - appId: 'new-app', - applicationAccessKey: 'new-key', - tables: { - newTable: { - tableName: 'extract_new', + describe('getSchema()', () => { + it('should return the schema configuration', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + expect(manager.getSchema()).toBe(baseSchema); + }); + }); + + describe('CRUD operations with mock factory', () => { + it('should support add and find operations', async () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + interface User { + id: string; + email: string; + name?: string; + status: string; + tags?: string[]; + } + + const table = manager.table('test-conn', 'users', 'test@example.com'); + + // Add + const added = await table.add([{ id: '1', email: 'alice@example.com', name: 'Alice', status: 'Active' }]); + expect(added).toHaveLength(1); + + // Find + const users = await table.findAll(); + expect(users).toHaveLength(1); + expect(users[0].email).toBe('alice@example.com'); + }); + + it('should support update and delete operations', async () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const table = manager.table('test-conn', 'users', 'test@example.com'); + + // Add initial data + await table.add([{ id: '1', email: 'alice@example.com', status: 'Active' }]); + + // Update + const updated = await table.update([{ id: '1', email: 'alice.new@example.com', status: 'Active' }]); + expect(updated[0].email).toBe('alice.new@example.com'); + + // Delete + const deleted = await table.delete([{ id: '1' }]); + expect(deleted).toBe(true); + + // Verify deleted + const users = await table.findAll(); + expect(users).toHaveLength(0); + }); + + it('should support pre-seeded mock data via factory', async () => { + // Note: MockDataProvider table names must match the AppSheet table names + // (e.g., 'extract_user') not the schema table names (e.g., 'users') + // because DynamicTable.findAll() uses definition.tableName + const testData: MockDataProvider = { + getTables: () => + new Map([ + [ + 'extract_user', // Use AppSheet table name, not schema name + { + rows: [ + { id: '1', email: 'alice@example.com', name: 'Alice' }, + { id: '2', email: 'bob@example.com', name: 'Bob' }, + ], keyField: 'id', - fields: { - id: { type: 'Text', required: true }, - }, }, - }, - }, - }, + ], + ]), }; - manager.reload(newSchema); + const factory = new MockAppSheetClientFactory(testData); + const manager = new SchemaManager(factory, baseSchema); - // New configuration should work - const table = manager.table('new-conn', 'newTable'); - expect(table).toBeInstanceOf(DynamicTable); - expect(table.getTableName()).toBe('extract_new'); + // First table call - gets seeded data + const table1 = manager.table('test-conn', 'users', 'test@example.com'); + const users1 = await table1.findAll(); + expect(users1).toHaveLength(2); + expect(users1[0].name).toBe('Alice'); + expect(users1[1].name).toBe('Bob'); - // Old configuration should not work - expect(() => manager.table('test-conn', 'users')).toThrow( - 'Connection "test-conn" not found' - ); + // Second table call - also gets seeded data (new client instance) + const table2 = manager.table('test-conn', 'users', 'test@example.com'); + const users2 = await table2.findAll(); + expect(users2).toHaveLength(2); + }); + }); + + describe('Multi-tenant scenarios', () => { + it('should create isolated tables for concurrent users', async () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + // Simulate two users making concurrent requests + const user1Table = manager.table('test-conn', 'users', 'user1@example.com'); + const user2Table = manager.table('test-conn', 'users', 'user2@example.com'); + + // Each user operates on their own table instance + await user1Table.add([{ id: '1', email: 'user1@example.com', status: 'Active' }]); + await user2Table.add([{ id: '2', email: 'user2@example.com', status: 'Active' }]); + + // Tables are different instances + expect(user1Table).not.toBe(user2Table); + }); + + it('should support same user accessing multiple tables', async () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const userEmail = 'admin@example.com'; + + const usersTable = manager.table('test-conn', 'users', userEmail); + const worklogsTable = manager.table('test-conn', 'worklogs', userEmail); + + // User can access different tables + await usersTable.add([{ id: '1', email: userEmail, status: 'Active' }]); + await worklogsTable.add([{ id: 'W1', date: '2025-01-01', hours: 8 }]); + + const users = await usersTable.findAll(); + const worklogs = await worklogsTable.findAll(); + + expect(users).toHaveLength(1); + expect(worklogs).toHaveLength(1); + }); + }); + + describe('getTableDefinition()', () => { + it('should return table definition for existing table', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const tableDef = manager.getTableDefinition('test-conn', 'users'); + + expect(tableDef).toBeDefined(); + expect(tableDef?.tableName).toBe('extract_user'); + expect(tableDef?.keyField).toBe('id'); + expect(tableDef?.fields).toHaveProperty('id'); + expect(tableDef?.fields).toHaveProperty('email'); + expect(tableDef?.fields).toHaveProperty('status'); + }); + + it('should return undefined for non-existent connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const tableDef = manager.getTableDefinition('nonexistent', 'users'); + + expect(tableDef).toBeUndefined(); + }); + + it('should return undefined for non-existent table', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const tableDef = manager.getTableDefinition('test-conn', 'nonexistent'); + + expect(tableDef).toBeUndefined(); + }); + + it('should return table definition from different connections', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const usersDef = manager.getTableDefinition('test-conn', 'users'); + const employeesDef = manager.getTableDefinition('hr-conn', 'employees'); + + expect(usersDef?.tableName).toBe('extract_user'); + expect(employeesDef?.tableName).toBe('extract_employee'); + }); + }); + + describe('getFieldDefinition()', () => { + it('should return field definition for existing field', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const fieldDef = manager.getFieldDefinition('test-conn', 'users', 'email'); + + expect(fieldDef).toBeDefined(); + expect(fieldDef?.type).toBe('Email'); + expect(fieldDef?.required).toBe(true); + }); + + it('should return field definition with allowedValues for Enum field', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const fieldDef = manager.getFieldDefinition('test-conn', 'users', 'status'); + + expect(fieldDef).toBeDefined(); + expect(fieldDef?.type).toBe('Enum'); + expect(fieldDef?.required).toBe(true); + expect(fieldDef?.allowedValues).toEqual(['Active', 'Inactive', 'Pending']); + }); + + it('should return field definition for EnumList field', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const fieldDef = manager.getFieldDefinition('test-conn', 'users', 'tags'); + + expect(fieldDef).toBeDefined(); + expect(fieldDef?.type).toBe('EnumList'); + expect(fieldDef?.required).toBe(false); + expect(fieldDef?.allowedValues).toEqual(['Admin', 'User', 'Guest']); + }); + + it('should return undefined for non-existent connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const fieldDef = manager.getFieldDefinition('nonexistent', 'users', 'email'); + + expect(fieldDef).toBeUndefined(); + }); + + it('should return undefined for non-existent table', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const fieldDef = manager.getFieldDefinition('test-conn', 'nonexistent', 'email'); + + expect(fieldDef).toBeUndefined(); + }); + + it('should return undefined for non-existent field', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const fieldDef = manager.getFieldDefinition('test-conn', 'users', 'nonexistent'); + + expect(fieldDef).toBeUndefined(); + }); + }); + + describe('getAllowedValues()', () => { + it('should return allowed values for Enum field', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const values = manager.getAllowedValues('test-conn', 'users', 'status'); + + expect(values).toEqual(['Active', 'Inactive', 'Pending']); + }); + + it('should return allowed values for EnumList field', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const values = manager.getAllowedValues('test-conn', 'users', 'tags'); + + expect(values).toEqual(['Admin', 'User', 'Guest']); + }); + + it('should return undefined for field without allowedValues', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const values = manager.getAllowedValues('test-conn', 'users', 'email'); + + expect(values).toBeUndefined(); + }); + + it('should return undefined for non-existent field', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const values = manager.getAllowedValues('test-conn', 'users', 'nonexistent'); + + expect(values).toBeUndefined(); + }); + + it('should return undefined for non-existent table', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const values = manager.getAllowedValues('test-conn', 'nonexistent', 'status'); + + expect(values).toBeUndefined(); + }); + + it('should return undefined for non-existent connection', () => { + const factory = new MockAppSheetClientFactory(); + const manager = new SchemaManager(factory, baseSchema); + + const values = manager.getAllowedValues('nonexistent', 'users', 'status'); + + expect(values).toBeUndefined(); }); }); });