@qwickapps/server 1.3.0 → 1.3.1

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.
Files changed (132) hide show
  1. package/README.md +154 -0
  2. package/dist/core/control-panel.d.ts.map +1 -1
  3. package/dist/core/control-panel.js +30 -2
  4. package/dist/core/control-panel.js.map +1 -1
  5. package/dist/core/plugin-registry.d.ts +36 -0
  6. package/dist/core/plugin-registry.d.ts.map +1 -1
  7. package/dist/core/plugin-registry.js +26 -0
  8. package/dist/core/plugin-registry.js.map +1 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/plugins/auth/adapters/index.d.ts +1 -0
  14. package/dist/plugins/auth/adapters/index.d.ts.map +1 -1
  15. package/dist/plugins/auth/adapters/index.js +1 -0
  16. package/dist/plugins/auth/adapters/index.js.map +1 -1
  17. package/dist/plugins/auth/adapters/supabase-adapter.d.ts.map +1 -1
  18. package/dist/plugins/auth/adapters/supabase-adapter.js.map +1 -1
  19. package/dist/plugins/auth/adapters/supertokens-adapter.d.ts +18 -0
  20. package/dist/plugins/auth/adapters/supertokens-adapter.d.ts.map +1 -0
  21. package/dist/plugins/auth/adapters/supertokens-adapter.js +267 -0
  22. package/dist/plugins/auth/adapters/supertokens-adapter.js.map +1 -0
  23. package/dist/plugins/auth/env-config.d.ts +88 -0
  24. package/dist/plugins/auth/env-config.d.ts.map +1 -0
  25. package/dist/plugins/auth/env-config.js +489 -0
  26. package/dist/plugins/auth/env-config.js.map +1 -0
  27. package/dist/plugins/auth/index.d.ts +3 -1
  28. package/dist/plugins/auth/index.d.ts.map +1 -1
  29. package/dist/plugins/auth/index.js +3 -0
  30. package/dist/plugins/auth/index.js.map +1 -1
  31. package/dist/plugins/auth/supertokens-adapter.test.d.ts +10 -0
  32. package/dist/plugins/auth/supertokens-adapter.test.d.ts.map +1 -0
  33. package/dist/plugins/auth/supertokens-adapter.test.js +486 -0
  34. package/dist/plugins/auth/supertokens-adapter.test.js.map +1 -0
  35. package/dist/plugins/auth/types.d.ts +70 -0
  36. package/dist/plugins/auth/types.d.ts.map +1 -1
  37. package/dist/plugins/auth/types.js.map +1 -1
  38. package/dist/plugins/cache-plugin.test.js +3 -0
  39. package/dist/plugins/cache-plugin.test.js.map +1 -1
  40. package/dist/plugins/index.d.ts +4 -2
  41. package/dist/plugins/index.d.ts.map +1 -1
  42. package/dist/plugins/index.js +3 -1
  43. package/dist/plugins/index.js.map +1 -1
  44. package/dist/plugins/postgres-plugin.test.js +3 -0
  45. package/dist/plugins/postgres-plugin.test.js.map +1 -1
  46. package/dist/plugins/preferences/__tests__/deep-merge.test.d.ts +7 -0
  47. package/dist/plugins/preferences/__tests__/deep-merge.test.d.ts.map +1 -0
  48. package/dist/plugins/preferences/__tests__/deep-merge.test.js +215 -0
  49. package/dist/plugins/preferences/__tests__/deep-merge.test.js.map +1 -0
  50. package/dist/plugins/preferences/__tests__/preferences-plugin.test.d.ts +7 -0
  51. package/dist/plugins/preferences/__tests__/preferences-plugin.test.d.ts.map +1 -0
  52. package/dist/plugins/preferences/__tests__/preferences-plugin.test.js +265 -0
  53. package/dist/plugins/preferences/__tests__/preferences-plugin.test.js.map +1 -0
  54. package/dist/plugins/preferences/index.d.ts +12 -0
  55. package/dist/plugins/preferences/index.d.ts.map +1 -0
  56. package/dist/plugins/preferences/index.js +13 -0
  57. package/dist/plugins/preferences/index.js.map +1 -0
  58. package/dist/plugins/preferences/preferences-plugin.d.ts +39 -0
  59. package/dist/plugins/preferences/preferences-plugin.d.ts.map +1 -0
  60. package/dist/plugins/preferences/preferences-plugin.js +226 -0
  61. package/dist/plugins/preferences/preferences-plugin.js.map +1 -0
  62. package/dist/plugins/preferences/stores/index.d.ts +9 -0
  63. package/dist/plugins/preferences/stores/index.d.ts.map +1 -0
  64. package/dist/plugins/preferences/stores/index.js +9 -0
  65. package/dist/plugins/preferences/stores/index.js.map +1 -0
  66. package/dist/plugins/preferences/stores/postgres-store.d.ts +41 -0
  67. package/dist/plugins/preferences/stores/postgres-store.d.ts.map +1 -0
  68. package/dist/plugins/preferences/stores/postgres-store.js +181 -0
  69. package/dist/plugins/preferences/stores/postgres-store.js.map +1 -0
  70. package/dist/plugins/preferences/types.d.ts +91 -0
  71. package/dist/plugins/preferences/types.d.ts.map +1 -0
  72. package/dist/plugins/preferences/types.js +10 -0
  73. package/dist/plugins/preferences/types.js.map +1 -0
  74. package/dist/plugins/users/__tests__/users-plugin.test.d.ts +9 -0
  75. package/dist/plugins/users/__tests__/users-plugin.test.d.ts.map +1 -0
  76. package/dist/plugins/users/__tests__/users-plugin.test.js +546 -0
  77. package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -0
  78. package/dist/plugins/users/index.d.ts +2 -2
  79. package/dist/plugins/users/index.d.ts.map +1 -1
  80. package/dist/plugins/users/index.js +1 -1
  81. package/dist/plugins/users/index.js.map +1 -1
  82. package/dist/plugins/users/types.d.ts +36 -0
  83. package/dist/plugins/users/types.d.ts.map +1 -1
  84. package/dist/plugins/users/users-plugin.d.ts +8 -2
  85. package/dist/plugins/users/users-plugin.d.ts.map +1 -1
  86. package/dist/plugins/users/users-plugin.js +122 -5
  87. package/dist/plugins/users/users-plugin.js.map +1 -1
  88. package/dist-ui/assets/{index-Bsp2ntcw.js → index-BY8OxNgO.js} +112 -112
  89. package/dist-ui/assets/index-BY8OxNgO.js.map +1 -0
  90. package/dist-ui/index.html +1 -1
  91. package/dist-ui-lib/api/controlPanelApi.d.ts +53 -7
  92. package/dist-ui-lib/dashboard/WidgetComponentRegistry.d.ts +9 -5
  93. package/dist-ui-lib/dashboard/builtInWidgets.d.ts +7 -1
  94. package/dist-ui-lib/index.js +2382 -3651
  95. package/dist-ui-lib/index.js.map +1 -1
  96. package/dist-ui-lib/pages/AuthPage.d.ts +1 -0
  97. package/dist-ui-lib/pages/PluginsPage.d.ts +1 -0
  98. package/package.json +7 -2
  99. package/src/core/control-panel.ts +33 -2
  100. package/src/core/plugin-registry.ts +63 -0
  101. package/src/index.ts +7 -0
  102. package/src/plugins/auth/adapters/index.ts +1 -0
  103. package/src/plugins/auth/adapters/supabase-adapter.ts +22 -14
  104. package/src/plugins/auth/adapters/supertokens-adapter.ts +326 -0
  105. package/src/plugins/auth/env-config.ts +572 -0
  106. package/src/plugins/auth/index.ts +9 -0
  107. package/src/plugins/auth/supertokens-adapter.test.ts +621 -0
  108. package/src/plugins/auth/types.ts +80 -0
  109. package/src/plugins/cache-plugin.test.ts +3 -0
  110. package/src/plugins/index.ts +26 -0
  111. package/src/plugins/postgres-plugin.test.ts +3 -0
  112. package/src/plugins/preferences/__tests__/deep-merge.test.ts +242 -0
  113. package/src/plugins/preferences/__tests__/preferences-plugin.test.ts +350 -0
  114. package/src/plugins/preferences/index.ts +30 -0
  115. package/src/plugins/preferences/preferences-plugin.ts +270 -0
  116. package/src/plugins/preferences/stores/index.ts +9 -0
  117. package/src/plugins/preferences/stores/postgres-store.ts +252 -0
  118. package/src/plugins/preferences/types.ts +100 -0
  119. package/src/plugins/users/__tests__/users-plugin.test.ts +690 -0
  120. package/src/plugins/users/index.ts +3 -0
  121. package/src/plugins/users/types.ts +38 -0
  122. package/src/plugins/users/users-plugin.ts +142 -5
  123. package/ui/src/App.tsx +4 -1
  124. package/ui/src/api/controlPanelApi.ts +100 -1
  125. package/ui/src/components/ControlPanelApp.tsx +3 -0
  126. package/ui/src/dashboard/PluginWidgetRenderer.tsx +13 -10
  127. package/ui/src/dashboard/WidgetComponentRegistry.tsx +13 -9
  128. package/ui/src/dashboard/builtInWidgets.tsx +8 -2
  129. package/ui/src/pages/AuthPage.tsx +259 -0
  130. package/ui/src/pages/PluginsPage.tsx +394 -0
  131. package/ui/vite.lib.config.ts +5 -0
  132. package/dist-ui/assets/index-Bsp2ntcw.js.map +0 -1
@@ -0,0 +1,690 @@
1
+ /**
2
+ * Users Plugin Tests
3
+ *
4
+ * Unit tests for the users plugin including the new endpoints:
5
+ * - GET /users/:id/info
6
+ * - POST /users/sync
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import {
11
+ createUsersPlugin,
12
+ getUserStore,
13
+ getUserById,
14
+ getUserByEmail,
15
+ findOrCreateUser,
16
+ } from '../users-plugin.js';
17
+ import type { UserStore, UsersPluginConfig, User } from '../types.js';
18
+ import type { PluginRegistry } from '../../../core/plugin-registry.js';
19
+
20
+ // Mock the imported helper functions from other plugins
21
+ vi.mock('../../entitlements/entitlements-plugin.js', () => ({
22
+ getEntitlements: vi.fn(),
23
+ }));
24
+
25
+ vi.mock('../../preferences/preferences-plugin.js', () => ({
26
+ getPreferences: vi.fn(),
27
+ }));
28
+
29
+ vi.mock('../../bans/bans-plugin.js', () => ({
30
+ getActiveBan: vi.fn(),
31
+ }));
32
+
33
+ // Import mocked functions for test setup
34
+ import { getEntitlements } from '../../entitlements/entitlements-plugin.js';
35
+ import { getPreferences } from '../../preferences/preferences-plugin.js';
36
+ import { getActiveBan } from '../../bans/bans-plugin.js';
37
+
38
+ describe('Users Plugin', () => {
39
+ // Mock user data
40
+ const mockUser: User = {
41
+ id: 'test-user-id-123',
42
+ email: 'test@example.com',
43
+ name: 'Test User',
44
+ external_id: 'auth0|abc123',
45
+ provider: 'auth0',
46
+ picture: 'https://example.com/avatar.jpg',
47
+ created_at: new Date('2025-01-01'),
48
+ updated_at: new Date('2025-01-01'),
49
+ last_login_at: new Date('2025-12-13'),
50
+ };
51
+
52
+ // Mock store
53
+ const createMockStore = (): UserStore => ({
54
+ name: 'mock',
55
+ initialize: vi.fn().mockResolvedValue(undefined),
56
+ getById: vi.fn().mockResolvedValue(mockUser),
57
+ getByEmail: vi.fn().mockResolvedValue(mockUser),
58
+ getByExternalId: vi.fn().mockResolvedValue(null),
59
+ create: vi.fn().mockResolvedValue(mockUser),
60
+ update: vi.fn().mockResolvedValue(mockUser),
61
+ delete: vi.fn().mockResolvedValue(true),
62
+ search: vi.fn().mockResolvedValue({ users: [mockUser], total: 1, page: 1, limit: 20, totalPages: 1 }),
63
+ updateLastLogin: vi.fn().mockResolvedValue(undefined),
64
+ shutdown: vi.fn().mockResolvedValue(undefined),
65
+ });
66
+
67
+ // Mock registry with configurable plugins
68
+ const createMockRegistry = (plugins: string[] = []): PluginRegistry =>
69
+ ({
70
+ hasPlugin: vi.fn().mockImplementation((id: string) => plugins.includes(id)),
71
+ getPlugin: vi.fn().mockReturnValue(null),
72
+ listPlugins: vi.fn().mockReturnValue([]),
73
+ addRoute: vi.fn(),
74
+ addMenuItem: vi.fn(),
75
+ addPage: vi.fn(),
76
+ addWidget: vi.fn(),
77
+ getRoutes: vi.fn().mockReturnValue([]),
78
+ getMenuItems: vi.fn().mockReturnValue([]),
79
+ getPages: vi.fn().mockReturnValue([]),
80
+ getWidgets: vi.fn().mockReturnValue([]),
81
+ getConfig: vi.fn().mockReturnValue({}),
82
+ setConfig: vi.fn().mockResolvedValue(undefined),
83
+ subscribe: vi.fn().mockReturnValue(() => {}),
84
+ emit: vi.fn(),
85
+ registerHealthCheck: vi.fn(),
86
+ getApp: vi.fn().mockReturnValue({} as any),
87
+ getRouter: vi.fn().mockReturnValue({} as any),
88
+ getLogger: vi.fn().mockReturnValue({
89
+ debug: vi.fn(),
90
+ info: vi.fn(),
91
+ warn: vi.fn(),
92
+ error: vi.fn(),
93
+ }),
94
+ }) as unknown as PluginRegistry;
95
+
96
+ let mockStore: UserStore;
97
+ let mockRegistry: PluginRegistry;
98
+
99
+ beforeEach(() => {
100
+ vi.clearAllMocks();
101
+ mockStore = createMockStore();
102
+ mockRegistry = createMockRegistry();
103
+ });
104
+
105
+ afterEach(async () => {
106
+ // Clean up by stopping the plugin if it was started
107
+ const store = getUserStore();
108
+ if (store) {
109
+ const plugin = createUsersPlugin({ store: mockStore });
110
+ await plugin.onStop();
111
+ }
112
+ });
113
+
114
+ describe('createUsersPlugin', () => {
115
+ it('should create a plugin with correct id', () => {
116
+ const plugin = createUsersPlugin({ store: mockStore });
117
+ expect(plugin.id).toBe('users');
118
+ });
119
+
120
+ it('should create a plugin with correct name', () => {
121
+ const plugin = createUsersPlugin({ store: mockStore });
122
+ expect(plugin.name).toBe('Users');
123
+ });
124
+
125
+ it('should create a plugin with version', () => {
126
+ const plugin = createUsersPlugin({ store: mockStore });
127
+ expect(plugin.version).toBe('1.0.0');
128
+ });
129
+ });
130
+
131
+ describe('onStart', () => {
132
+ it('should initialize store on start', async () => {
133
+ const plugin = createUsersPlugin({ store: mockStore });
134
+ await plugin.onStart({}, mockRegistry);
135
+
136
+ expect(mockStore.initialize).toHaveBeenCalled();
137
+ });
138
+
139
+ it('should register health check', async () => {
140
+ const plugin = createUsersPlugin({ store: mockStore });
141
+ await plugin.onStart({}, mockRegistry);
142
+
143
+ expect(mockRegistry.registerHealthCheck).toHaveBeenCalledWith(
144
+ expect.objectContaining({
145
+ name: 'users-store',
146
+ type: 'custom',
147
+ })
148
+ );
149
+ });
150
+
151
+ it('should register CRUD routes by default', async () => {
152
+ const plugin = createUsersPlugin({ store: mockStore });
153
+ await plugin.onStart({}, mockRegistry);
154
+
155
+ // GET /users, GET /users/:id, POST /users, PUT /users/:id, DELETE /users/:id
156
+ // + GET /users/:id/info, POST /users/sync
157
+ expect(mockRegistry.addRoute).toHaveBeenCalledTimes(7);
158
+ });
159
+
160
+ it('should register /users/:id/info route', async () => {
161
+ const plugin = createUsersPlugin({ store: mockStore });
162
+ await plugin.onStart({}, mockRegistry);
163
+
164
+ const calls = (mockRegistry.addRoute as any).mock.calls;
165
+ const infoRoute = calls.find((c: any) => c[0].path === '/users/:id/info');
166
+
167
+ expect(infoRoute).toBeDefined();
168
+ expect(infoRoute[0]).toMatchObject({
169
+ method: 'get',
170
+ path: '/users/:id/info',
171
+ pluginId: 'users',
172
+ });
173
+ });
174
+
175
+ it('should register /users/sync route', async () => {
176
+ const plugin = createUsersPlugin({ store: mockStore });
177
+ await plugin.onStart({}, mockRegistry);
178
+
179
+ const calls = (mockRegistry.addRoute as any).mock.calls;
180
+ const syncRoute = calls.find((c: any) => c[0].path === '/users/sync');
181
+
182
+ expect(syncRoute).toBeDefined();
183
+ expect(syncRoute[0]).toMatchObject({
184
+ method: 'post',
185
+ path: '/users/sync',
186
+ pluginId: 'users',
187
+ });
188
+ });
189
+
190
+ it('should use custom API prefix when provided', async () => {
191
+ const plugin = createUsersPlugin({
192
+ store: mockStore,
193
+ api: { prefix: '/people' },
194
+ });
195
+ await plugin.onStart({}, mockRegistry);
196
+
197
+ const calls = (mockRegistry.addRoute as any).mock.calls;
198
+ expect(calls[0][0].path).toBe('/people');
199
+ });
200
+ });
201
+
202
+ describe('onStop', () => {
203
+ it('should shutdown store', async () => {
204
+ const plugin = createUsersPlugin({ store: mockStore });
205
+ await plugin.onStart({}, mockRegistry);
206
+ await plugin.onStop();
207
+
208
+ expect(mockStore.shutdown).toHaveBeenCalled();
209
+ });
210
+
211
+ it('should clear store reference', async () => {
212
+ const plugin = createUsersPlugin({ store: mockStore });
213
+ await plugin.onStart({}, mockRegistry);
214
+ await plugin.onStop();
215
+
216
+ expect(getUserStore()).toBeNull();
217
+ });
218
+ });
219
+
220
+ describe('helper functions', () => {
221
+ describe('getUserStore', () => {
222
+ it('should return null when plugin not started', () => {
223
+ expect(getUserStore()).toBeNull();
224
+ });
225
+
226
+ it('should return store when plugin started', async () => {
227
+ const plugin = createUsersPlugin({ store: mockStore });
228
+ await plugin.onStart({}, mockRegistry);
229
+
230
+ expect(getUserStore()).toBe(mockStore);
231
+ });
232
+ });
233
+
234
+ describe('getUserById', () => {
235
+ it('should throw when plugin not initialized', async () => {
236
+ await expect(getUserById('user-id')).rejects.toThrow(
237
+ 'Users plugin not initialized'
238
+ );
239
+ });
240
+
241
+ it('should return user when found', async () => {
242
+ const plugin = createUsersPlugin({ store: mockStore });
243
+ await plugin.onStart({}, mockRegistry);
244
+
245
+ const result = await getUserById(mockUser.id);
246
+ expect(result).toEqual(mockUser);
247
+ });
248
+
249
+ it('should return null when user not found', async () => {
250
+ const plugin = createUsersPlugin({ store: mockStore });
251
+ await plugin.onStart({}, mockRegistry);
252
+
253
+ (mockStore.getById as any).mockResolvedValue(null);
254
+
255
+ const result = await getUserById('non-existent');
256
+ expect(result).toBeNull();
257
+ });
258
+ });
259
+
260
+ describe('getUserByEmail', () => {
261
+ it('should throw when plugin not initialized', async () => {
262
+ await expect(getUserByEmail('test@example.com')).rejects.toThrow(
263
+ 'Users plugin not initialized'
264
+ );
265
+ });
266
+
267
+ it('should return user when found', async () => {
268
+ const plugin = createUsersPlugin({ store: mockStore });
269
+ await plugin.onStart({}, mockRegistry);
270
+
271
+ const result = await getUserByEmail(mockUser.email);
272
+ expect(result).toEqual(mockUser);
273
+ });
274
+ });
275
+
276
+ describe('findOrCreateUser', () => {
277
+ it('should throw when plugin not initialized', async () => {
278
+ await expect(
279
+ findOrCreateUser({
280
+ email: 'test@example.com',
281
+ external_id: 'auth0|123',
282
+ provider: 'auth0',
283
+ })
284
+ ).rejects.toThrow('Users plugin not initialized');
285
+ });
286
+
287
+ it('should return existing user when found by external_id', async () => {
288
+ const plugin = createUsersPlugin({ store: mockStore });
289
+ await plugin.onStart({}, mockRegistry);
290
+
291
+ (mockStore.getByExternalId as any).mockResolvedValue(mockUser);
292
+
293
+ const result = await findOrCreateUser({
294
+ email: 'test@example.com',
295
+ external_id: 'auth0|abc123',
296
+ provider: 'auth0',
297
+ });
298
+
299
+ expect(result).toEqual(mockUser);
300
+ expect(mockStore.updateLastLogin).toHaveBeenCalledWith(mockUser.id);
301
+ expect(mockStore.create).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it('should return existing user when found by email', async () => {
305
+ const plugin = createUsersPlugin({ store: mockStore });
306
+ await plugin.onStart({}, mockRegistry);
307
+
308
+ // Not found by external_id
309
+ (mockStore.getByExternalId as any).mockResolvedValue(null);
310
+ // Found by email
311
+ (mockStore.getByEmail as any).mockResolvedValue(mockUser);
312
+
313
+ const result = await findOrCreateUser({
314
+ email: 'test@example.com',
315
+ external_id: 'auth0|new123',
316
+ provider: 'auth0',
317
+ });
318
+
319
+ expect(result).toEqual(mockUser);
320
+ expect(mockStore.updateLastLogin).toHaveBeenCalledWith(mockUser.id);
321
+ expect(mockStore.create).not.toHaveBeenCalled();
322
+ });
323
+
324
+ it('should create new user when not found', async () => {
325
+ const plugin = createUsersPlugin({ store: mockStore });
326
+ await plugin.onStart({}, mockRegistry);
327
+
328
+ // Not found by external_id or email
329
+ (mockStore.getByExternalId as any).mockResolvedValue(null);
330
+ (mockStore.getByEmail as any).mockResolvedValue(null);
331
+
332
+ const newUser: User = { ...mockUser, id: 'new-user-id' };
333
+ (mockStore.create as any).mockResolvedValue(newUser);
334
+
335
+ const result = await findOrCreateUser({
336
+ email: 'new@example.com',
337
+ external_id: 'auth0|new123',
338
+ provider: 'auth0',
339
+ name: 'New User',
340
+ });
341
+
342
+ expect(result).toEqual(newUser);
343
+ expect(mockStore.create).toHaveBeenCalledWith({
344
+ email: 'new@example.com',
345
+ external_id: 'auth0|new123',
346
+ provider: 'auth0',
347
+ name: 'New User',
348
+ picture: undefined,
349
+ });
350
+ });
351
+ });
352
+ });
353
+
354
+ describe('GET /users/:id/info endpoint', () => {
355
+ it('should build user info with all plugins loaded', async () => {
356
+ // Setup registry with all plugins
357
+ const registryWithPlugins = createMockRegistry(['entitlements', 'preferences', 'bans']);
358
+
359
+ // Setup mock responses
360
+ const mockEntitlements = { entitlements: ['pro', 'vtf', 'signals'], identifier: mockUser.email };
361
+ const mockPreferences = { theme: 'dark', notifications: { email: true } };
362
+ const mockBan = null; // Not banned
363
+
364
+ (getEntitlements as any).mockResolvedValue(mockEntitlements);
365
+ (getPreferences as any).mockResolvedValue(mockPreferences);
366
+ (getActiveBan as any).mockResolvedValue(mockBan);
367
+
368
+ const plugin = createUsersPlugin({ store: mockStore });
369
+ await plugin.onStart({}, registryWithPlugins);
370
+
371
+ // Find the handler for /users/:id/info
372
+ const calls = (registryWithPlugins.addRoute as any).mock.calls;
373
+ const infoRoute = calls.find((c: any) => c[0].path === '/users/:id/info');
374
+ const handler = infoRoute[0].handler;
375
+
376
+ // Mock request and response
377
+ const req = { params: { id: mockUser.id } } as any;
378
+ const res = {
379
+ json: vi.fn(),
380
+ status: vi.fn().mockReturnThis(),
381
+ } as any;
382
+
383
+ await handler(req, res);
384
+
385
+ expect(res.json).toHaveBeenCalledWith({
386
+ user: mockUser,
387
+ entitlements: ['pro', 'vtf', 'signals'],
388
+ preferences: mockPreferences,
389
+ ban: null,
390
+ });
391
+ });
392
+
393
+ it('should build user info with only users plugin loaded', async () => {
394
+ // Registry with no other plugins
395
+ const registryNoPlugins = createMockRegistry([]);
396
+
397
+ const plugin = createUsersPlugin({ store: mockStore });
398
+ await plugin.onStart({}, registryNoPlugins);
399
+
400
+ // Find the handler
401
+ const calls = (registryNoPlugins.addRoute as any).mock.calls;
402
+ const infoRoute = calls.find((c: any) => c[0].path === '/users/:id/info');
403
+ const handler = infoRoute[0].handler;
404
+
405
+ const req = { params: { id: mockUser.id } } as any;
406
+ const res = {
407
+ json: vi.fn(),
408
+ status: vi.fn().mockReturnThis(),
409
+ } as any;
410
+
411
+ await handler(req, res);
412
+
413
+ // Should only have user field
414
+ expect(res.json).toHaveBeenCalledWith({ user: mockUser });
415
+ });
416
+
417
+ it('should return 404 when user not found', async () => {
418
+ (mockStore.getById as any).mockResolvedValue(null);
419
+
420
+ const plugin = createUsersPlugin({ store: mockStore });
421
+ await plugin.onStart({}, mockRegistry);
422
+
423
+ const calls = (mockRegistry.addRoute as any).mock.calls;
424
+ const infoRoute = calls.find((c: any) => c[0].path === '/users/:id/info');
425
+ const handler = infoRoute[0].handler;
426
+
427
+ const req = { params: { id: 'non-existent' } } as any;
428
+ const res = {
429
+ json: vi.fn(),
430
+ status: vi.fn().mockReturnThis(),
431
+ } as any;
432
+
433
+ await handler(req, res);
434
+
435
+ expect(res.status).toHaveBeenCalledWith(404);
436
+ expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
437
+ });
438
+
439
+ it('should include ban info when user is banned', async () => {
440
+ const registryWithBans = createMockRegistry(['bans']);
441
+
442
+ const mockBan = {
443
+ id: 'ban-123',
444
+ user_id: mockUser.id,
445
+ reason: 'Terms of service violation',
446
+ banned_by: 'admin',
447
+ banned_at: new Date('2025-12-01'),
448
+ expires_at: new Date('2025-12-31'),
449
+ is_active: true,
450
+ };
451
+
452
+ (getActiveBan as any).mockResolvedValue(mockBan);
453
+
454
+ const plugin = createUsersPlugin({ store: mockStore });
455
+ await plugin.onStart({}, registryWithBans);
456
+
457
+ const calls = (registryWithBans.addRoute as any).mock.calls;
458
+ const infoRoute = calls.find((c: any) => c[0].path === '/users/:id/info');
459
+ const handler = infoRoute[0].handler;
460
+
461
+ const req = { params: { id: mockUser.id } } as any;
462
+ const res = {
463
+ json: vi.fn(),
464
+ status: vi.fn().mockReturnThis(),
465
+ } as any;
466
+
467
+ await handler(req, res);
468
+
469
+ expect(res.json).toHaveBeenCalledWith({
470
+ user: mockUser,
471
+ ban: {
472
+ id: 'ban-123',
473
+ reason: 'Terms of service violation',
474
+ banned_at: mockBan.banned_at,
475
+ expires_at: mockBan.expires_at,
476
+ },
477
+ });
478
+ });
479
+
480
+ it('should continue without failing when a plugin helper throws', async () => {
481
+ const registryWithPlugins = createMockRegistry(['entitlements', 'preferences']);
482
+
483
+ // Entitlements throws, preferences succeeds
484
+ (getEntitlements as any).mockRejectedValue(new Error('Entitlements service unavailable'));
485
+ (getPreferences as any).mockResolvedValue({ theme: 'light' });
486
+
487
+ const plugin = createUsersPlugin({ store: mockStore });
488
+ await plugin.onStart({}, registryWithPlugins);
489
+
490
+ const calls = (registryWithPlugins.addRoute as any).mock.calls;
491
+ const infoRoute = calls.find((c: any) => c[0].path === '/users/:id/info');
492
+ const handler = infoRoute[0].handler;
493
+
494
+ const req = { params: { id: mockUser.id } } as any;
495
+ const res = {
496
+ json: vi.fn(),
497
+ status: vi.fn().mockReturnThis(),
498
+ } as any;
499
+
500
+ // Should not throw, should continue with available data
501
+ await handler(req, res);
502
+
503
+ // Should have user and preferences, but not entitlements
504
+ expect(res.json).toHaveBeenCalledWith({
505
+ user: mockUser,
506
+ preferences: { theme: 'light' },
507
+ });
508
+ });
509
+ });
510
+
511
+ describe('POST /users/sync endpoint', () => {
512
+ it('should find or create user and return full info', async () => {
513
+ const registryWithPlugins = createMockRegistry(['entitlements', 'preferences', 'bans']);
514
+
515
+ (mockStore.getByExternalId as any).mockResolvedValue(null);
516
+ (mockStore.getByEmail as any).mockResolvedValue(null);
517
+ (mockStore.create as any).mockResolvedValue(mockUser);
518
+
519
+ (getEntitlements as any).mockResolvedValue({ entitlements: ['free'] });
520
+ (getPreferences as any).mockResolvedValue({ theme: 'light' });
521
+ (getActiveBan as any).mockResolvedValue(null);
522
+
523
+ const plugin = createUsersPlugin({ store: mockStore });
524
+ await plugin.onStart({}, registryWithPlugins);
525
+
526
+ const calls = (registryWithPlugins.addRoute as any).mock.calls;
527
+ const syncRoute = calls.find((c: any) => c[0].path === '/users/sync');
528
+ const handler = syncRoute[0].handler;
529
+
530
+ const req = {
531
+ body: {
532
+ email: 'new@example.com',
533
+ external_id: 'auth0|new123',
534
+ provider: 'auth0',
535
+ name: 'New User',
536
+ },
537
+ } as any;
538
+ const res = {
539
+ json: vi.fn(),
540
+ status: vi.fn().mockReturnThis(),
541
+ } as any;
542
+
543
+ await handler(req, res);
544
+
545
+ expect(res.json).toHaveBeenCalledWith({
546
+ user: mockUser,
547
+ entitlements: ['free'],
548
+ preferences: { theme: 'light' },
549
+ ban: null,
550
+ });
551
+ });
552
+
553
+ it('should return 400 when email is missing', async () => {
554
+ const plugin = createUsersPlugin({ store: mockStore });
555
+ await plugin.onStart({}, mockRegistry);
556
+
557
+ const calls = (mockRegistry.addRoute as any).mock.calls;
558
+ const syncRoute = calls.find((c: any) => c[0].path === '/users/sync');
559
+ const handler = syncRoute[0].handler;
560
+
561
+ const req = {
562
+ body: {
563
+ external_id: 'auth0|123',
564
+ provider: 'auth0',
565
+ },
566
+ } as any;
567
+ const res = {
568
+ json: vi.fn(),
569
+ status: vi.fn().mockReturnThis(),
570
+ } as any;
571
+
572
+ await handler(req, res);
573
+
574
+ expect(res.status).toHaveBeenCalledWith(400);
575
+ expect(res.json).toHaveBeenCalledWith({ error: 'Valid email is required' });
576
+ });
577
+
578
+ it('should return 400 when email is invalid', async () => {
579
+ const plugin = createUsersPlugin({ store: mockStore });
580
+ await plugin.onStart({}, mockRegistry);
581
+
582
+ const calls = (mockRegistry.addRoute as any).mock.calls;
583
+ const syncRoute = calls.find((c: any) => c[0].path === '/users/sync');
584
+ const handler = syncRoute[0].handler;
585
+
586
+ const req = {
587
+ body: {
588
+ email: 'invalid-email',
589
+ external_id: 'auth0|123',
590
+ provider: 'auth0',
591
+ },
592
+ } as any;
593
+ const res = {
594
+ json: vi.fn(),
595
+ status: vi.fn().mockReturnThis(),
596
+ } as any;
597
+
598
+ await handler(req, res);
599
+
600
+ expect(res.status).toHaveBeenCalledWith(400);
601
+ expect(res.json).toHaveBeenCalledWith({ error: 'Valid email is required' });
602
+ });
603
+
604
+ it('should return 400 when external_id is missing', async () => {
605
+ const plugin = createUsersPlugin({ store: mockStore });
606
+ await plugin.onStart({}, mockRegistry);
607
+
608
+ const calls = (mockRegistry.addRoute as any).mock.calls;
609
+ const syncRoute = calls.find((c: any) => c[0].path === '/users/sync');
610
+ const handler = syncRoute[0].handler;
611
+
612
+ const req = {
613
+ body: {
614
+ email: 'test@example.com',
615
+ provider: 'auth0',
616
+ },
617
+ } as any;
618
+ const res = {
619
+ json: vi.fn(),
620
+ status: vi.fn().mockReturnThis(),
621
+ } as any;
622
+
623
+ await handler(req, res);
624
+
625
+ expect(res.status).toHaveBeenCalledWith(400);
626
+ expect(res.json).toHaveBeenCalledWith({ error: 'external_id is required' });
627
+ });
628
+
629
+ it('should return 400 when provider is missing', async () => {
630
+ const plugin = createUsersPlugin({ store: mockStore });
631
+ await plugin.onStart({}, mockRegistry);
632
+
633
+ const calls = (mockRegistry.addRoute as any).mock.calls;
634
+ const syncRoute = calls.find((c: any) => c[0].path === '/users/sync');
635
+ const handler = syncRoute[0].handler;
636
+
637
+ const req = {
638
+ body: {
639
+ email: 'test@example.com',
640
+ external_id: 'auth0|123',
641
+ },
642
+ } as any;
643
+ const res = {
644
+ json: vi.fn(),
645
+ status: vi.fn().mockReturnThis(),
646
+ } as any;
647
+
648
+ await handler(req, res);
649
+
650
+ expect(res.status).toHaveBeenCalledWith(400);
651
+ expect(res.json).toHaveBeenCalledWith({ error: 'provider is required' });
652
+ });
653
+
654
+ it('should sync existing user and return info', async () => {
655
+ const registryWithPlugins = createMockRegistry(['entitlements']);
656
+
657
+ // User already exists
658
+ (mockStore.getByExternalId as any).mockResolvedValue(mockUser);
659
+ (getEntitlements as any).mockResolvedValue({ entitlements: ['pro', 'vtf'] });
660
+
661
+ const plugin = createUsersPlugin({ store: mockStore });
662
+ await plugin.onStart({}, registryWithPlugins);
663
+
664
+ const calls = (registryWithPlugins.addRoute as any).mock.calls;
665
+ const syncRoute = calls.find((c: any) => c[0].path === '/users/sync');
666
+ const handler = syncRoute[0].handler;
667
+
668
+ const req = {
669
+ body: {
670
+ email: mockUser.email,
671
+ external_id: mockUser.external_id,
672
+ provider: mockUser.provider,
673
+ },
674
+ } as any;
675
+ const res = {
676
+ json: vi.fn(),
677
+ status: vi.fn().mockReturnThis(),
678
+ } as any;
679
+
680
+ await handler(req, res);
681
+
682
+ expect(mockStore.create).not.toHaveBeenCalled();
683
+ expect(mockStore.updateLastLogin).toHaveBeenCalledWith(mockUser.id);
684
+ expect(res.json).toHaveBeenCalledWith({
685
+ user: mockUser,
686
+ entitlements: ['pro', 'vtf'],
687
+ });
688
+ });
689
+ });
690
+ });
@@ -14,6 +14,7 @@ export {
14
14
  getUserById,
15
15
  getUserByEmail,
16
16
  findOrCreateUser,
17
+ buildUserInfo,
17
18
  } from './users-plugin.js';
18
19
 
19
20
  // Types
@@ -29,6 +30,8 @@ export type {
29
30
  UserSyncConfig,
30
31
  UsersApiConfig,
31
32
  UsersUiConfig,
33
+ UserInfo,
34
+ UserSyncInput,
32
35
  } from './types.js';
33
36
 
34
37
  // Stores