@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 +89 -0
- package/package.json +23 -0
- package/src/index.ts +22 -0
- package/src/mock-db.ts +90 -0
- package/src/mock-event-bus.ts +109 -0
- package/src/mock-fetch.ts +37 -0
- package/src/mock-logger.ts +40 -0
- package/src/mock-plugin-installer.ts +69 -0
- package/src/mock-queue-factory.ts +140 -0
- package/src/mock-signal-service.test.ts +206 -0
- package/src/mock-signal-service.ts +189 -0
- package/tsconfig.json +10 -0
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
|
+
}
|