diff --git a/tests/unit/cache/local.test.ts b/tests/unit/cache/local.test.ts index 1259f33..6a056e0 100644 --- a/tests/unit/cache/local.test.ts +++ b/tests/unit/cache/local.test.ts @@ -94,4 +94,70 @@ describe("LocalCache", () => { expect((testCache as any).cache.size).toBe(maxItems); }); + + it("should support custom TTL override per item", async () => { + await cache.set("shortTTL", { data: "short" }, 500); // Custom short TTL + await cache.set("defaultTTL", { data: "default" }); // Uses default 5000ms TTL + + const shortBefore = await cache.get("shortTTL"); + expect(shortBefore).toEqual({ data: "short" }); + + jest.advanceTimersByTime(501); + + const shortAfter = await cache.get("shortTTL"); + expect(shortAfter).toBeNull(); + + const defaultAfter = await cache.get("defaultTTL"); + expect(defaultAfter).toEqual({ data: "default" }); + }); + + it("should delete an item", async () => { + await cache.set("key1", { data: "value1" }); + + const valueBefore = await cache.get("key1"); + expect(valueBefore).toEqual({ data: "value1" }); + + await cache.delete("key1"); + + const valueAfter = await cache.get("key1"); + expect(valueAfter).toBeNull(); + }); + + it("should remove unlisted keys with deleteMissing", async () => { + await cache.set("keep1", { data: "keep1" }); + await cache.set("keep2", { data: "keep2" }); + await cache.set("remove1", { data: "remove1" }); + await cache.set("remove2", { data: "remove2" }); + + await cache.deleteMissing(["keep1", "keep2"]); + + const keep1 = await cache.get("keep1"); + const keep2 = await cache.get("keep2"); + const remove1 = await cache.get("remove1"); + const remove2 = await cache.get("remove2"); + + expect(keep1).toEqual({ data: "keep1" }); + expect(keep2).toEqual({ data: "keep2" }); + expect(remove1).toBeNull(); + expect(remove2).toBeNull(); + }); + + it("should handle concurrent read/write safely", async () => { + const operations = []; + for (let i = 0; i < 50; i++) { + operations.push(cache.set(`concurrent${i}`, { data: `value${i}` })); + operations.push(cache.get(`concurrent${i}`)); + } + + await expect(Promise.all(operations)).resolves.not.toThrow(); + + // Verify that the last items written are consistent + for (let i = 0; i < 10; i++) { + const key = `concurrent${i}`; + const value = await cache.get(key); + if (value !== null) { + expect(value).toEqual({ data: `value${i}` }); + } + } + }); }); diff --git a/tests/unit/datastream/datastream-client.test.ts b/tests/unit/datastream/datastream-client.test.ts index b339675..3751cca 100644 --- a/tests/unit/datastream/datastream-client.test.ts +++ b/tests/unit/datastream/datastream-client.test.ts @@ -196,6 +196,42 @@ describe('DataStreamClient', () => { expect(errorSpy).toHaveBeenCalledWith(new Error('test error')); }); + test('should handle reconnection state', async () => { + const connectSpy = jest.fn(); + const disconnectSpy = jest.fn(); + + client.on('connected', connectSpy); + client.on('disconnected', disconnectSpy); + + await client.start(); + + // Get the event handlers registered on the WebSocket client mock + const onCalls = mockDatastreamWSClientInstance.on.mock.calls; + const connectedHandler = onCalls.find((call: [string, Function]) => call[0] === 'connected')?.[1]; + const disconnectedHandler = onCalls.find((call: [string, Function]) => call[0] === 'disconnected')?.[1]; + + // Simulate initial connected state + mockDatastreamWSClientInstance.isConnected.mockReturnValue(true); + if (connectedHandler) connectedHandler(); + + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(client.isConnected()).toBe(true); + + // Simulate disconnect + mockDatastreamWSClientInstance.isConnected.mockReturnValue(false); + if (disconnectedHandler) disconnectedHandler(); + + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(client.isConnected()).toBe(false); + + // Simulate reconnection (connected event fires again) + mockDatastreamWSClientInstance.isConnected.mockReturnValue(true); + if (connectedHandler) connectedHandler(); + + expect(connectSpy).toHaveBeenCalledTimes(2); + expect(client.isConnected()).toBe(true); + }); + test('should handle company messages and update cache', async () => { await client.start(); @@ -262,6 +298,65 @@ describe('DataStreamClient', () => { expect(retrievedFlag).toEqual(mockFlag); }); + test('should handle partial entity message merging', async () => { + await client.start(); + + // Get message handler + const DatastreamWSClientMock = DatastreamWSClient as jest.MockedClass; + const messageHandler = DatastreamWSClientMock.mock.calls[0][0].messageHandler; + + // Send a FULL company message with all fields + const fullCompany = { + id: 'company-partial', + account_id: 'account-123', + environment_id: 'env-123', + keys: { name: 'Partial Corp' }, + traits: [{ key: 'tier', value: 'free' }], + rules: [], + metrics: [], + plan_ids: ['plan-1'], + billing_product_ids: [], + crm_product_ids: [], + credit_balances: {}, + } as unknown as Schematic.RulesengineCompany; + + await messageHandler({ + entity_type: EntityType.COMPANY, + message_type: MessageType.FULL, + data: fullCompany, + }); + + // Verify the full company is cached + const cachedFull = await client.getCompany({ name: 'Partial Corp' }); + expect(cachedFull).toEqual(fullCompany); + + // Send a PARTIAL company message that updates only some fields + const partialCompany = { + id: 'company-partial', + keys: { name: 'Partial Corp' }, + traits: [{ key: 'tier', value: 'enterprise' }], + plan_ids: ['plan-2'], + } as unknown as Schematic.RulesengineCompany; + + await messageHandler({ + entity_type: EntityType.COMPANY, + message_type: MessageType.PARTIAL, + 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. + 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(); + }, 10000); + test('should request data from datastream when not in cache', async () => { // Set up connected state mockDatastreamWSClientInstance.isConnected.mockReturnValue(true); @@ -812,6 +907,32 @@ describe('DataStreamClient', () => { expect(bySlug).toEqual(updatedCompany); }); + test('should handle deep copy to prevent mutation of cached entities', async () => { + // Cache a company + await messageHandler({ + entity_type: EntityType.COMPANY, + message_type: MessageType.FULL, + data: multiKeyCompany, + }); + + // Retrieve the company from cache + const firstRetrieval = await client.getCompany({ name: 'acme' }); + expect(firstRetrieval).toEqual(multiKeyCompany); + + // Mutate a field on the returned object + (firstRetrieval as any).traits = [{ key: 'mutated', value: 'yes' }]; + + // Retrieve the company again from cache + const secondRetrieval = await client.getCompany({ name: 'acme' }); + + // NOTE: The LocalCache returns references, not copies, so mutation of + // a retrieved object DOES affect subsequent cache reads. This test + // documents the current behavior: the cache does not deep-copy on get. + // If deep-copy-on-read were implemented, secondRetrieval.traits would + // still equal the original (empty array). + expect((secondRetrieval as any).traits).toEqual([{ key: 'mutated', value: 'yes' }]); + }); + test('should update company metrics and reflect via all keys', async () => { const companyWithMetrics = { ...multiKeyCompany, diff --git a/tests/unit/events.test.ts b/tests/unit/events.test.ts new file mode 100644 index 0000000..4068375 --- /dev/null +++ b/tests/unit/events.test.ts @@ -0,0 +1,284 @@ +/* eslint @typescript-eslint/no-explicit-any: 0 */ + +import { EventBuffer } from "../../src/events"; +import { EventsClient } from "../../src/api/resources/events/client/Client"; +import { CreateEventRequestBody } from "../../src/api"; +import { Logger } from "../../src/logger"; + +process.env.NODE_ENV = "test"; + +jest.useFakeTimers(); + +describe("EventBuffer", () => { + let mockEventsApi: jest.Mocked; + let mockLogger: jest.Mocked; + + beforeEach(() => { + const mockResponse = { + data: { + events: [], + }, + params: {}, + }; + + mockEventsApi = { + createEventBatch: jest.fn().mockResolvedValue(mockResponse), + } as any; + + mockLogger = { + error: jest.fn(), + log: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + } as any; + }); + + it("should push and flush events correctly", async () => { + const event1: CreateEventRequestBody = { + body: { + company: { id: "test-company" }, + event: "test-event", + user: { id: "test-user" }, + }, + eventType: "track", + sentAt: new Date(), + }; + const event2: CreateEventRequestBody = { + body: { + company: { id: "test-company" }, + event: "test-event-2", + user: { id: "test-user" }, + }, + eventType: "track", + sentAt: new Date(), + }; + const buffer = new EventBuffer(mockEventsApi, { + logger: mockLogger, + maxSize: 1, // Set max size to 1 item + interval: 1000, + }); + + await buffer.push(event1); + + expect(mockEventsApi.createEventBatch).not.toHaveBeenCalled(); + + // Force first flush by exceeding max size + await buffer.push(event2); + + expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(1); + expect(mockEventsApi.createEventBatch).toHaveBeenCalledWith({ + events: [event1], + }); + + // Wait for the next periodic flush + jest.advanceTimersByTime(1001); + expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(2); + expect(mockEventsApi.createEventBatch).toHaveBeenCalledWith({ + events: [event2], + }); + }); + + // The rest of the tests remain unchanged as they don't directly test the maxSize behavior + it("should log error if flushing fails", async () => { + mockEventsApi.createEventBatch.mockRejectedValue(new Error("Flush error")); + + const buffer = new EventBuffer(mockEventsApi, { + logger: mockLogger, + interval: 1000, + maxRetries: 1, + initialRetryDelay: 1, + }); + + const event: CreateEventRequestBody = { + body: { + company: { id: "test-company" }, + event: "test-event", + user: { id: "test-user" }, + }, + eventType: "track", + sentAt: new Date(), + }; + await buffer.push(event); + await buffer.push(event); + + // Since we're skipping delays in test environment, + // we can just call flush directly + await buffer.flush(); + + expect(mockLogger.error).toHaveBeenCalledWith( + "Event batch submission failed after 1 retries:", + expect.any(Error) + ); + }); + + it("should stop accepting events after stop is called", async () => { + const buffer = new EventBuffer(mockEventsApi, { + interval: 1000, + logger: mockLogger, + }); + + const event: CreateEventRequestBody = { + body: { + company: { id: "test-company" }, + event: "test-event", + user: { id: "test-user" }, + }, + eventType: "track", + sentAt: new Date(), + }; + await buffer.push(event); + + await buffer.stop(); + + await buffer.push(event); + + expect(mockLogger.error).toHaveBeenCalledWith("Event buffer is stopped, not accepting new events"); + expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(1); + }); + + it("should periodically flush events", async () => { + const buffer = new EventBuffer(mockEventsApi, { + interval: 1000, + logger: mockLogger, + }); + + const event: CreateEventRequestBody = { + body: { + company: { id: "test-company" }, + event: "test-event", + user: { id: "test-user" }, + }, + eventType: "track", + sentAt: new Date(), + }; + await buffer.push(event); + + jest.advanceTimersByTime(1000); + + expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(1); + expect(mockEventsApi.createEventBatch).toHaveBeenCalledWith({ + events: [event], + }); + }); + + it("should not flush events if shutdown", async () => { + const buffer = new EventBuffer(mockEventsApi, { + interval: 1000, + logger: mockLogger, + }); + + const event: CreateEventRequestBody = { + body: { + company: { id: "test-company" }, + event: "test-event", + user: { id: "test-user" }, + }, + eventType: "track", + sentAt: new Date(), + }; + await buffer.push(event); + + buffer["shutdown"] = true; + + jest.advanceTimersByTime(1000); + + expect(mockEventsApi.createEventBatch).not.toHaveBeenCalled(); + }); + + it("should handle track events with quantity", async () => { + const event: CreateEventRequestBody = { + body: { + company: { id: "test-company" }, + event: "test-event", + user: { id: "test-user" }, + quantity: 5, + }, + eventType: "track", + sentAt: new Date(), + }; + const buffer = new EventBuffer(mockEventsApi, { + logger: mockLogger, + interval: 1000, + }); + + await buffer.push(event); + + jest.advanceTimersByTime(1000); + + expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(1); + expect(mockEventsApi.createEventBatch).toHaveBeenCalledWith({ + events: [event], + }); + + const sentEvents = mockEventsApi.createEventBatch.mock.calls[0][0].events; + expect(sentEvents[0].body).toHaveProperty("quantity", 5); + }); + + it("should drop events silently in offline mode", async () => { + const buffer = new EventBuffer(mockEventsApi, { + logger: mockLogger, + interval: 1000, + offline: true, + }); + + const event: CreateEventRequestBody = { + body: { + company: { id: "test-company" }, + event: "test-event", + user: { id: "test-user" }, + }, + eventType: "track", + sentAt: new Date(), + }; + + // push() should not throw in offline mode + await expect(buffer.push(event)).resolves.not.toThrow(); + + jest.advanceTimersByTime(1000); + + // Events should never be sent in offline mode + expect(mockEventsApi.createEventBatch).not.toHaveBeenCalled(); + }); + + it("should retry and succeed after a failure", async () => { + const mockResponse = { + data: { + events: [], + }, + params: {}, + }; + + // First call fails, second succeeds + mockEventsApi.createEventBatch + .mockRejectedValueOnce(new Error("Temporary failure")) + .mockResolvedValueOnce(mockResponse); + + const buffer = new EventBuffer(mockEventsApi, { + logger: mockLogger, + interval: 1000, + maxRetries: 3, + initialRetryDelay: 1, + }); + + const event: CreateEventRequestBody = { + body: { + company: { id: "test-company" }, + event: "test-event", + user: { id: "test-user" }, + }, + eventType: "track", + sentAt: new Date(), + }; + await buffer.push(event); + + // Since we're skipping delays in test environment, + // we can just call flush directly + await buffer.flush(); + + // Verify that the createEventBatch was called twice (once failed, once succeeded) + expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(2); + + expect(mockLogger.info).toHaveBeenCalledWith("Event batch submission succeeded after 1 retries"); + }); +}); diff --git a/tests/unit/wrapper.test.ts b/tests/unit/wrapper.test.ts new file mode 100644 index 0000000..0333c8d --- /dev/null +++ b/tests/unit/wrapper.test.ts @@ -0,0 +1,191 @@ +import { SchematicClient } from "../../src/wrapper"; +import type { CacheProvider } from "../../src/cache"; +import type { CheckFlagWithEntitlementResponse } from "../../src/wrapper"; + +// Mock the features.checkFlag API call +const mockCheckFlag = jest.fn(); + +jest.mock("../../src/Client", () => { + class MockBaseClient { + features = { + checkFlag: mockCheckFlag, + checkFlags: jest.fn().mockResolvedValue({ + data: { flags: [] }, + }), + }; + events = {}; + } + return { SchematicClient: MockBaseClient }; +}); + +// Mock the EventBuffer to avoid side effects +jest.mock("../../src/events", () => { + return { + EventBuffer: jest.fn().mockImplementation(() => ({ + push: jest.fn(), + stop: jest.fn().mockResolvedValue(undefined), + })), + }; +}); + +describe("SchematicClient wrapper - flag checking behavior", () => { + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("offline mode", () => { + it("should return configured default in offline mode", async () => { + const client = new SchematicClient({ + offline: true, + flagDefaults: { "test-flag": true }, + logger: mockLogger, + }); + + const result = await client.checkFlag({}, "test-flag"); + + expect(result).toBe(true); + expect(mockCheckFlag).not.toHaveBeenCalled(); + + await client.close(); + }); + + it("should return false in offline mode when no default configured", async () => { + const client = new SchematicClient({ + offline: true, + logger: mockLogger, + }); + + const result = await client.checkFlag({}, "unknown-flag"); + + expect(result).toBe(false); + expect(mockCheckFlag).not.toHaveBeenCalled(); + + await client.close(); + }); + }); + + describe("API error handling", () => { + it("should return false when API errors and no default configured", async () => { + mockCheckFlag.mockRejectedValue(new Error("API unavailable")); + + const client = new SchematicClient({ + apiKey: "test-api-key", + cacheProviders: { flagChecks: [] }, + logger: mockLogger, + }); + + const result = await client.checkFlag( + { company: { id: "comp-1" } }, + "test-flag", + ); + + expect(result).toBe(false); + + await client.close(); + }); + + it("should return configured default when API errors", async () => { + mockCheckFlag.mockRejectedValue(new Error("API unavailable")); + + const client = new SchematicClient({ + apiKey: "test-api-key", + flagDefaults: { "test-flag": true }, + cacheProviders: { flagChecks: [] }, + logger: mockLogger, + }); + + const result = await client.checkFlag( + { company: { id: "comp-1" } }, + "test-flag", + ); + + expect(result).toBe(true); + + await client.close(); + }); + }); + + describe("caching behavior", () => { + it("should use different cache keys for different contexts", async () => { + mockCheckFlag.mockResolvedValue({ + data: { + value: true, + flag: "test-flag", + reason: "match", + }, + }); + + const mockCacheProvider: CacheProvider = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + }; + + const client = new SchematicClient({ + apiKey: "test-api-key", + cacheProviders: { flagChecks: [mockCacheProvider] }, + logger: mockLogger, + }); + + await client.checkFlag( + { company: { id: "comp-1" } }, + "test-flag", + ); + await client.checkFlag( + { company: { id: "comp-2" } }, + "test-flag", + ); + + // Two different contexts should produce two cache get calls with different keys + expect(mockCacheProvider.get).toHaveBeenCalledTimes(2); + const firstKey = (mockCacheProvider.get as jest.Mock).mock.calls[0][0]; + const secondKey = (mockCacheProvider.get as jest.Mock).mock.calls[1][0]; + expect(firstKey).not.toEqual(secondKey); + + // Two API calls should have been made since cache returned null both times + expect(mockCheckFlag).toHaveBeenCalledTimes(2); + + await client.close(); + }); + + it("should return API value when cache is disabled", async () => { + mockCheckFlag.mockResolvedValue({ + data: { + value: true, + flag: "test-flag", + reason: "match", + }, + }); + + const client = new SchematicClient({ + apiKey: "test-api-key", + cacheProviders: { flagChecks: [] }, + logger: mockLogger, + }); + + const result1 = await client.checkFlag( + { company: { id: "comp-1" } }, + "test-flag", + ); + const result2 = await client.checkFlag( + { company: { id: "comp-1" } }, + "test-flag", + ); + + expect(result1).toBe(true); + expect(result2).toBe(true); + + // With no cache providers, every call should hit the API + expect(mockCheckFlag).toHaveBeenCalledTimes(2); + + await client.close(); + }); + }); +});