diff --git a/src/datastream/datastream-client.ts b/src/datastream/datastream-client.ts index 52d0784..8043eb5 100644 --- a/src/datastream/datastream-client.ts +++ b/src/datastream/datastream-client.ts @@ -4,6 +4,7 @@ import { DataStreamResp, DataStreamReq, DataStreamError, EntityType, MessageType import { RulesEngineClient } from '../rules-engine'; import { Logger } from '../logger'; import { LazyEmitter } from './emitter'; +import { partialCompany, partialUser, extractIdFromData, deepCopyCompany as deepCopyCompanyFn } from './merge'; // Import cache providers from the cache module import type { CacheProvider } from '../cache/types'; @@ -681,8 +682,35 @@ export class DataStreamClient extends LazyEmitter { * handleCompanyMessage processes company-specific datastream messages */ private async handleCompanyMessage(message: DataStreamResp): Promise { - const company = message.data as Schematic.RulesengineCompany; - + let company: Schematic.RulesengineCompany; + + if (message.message_type === MessageType.PARTIAL) { + const data = message.data as Record; + let id: string; + try { + id = extractIdFromData(data); + } catch (error) { + this.logger.error(`Failed to extract company ID from partial message: ${error}`); + return; + } + + const resourceKey = this.resourceIdCacheKey(CACHE_KEY_PREFIX_COMPANY, id); + const existing = await this.companyCacheProvider.get(resourceKey); + if (!existing) { + this.logger.warn(`Cache miss for partial company '${id}', skipping`); + return; + } + + try { + company = partialCompany(existing, data); + } catch (error) { + this.logger.error(`Failed to merge partial company: ${error}`); + return; + } + } else { + company = message.data as Schematic.RulesengineCompany; + } + if (!company) { return; } @@ -720,8 +748,35 @@ export class DataStreamClient extends LazyEmitter { * handleUserMessage processes user-specific datastream messages */ private async handleUserMessage(message: DataStreamResp): Promise { - const user = message.data as Schematic.RulesengineUser; - + let user: Schematic.RulesengineUser; + + if (message.message_type === MessageType.PARTIAL) { + const data = message.data as Record; + let id: string; + try { + id = extractIdFromData(data); + } catch (error) { + this.logger.error(`Failed to extract user ID from partial message: ${error}`); + return; + } + + const resourceKey = this.resourceIdCacheKey(CACHE_KEY_PREFIX_USER, id); + const existing = await this.userCacheProvider.get(resourceKey); + if (!existing) { + this.logger.warn(`Cache miss for partial user '${id}', skipping`); + return; + } + + try { + user = partialUser(existing, data); + } catch (error) { + this.logger.error(`Failed to merge partial user: ${error}`); + return; + } + } else { + user = message.data as Schematic.RulesengineUser; + } + if (!user) { return; } @@ -1226,8 +1281,7 @@ export class DataStreamClient extends LazyEmitter { * deepCopyCompany creates a complete deep copy of a Company struct */ private deepCopyCompany(company: Schematic.RulesengineCompany): Schematic.RulesengineCompany { - // Use JSON parsing for a deep copy - this matches the Go implementation approach - return JSON.parse(JSON.stringify(company)); + return deepCopyCompanyFn(company); } private async evaluateFlag( diff --git a/src/datastream/merge.ts b/src/datastream/merge.ts new file mode 100644 index 0000000..890e066 --- /dev/null +++ b/src/datastream/merge.ts @@ -0,0 +1,196 @@ +import type * as Schematic from "../api/types"; + +/** + * Helper to read a property that may be in camelCase or snake_case form. + * Wire data from WebSocket uses snake_case; Fern-generated types use camelCase. + */ +function getProp(obj: Record, camel: string, snake: string): unknown { + return obj[camel] ?? obj[snake]; +} + +/** + * Creates a complete deep copy of a Company object. + */ +export function deepCopyCompany(c: Schematic.RulesengineCompany): Schematic.RulesengineCompany { + return JSON.parse(JSON.stringify(c)); +} + +/** + * Creates a complete deep copy of a User object. + */ +export function deepCopyUser(u: Schematic.RulesengineUser): Schematic.RulesengineUser { + return JSON.parse(JSON.stringify(u)); +} + +/** + * Extracts the "id" field from a parsed datastream message data object. + * Throws if the id field is missing or empty. + */ +export function extractIdFromData(data: Record): string { + const id = data.id as string | undefined; + if (!id) { + throw new Error("partial message missing required field: id"); + } + return id; +} + +/** + * Merges a partial update into an existing Company. + * Deep-copies the existing company, then applies only the fields + * present in the partial object. The "id" field must be present. + * + * Wire format uses snake_case keys. The existing company from cache + * may have either camelCase or snake_case keys depending on how it + * was stored. + */ +export function partialCompany( + existing: Schematic.RulesengineCompany, + partial: Record, +): Schematic.RulesengineCompany { + if (!("id" in partial)) { + throw new Error("partial company message missing required field: id"); + } + + const merged = deepCopyCompany(existing) as unknown as Record; + + for (const key of Object.keys(partial)) { + switch (key) { + case "id": + case "account_id": + case "environment_id": + merged[key] = partial[key]; + break; + case "base_plan_id": + merged[key] = partial[key] ?? null; + break; + case "billing_product_ids": + case "plan_ids": + case "plan_version_ids": + case "entitlements": + case "rules": + case "traits": + case "subscription": + merged[key] = partial[key]; + break; + case "keys": { + const existingKeys = (getProp(merged, "keys", "keys") ?? {}) as Record; + const incomingKeys = partial[key] as Record; + merged[key] = { ...existingKeys, ...incomingKeys }; + break; + } + case "credit_balances": { + const existingCB = (getProp(merged, "creditBalances", "credit_balances") ?? {}) as Record< + string, + number + >; + const incomingCB = partial[key] as Record; + merged[key] = { ...existingCB, ...incomingCB }; + break; + } + case "metrics": { + const existingMetrics = ((getProp(merged, "metrics", "metrics") as unknown[]) ?? + []) as Schematic.RulesengineCompanyMetric[]; + const incomingMetrics = partial[key] as Schematic.RulesengineCompanyMetric[]; + merged[key] = upsertMetrics(existingMetrics, incomingMetrics); + break; + } + // Ignore unknown keys silently + } + } + + return merged as unknown as Schematic.RulesengineCompany; +} + +/** + * Merges a partial update into an existing User. + * Deep-copies the existing user, then applies only the fields + * present in the partial object. The "id" field must be present. + */ +export function partialUser( + existing: Schematic.RulesengineUser, + partial: Record, +): Schematic.RulesengineUser { + if (!("id" in partial)) { + throw new Error("partial user message missing required field: id"); + } + + const merged = deepCopyUser(existing) as unknown as Record; + + for (const key of Object.keys(partial)) { + switch (key) { + case "id": + case "account_id": + case "environment_id": + merged[key] = partial[key]; + break; + case "keys": { + const existingKeys = (getProp(merged, "keys", "keys") ?? {}) as Record; + const incomingKeys = partial[key] as Record; + merged[key] = { ...existingKeys, ...incomingKeys }; + break; + } + case "traits": + case "rules": + merged[key] = partial[key]; + break; + // Ignore unknown keys silently + } + } + + return merged as unknown as Schematic.RulesengineUser; +} + +/** + * Metric key used for deduplication during upsert. + */ +interface MetricKey { + eventSubtype: string; + period: string; + monthReset: string; +} + +function metricKeyString(m: MetricKey): string { + return `${m.eventSubtype}|${m.period}|${m.monthReset}`; +} + +function getMetricKey(m: Record): MetricKey { + return { + eventSubtype: ((m.eventSubtype ?? m.event_subtype) as string) || "", + period: ((m.period as string) || ""), + monthReset: ((m.monthReset ?? m.month_reset) as string) || "", + }; +} + +/** + * Merges incoming metrics into existing ones. Metrics are matched by + * (eventSubtype, period, monthReset); matches are replaced, new metrics + * are appended. + */ +function upsertMetrics( + existing: Schematic.RulesengineCompanyMetric[], + incoming: Schematic.RulesengineCompanyMetric[], +): Schematic.RulesengineCompanyMetric[] { + const result = [...existing]; + const index = new Map(); + + for (let i = 0; i < result.length; i++) { + const m = result[i]; + if (m) { + const k = metricKeyString(getMetricKey(m as unknown as Record)); + index.set(k, i); + } + } + + for (const m of incoming) { + if (!m) continue; + const k = metricKeyString(getMetricKey(m as unknown as Record)); + const idx = index.get(k); + if (idx !== undefined) { + result[idx] = m; + } else { + result.push(m); + } + } + + return result; +} diff --git a/tests/unit/datastream/datastream-client.test.ts b/tests/unit/datastream/datastream-client.test.ts index 3751cca..4b879c6 100644 --- a/tests/unit/datastream/datastream-client.test.ts +++ b/tests/unit/datastream/datastream-client.test.ts @@ -344,17 +344,73 @@ describe('DataStreamClient', () => { data: partialCompany, }); - // NOTE: The current implementation does not merge partial messages with - // existing cached data. Both FULL and PARTIAL message types overwrite - // the cache entirely. This test documents that behavior: after a PARTIAL - // message, only the fields present in the partial payload are retained. + // Partial messages are now properly merged: fields in the partial update + // the cached entity, while fields not present in the partial are preserved. const cachedAfterPartial = await client.getCompany({ name: 'Partial Corp' }); expect(cachedAfterPartial.id).toBe('company-partial'); expect((cachedAfterPartial as any).traits).toEqual([{ key: 'tier', value: 'enterprise' }]); expect((cachedAfterPartial as any).plan_ids).toEqual(['plan-2']); - // Original fields not present in the partial message are lost (overwritten) - expect((cachedAfterPartial as any).metrics).toBeUndefined(); - expect((cachedAfterPartial as any).rules).toBeUndefined(); + // Original fields not present in the partial message are preserved + expect((cachedAfterPartial as any).metrics).toEqual([]); + expect((cachedAfterPartial as any).rules).toEqual([]); + expect((cachedAfterPartial as any).account_id).toBe('account-123'); + expect((cachedAfterPartial as any).billing_product_ids).toEqual([]); + }, 10000); + + test('should skip partial company message when entity is not in cache', async () => { + await client.start(); + + const DatastreamWSClientMock = DatastreamWSClient as jest.MockedClass; + const messageHandler = DatastreamWSClientMock.mock.calls[0][0].messageHandler; + + // Send a PARTIAL for a company that was never cached via FULL + await messageHandler({ + entity_type: EntityType.COMPANY, + message_type: MessageType.PARTIAL, + data: { + id: 'company-unknown', + keys: { name: 'Ghost Corp' }, + traits: [{ key: 'tier', value: 'enterprise' }], + }, + }); + + // Warn should be logged about the cache miss + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("Cache miss for partial company 'company-unknown'") + ); + + // No company should have been cached (no cacheCompanyForKeys call for this entity) + expect(mockLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('Ghost Corp') + ); + }, 10000); + + test('should skip partial user message when entity is not in cache', async () => { + await client.start(); + + const DatastreamWSClientMock = DatastreamWSClient as jest.MockedClass; + const messageHandler = DatastreamWSClientMock.mock.calls[0][0].messageHandler; + + // Send a PARTIAL for a user that was never cached via FULL + await messageHandler({ + entity_type: EntityType.USER, + message_type: MessageType.PARTIAL, + data: { + id: 'user-unknown', + keys: { email: 'ghost@example.com' }, + traits: [{ key: 'tier', value: 'enterprise' }], + }, + }); + + // Warn should be logged about the cache miss + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("Cache miss for partial user 'user-unknown'") + ); + + // No user should have been cached + expect(mockLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('ghost@example.com') + ); }, 10000); test('should request data from datastream when not in cache', async () => { diff --git a/tests/unit/datastream/merge.test.ts b/tests/unit/datastream/merge.test.ts new file mode 100644 index 0000000..2dcbb82 --- /dev/null +++ b/tests/unit/datastream/merge.test.ts @@ -0,0 +1,513 @@ +import * as Schematic from '../../../src/api/types'; +import { + partialCompany, + partialUser, + extractIdFromData, + deepCopyCompany, + deepCopyUser, +} from '../../../src/datastream/merge'; + +// Helper: base company in snake_case wire format (matches WebSocket data) +function baseCompany(): Schematic.RulesengineCompany { + return { + id: 'co-1', + account_id: 'acc-1', + environment_id: 'env-1', + base_plan_id: 'plan-1', + billing_product_ids: ['bp-1'], + credit_balances: { 'credit-1': 100.0 }, + keys: { domain: 'example.com' }, + plan_ids: ['plan-1'], + plan_version_ids: ['pv-1'], + traits: [ + { value: 'Enterprise', trait_definition: { id: 'plan', comparable_type: 'string', entity_type: 'company' } }, + ], + entitlements: [ + { feature_id: 'feat-1', feature_key: 'feature-one', value_type: 'boolean' }, + ], + metrics: [], + rules: [], + } as unknown as Schematic.RulesengineCompany; +} + +function baseUser(): Schematic.RulesengineUser { + return { + id: 'user-1', + account_id: 'acc-1', + environment_id: 'env-1', + keys: { email: 'user@example.com' }, + traits: [ + { value: 'Premium', trait_definition: { id: 'tier', comparable_type: 'string', entity_type: 'user' } }, + ], + rules: [], + } as unknown as Schematic.RulesengineUser; +} + +function makeRule(id: string): Record { + return { + id, + account_id: 'acc-1', + environment_id: 'env-1', + name: id, + priority: 1, + rule_type: 'default', + value: true, + conditions: [], + condition_groups: [], + }; +} + +// --- partialCompany tests --- + +describe('partialCompany', () => { + test('only traits - replaces traits, preserves other fields', () => { + const existing = baseCompany(); + const partial = { + id: 'co-1', + traits: [{ value: 'Startup', trait_definition: { id: 'plan', comparable_type: 'string', entity_type: 'company' } }], + }; + + const merged = partialCompany(existing, partial); + const m = merged as unknown as Record; + + expect((m.traits as unknown[]).length).toBe(1); + expect((m.traits as Record[])[0].value).toBe('Startup'); + + // Other fields preserved + expect(m.account_id).toBe('acc-1'); + expect(m.environment_id).toBe('env-1'); + expect(m.keys).toEqual({ domain: 'example.com' }); + expect(m.billing_product_ids).toEqual(['bp-1']); + expect(m.base_plan_id).toBe('plan-1'); + }); + + test('merges keys - new key added, existing preserved', () => { + const existing = baseCompany(); + const partial = { id: 'co-1', keys: { slug: 'new-slug' } }; + + const merged = partialCompany(existing, partial); + const m = merged as unknown as Record; + + expect(m.keys).toEqual({ domain: 'example.com', slug: 'new-slug' }); + expect((m.traits as unknown[]).length).toBe(1); + }); + + test('merges credit_balances - new balance added, existing preserved', () => { + const existing = baseCompany(); + const partial = { id: 'co-1', credit_balances: { 'credit-2': 200.0 } }; + + const merged = partialCompany(existing, partial); + const m = merged as unknown as Record; + + expect(m.credit_balances).toEqual({ 'credit-1': 100.0, 'credit-2': 200.0 }); + }); + + test('overwrites credit balance', () => { + const existing = baseCompany(); + const partial = { id: 'co-1', credit_balances: { 'credit-1': 50.0 } }; + + const merged = partialCompany(existing, partial); + const m = merged as unknown as Record; + + expect(m.credit_balances).toEqual({ 'credit-1': 50.0 }); + }); + + test('upserts metrics - updates existing, appends new', () => { + const existing = baseCompany(); + (existing as unknown as Record).metrics = [ + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'event-a', period: 'all_time', month_reset: 'first_of_month', + value: 10, created_at: '2026-01-01T00:00:00Z', + }, + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'event-b', period: 'current_month', month_reset: 'first_of_month', + value: 5, created_at: '2026-01-01T00:00:00Z', + }, + ]; + + const partial = { + id: 'co-1', + metrics: [ + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'event-a', period: 'all_time', month_reset: 'first_of_month', + value: 42, created_at: '2026-01-01T00:00:00Z', + }, + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'event-c', period: 'current_day', month_reset: 'billing_cycle', + value: 1, created_at: '2026-01-01T00:00:00Z', + }, + ], + }; + + const merged = partialCompany(existing, partial); + const metrics = (merged as unknown as Record).metrics as Record[]; + + expect(metrics.length).toBe(3); + // event-a updated in place + expect(metrics[0].event_subtype).toBe('event-a'); + expect(metrics[0].value).toBe(42); + // event-b unchanged + expect(metrics[1].event_subtype).toBe('event-b'); + expect(metrics[1].value).toBe(5); + // event-c appended + expect(metrics[2].event_subtype).toBe('event-c'); + expect(metrics[2].value).toBe(1); + + // Original not mutated + const origMetrics = (existing as unknown as Record).metrics as Record[]; + expect(origMetrics[0].value).toBe(10); + }); + + test('empty entitlements clears existing', () => { + const existing = baseCompany(); + const partial = { id: 'co-1', entitlements: [] }; + + const merged = partialCompany(existing, partial); + const m = merged as unknown as Record; + + expect(m.entitlements).toEqual([]); + expect(m.account_id).toBe('acc-1'); + }); + + test('null base_plan_id sets to null', () => { + const existing = baseCompany(); + const partial = { id: 'co-1', base_plan_id: null }; + + const merged = partialCompany(existing, partial); + const m = merged as unknown as Record; + + expect(m.base_plan_id).toBeNull(); + expect(m.billing_product_ids).toEqual(['bp-1']); + }); + + test('missing id throws error', () => { + const existing = baseCompany(); + const partial = { traits: [] }; + + expect(() => partialCompany(existing, partial)).toThrow('missing required field: id'); + }); + + test('does not mutate original', () => { + const existing = baseCompany(); + const origKeys = { ...(existing as unknown as Record).keys as Record }; + + const partial = { id: 'co-1', keys: { slug: 'new-slug' }, traits: [] }; + + const merged = partialCompany(existing, partial); + const m = merged as unknown as Record; + + expect((existing as unknown as Record).keys).toEqual(origKeys); + expect(((existing as unknown as Record).traits as unknown[]).length).toBe(1); + + expect(m.keys).toEqual({ domain: 'example.com', slug: 'new-slug' }); + expect((m.traits as unknown[]).length).toBe(0); + }); + + test('rules - replaces rules, original unchanged', () => { + const existing = baseCompany(); + (existing as unknown as Record).rules = [makeRule('rule-old')]; + + const partial = { id: 'co-1', rules: [makeRule('rule-new')] }; + + const merged = partialCompany(existing, partial); + const mergedRules = (merged as unknown as Record).rules as Record[]; + const origRules = (existing as unknown as Record).rules as Record[]; + + expect(mergedRules.length).toBe(1); + expect(mergedRules[0].id).toBe('rule-new'); + expect(origRules[0].id).toBe('rule-old'); + }); + + test('full entity partial message - all fields updated', () => { + const existing = baseCompany(); + (existing as unknown as Record).metrics = [ + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'event-a', period: 'all_time', month_reset: 'first_of_month', + value: 10, created_at: '2026-01-01T00:00:00Z', + }, + ]; + (existing as unknown as Record).rules = [makeRule('rule-1')]; + + const partial = { + id: 'co-1', + account_id: 'acc-2', + environment_id: 'env-2', + base_plan_id: 'plan-99', + billing_product_ids: ['bp-10', 'bp-20'], + credit_balances: { 'credit-1': 999.0, 'credit-new': 50.0 }, + entitlements: [ + { feature_id: 'feat-new', feature_key: 'feature-new', value_type: 'boolean' }, + { feature_id: 'feat-2', feature_key: 'feature-two', value_type: 'boolean' }, + ], + keys: { domain: 'new.com', slug: 'new-slug' }, + metrics: [ + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'event-a', period: 'all_time', month_reset: 'first_of_month', + value: 42, created_at: '2026-01-01T00:00:00Z', + }, + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'event-new', period: 'current_day', month_reset: 'billing_cycle', + value: 7, created_at: '2026-01-01T00:00:00Z', + }, + ], + plan_ids: ['plan-99', 'plan-100'], + plan_version_ids: ['pv-99'], + rules: [makeRule('rule-new-1'), makeRule('rule-new-2')], + subscription: { id: 'sub-new', period_start: '2026-01-01T00:00:00Z', period_end: '2027-01-01T00:00:00Z' }, + traits: [ + { value: 'Startup', trait_definition: { id: 'tier', comparable_type: 'string', entity_type: 'company' } }, + { value: 'Annual', trait_definition: { id: 'billing', comparable_type: 'string', entity_type: 'company' } }, + ], + }; + + const merged = partialCompany(existing, partial); + const m = merged as unknown as Record; + + expect(m.id).toBe('co-1'); + expect(m.account_id).toBe('acc-2'); + expect(m.environment_id).toBe('env-2'); + expect(m.base_plan_id).toBe('plan-99'); + expect(m.billing_product_ids).toEqual(['bp-10', 'bp-20']); + + // Credit balances merge: credit-1 overwritten, credit-new added + expect(m.credit_balances).toEqual({ 'credit-1': 999.0, 'credit-new': 50.0 }); + + const entitlements = m.entitlements as Record[]; + expect(entitlements.length).toBe(2); + expect(entitlements[0].feature_id).toBe('feat-new'); + expect(entitlements[1].feature_id).toBe('feat-2'); + + // Keys merge: domain overwritten, slug added + expect(m.keys).toEqual({ domain: 'new.com', slug: 'new-slug' }); + + // Metrics upsert: event-a updated, event-new appended + const metrics = m.metrics as Record[]; + expect(metrics.length).toBe(2); + expect(metrics[0].event_subtype).toBe('event-a'); + expect(metrics[0].value).toBe(42); + expect(metrics[1].event_subtype).toBe('event-new'); + expect(metrics[1].value).toBe(7); + + expect(m.plan_ids).toEqual(['plan-99', 'plan-100']); + expect(m.plan_version_ids).toEqual(['pv-99']); + + const rules = m.rules as Record[]; + expect(rules.length).toBe(2); + expect(rules[0].id).toBe('rule-new-1'); + expect(rules[1].id).toBe('rule-new-2'); + + expect((m.subscription as Record).id).toBe('sub-new'); + + const traits = m.traits as Record[]; + expect(traits.length).toBe(2); + expect(traits[0].value).toBe('Startup'); + expect(traits[1].value).toBe('Annual'); + + // Original not mutated + const orig = existing as unknown as Record; + expect(orig.account_id).toBe('acc-1'); + expect(orig.base_plan_id).toBe('plan-1'); + expect(orig.keys).toEqual({ domain: 'example.com' }); + expect((orig.metrics as Record[])[0].value).toBe(10); + }); +}); + +// --- partialUser tests --- + +describe('partialUser', () => { + test('only traits - replaces traits, preserves keys', () => { + const existing = baseUser(); + const partial = { + id: 'user-1', + traits: [{ value: 'Free', trait_definition: { id: 'tier', comparable_type: 'string', entity_type: 'user' } }], + }; + + const merged = partialUser(existing, partial); + const m = merged as unknown as Record; + + expect((m.traits as Record[]).length).toBe(1); + expect((m.traits as Record[])[0].value).toBe('Free'); + expect(m.keys).toEqual({ email: 'user@example.com' }); + }); + + test('merges keys - new key added, existing preserved', () => { + const existing = baseUser(); + const partial = { id: 'user-1', keys: { slack_id: 'U123' } }; + + const merged = partialUser(existing, partial); + const m = merged as unknown as Record; + + expect(m.keys).toEqual({ email: 'user@example.com', slack_id: 'U123' }); + expect((m.traits as unknown[]).length).toBe(1); + }); + + test('missing id throws error', () => { + const existing = baseUser(); + const partial = { keys: { email: 'new@example.com' } }; + + expect(() => partialUser(existing, partial)).toThrow('missing required field: id'); + }); + + test('does not mutate original', () => { + const existing = baseUser(); + const origKeys = { ...(existing as unknown as Record).keys as Record }; + + const partial = { id: 'user-1', keys: { slug: 'new' }, traits: [] }; + + const merged = partialUser(existing, partial); + const m = merged as unknown as Record; + + expect((existing as unknown as Record).keys).toEqual(origKeys); + expect(((existing as unknown as Record).traits as unknown[]).length).toBe(1); + + expect(m.keys).toEqual({ email: 'user@example.com', slug: 'new' }); + expect((m.traits as unknown[]).length).toBe(0); + }); + + test('full entity partial message - all fields updated', () => { + const existing = baseUser(); + (existing as unknown as Record).rules = [makeRule('rule-1')]; + + const partial = { + id: 'user-1', + account_id: 'acc-2', + environment_id: 'env-2', + keys: { email: 'new@example.com', slack_id: 'U999' }, + traits: [ + { value: 'Free', trait_definition: { id: 'tier', comparable_type: 'string', entity_type: 'user' } }, + { value: 'Monthly', trait_definition: { id: 'billing', comparable_type: 'string', entity_type: 'user' } }, + ], + rules: [makeRule('rule-new-1'), makeRule('rule-new-2')], + }; + + const merged = partialUser(existing, partial); + const m = merged as unknown as Record; + + expect(m.id).toBe('user-1'); + expect(m.account_id).toBe('acc-2'); + expect(m.environment_id).toBe('env-2'); + + // Keys merge: email overwritten, slack_id added + expect(m.keys).toEqual({ email: 'new@example.com', slack_id: 'U999' }); + + const traits = m.traits as Record[]; + expect(traits.length).toBe(2); + expect(traits[0].value).toBe('Free'); + expect(traits[1].value).toBe('Monthly'); + + const rules = m.rules as Record[]; + expect(rules.length).toBe(2); + expect(rules[0].id).toBe('rule-new-1'); + expect(rules[1].id).toBe('rule-new-2'); + + // Original not mutated + const orig = existing as unknown as Record; + expect(orig.account_id).toBe('acc-1'); + expect(orig.keys).toEqual({ email: 'user@example.com' }); + expect((orig.traits as unknown[]).length).toBe(1); + expect((orig.rules as Record[])[0].id).toBe('rule-1'); + }); +}); + +// --- extractIdFromData tests --- + +describe('extractIdFromData', () => { + test('valid data returns id', () => { + const id = extractIdFromData({ id: 'co-1', traits: [] }); + expect(id).toBe('co-1'); + }); + + test('missing id throws error', () => { + expect(() => extractIdFromData({ traits: [] })).toThrow('missing required field: id'); + }); + + test('empty id throws error', () => { + expect(() => extractIdFromData({ id: '' })).toThrow('missing required field: id'); + }); +}); + +// --- deepCopyCompany tests --- + +describe('deepCopyCompany', () => { + test('full copy - all nested structures independent', () => { + const orig = baseCompany(); + const origRaw = orig as unknown as Record; + origRaw.metrics = [ + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'event-1', period: 'all_time', month_reset: 'first_of_month', + value: 42, created_at: '2026-01-01T00:00:00Z', + }, + ]; + origRaw.subscription = { id: 'sub-1', period_start: '2026-01-01T00:00:00Z', period_end: '2027-01-01T00:00:00Z' }; + + const cp = deepCopyCompany(orig); + const cpRaw = cp as unknown as Record; + + // Keys are independent + (cpRaw.keys as Record).domain = 'changed.com'; + expect((origRaw.keys as Record).domain).toBe('example.com'); + + // Credit balances are independent + (cpRaw.credit_balances as Record)['credit-1'] = 999; + expect((origRaw.credit_balances as Record)['credit-1']).toBe(100.0); + + // Metrics are independent + ((cpRaw.metrics as Record[])[0]).value = 999; + expect(((origRaw.metrics as Record[])[0]).value).toBe(42); + + // Subscription is independent + (cpRaw.subscription as Record).id = 'changed'; + expect((origRaw.subscription as Record).id).toBe('sub-1'); + + // Traits are independent + ((cpRaw.traits as Record[])[0]).value = 'changed'; + expect(((origRaw.traits as Record[])[0]).value).toBe('Enterprise'); + }); +}); + +// --- deepCopyUser tests --- + +describe('deepCopyUser', () => { + test('empty fields - user with only required fields', () => { + const cp = deepCopyUser({ + id: 'u1', + account_id: 'acc-1', + environment_id: 'env-1', + keys: {}, + traits: [], + rules: [], + } as unknown as Schematic.RulesengineUser); + const cpRaw = cp as unknown as Record; + + expect(cpRaw.id).toBe('u1'); + expect(cpRaw.keys).toEqual({}); + expect(cpRaw.traits).toEqual([]); + expect(cpRaw.rules).toEqual([]); + }); + + test('full copy - all fields independent', () => { + const orig = baseUser(); + const origRaw = orig as unknown as Record; + origRaw.rules = [makeRule('r1')]; + + const cp = deepCopyUser(orig); + const cpRaw = cp as unknown as Record; + + (cpRaw.keys as Record).email = 'changed'; + expect((origRaw.keys as Record).email).toBe('user@example.com'); + + (cpRaw.traits as Record[])[0] = { value: 'Free' }; + expect(((origRaw.traits as Record[])[0]).value).toBe('Premium'); + + (cpRaw.rules as Record[])[0] = makeRule('r2'); + expect(((origRaw.rules as Record[])[0]).id).toBe('r1'); + }); +});