From cfe01852490912af02c9bff8dd10545cf5cb8017 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 10 Oct 2025 04:56:25 -0400 Subject: [PATCH] Simplify scale test API module to synthetic data generator - Transform from HTTP-based API to deterministic synthetic data generator - Generate up to 3M contacts on-the-fly without external dependencies - Follow Hubspot module pattern: export Api, Definition, Config - Remove activities, bulk exports, config endpoints, mutation tracking - Move OpenAPI spec to module directory and simplify - Fix typo: rename defintion.ts to definition.ts - Update tests to verify synthetic generation (11 tests passing) - Add comprehensive README and FUTURE_ROADMAP documentation --- openapi/frigg-scale-test-mock-crm.yaml | 525 ------------------ package-lock.json | 1 + .../frigg-scale-test/FUTURE_ROADMAP.md | 42 ++ packages/v1-ready/frigg-scale-test/README.md | 145 +++++ .../frigg-scale-test/defaultConfig.json | 6 + .../frigg-scale-test-mock-crm.yaml | 109 ++++ packages/v1-ready/frigg-scale-test/src/api.ts | 258 +++++---- .../frigg-scale-test/src/definition.ts | 57 ++ .../frigg-scale-test/src/defintion.ts | 55 -- .../frigg-scale-test/src/handler-proxy.d.ts | 4 - .../v1-ready/frigg-scale-test/src/index.ts | 7 +- .../frigg-scale-test/test/smoke.test.ts | 215 ++++--- 12 files changed, 602 insertions(+), 822 deletions(-) delete mode 100644 openapi/frigg-scale-test-mock-crm.yaml create mode 100644 packages/v1-ready/frigg-scale-test/FUTURE_ROADMAP.md create mode 100644 packages/v1-ready/frigg-scale-test/defaultConfig.json create mode 100644 packages/v1-ready/frigg-scale-test/frigg-scale-test-mock-crm.yaml create mode 100644 packages/v1-ready/frigg-scale-test/src/definition.ts delete mode 100644 packages/v1-ready/frigg-scale-test/src/defintion.ts delete mode 100644 packages/v1-ready/frigg-scale-test/src/handler-proxy.d.ts diff --git a/openapi/frigg-scale-test-mock-crm.yaml b/openapi/frigg-scale-test-mock-crm.yaml deleted file mode 100644 index 5fff1f8..0000000 --- a/openapi/frigg-scale-test-mock-crm.yaml +++ /dev/null @@ -1,525 +0,0 @@ -openapi: 3.0.3 -info: - title: Frigg Scale Test Mock CRM - version: 0.1.0 - description: Hybrid mock CRM for Frigg scale testing: stateless synthetic base + small persisted change-log. -servers: - - url: https://mock.local -tags: - - name: Health - - name: Config - - name: Contacts - - name: Activities - - name: Bulk - - name: State -paths: - /health: - get: - tags: [Health] - summary: Liveness check - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/Health" - /config/{accountId}: - get: - tags: [Config] - summary: Get per-account config - parameters: - - in: path - name: accountId - required: true - schema: - type: string - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/Config" - put: - tags: [Config] - summary: Upsert per-account config - parameters: - - in: path - name: accountId - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Config" - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/Config" - /contacts: - get: - tags: [Contacts] - summary: List contacts (cursor pagination; optional delta) - parameters: - - in: query - name: accountId - required: true - schema: - type: string - - in: query - name: limit - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 100 - - in: query - name: cursor - schema: - type: string - - in: query - name: updatedSince - schema: - type: string - format: date-time - responses: - "200": - description: OK - headers: - X-RateLimit-Limit: - schema: - type: integer - X-RateLimit-Remaining: - schema: - type: integer - Retry-After: - schema: - type: integer - description: Present when throttled - content: - application/json: - schema: - $ref: "#/components/schemas/ContactPage" - "429": - description: Throttled - headers: - Retry-After: - schema: - type: integer - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /activities: - get: - tags: [Activities] - summary: List activities (cursor pagination; filter by type/contactId; delta) - parameters: - - in: query - name: accountId - required: true - schema: - type: string - - in: query - name: limit - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 100 - - in: query - name: cursor - schema: - type: string - - in: query - name: type - schema: - type: string - enum: [phone_call, email, sms] - - in: query - name: contactId - schema: - type: string - - in: query - name: updatedSince - schema: - type: string - format: date-time - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/ActivityPage" - post: - tags: [Activities] - summary: Create an activity (writes to hybrid change-log) - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ActivityWrite" - responses: - "200": - description: Created - content: - application/json: - schema: - $ref: "#/components/schemas/Activity" - /bulk/exports/contacts: - post: - tags: [Bulk] - summary: Request contacts export (NDJSON/CSV) - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [accountId, format] - properties: - accountId: - type: string - format: - type: string - enum: [ndjson, csv] - default: ndjson - fields: - type: array - items: - type: string - responses: - "202": - description: Accepted - content: - application/json: - schema: - $ref: "#/components/schemas/ExportJobAccepted" - /bulk/exports/activities: - post: - tags: [Bulk] - summary: Request activities export (NDJSON/CSV) - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [accountId, format] - properties: - accountId: - type: string - format: - type: string - enum: [ndjson, csv] - default: ndjson - fields: - type: array - items: - type: string - responses: - "202": - description: Accepted - content: - application/json: - schema: - $ref: "#/components/schemas/ExportJobAccepted" - /bulk/exports/{jobId}: - get: - tags: [Bulk] - summary: Get export job status (and download URL if complete) - parameters: - - in: path - name: jobId - required: true - schema: - type: string - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/ExportJob" - "404": - description: Not found - /state/mutations: - get: - tags: [State] - summary: List hybrid change-log mutations (testing/admin) - parameters: - - in: query - name: accountId - required: true - schema: - type: string - - in: query - name: since - schema: - type: string - format: date-time - - in: query - name: cursor - schema: - type: string - - in: query - name: limit - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 100 - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/MutationPage" -components: - schemas: - Health: - type: object - properties: - ok: - type: boolean - Error: - type: object - properties: - error: - type: string - message: - type: string - Config: - type: object - properties: - pageSizeDefault: - type: integer - default: 100 - pageSizeMax: - type: integer - default: 1000 - rpsLimit: - type: number - default: 10 - latencyMs: - type: integer - default: 0 - jitterMs: - type: integer - default: 0 - errorRatePct: - type: number - default: 0 - throttleRatePct: - type: number - default: 0 - supportsDelta: - type: boolean - default: true - bulkMaxCount: - type: integer - default: 1000000 - features: - type: array - items: - type: string - Contact: - type: object - required: [id, email, updatedAt] - properties: - id: - type: string - email: - type: string - format: email - phone: - type: string - firstName: - type: string - lastName: - type: string - company: - type: string - address: - type: string - city: - type: string - region: - type: string - country: - type: string - postalCode: - type: string - updatedAt: - type: string - format: date-time - payload: - type: object - additionalProperties: true - ContactPage: - type: object - required: [items] - properties: - items: - type: array - items: - $ref: "#/components/schemas/Contact" - nextCursor: - type: string - nullable: true - Activity: - type: object - required: [id, type, updatedAt, subject, contactId] - properties: - id: - type: string - type: - type: string - enum: [phone_call, email, sms] - contactId: - type: string - direction: - type: string - enum: [inbound, outbound] - subject: - type: string - body: - type: string - from: - type: string - to: - type: string - status: - type: string - enum: [queued, sent, delivered, failed, completed] - durationSec: - type: number - nullable: true - sentAt: - type: string - format: date-time - nullable: true - updatedAt: - type: string - format: date-time - payload: - type: object - additionalProperties: true - ActivityWrite: - type: object - required: [accountId, type, contactId, subject] - properties: - accountId: - type: string - type: - type: string - enum: [phone_call, email, sms] - contactId: - type: string - direction: - type: string - enum: [inbound, outbound] - subject: - type: string - body: - type: string - from: - type: string - to: - type: string - status: - type: string - durationSec: - type: number - sentAt: - type: string - format: date-time - payload: - type: object - additionalProperties: true - ActivityPage: - type: object - required: [items] - properties: - items: - type: array - items: - $ref: "#/components/schemas/Activity" - nextCursor: - type: string - nullable: true - ExportJobAccepted: - type: object - properties: - jobId: - type: string - status: - type: string - enum: [PENDING] - format: - type: string - enum: [ndjson, csv] - ExportJob: - type: object - properties: - jobId: - type: string - status: - type: string - enum: [PENDING, RUNNING, COMPLETE, FAILED] - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - itemCount: - type: integer - downloadUrl: - type: string - format: uri - nullable: true - format: - type: string - enum: [ndjson, csv] - Mutation: - type: object - properties: - id: - type: string - accountId: - type: string - entity: - type: string - enum: [contact, activity] - op: - type: string - enum: [create, update, delete] - refId: - type: string - at: - type: string - format: date-time - payload: - type: object - additionalProperties: true - MutationPage: - type: object - properties: - items: - type: array - items: - $ref: "#/components/schemas/Mutation" - nextCursor: - type: string - nullable: true diff --git a/package-lock.json b/package-lock.json index 615c142..ff44d7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24203,6 +24203,7 @@ } }, "packages/v1-ready/frigg-scale-test": { + "name": "@friggframework/api-module-frigg-scale-test", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/packages/v1-ready/frigg-scale-test/FUTURE_ROADMAP.md b/packages/v1-ready/frigg-scale-test/FUTURE_ROADMAP.md new file mode 100644 index 0000000..2074231 --- /dev/null +++ b/packages/v1-ready/frigg-scale-test/FUTURE_ROADMAP.md @@ -0,0 +1,42 @@ +# Future Roadmap + +This document tracks features that were part of the original specification but have been commented out or removed to simplify the initial implementation. These may be added back in future iterations. + +## Removed Features + +### Activities +- **listActivities**: List activities with filtering by type, contactId, and delta sync +- **createActivity**: Create new activities (phone calls, emails, SMS) +- Activity types supported: phone_call, email, sms + +### Bulk Export Operations +- **requestContactsExport**: Request bulk export of contacts in NDJSON/CSV format +- **requestActivitiesExport**: Request bulk export of activities +- **getExportJob**: Poll export job status and retrieve download URL + +### Configuration Management +- **getConfig**: Retrieve per-account configuration (page size, rate limits, latency simulation, error rates) +- **putConfig**: Update per-account configuration for testing different scenarios + +### Mutation Tracking +- **Hybrid change-log**: Track create/update/delete operations on contacts and activities +- Persistent state layer for testing delta sync and webhook simulations + +## Rationale for Removal + +The current focus is on providing a simple, deterministic synthetic data generator for contact scale testing. The above features added complexity around: +- HTTP communication layers +- State persistence and management +- Multiple entity types +- Advanced testing scenarios + +These can be incrementally added back as needed for more sophisticated testing scenarios. + +## Implementation Notes for Future + +When re-implementing these features, consider: +1. The lambda handler in `/services/frigg-scale-test-lambda` may need updates +2. OpenAPI spec in `frigg-scale-test-mock-crm.yaml` can be expanded +3. Maintain deterministic generation for all synthetic data +4. Consider memory efficiency for bulk operations + diff --git a/packages/v1-ready/frigg-scale-test/README.md b/packages/v1-ready/frigg-scale-test/README.md index e69de29..8815299 100644 --- a/packages/v1-ready/frigg-scale-test/README.md +++ b/packages/v1-ready/frigg-scale-test/README.md @@ -0,0 +1,145 @@ +# Frigg Scale Test API Module + +A synthetic data generator for scale testing that deterministically generates up to 3 million contacts without requiring external services or data storage. + +## Features + +- **Deterministic Generation**: Same input parameters always produce the same contacts +- **Memory Efficient**: Generates contacts on-the-fly during pagination without storing them in memory +- **Scalable**: Supports up to 3 million unique contacts per account +- **Predictable Pagination**: Cursor-based pagination with configurable page sizes +- **No Dependencies**: No external services or databases required + +## Installation + +```bash +npm install @friggframework/api-module-frigg-scale-test +``` + +## Usage + +```javascript +const { Api, Definition, Config } = require('@friggframework/api-module-frigg-scale-test'); + +// Initialize the API +const api = new Api(); + +// Health check +const health = await api.health(); +console.log(health); // { ok: true } + +// List contacts with pagination +const page1 = await api.listContacts({ + accountId: 'demo', + limit: 100 +}); + +console.log(page1.items.length); // 100 +console.log(page1.nextCursor); // '100' (offset for next page) + +// Get next page +const page2 = await api.listContacts({ + accountId: 'demo', + limit: 100, + cursor: page1.nextCursor +}); + +// Filter by updatedSince +const recentContacts = await api.listContacts({ + accountId: 'demo', + limit: 100, + updatedSince: '2024-01-01T00:00:00Z' +}); +``` + +## API Methods + +### `health()` + +Returns a health check status. + +**Returns:** `Promise<{ ok: boolean }>` + +### `listContacts(params)` + +Generates and returns a page of synthetic contacts. + +**Parameters:** +- `accountId` (string, required): Account identifier used for deterministic generation +- `limit` (number, optional): Number of contacts to return (default: 100, max: 1000) +- `cursor` (string, optional): Pagination cursor (offset) +- `updatedSince` (string, optional): ISO 8601 timestamp to filter contacts + +**Returns:** `Promise<{ items: Contact[], nextCursor: string | null }>` + +**Contact Schema:** +```typescript +{ + id: string; + email: string; + phone: string; + firstName: string; + lastName: string; + company: string; + address: string; + city: string; + region: string; + country: string; + postalCode: string; + updatedAt: string; // ISO 8601 + payload: { + generatedIndex: number; + accountId: string; + }; +} +``` + +## Configuration + +The module exports a `Config` object with metadata: + +```javascript +{ + "name": "frigg-scale-test", + "label": "Frigg Scale Test", + "description": "Mock CRM for scale testing with deterministic synthetic data" +} +``` + +## Definition + +The module exports a Frigg Framework `Definition` object for integration with the Frigg framework's authentication and module system. + +## Deterministic Generation + +All contacts are generated deterministically using a seeded random number generator. The seed is derived from the `accountId` and contact index, ensuring: + +1. Same `accountId` + `index` always produces the same contact +2. Different `accountId` values produce different contact sets +3. Contacts remain unique across the full 3 million record range + +## Future Features + +See [FUTURE_ROADMAP.md](./FUTURE_ROADMAP.md) for planned features including: +- Activities (phone calls, emails, SMS) +- Bulk export operations +- Configuration management for testing scenarios +- Mutation tracking for delta sync testing + +## Development + +```bash +# Build +npm run build + +# Test +npm test + +# Lint +npm run lint +``` + +## License + +MIT + diff --git a/packages/v1-ready/frigg-scale-test/defaultConfig.json b/packages/v1-ready/frigg-scale-test/defaultConfig.json new file mode 100644 index 0000000..8ed2f15 --- /dev/null +++ b/packages/v1-ready/frigg-scale-test/defaultConfig.json @@ -0,0 +1,6 @@ +{ + "name": "frigg-scale-test", + "label": "Frigg Scale Test", + "description": "Mock CRM for scale testing with deterministic synthetic data" +} + diff --git a/packages/v1-ready/frigg-scale-test/frigg-scale-test-mock-crm.yaml b/packages/v1-ready/frigg-scale-test/frigg-scale-test-mock-crm.yaml new file mode 100644 index 0000000..839bddf --- /dev/null +++ b/packages/v1-ready/frigg-scale-test/frigg-scale-test-mock-crm.yaml @@ -0,0 +1,109 @@ +openapi: 3.0.3 +info: + title: Frigg Scale Test Mock CRM + version: 0.1.0 + description: Synthetic data generator for Frigg scale testing with deterministic contact generation (up to 3M records) +servers: + - url: https://mock.local +tags: + - name: Health + - name: Contacts +paths: + /health: + get: + tags: [Health] + summary: Health check + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + /contacts: + get: + tags: [Contacts] + summary: List contacts (cursor pagination, deterministically generated) + parameters: + - in: query + name: accountId + required: true + schema: + type: string + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + - in: query + name: cursor + schema: + type: string + description: Offset cursor for pagination + - in: query + name: updatedSince + schema: + type: string + format: date-time + description: Filter contacts updated after this timestamp + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ContactPage" +components: + schemas: + Health: + type: object + properties: + ok: + type: boolean + Contact: + type: object + required: [id, email, updatedAt] + properties: + id: + type: string + email: + type: string + format: email + phone: + type: string + firstName: + type: string + lastName: + type: string + company: + type: string + address: + type: string + city: + type: string + region: + type: string + country: + type: string + postalCode: + type: string + updatedAt: + type: string + format: date-time + payload: + type: object + additionalProperties: true + ContactPage: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/Contact" + nextCursor: + type: string + nullable: true + diff --git a/packages/v1-ready/frigg-scale-test/src/api.ts b/packages/v1-ready/frigg-scale-test/src/api.ts index 75306f0..0ac3a44 100644 --- a/packages/v1-ready/frigg-scale-test/src/api.ts +++ b/packages/v1-ready/frigg-scale-test/src/api.ts @@ -5,147 +5,159 @@ export type ListParams = { updatedSince?: string; }; -export type ListActivitiesParams = ListParams & { - type?: "phone_call" | "email" | "sms"; - contactId?: string; -}; - -export default class FriggScaleTestAPI { - constructor(readonly opts: { baseUrl?: string; apiKey?: string } = {}) {} +// Simple seeded pseudo-random number generator for deterministic results +class SeededRandom { + private seed: number; - private get base(): string { - return ( - this.opts.baseUrl || - process.env.FRIGG_SCALE_TEST_BASE_URL || - "http://localhost:4000" - ); + constructor(seed: number) { + this.seed = seed; } - private headers(): Record { - const apiKey = this.opts.apiKey || process.env.FRIGG_SCALE_TEST_API_KEY; - return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; + next(): number { + this.seed = (this.seed * 9301 + 49297) % 233280; + return this.seed / 233280; } - async health() { - const r = await fetch(new URL("/health", this.base)); - if (!r.ok) throw new Error(`health ${r.status}`); - return r.json(); + nextInt(min: number, max: number): number { + return Math.floor(this.next() * (max - min + 1)) + min; } +} - async getConfig(accountId: string) { - const r = await fetch( - new URL(`/config/${encodeURIComponent(accountId)}`, this.base), - { - headers: this.headers(), - } - ); - if (!r.ok) throw new Error(`getConfig ${r.status}`); - return r.json(); +// Hash function to convert string to seed +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer } + return Math.abs(hash); +} - async putConfig(accountId: string, cfg: any) { - const headers: Record = { - "content-type": "application/json", - ...this.headers(), - }; - const r = await fetch( - new URL(`/config/${encodeURIComponent(accountId)}`, this.base), - { - method: "PUT", - headers, - body: JSON.stringify(cfg), - } - ); - if (!r.ok) throw new Error(`putConfig ${r.status}`); - return r.json(); +const FIRST_NAMES = [ + "James", "Mary", "John", "Patricia", "Robert", "Jennifer", "Michael", "Linda", + "William", "Barbara", "David", "Elizabeth", "Richard", "Susan", "Joseph", "Jessica", + "Thomas", "Sarah", "Charles", "Karen", "Christopher", "Nancy", "Daniel", "Lisa", + "Matthew", "Betty", "Anthony", "Margaret", "Mark", "Sandra", "Donald", "Ashley", +]; + +const LAST_NAMES = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", + "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", + "Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", "White", + "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", "Walker", "Young", +]; + +const COMPANIES = [ + "Acme Corp", "Global Industries", "Tech Solutions", "Innovation Labs", "Enterprise Systems", + "Digital Ventures", "Cloud Services", "Data Analytics", "Software Group", "Consulting Partners", + "Strategic Solutions", "Business Dynamics", "Market Leaders", "Growth Capital", "Venture Partners", +]; + +const CITIES = [ + "New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia", "San Antonio", + "San Diego", "Dallas", "San Jose", "Austin", "Jacksonville", "Fort Worth", "Columbus", + "Charlotte", "San Francisco", "Indianapolis", "Seattle", "Denver", "Washington", +]; + +const REGIONS = [ + "NY", "CA", "IL", "TX", "AZ", "PA", "FL", "OH", "NC", "WA", "CO", "DC", +]; + +const COUNTRIES = ["USA", "United States", "US"]; + +export default class Api { + private readonly maxContacts = 3000000; + + constructor(readonly opts: { baseUrl?: string; apiKey?: string } = {}) { } + + async health() { + return { ok: true }; } async listContacts(params: ListParams) { - const url = new URL(`/contacts`, this.base); - url.searchParams.set("accountId", params.accountId); - if (params.limit) url.searchParams.set("limit", String(params.limit)); - if (params.cursor) url.searchParams.set("cursor", params.cursor); - if (params.updatedSince) - url.searchParams.set("updatedSince", params.updatedSince); - const r = await fetch(url, { headers: this.headers() }); - if (!r.ok) throw new Error(`listContacts ${r.status}`); - return r.json(); - } + const { accountId, limit = 100, cursor, updatedSince } = params; - async listActivities(params: ListActivitiesParams) { - const url = new URL(`/activities`, this.base); - url.searchParams.set("accountId", params.accountId); - if (params.limit) url.searchParams.set("limit", String(params.limit)); - if (params.cursor) url.searchParams.set("cursor", params.cursor); - if (params.updatedSince) - url.searchParams.set("updatedSince", params.updatedSince); - if (params.type) url.searchParams.set("type", params.type); - if (params.contactId) url.searchParams.set("contactId", params.contactId); - const r = await fetch(url, { headers: this.headers() }); - if (!r.ok) throw new Error(`listActivities ${r.status}`); - return r.json(); - } + // Parse cursor (offset) or start at 0 + const offset = cursor ? parseInt(cursor, 10) : 0; + const actualLimit = Math.min(limit, 1000); // Cap at 1000 per page - async createActivity(body: any) { - const headers: Record = { - "content-type": "application/json", - ...this.headers(), - }; - const r = await fetch(new URL(`/activities`, this.base), { - method: "POST", - headers, - body: JSON.stringify(body), - }); - if (!r.ok) throw new Error(`createActivity ${r.status}`); - return r.json(); - } + // Generate contacts deterministically + const items = []; + const endOffset = Math.min(offset + actualLimit, this.maxContacts); - async requestContactsExport(body: { - accountId: string; - format?: "ndjson" | "csv"; - fields?: string[]; - }) { - const headers: Record = { - "content-type": "application/json", - ...this.headers(), - }; - const r = await fetch(new URL(`/bulk/exports/contacts`, this.base), { - method: "POST", - headers, - body: JSON.stringify(body), - }); - if (r.status !== 202 && r.status !== 200) - throw new Error(`requestContactsExport ${r.status}`); - return r.json(); - } + for (let i = offset; i < endOffset; i++) { + const contact = this.generateContact(accountId, i); + + // Filter by updatedSince if provided + if (updatedSince) { + const updatedSinceDate = new Date(updatedSince); + const contactUpdatedDate = new Date(contact.updatedAt); + if (contactUpdatedDate < updatedSinceDate) { + continue; + } + } + + items.push(contact); + } + + // Determine if there are more records + const hasMore = endOffset < this.maxContacts; + const nextCursor = hasMore ? endOffset.toString() : null; - async requestActivitiesExport(body: { - accountId: string; - format?: "ndjson" | "csv"; - fields?: string[]; - }) { - const headers: Record = { - "content-type": "application/json", - ...this.headers(), + return { + items, + nextCursor, }; - const r = await fetch(new URL(`/bulk/exports/activities`, this.base), { - method: "POST", - headers, - body: JSON.stringify(body), - }); - if (r.status !== 202 && r.status !== 200) - throw new Error(`requestActivitiesExport ${r.status}`); - return r.json(); } - async getExportJob(jobId: string) { - const r = await fetch( - new URL(`/bulk/exports/${encodeURIComponent(jobId)}`, this.base), - { - headers: this.headers(), - } - ); - if (!r.ok) throw new Error(`getExportJob ${r.status}`); - return r.json(); + private generateContact(accountId: string, index: number) { + // Create deterministic seed from accountId and index + const seed = hashString(`${accountId}-${index}`); + const rng = new SeededRandom(seed); + + const firstName = FIRST_NAMES[rng.nextInt(0, FIRST_NAMES.length - 1)]; + const lastName = LAST_NAMES[rng.nextInt(0, LAST_NAMES.length - 1)]; + const company = COMPANIES[rng.nextInt(0, COMPANIES.length - 1)]; + const city = CITIES[rng.nextInt(0, CITIES.length - 1)]; + const region = REGIONS[rng.nextInt(0, REGIONS.length - 1)]; + const country = COUNTRIES[rng.nextInt(0, COUNTRIES.length - 1)]; + + // Generate email and phone deterministically + const emailDomain = ["example.com", "test.com", "demo.com", "sample.com"]; + const domain = emailDomain[rng.nextInt(0, emailDomain.length - 1)]; + const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}.${index}@${domain}`; + + const areaCode = 200 + rng.nextInt(0, 799); + const phone = `+1-${areaCode}-${rng.nextInt(100, 999)}-${rng.nextInt(1000, 9999)}`; + + const postalCode = `${rng.nextInt(10000, 99999)}`; + const address = `${rng.nextInt(1, 9999)} ${["Main", "Oak", "Maple", "Cedar", "Pine"][rng.nextInt(0, 4)]} St`; + + // Generate updatedAt timestamp (spread across last year) + const now = new Date(); + const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); + const timeDiff = now.getTime() - yearAgo.getTime(); + const randomTime = yearAgo.getTime() + (rng.next() * timeDiff); + const updatedAt = new Date(randomTime).toISOString(); + + return { + id: `contact-${index}`, + email, + phone, + firstName, + lastName, + company, + address, + city, + region, + country, + postalCode, + updatedAt, + payload: { + generatedIndex: index, + accountId, + }, + }; } } diff --git a/packages/v1-ready/frigg-scale-test/src/definition.ts b/packages/v1-ready/frigg-scale-test/src/definition.ts new file mode 100644 index 0000000..ce7d4af --- /dev/null +++ b/packages/v1-ready/frigg-scale-test/src/definition.ts @@ -0,0 +1,57 @@ +import { FriggModuleAuthDefinition } from "@friggframework/core"; +import Api from "./api"; +const config = require("../defaultConfig.json"); + +const Definition: FriggModuleAuthDefinition = { + API: Api, + getName: () => config.label, + moduleName: config.name, + requiredAuthMethods: { + getToken: async ( + api: Api, + params: { data: { apiKey?: string; access_token?: string } } + ) => { + // For scale test API, use API key authentication + const apiKey = params.data?.apiKey || params.data?.access_token; + return { access_token: apiKey }; + }, + getEntityDetails: async ( + api: Api, + callbackParams: any, + tokenResponse: any, + userId: string + ) => { + const healthCheck = await api.health(); + return { + identifiers: { + externalId: "scale-test-account", + user: userId, + }, + details: { + name: "Scale Test Account", + status: healthCheck.ok ? "healthy" : "error", + }, + }; + }, + apiPropertiesToPersist: { + credential: ["access_token"], + entity: [], + }, + getCredentialDetails: async (api: Api, userId: string) => { + return { + identifiers: { + externalId: "scale-test-account", + user: userId, + }, + details: {}, + }; + }, + testAuthRequest: async (api: Api) => api.health(), + }, + env: { + apiKey: process.env.SCALE_TEST_API_KEY, + }, +}; + +export default Definition; + diff --git a/packages/v1-ready/frigg-scale-test/src/defintion.ts b/packages/v1-ready/frigg-scale-test/src/defintion.ts deleted file mode 100644 index 1364721..0000000 --- a/packages/v1-ready/frigg-scale-test/src/defintion.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { FriggModuleAuthDefinition } from "@friggframework/core"; -import FriggScaleTestAPI from "./api"; - -const definition: FriggModuleAuthDefinition = { - API: FriggScaleTestAPI, - getName: () => "Frgg Scale Test API", - moduleName: "scale-test", - requiredAuthMethods: { - getToken: async ( - api: FriggScaleTestAPI, - params: { data: { apiKey?: string; access_token?: string } } - ) => { - // For scale test API, use API key authentication - const apiKey = params.data?.apiKey || params.data?.access_token; - return { access_token: apiKey }; - }, - getEntityDetails: async ( - api: FriggScaleTestAPI, - callbackParams: any, - tokenResponse: any, - userId: string - ) => { - const healthCheck = await api.health(); - return { - identifiers: { - externalId: "scale-test-account", - user: userId, - }, - details: { - name: "Scale Test Account", - status: healthCheck.ok ? "healthy" : "error", - }, - }; - }, - apiPropertiesToPersist: { - credential: ["access_token"], - entity: [], - }, - getCredentialDetails: async (api: FriggScaleTestAPI, userId: string) => { - return { - identifiers: { - externalId: "scale-test-account", - user: userId, - }, - details: {}, - }; - }, - testAuthRequest: async (api: FriggScaleTestAPI) => api.health(), - }, - env: { - apiKey: process.env.SCALE_TEST_API_KEY, - }, -}; - -export default definition; diff --git a/packages/v1-ready/frigg-scale-test/src/handler-proxy.d.ts b/packages/v1-ready/frigg-scale-test/src/handler-proxy.d.ts deleted file mode 100644 index 568ff86..0000000 --- a/packages/v1-ready/frigg-scale-test/src/handler-proxy.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "services/frigg-scale-test-lambda/src/handler" { - import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; - export function handler(event: APIGatewayProxyEventV2): Promise; -} diff --git a/packages/v1-ready/frigg-scale-test/src/index.ts b/packages/v1-ready/frigg-scale-test/src/index.ts index 9175250..52ba44a 100644 --- a/packages/v1-ready/frigg-scale-test/src/index.ts +++ b/packages/v1-ready/frigg-scale-test/src/index.ts @@ -1,4 +1,5 @@ -import FriggScaleTestAPI from "./api"; -import Definition from "./defintion"; +import Api from "./api"; +import Definition from "./definition"; +const Config = require("../defaultConfig.json"); -export { FriggScaleTestAPI, Definition }; +export { Api, Definition, Config }; diff --git a/packages/v1-ready/frigg-scale-test/test/smoke.test.ts b/packages/v1-ready/frigg-scale-test/test/smoke.test.ts index 997948c..2fc6ee1 100644 --- a/packages/v1-ready/frigg-scale-test/test/smoke.test.ts +++ b/packages/v1-ready/frigg-scale-test/test/smoke.test.ts @@ -1,129 +1,120 @@ -import { createServer, IncomingMessage, ServerResponse } from "http"; -import { AddressInfo } from "net"; -import { handler } from "services/frigg-scale-test-lambda/src/handler"; -import FriggScaleTestAPI from "../src/api"; -import { APIGatewayProxyEventV2 } from "aws-lambda"; - -let server: ReturnType; -let baseUrl: string; - -function buildEvent( - req: IncomingMessage, - body: string -): APIGatewayProxyEventV2 { - const url = new URL(req.url || "/", "http://localhost"); - const headers: Record = {}; - for (const [key, value] of Object.entries(req.headers)) { - if (typeof value === "string") headers[key] = value; - if (Array.isArray(value)) headers[key] = value.join(","); - } - const query: Record = {}; - url.searchParams.forEach((value, key) => { - query[key] = value; +import Api from "../src/api"; + +const api = new Api(); + +describe("Frigg Scale Test Mock CRM - Synthetic Data Generator", () => { + it("health check returns ok", async () => { + const health = await api.health(); + expect(health.ok).toBe(true); }); - return { - version: "2.0", - routeKey: "$default", - rawPath: url.pathname, - rawQueryString: url.search.slice(1), - headers, - queryStringParameters: Object.keys(query).length ? query : null, - requestContext: { - accountId: "local", - apiId: "local", - domainName: "localhost", - domainPrefix: "", - requestId: "local", - routeKey: "$default", - stage: "$default", - time: new Date().toISOString(), - timeEpoch: Date.now(), - http: { - method: req.method || "GET", - path: url.pathname, - protocol: "HTTP/1.1", - sourceIp: "127.0.0.1", - userAgent: "jest", - }, - }, - isBase64Encoded: false, - body: body || undefined, - pathParameters: null, - stageVariables: null, - cookies: [], - multiValueQueryStringParameters: null, - } as unknown as APIGatewayProxyEventV2; -} - -function ensureStructured(result: Awaited>) { - if (typeof result === "string") { - return { statusCode: 200, headers: {}, body: result } as const; - } - return result; -} - -beforeAll(async () => { - server = createServer((req: IncomingMessage, res: ServerResponse) => { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", async () => { - const body = Buffer.concat(chunks).toString(); - try { - const event = buildEvent(req, body); - const result = ensureStructured(await handler(event)); - res.statusCode = result.statusCode || 200; - for (const [key, value] of Object.entries(result.headers || {})) { - if (value !== undefined) { - res.setHeader(key, value as string); - } - } - res.end(result.body || ""); - } catch (err: any) { - res.statusCode = 500; - res.end(JSON.stringify({ error: err?.message || "error" })); - } - }); + + it("listContacts generates contacts with pagination", async () => { + const page = await api.listContacts({ accountId: "demo", limit: 10 }); + expect(Array.isArray(page.items)).toBe(true); + expect(page.items.length).toBe(10); + expect(page.nextCursor).toBeTruthy(); }); - await new Promise((resolve) => { - server.listen(0, () => resolve()); + it("contacts have required fields", async () => { + const page = await api.listContacts({ accountId: "demo", limit: 1 }); + const contact = page.items[0]; + + expect(contact.id).toBeTruthy(); + expect(contact.email).toMatch(/@/); + expect(contact.firstName).toBeTruthy(); + expect(contact.lastName).toBeTruthy(); + expect(contact.phone).toBeTruthy(); + expect(contact.company).toBeTruthy(); + expect(contact.address).toBeTruthy(); + expect(contact.city).toBeTruthy(); + expect(contact.region).toBeTruthy(); + expect(contact.country).toBeTruthy(); + expect(contact.postalCode).toBeTruthy(); + expect(contact.updatedAt).toBeTruthy(); }); - const address = server.address() as AddressInfo; - baseUrl = `http://127.0.0.1:${address.port}`; - process.env.FRIGG_SCALE_TEST_BASE_URL = baseUrl; -}); -afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); -}); + it("generates deterministic results (same params = same data)", async () => { + const page1 = await api.listContacts({ accountId: "demo", limit: 5 }); + const page2 = await api.listContacts({ accountId: "demo", limit: 5 }); + + expect(page1.items).toEqual(page2.items); + }); -const api = new FriggScaleTestAPI(); + it("generates different contacts for different accountIds", async () => { + const page1 = await api.listContacts({ accountId: "account-1", limit: 5 }); + const page2 = await api.listContacts({ accountId: "account-2", limit: 5 }); -describe("Frigg Scale Test Mock CRM", () => { - it("health", async () => { - const health = await api.health(); - expect(health.ok).toBeTruthy(); + expect(page1.items[0].email).not.toEqual(page2.items[0].email); }); - it("contacts pagination", async () => { - const page = await api.listContacts({ accountId: "demo", limit: 10 }); - expect(Array.isArray(page.items)).toBe(true); - expect(page.items.length).toBeLessThanOrEqual(10); + it("pagination cursor works correctly", async () => { + const page1 = await api.listContacts({ accountId: "demo", limit: 10 }); + const page2 = await api.listContacts({ + accountId: "demo", + limit: 10, + cursor: page1.nextCursor! + }); + + expect(page2.items.length).toBe(10); + expect(page1.items[0].id).not.toEqual(page2.items[0].id); + expect(page1.items[9].id).not.toEqual(page2.items[0].id); }); - it("activities list and create", async () => { - const list = await api.listActivities({ + it("generates unique contact IDs across pages", async () => { + const page1 = await api.listContacts({ accountId: "demo", limit: 100 }); + const page2 = await api.listContacts({ accountId: "demo", - limit: 5, - type: "email", + limit: 100, + cursor: page1.nextCursor! }); - expect(Array.isArray(list.items)).toBe(true); - const created = await api.createActivity({ + + const ids1 = new Set(page1.items.map(c => c.id)); + const ids2 = new Set(page2.items.map(c => c.id)); + + // Check no overlap + const intersection = [...ids1].filter(id => ids2.has(id)); + expect(intersection.length).toBe(0); + }); + + it("respects limit parameter", async () => { + const limits = [1, 10, 50, 100, 500]; + + for (const limit of limits) { + const page = await api.listContacts({ accountId: "demo", limit }); + expect(page.items.length).toBe(limit); + } + }); + + it("supports up to 3 million contacts", async () => { + // Jump to near the end + const offset = 2999990; + const page = await api.listContacts({ accountId: "demo", - type: "sms", - contactId: "contact-1", - subject: "Ping", + limit: 20, + cursor: offset.toString() }); - expect(created.id).toBeTruthy(); + + expect(page.items.length).toBe(10); // Only 10 left (2999990 to 2999999) + expect(page.nextCursor).toBeNull(); // No more pages + expect(page.items[page.items.length - 1].id).toBe("contact-2999999"); + }); + + it("generates unique emails across multiple contacts", async () => { + const page = await api.listContacts({ accountId: "demo", limit: 1000 }); + const emails = new Set(page.items.map(c => c.email)); + + expect(emails.size).toBe(1000); // All emails should be unique + }); + + it("updatedSince filter works correctly", async () => { + const futureDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(); + const page = await api.listContacts({ + accountId: "demo", + limit: 100, + updatedSince: futureDate + }); + + // All contacts should be filtered out since they're in the past + expect(page.items.length).toBe(0); }); });