@checkstack/test-utils-backend 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,89 @@
1
+ # @checkstack/test-utils-backend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/backend-api@0.0.2
10
+ - @checkstack/common@0.0.2
11
+ - @checkstack/queue-api@0.0.2
12
+ - @checkstack/signal-common@0.0.2
13
+
14
+ ## 0.1.1
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [b4eb432]
19
+ - Updated dependencies [a65e002]
20
+ - @checkstack/backend-api@1.1.0
21
+ - @checkstack/common@0.2.0
22
+ - @checkstack/queue-api@1.0.1
23
+ - @checkstack/signal-common@0.1.1
24
+
25
+ ## 0.1.0
26
+
27
+ ### Minor Changes
28
+
29
+ - b55fae6: Added realtime Signal Service for backend-to-frontend push notifications via WebSockets.
30
+
31
+ ## New Packages
32
+
33
+ - **@checkstack/signal-common**: Shared types including `Signal`, `SignalService`, `createSignal()`, and WebSocket protocol messages
34
+ - **@checkstack/signal-backend**: `SignalServiceImpl` with EventBus integration and Bun WebSocket handler using native pub/sub
35
+ - **@checkstack/signal-frontend**: React `SignalProvider` and `useSignal()` hook for consuming typed signals
36
+
37
+ ## Changes
38
+
39
+ - **@checkstack/backend-api**: Added `coreServices.signalService` reference for plugins to emit signals
40
+ - **@checkstack/backend**: Integrated WebSocket server at `/api/signals/ws` with session-based authentication
41
+
42
+ ## Usage
43
+
44
+ Backend plugins can emit signals:
45
+
46
+ ```typescript
47
+ import { coreServices } from "@checkstack/backend-api";
48
+ import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
49
+
50
+ const signalService = context.signalService;
51
+ await signalService.sendToUser(NOTIFICATION_RECEIVED, userId, { ... });
52
+ ```
53
+
54
+ Frontend components subscribe to signals:
55
+
56
+ ```tsx
57
+ import { useSignal } from "@checkstack/signal-frontend";
58
+ import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
59
+
60
+ useSignal(NOTIFICATION_RECEIVED, (payload) => {
61
+ // Handle realtime notification
62
+ });
63
+ ```
64
+
65
+ ### Patch Changes
66
+
67
+ - e4d83fc: Add BullMQ queue plugin with orphaned job cleanup
68
+
69
+ - **queue-api**: Added `listRecurringJobs()` method to Queue interface for detecting orphaned jobs
70
+ - **queue-bullmq-backend**: New plugin implementing BullMQ (Redis) queue backend with job schedulers, consumer groups, and distributed job persistence
71
+ - **queue-bullmq-common**: New common package with queue permissions
72
+ - **queue-memory-backend**: Implemented `listRecurringJobs()` for in-memory queue
73
+ - **healthcheck-backend**: Enhanced `bootstrapHealthChecks` to clean up orphaned job schedulers using `listRecurringJobs()`
74
+ - **test-utils-backend**: Added `listRecurringJobs()` to mock queue factory
75
+
76
+ This enables production-ready distributed queue processing with Redis persistence and automatic cleanup of orphaned jobs when health checks are deleted.
77
+
78
+ - Updated dependencies [ffc28f6]
79
+ - Updated dependencies [e4d83fc]
80
+ - Updated dependencies [71275dd]
81
+ - Updated dependencies [ae19ff6]
82
+ - Updated dependencies [b55fae6]
83
+ - Updated dependencies [b354ab3]
84
+ - Updated dependencies [8e889b4]
85
+ - Updated dependencies [81f3f85]
86
+ - @checkstack/common@0.1.0
87
+ - @checkstack/backend-api@1.0.0
88
+ - @checkstack/queue-api@1.0.0
89
+ - @checkstack/signal-common@0.1.0
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@checkstack/test-utils-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "dependencies": {
12
+ "@checkstack/backend-api": "workspace:*",
13
+ "@checkstack/common": "workspace:*",
14
+ "@checkstack/queue-api": "workspace:*",
15
+ "@checkstack/signal-common": "workspace:*"
16
+ },
17
+ "devDependencies": {
18
+ "@checkstack/tsconfig": "workspace:*",
19
+ "@checkstack/scripts": "workspace:*",
20
+ "@types/bun": "latest",
21
+ "zod": "^4.0.0"
22
+ }
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export { createMockLogger, createMockLoggerModule } from "./mock-logger";
2
+ export {
3
+ createMockQueueManager,
4
+ createMockQueueFactory,
5
+ } from "./mock-queue-factory";
6
+ export { createMockDb, createMockDbModule } from "./mock-db";
7
+ export { createMockFetch } from "./mock-fetch";
8
+ export {
9
+ createMockSignalService,
10
+ type MockSignalService,
11
+ type RecordedSignal,
12
+ } from "./mock-signal-service";
13
+ export {
14
+ createMockEventBus,
15
+ type MockEventBus,
16
+ type EmittedEvent,
17
+ } from "./mock-event-bus";
18
+ export {
19
+ createMockPluginInstaller,
20
+ type MockPluginInstaller,
21
+ type InstallResult,
22
+ } from "./mock-plugin-installer";
package/src/mock-db.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { mock } from "bun:test";
2
+
3
+ /**
4
+ * Creates a mock Drizzle database instance suitable for unit testing.
5
+ * This mock supports the most common query patterns:
6
+ * - select().from()
7
+ * - select().from().where()
8
+ * - select().from().where().limit()
9
+ * - insert().values()
10
+ * - insert().values().onConflictDoUpdate()
11
+ * - update().set().where()
12
+ *
13
+ * @returns A mock database object that can be used in place of a real Drizzle database
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const mockDb = createMockDb();
18
+ * const service = new MyService(mockDb);
19
+ * ```
20
+ */
21
+ export function createMockDb() {
22
+ const createSelectChain = () => {
23
+ const whereResult = Object.assign(Promise.resolve([]), {
24
+ limit: mock(() => Promise.resolve([])),
25
+ orderBy: mock(function () {
26
+ return Object.assign(Promise.resolve([]), {
27
+ limit: mock(() => Promise.resolve([])),
28
+ });
29
+ }),
30
+ });
31
+ const innerJoinResult = Object.assign(Promise.resolve([]), {
32
+ where: mock(() => whereResult),
33
+ leftJoin: mock(function () {
34
+ return Object.assign(Promise.resolve([]), {
35
+ where: mock(() => whereResult),
36
+ });
37
+ }),
38
+ });
39
+ const fromResult = Object.assign(Promise.resolve([]), {
40
+ where: mock(() => whereResult),
41
+ innerJoin: mock(() => innerJoinResult),
42
+ leftJoin: mock(() => innerJoinResult),
43
+ groupBy: mock(function () {
44
+ return Object.assign(Promise.resolve([]), {
45
+ as: mock(() => ({})), // For subquery aliasing
46
+ });
47
+ }),
48
+ });
49
+ return {
50
+ from: mock(() => fromResult),
51
+ };
52
+ };
53
+
54
+ return {
55
+ select: mock(() => createSelectChain()),
56
+ insert: mock(() => ({
57
+ values: mock(() => ({
58
+ onConflictDoUpdate: mock(() => Promise.resolve()),
59
+ returning: mock(() => Promise.resolve([])),
60
+ })),
61
+ })),
62
+ update: mock(() => ({
63
+ set: mock(() => ({
64
+ where: mock(() => Promise.resolve()),
65
+ returning: mock(() => Promise.resolve([])),
66
+ })),
67
+ })),
68
+ delete: mock(() => ({
69
+ where: mock(() => Promise.resolve()),
70
+ })),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Creates a mock database module export suitable for use with Bun's mock.module().
76
+ * This includes both the database instance and the admin pool.
77
+ *
78
+ * @returns An object with adminPool and db properties
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * mock.module("./db", () => createMockDbModule());
83
+ * ```
84
+ */
85
+ export function createMockDbModule() {
86
+ return {
87
+ adminPool: { query: mock(() => Promise.resolve()) },
88
+ db: createMockDb(),
89
+ };
90
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Mock EventBus for testing plugin lifecycle and hook emissions.
3
+ * Tracks all emitted events and allows triggering broadcasts for multi-instance simulation.
4
+ */
5
+
6
+ export interface MockEventBusOptions {
7
+ /** If true, auto-resolves all subscriptions */
8
+ autoResolve?: boolean;
9
+ }
10
+
11
+ export interface EmittedEvent {
12
+ hook: string;
13
+ payload: unknown;
14
+ }
15
+
16
+ export interface MockEventBus {
17
+ emit: (hook: { id: string }, payload: unknown) => Promise<void>;
18
+ emitLocal: (hook: { id: string }, payload: unknown) => Promise<void>;
19
+ subscribe: (
20
+ pluginId: string,
21
+ hook: { id: string },
22
+ listener: (payload: unknown) => Promise<void>
23
+ ) => Promise<() => void>;
24
+ subscribeLocal: (
25
+ hook: { id: string },
26
+ listener: (payload: unknown) => Promise<void>
27
+ ) => () => void;
28
+ unsubscribe: () => Promise<void>;
29
+
30
+ // Test helpers
31
+ _emittedEvents: EmittedEvent[];
32
+ _localEmittedEvents: EmittedEvent[];
33
+ _triggerBroadcast: (hook: { id: string }, payload: unknown) => Promise<void>;
34
+ _clear: () => void;
35
+ }
36
+
37
+ export function createMockEventBus(
38
+ _options?: MockEventBusOptions
39
+ ): MockEventBus {
40
+ const subscriptions = new Map<
41
+ string,
42
+ ((payload: unknown) => Promise<void>)[]
43
+ >();
44
+ const localSubscriptions = new Map<
45
+ string,
46
+ ((payload: unknown) => Promise<void>)[]
47
+ >();
48
+ const emittedEvents: EmittedEvent[] = [];
49
+ const localEmittedEvents: EmittedEvent[] = [];
50
+
51
+ return {
52
+ emit: async (hook: { id: string }, payload: unknown) => {
53
+ emittedEvents.push({ hook: hook.id, payload });
54
+ const listeners = subscriptions.get(hook.id) || [];
55
+ await Promise.all(listeners.map((l) => l(payload)));
56
+ },
57
+
58
+ emitLocal: async (hook: { id: string }, payload: unknown) => {
59
+ localEmittedEvents.push({ hook: hook.id, payload });
60
+ const listeners = localSubscriptions.get(hook.id) || [];
61
+ await Promise.all(listeners.map((l) => l(payload)));
62
+ },
63
+
64
+ subscribe: async (
65
+ _pluginId: string,
66
+ hook: { id: string },
67
+ listener: (payload: unknown) => Promise<void>
68
+ ) => {
69
+ const listeners = subscriptions.get(hook.id) || [];
70
+ listeners.push(listener);
71
+ subscriptions.set(hook.id, listeners);
72
+ return () => {
73
+ const idx = listeners.indexOf(listener);
74
+ if (idx !== -1) listeners.splice(idx, 1);
75
+ };
76
+ },
77
+
78
+ subscribeLocal: (
79
+ hook: { id: string },
80
+ listener: (payload: unknown) => Promise<void>
81
+ ) => {
82
+ const listeners = localSubscriptions.get(hook.id) || [];
83
+ listeners.push(listener);
84
+ localSubscriptions.set(hook.id, listeners);
85
+ return () => {
86
+ const idx = listeners.indexOf(listener);
87
+ if (idx !== -1) listeners.splice(idx, 1);
88
+ };
89
+ },
90
+
91
+ unsubscribe: async () => {},
92
+
93
+ // Test helpers
94
+ _emittedEvents: emittedEvents,
95
+ _localEmittedEvents: localEmittedEvents,
96
+
97
+ _triggerBroadcast: async (hook: { id: string }, payload: unknown) => {
98
+ const listeners = subscriptions.get(hook.id) || [];
99
+ await Promise.all(listeners.map((l) => l(payload)));
100
+ },
101
+
102
+ _clear: () => {
103
+ emittedEvents.length = 0;
104
+ localEmittedEvents.length = 0;
105
+ subscriptions.clear();
106
+ localSubscriptions.clear();
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,37 @@
1
+ import { mock } from "bun:test";
2
+ import type { Fetch } from "@checkstack/backend-api";
3
+
4
+ /**
5
+ * Creates a mock Fetch instance suitable for unit testing.
6
+ * This mock provides the fetch method and forPlugin helper for plugin-scoped requests.
7
+ *
8
+ * @returns A mock Fetch object
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const mockFetch = createMockFetch();
13
+ * const response = await mockFetch.forPlugin("catalog-backend").get("/entities");
14
+ * ```
15
+ */
16
+ export function createMockFetch(): Fetch {
17
+ return {
18
+ fetch: mock(() => Promise.resolve({ ok: true, text: () => "" })),
19
+ forPlugin: mock(() => ({
20
+ get: mock(() =>
21
+ Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
22
+ ),
23
+ post: mock(() =>
24
+ Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
25
+ ),
26
+ put: mock(() =>
27
+ Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
28
+ ),
29
+ patch: mock(() =>
30
+ Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
31
+ ),
32
+ delete: mock(() =>
33
+ Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
34
+ ),
35
+ })),
36
+ } as unknown as Fetch;
37
+ }
@@ -0,0 +1,40 @@
1
+ import { mock } from "bun:test";
2
+
3
+ /**
4
+ * Creates a mock logger instance suitable for unit testing.
5
+ * This mock provides all standard logger methods (info, debug, warn, error)
6
+ * and a child() method that returns another mock logger.
7
+ *
8
+ * @returns A mock logger object
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const mockLogger = createMockLogger();
13
+ * myService.setLogger(mockLogger);
14
+ * ```
15
+ */
16
+ export function createMockLogger() {
17
+ return {
18
+ info: mock(),
19
+ debug: mock(),
20
+ warn: mock(),
21
+ error: mock(),
22
+ child: mock(() => createMockLogger()),
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Creates a mock logger module export suitable for use with Bun's mock.module().
28
+ *
29
+ * @returns An object with rootLogger property
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * mock.module("./logger", () => createMockLoggerModule());
34
+ * ```
35
+ */
36
+ export function createMockLoggerModule() {
37
+ return {
38
+ rootLogger: createMockLogger(),
39
+ };
40
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Mock PluginInstaller for testing plugin installation flows.
3
+ * Tracks all install calls and allows configuring responses.
4
+ */
5
+
6
+ export interface InstallResult {
7
+ name: string;
8
+ path: string;
9
+ }
10
+
11
+ export interface MockPluginInstallerOptions {
12
+ /** Custom install result generator */
13
+ installResult?: (packageName: string) => InstallResult;
14
+ /** If true, install will throw an error */
15
+ shouldFail?: boolean;
16
+ /** Error message to throw when shouldFail is true */
17
+ errorMessage?: string;
18
+ }
19
+
20
+ export interface MockPluginInstaller {
21
+ install: (packageName: string) => Promise<InstallResult>;
22
+
23
+ // Test helpers
24
+ _installCalls: string[];
25
+ _setInstallResult: (fn: (packageName: string) => InstallResult) => void;
26
+ _setShouldFail: (shouldFail: boolean, errorMessage?: string) => void;
27
+ _clear: () => void;
28
+ }
29
+
30
+ export function createMockPluginInstaller(
31
+ options?: MockPluginInstallerOptions
32
+ ): MockPluginInstaller {
33
+ const installCalls: string[] = [];
34
+ let installResultFn =
35
+ options?.installResult ||
36
+ ((packageName: string) => ({
37
+ name: packageName,
38
+ path: `/runtime_plugins/node_modules/${packageName}`,
39
+ }));
40
+ let shouldFail = options?.shouldFail || false;
41
+ let errorMessage = options?.errorMessage || "Mock install failed";
42
+
43
+ return {
44
+ install: async (packageName: string) => {
45
+ installCalls.push(packageName);
46
+ if (shouldFail) {
47
+ throw new Error(errorMessage);
48
+ }
49
+ return installResultFn(packageName);
50
+ },
51
+
52
+ // Test helpers
53
+ _installCalls: installCalls,
54
+
55
+ _setInstallResult: (fn: (packageName: string) => InstallResult) => {
56
+ installResultFn = fn;
57
+ },
58
+
59
+ _setShouldFail: (fail: boolean, msg?: string) => {
60
+ shouldFail = fail;
61
+ if (msg) errorMessage = msg;
62
+ },
63
+
64
+ _clear: () => {
65
+ installCalls.length = 0;
66
+ shouldFail = false;
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,140 @@
1
+ import type {
2
+ Queue,
3
+ QueueManager,
4
+ QueueJob,
5
+ SwitchResult,
6
+ RecurringJobInfo,
7
+ RecurringJobDetails,
8
+ } from "@checkstack/queue-api";
9
+
10
+ /**
11
+ * Creates a mock QueueManager for testing.
12
+ * This manager creates simple in-memory mock queues for testing purposes.
13
+ *
14
+ * @returns A mock QueueManager
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const mockQueueManager = createMockQueueManager();
19
+ * const queue = mockQueueManager.getQueue("test-channel");
20
+ * ```
21
+ */
22
+ export function createMockQueueManager(): QueueManager {
23
+ const queues = new Map<string, Queue<unknown>>();
24
+ let activePluginId = "mock";
25
+
26
+ function createMockQueue<T>(_channelId: string): Queue<T> {
27
+ const consumers = new Map<
28
+ string,
29
+ {
30
+ handler: (job: QueueJob<T>) => Promise<void>;
31
+ maxRetries: number;
32
+ }
33
+ >();
34
+ const jobs: T[] = [];
35
+ const recurringJobs = new Map<
36
+ string,
37
+ { data: T; intervalSeconds: number }
38
+ >();
39
+
40
+ const mockQueue: Queue<T> = {
41
+ enqueue: async (data) => {
42
+ jobs.push(data);
43
+ // Trigger all consumers (with error handling like real queue)
44
+ for (const [_group, consumer] of consumers.entries()) {
45
+ try {
46
+ await consumer.handler({
47
+ id: `job-${Date.now()}`,
48
+ data,
49
+ timestamp: new Date(),
50
+ attempts: 0,
51
+ });
52
+ } catch (error) {
53
+ // Mock queue catches errors like real implementation
54
+ console.error("Mock queue caught error:", error);
55
+ }
56
+ }
57
+ return `job-${Date.now()}`;
58
+ },
59
+ consume: async (handler, options) => {
60
+ consumers.set(options.consumerGroup, {
61
+ handler: async (job: QueueJob<T>) => await handler(job),
62
+ maxRetries: options.maxRetries ?? 3,
63
+ });
64
+ },
65
+ scheduleRecurring: async (data, options) => {
66
+ recurringJobs.set(options.jobId, {
67
+ data,
68
+ intervalSeconds: options.intervalSeconds,
69
+ });
70
+ return options.jobId;
71
+ },
72
+ cancelRecurring: async (jobId) => {
73
+ recurringJobs.delete(jobId);
74
+ },
75
+ listRecurringJobs: async () => {
76
+ return [...recurringJobs.keys()];
77
+ },
78
+ getRecurringJobDetails: async (
79
+ jobId
80
+ ): Promise<RecurringJobDetails<T> | undefined> => {
81
+ const job = recurringJobs.get(jobId);
82
+ if (!job) return undefined;
83
+ return {
84
+ jobId,
85
+ data: job.data,
86
+ intervalSeconds: job.intervalSeconds,
87
+ };
88
+ },
89
+ getInFlightCount: async () => 0,
90
+ testConnection: async () => {
91
+ // Mock implementation - always succeeds
92
+ },
93
+ stop: async () => {
94
+ consumers.clear();
95
+ },
96
+ getStats: async () => ({
97
+ pending: jobs.length,
98
+ processing: 0,
99
+ completed: 0,
100
+ failed: 0,
101
+ consumerGroups: consumers.size,
102
+ }),
103
+ };
104
+
105
+ return mockQueue;
106
+ }
107
+
108
+ return {
109
+ getQueue: <T>(name: string): Queue<T> => {
110
+ // Return existing queue if already created
111
+ if (queues.has(name)) {
112
+ return queues.get(name)! as Queue<T>;
113
+ }
114
+
115
+ const mockQueue = createMockQueue<T>(name);
116
+ queues.set(name, mockQueue as Queue<unknown>);
117
+ return mockQueue;
118
+ },
119
+ getActivePlugin: () => activePluginId,
120
+ getActiveConfig: () => ({}),
121
+ setActiveBackend: async (pluginId: string): Promise<SwitchResult> => {
122
+ activePluginId = pluginId;
123
+ return { success: true, migratedRecurringJobs: 0, warnings: [] };
124
+ },
125
+ getInFlightJobCount: async () => 0,
126
+ listAllRecurringJobs: async (): Promise<RecurringJobInfo[]> => [],
127
+ startPolling: () => {},
128
+ shutdown: async () => {
129
+ for (const queue of queues.values()) {
130
+ await queue.stop();
131
+ }
132
+ queues.clear();
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * @deprecated Use createMockQueueManager instead
139
+ */
140
+ export const createMockQueueFactory = createMockQueueManager;
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import {
3
+ createMockSignalService,
4
+ type MockSignalService,
5
+ } from "../src/mock-signal-service";
6
+ import { createSignal } from "@checkstack/signal-common";
7
+ import { z } from "zod";
8
+
9
+ // Test signals
10
+ const TEST_SIGNAL_A = createSignal(
11
+ "test.signalA",
12
+ z.object({ value: z.string() })
13
+ );
14
+
15
+ const TEST_SIGNAL_B = createSignal(
16
+ "test.signalB",
17
+ z.object({ count: z.number() })
18
+ );
19
+
20
+ describe("createMockSignalService", () => {
21
+ let mockService: MockSignalService;
22
+
23
+ beforeEach(() => {
24
+ mockService = createMockSignalService();
25
+ });
26
+
27
+ describe("broadcast", () => {
28
+ it("should record broadcast signals", async () => {
29
+ await mockService.broadcast(TEST_SIGNAL_A, { value: "hello" });
30
+
31
+ const recorded = mockService.getRecordedSignals();
32
+ expect(recorded).toHaveLength(1);
33
+ expect(recorded[0].targetType).toBe("broadcast");
34
+ expect(recorded[0].signal.id).toBe("test.signalA");
35
+ expect(recorded[0].payload).toEqual({ value: "hello" });
36
+ });
37
+
38
+ it("should record timestamp", async () => {
39
+ const before = new Date();
40
+ await mockService.broadcast(TEST_SIGNAL_A, { value: "test" });
41
+ const after = new Date();
42
+
43
+ const recorded = mockService.getRecordedSignals()[0];
44
+ expect(recorded.timestamp.getTime()).toBeGreaterThanOrEqual(
45
+ before.getTime()
46
+ );
47
+ expect(recorded.timestamp.getTime()).toBeLessThanOrEqual(after.getTime());
48
+ });
49
+ });
50
+
51
+ describe("sendToUser", () => {
52
+ it("should record user-targeted signals with userId", async () => {
53
+ await mockService.sendToUser(TEST_SIGNAL_B, "user-123", { count: 42 });
54
+
55
+ const recorded = mockService.getRecordedSignals();
56
+ expect(recorded).toHaveLength(1);
57
+ expect(recorded[0].targetType).toBe("user");
58
+ expect(recorded[0].userIds).toEqual(["user-123"]);
59
+ expect(recorded[0].payload).toEqual({ count: 42 });
60
+ });
61
+ });
62
+
63
+ describe("sendToUsers", () => {
64
+ it("should record multi-user signals with all userIds", async () => {
65
+ const userIds = ["user-1", "user-2", "user-3"];
66
+ await mockService.sendToUsers(TEST_SIGNAL_A, userIds, { value: "multi" });
67
+
68
+ const recorded = mockService.getRecordedSignals();
69
+ expect(recorded).toHaveLength(1);
70
+ expect(recorded[0].targetType).toBe("users");
71
+ expect(recorded[0].userIds).toEqual(userIds);
72
+ });
73
+ });
74
+
75
+ describe("getRecordedSignalsById", () => {
76
+ it("should filter signals by ID", async () => {
77
+ await mockService.broadcast(TEST_SIGNAL_A, { value: "a1" });
78
+ await mockService.broadcast(TEST_SIGNAL_B, { count: 1 });
79
+ await mockService.broadcast(TEST_SIGNAL_A, { value: "a2" });
80
+
81
+ const signalARecords = mockService.getRecordedSignalsById("test.signalA");
82
+ expect(signalARecords).toHaveLength(2);
83
+ expect(signalARecords[0].payload).toEqual({ value: "a1" });
84
+ expect(signalARecords[1].payload).toEqual({ value: "a2" });
85
+ });
86
+
87
+ it("should return empty array for non-existent signal ID", () => {
88
+ const records = mockService.getRecordedSignalsById("non.existent");
89
+ expect(records).toHaveLength(0);
90
+ });
91
+ });
92
+
93
+ describe("getRecordedSignalsForUser", () => {
94
+ it("should return broadcasts and user-specific signals", async () => {
95
+ await mockService.broadcast(TEST_SIGNAL_A, { value: "broadcast" });
96
+ await mockService.sendToUser(TEST_SIGNAL_B, "user-1", { count: 10 });
97
+ await mockService.sendToUser(TEST_SIGNAL_B, "user-2", { count: 20 });
98
+
99
+ const user1Signals = mockService.getRecordedSignalsForUser("user-1");
100
+ expect(user1Signals).toHaveLength(2); // broadcast + user-specific
101
+
102
+ const user2Signals = mockService.getRecordedSignalsForUser("user-2");
103
+ expect(user2Signals).toHaveLength(2); // broadcast + user-specific
104
+ });
105
+
106
+ it("should include multi-user signals", async () => {
107
+ await mockService.sendToUsers(TEST_SIGNAL_A, ["user-1", "user-2"], {
108
+ value: "multi",
109
+ });
110
+
111
+ const user1Signals = mockService.getRecordedSignalsForUser("user-1");
112
+ expect(user1Signals).toHaveLength(1);
113
+
114
+ const user3Signals = mockService.getRecordedSignalsForUser("user-3");
115
+ expect(user3Signals).toHaveLength(0); // Not included in multi-user
116
+ });
117
+ });
118
+
119
+ describe("clearRecordedSignals", () => {
120
+ it("should clear all recorded signals", async () => {
121
+ await mockService.broadcast(TEST_SIGNAL_A, { value: "test" });
122
+ await mockService.sendToUser(TEST_SIGNAL_B, "user-1", { count: 1 });
123
+
124
+ expect(mockService.getRecordedSignals()).toHaveLength(2);
125
+
126
+ mockService.clearRecordedSignals();
127
+
128
+ expect(mockService.getRecordedSignals()).toHaveLength(0);
129
+ });
130
+ });
131
+
132
+ describe("wasSignalEmitted", () => {
133
+ it("should return true if signal was emitted", async () => {
134
+ await mockService.broadcast(TEST_SIGNAL_A, { value: "test" });
135
+
136
+ expect(mockService.wasSignalEmitted("test.signalA")).toBe(true);
137
+ expect(mockService.wasSignalEmitted("test.signalB")).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe("wasSignalSentToUser", () => {
142
+ it("should return true if signal was sent to specific user", async () => {
143
+ await mockService.sendToUser(TEST_SIGNAL_A, "user-123", { value: "hi" });
144
+
145
+ expect(mockService.wasSignalSentToUser("test.signalA", "user-123")).toBe(
146
+ true
147
+ );
148
+ expect(mockService.wasSignalSentToUser("test.signalA", "user-456")).toBe(
149
+ false
150
+ );
151
+ expect(mockService.wasSignalSentToUser("test.signalB", "user-123")).toBe(
152
+ false
153
+ );
154
+ });
155
+
156
+ it("should work with sendToUsers", async () => {
157
+ await mockService.sendToUsers(TEST_SIGNAL_B, ["user-1", "user-2"], {
158
+ count: 5,
159
+ });
160
+
161
+ expect(mockService.wasSignalSentToUser("test.signalB", "user-1")).toBe(
162
+ true
163
+ );
164
+ expect(mockService.wasSignalSentToUser("test.signalB", "user-2")).toBe(
165
+ true
166
+ );
167
+ expect(mockService.wasSignalSentToUser("test.signalB", "user-3")).toBe(
168
+ false
169
+ );
170
+ });
171
+ });
172
+
173
+ describe("multiple signal mixtures", () => {
174
+ it("should correctly track complex emission patterns", async () => {
175
+ // Simulate realistic notification scenario
176
+ await mockService.broadcast(TEST_SIGNAL_A, { value: "system-alert" });
177
+ await mockService.sendToUser(TEST_SIGNAL_B, "admin-1", { count: 5 });
178
+ await mockService.sendToUser(TEST_SIGNAL_B, "admin-2", { count: 3 });
179
+ await mockService.sendToUsers(TEST_SIGNAL_A, ["user-1", "user-2"], {
180
+ value: "team-update",
181
+ });
182
+ await mockService.broadcast(TEST_SIGNAL_B, { count: 100 });
183
+
184
+ // Total signals
185
+ expect(mockService.getRecordedSignals()).toHaveLength(5);
186
+
187
+ // By signal ID
188
+ expect(mockService.getRecordedSignalsById("test.signalA")).toHaveLength(
189
+ 2
190
+ );
191
+ expect(mockService.getRecordedSignalsById("test.signalB")).toHaveLength(
192
+ 3
193
+ );
194
+
195
+ // For specific users
196
+ expect(mockService.getRecordedSignalsForUser("admin-1")).toHaveLength(3); // 2 broadcast + 1 user
197
+ expect(mockService.getRecordedSignalsForUser("user-1")).toHaveLength(3); // 2 broadcast + 1 multi
198
+
199
+ // Emission checks
200
+ expect(mockService.wasSignalEmitted("test.signalA")).toBe(true);
201
+ expect(mockService.wasSignalSentToUser("test.signalB", "admin-1")).toBe(
202
+ true
203
+ );
204
+ });
205
+ });
206
+ });
@@ -0,0 +1,189 @@
1
+ import { qualifyPermissionId } from "@checkstack/common";
2
+ import type { SignalService, Signal } from "@checkstack/signal-common";
3
+
4
+ /**
5
+ * Recorded signal emission for testing assertions.
6
+ */
7
+ export interface RecordedSignal<T = unknown> {
8
+ signal: Signal<T>;
9
+ payload: T;
10
+ targetType: "broadcast" | "user" | "users" | "authorized";
11
+ userIds?: string[];
12
+ permission?: string; // For authorized signals
13
+ timestamp: Date;
14
+ }
15
+
16
+ /**
17
+ * Mock SignalService for testing with recording and assertion capabilities.
18
+ */
19
+ export interface MockSignalService extends SignalService {
20
+ /**
21
+ * Get all recorded signal emissions.
22
+ */
23
+ getRecordedSignals(): RecordedSignal[];
24
+
25
+ /**
26
+ * Get recorded signals filtered by signal ID.
27
+ */
28
+ getRecordedSignalsById(signalId: string): RecordedSignal[];
29
+
30
+ /**
31
+ * Get recorded signals sent to a specific user.
32
+ */
33
+ getRecordedSignalsForUser(userId: string): RecordedSignal[];
34
+
35
+ /**
36
+ * Clear all recorded signals.
37
+ */
38
+ clearRecordedSignals(): void;
39
+
40
+ /**
41
+ * Check if a specific signal was emitted.
42
+ */
43
+ wasSignalEmitted(signalId: string): boolean;
44
+
45
+ /**
46
+ * Check if a specific signal was sent to a user.
47
+ */
48
+ wasSignalSentToUser(signalId: string, userId: string): boolean;
49
+
50
+ /**
51
+ * Set a permission filter function for sendToAuthorizedUsers testing.
52
+ * If not set, sendToAuthorizedUsers will pass through all users.
53
+ */
54
+ setPermissionFilter(
55
+ filter: (userIds: string[], permission: string) => string[]
56
+ ): void;
57
+ }
58
+
59
+ /**
60
+ * Creates a mock SignalService for testing.
61
+ * Records all signal emissions for later assertions.
62
+ *
63
+ * @returns A mock SignalService with recording capabilities
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * import { createMockSignalService } from "@checkstack/test-utils-backend";
68
+ * import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
69
+ *
70
+ * const mockSignalService = createMockSignalService();
71
+ *
72
+ * // In your code under test
73
+ * await mockSignalService.sendToUser(NOTIFICATION_RECEIVED, "user-123", { ... });
74
+ *
75
+ * // Assertions
76
+ * expect(mockSignalService.wasSignalSentToUser("notification.received", "user-123")).toBe(true);
77
+ * expect(mockSignalService.getRecordedSignalsForUser("user-123")).toHaveLength(1);
78
+ * ```
79
+ */
80
+ export function createMockSignalService(): MockSignalService {
81
+ const recordedSignals: RecordedSignal[] = [];
82
+ let permissionFilter:
83
+ | ((userIds: string[], permission: string) => string[])
84
+ | undefined;
85
+
86
+ return {
87
+ async broadcast<T>(signal: Signal<T>, payload: T): Promise<void> {
88
+ recordedSignals.push({
89
+ signal: signal as Signal<unknown>,
90
+ payload,
91
+ targetType: "broadcast",
92
+ timestamp: new Date(),
93
+ });
94
+ },
95
+
96
+ async sendToUser<T>(
97
+ signal: Signal<T>,
98
+ userId: string,
99
+ payload: T
100
+ ): Promise<void> {
101
+ recordedSignals.push({
102
+ signal: signal as Signal<unknown>,
103
+ payload,
104
+ targetType: "user",
105
+ userIds: [userId],
106
+ timestamp: new Date(),
107
+ });
108
+ },
109
+
110
+ async sendToUsers<T>(
111
+ signal: Signal<T>,
112
+ userIds: string[],
113
+ payload: T
114
+ ): Promise<void> {
115
+ recordedSignals.push({
116
+ signal: signal as Signal<unknown>,
117
+ payload,
118
+ targetType: "users",
119
+ userIds,
120
+ timestamp: new Date(),
121
+ });
122
+ },
123
+
124
+ getRecordedSignals(): RecordedSignal[] {
125
+ return [...recordedSignals];
126
+ },
127
+
128
+ getRecordedSignalsById(signalId: string): RecordedSignal[] {
129
+ return recordedSignals.filter((r) => r.signal.id === signalId);
130
+ },
131
+
132
+ getRecordedSignalsForUser(userId: string): RecordedSignal[] {
133
+ return recordedSignals.filter(
134
+ (r) =>
135
+ r.targetType === "broadcast" ||
136
+ (r.userIds && r.userIds.includes(userId))
137
+ );
138
+ },
139
+
140
+ clearRecordedSignals(): void {
141
+ recordedSignals.length = 0;
142
+ },
143
+
144
+ wasSignalEmitted(signalId: string): boolean {
145
+ return recordedSignals.some((r) => r.signal.id === signalId);
146
+ },
147
+
148
+ wasSignalSentToUser(signalId: string, userId: string): boolean {
149
+ return recordedSignals.some(
150
+ (r) =>
151
+ r.signal.id === signalId && r.userIds && r.userIds.includes(userId)
152
+ );
153
+ },
154
+
155
+ async sendToAuthorizedUsers<T>(
156
+ signal: Signal<T>,
157
+ userIds: string[],
158
+ payload: T,
159
+ pluginMetadata: { pluginId: string },
160
+ permission: { id: string }
161
+ ): Promise<void> {
162
+ // Construct fully-qualified permission ID
163
+ const qualifiedPermission = qualifyPermissionId(
164
+ pluginMetadata,
165
+ permission
166
+ );
167
+
168
+ // Apply permission filter if set
169
+ const filteredUserIds = permissionFilter
170
+ ? permissionFilter(userIds, qualifiedPermission)
171
+ : userIds;
172
+
173
+ recordedSignals.push({
174
+ signal: signal as Signal<unknown>,
175
+ payload,
176
+ targetType: "authorized",
177
+ userIds: filteredUserIds,
178
+ permission: qualifiedPermission,
179
+ timestamp: new Date(),
180
+ });
181
+ },
182
+
183
+ setPermissionFilter(
184
+ filter: (userIds: string[], permission: string) => string[]
185
+ ): void {
186
+ permissionFilter = filter;
187
+ },
188
+ };
189
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": [
8
+ "src/**/*"
9
+ ]
10
+ }