@wopr-network/platform-core 1.68.0 → 1.69.0

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 (77) hide show
  1. package/dist/backup/types.d.ts +1 -1
  2. package/dist/server/__tests__/build-container.test.d.ts +1 -0
  3. package/dist/server/__tests__/build-container.test.js +339 -0
  4. package/dist/server/__tests__/container.test.d.ts +1 -0
  5. package/dist/server/__tests__/container.test.js +170 -0
  6. package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
  7. package/dist/server/__tests__/lifecycle.test.js +90 -0
  8. package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
  9. package/dist/server/__tests__/mount-routes.test.js +151 -0
  10. package/dist/server/boot-config.d.ts +51 -0
  11. package/dist/server/boot-config.js +7 -0
  12. package/dist/server/container.d.ts +81 -0
  13. package/dist/server/container.js +134 -0
  14. package/dist/server/index.d.ts +33 -0
  15. package/dist/server/index.js +66 -0
  16. package/dist/server/lifecycle.d.ts +25 -0
  17. package/dist/server/lifecycle.js +46 -0
  18. package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
  19. package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
  20. package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
  21. package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
  22. package/dist/server/middleware/admin-auth.d.ts +18 -0
  23. package/dist/server/middleware/admin-auth.js +38 -0
  24. package/dist/server/middleware/tenant-proxy.d.ts +56 -0
  25. package/dist/server/middleware/tenant-proxy.js +162 -0
  26. package/dist/server/mount-routes.d.ts +30 -0
  27. package/dist/server/mount-routes.js +74 -0
  28. package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
  29. package/dist/server/routes/__tests__/admin.test.js +267 -0
  30. package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
  31. package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
  32. package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
  33. package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
  34. package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
  35. package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
  36. package/dist/server/routes/admin.d.ts +111 -0
  37. package/dist/server/routes/admin.js +273 -0
  38. package/dist/server/routes/crypto-webhook.d.ts +23 -0
  39. package/dist/server/routes/crypto-webhook.js +82 -0
  40. package/dist/server/routes/provision-webhook.d.ts +38 -0
  41. package/dist/server/routes/provision-webhook.js +160 -0
  42. package/dist/server/routes/stripe-webhook.d.ts +10 -0
  43. package/dist/server/routes/stripe-webhook.js +29 -0
  44. package/dist/server/test-container.d.ts +15 -0
  45. package/dist/server/test-container.js +103 -0
  46. package/dist/trpc/auth-helpers.d.ts +17 -0
  47. package/dist/trpc/auth-helpers.js +26 -0
  48. package/dist/trpc/container-factories.d.ts +300 -0
  49. package/dist/trpc/container-factories.js +80 -0
  50. package/dist/trpc/index.d.ts +2 -0
  51. package/dist/trpc/index.js +2 -0
  52. package/package.json +5 -1
  53. package/src/server/__tests__/build-container.test.ts +402 -0
  54. package/src/server/__tests__/container.test.ts +204 -0
  55. package/src/server/__tests__/lifecycle.test.ts +106 -0
  56. package/src/server/__tests__/mount-routes.test.ts +169 -0
  57. package/src/server/boot-config.ts +84 -0
  58. package/src/server/container.ts +237 -0
  59. package/src/server/index.ts +92 -0
  60. package/src/server/lifecycle.ts +62 -0
  61. package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
  62. package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
  63. package/src/server/middleware/admin-auth.ts +51 -0
  64. package/src/server/middleware/tenant-proxy.ts +192 -0
  65. package/src/server/mount-routes.ts +113 -0
  66. package/src/server/routes/__tests__/admin.test.ts +320 -0
  67. package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
  68. package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
  69. package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
  70. package/src/server/routes/admin.ts +334 -0
  71. package/src/server/routes/crypto-webhook.ts +110 -0
  72. package/src/server/routes/provision-webhook.ts +212 -0
  73. package/src/server/routes/stripe-webhook.ts +36 -0
  74. package/src/server/test-container.ts +120 -0
  75. package/src/trpc/auth-helpers.ts +28 -0
  76. package/src/trpc/container-factories.ts +114 -0
  77. package/src/trpc/index.ts +9 -0
@@ -9,8 +9,8 @@ export type SnapshotTrigger = z.infer<typeof snapshotTriggerSchema>;
9
9
  /** Subscription tiers per WOP-440 */
10
10
  export declare const tierSchema: z.ZodEnum<{
11
11
  free: "free";
12
- starter: "starter";
13
12
  pro: "pro";
13
+ starter: "starter";
14
14
  enterprise: "enterprise";
15
15
  }>;
16
16
  export type Tier = z.infer<typeof tierSchema>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,339 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ // ---------------------------------------------------------------------------
3
+ // Mocks — all class mocks use real classes (not arrow fns) so `new` works.
4
+ // ---------------------------------------------------------------------------
5
+ // Mock pg.Pool
6
+ const mockPoolEnd = vi.fn().mockResolvedValue(undefined);
7
+ const poolConstructorCalls = [];
8
+ class MockPoolClass {
9
+ connectionString;
10
+ end = mockPoolEnd;
11
+ query = vi.fn().mockResolvedValue({ rows: [] });
12
+ constructor(opts) {
13
+ this.connectionString = opts.connectionString;
14
+ poolConstructorCalls.push(opts);
15
+ }
16
+ }
17
+ vi.mock("pg", () => ({ Pool: MockPoolClass }));
18
+ // Mock drizzle db creation
19
+ const mockDb = { __brand: "drizzle-db" };
20
+ vi.mock("../../db/index.js", () => ({
21
+ createDb: vi.fn(() => mockDb),
22
+ }));
23
+ // Mock drizzle migrator
24
+ const mockMigrate = vi.fn().mockResolvedValue(undefined);
25
+ vi.mock("drizzle-orm/node-postgres/migrator", () => ({
26
+ migrate: mockMigrate,
27
+ }));
28
+ // Mock platformBoot
29
+ const mockProductConfig = {
30
+ product: {
31
+ slug: "test",
32
+ brandName: "Test",
33
+ domain: "test.dev",
34
+ appDomain: "app.test.dev",
35
+ fromEmail: "hi@test.dev",
36
+ emailSupport: "support@test.dev",
37
+ },
38
+ navItems: [],
39
+ domains: [],
40
+ features: null,
41
+ fleet: null,
42
+ billing: null,
43
+ };
44
+ const mockPlatformBoot = vi.fn().mockResolvedValue({
45
+ service: {},
46
+ config: mockProductConfig,
47
+ corsOrigins: ["https://test.dev"],
48
+ seeded: false,
49
+ });
50
+ vi.mock("../../product-config/boot.js", () => ({
51
+ platformBoot: (...args) => mockPlatformBoot(...args),
52
+ }));
53
+ // Mock DrizzleLedger
54
+ const mockSeedSystemAccounts = vi.fn().mockResolvedValue(undefined);
55
+ class MockDrizzleLedgerClass {
56
+ seedSystemAccounts = mockSeedSystemAccounts;
57
+ balance = vi.fn().mockResolvedValue(0);
58
+ credit = vi.fn();
59
+ debit = vi.fn();
60
+ post = vi.fn();
61
+ hasReferenceId = vi.fn().mockResolvedValue(false);
62
+ history = vi.fn().mockResolvedValue([]);
63
+ tenantsWithBalance = vi.fn().mockResolvedValue([]);
64
+ memberUsage = vi.fn().mockResolvedValue([]);
65
+ lifetimeSpend = vi.fn().mockResolvedValue(0);
66
+ lifetimeSpendBatch = vi.fn().mockResolvedValue(new Map());
67
+ expiredCredits = vi.fn().mockResolvedValue([]);
68
+ trialBalance = vi.fn().mockResolvedValue({ balanced: true });
69
+ accountBalance = vi.fn().mockResolvedValue(0);
70
+ existsByReferenceIdLike = vi.fn().mockResolvedValue(false);
71
+ sumPurchasesForPeriod = vi.fn().mockResolvedValue(0);
72
+ getActiveTenantIdsInWindow = vi.fn().mockResolvedValue([]);
73
+ debitCapped = vi.fn().mockResolvedValue(null);
74
+ }
75
+ vi.mock("../../credits/ledger.js", async (importOriginal) => {
76
+ const orig = await importOriginal();
77
+ return { ...orig, DrizzleLedger: MockDrizzleLedgerClass };
78
+ });
79
+ // Mock org/tenancy
80
+ class MockDrizzleOrgMemberRepositoryClass {
81
+ listMembers = vi.fn().mockResolvedValue([]);
82
+ addMember = vi.fn();
83
+ }
84
+ vi.mock("../../tenancy/org-member-repository.js", () => ({
85
+ DrizzleOrgMemberRepository: MockDrizzleOrgMemberRepositoryClass,
86
+ }));
87
+ class MockDrizzleOrgRepositoryClass {
88
+ }
89
+ vi.mock("../../tenancy/drizzle-org-repository.js", () => ({
90
+ DrizzleOrgRepository: MockDrizzleOrgRepositoryClass,
91
+ }));
92
+ const orgServiceConstructorCalls = [];
93
+ class MockOrgServiceClass {
94
+ getOrCreatePersonalOrg = vi.fn();
95
+ constructor(...args) {
96
+ orgServiceConstructorCalls.push(args);
97
+ }
98
+ }
99
+ vi.mock("../../tenancy/org-service.js", () => ({
100
+ OrgService: MockOrgServiceClass,
101
+ }));
102
+ // Mock auth
103
+ class MockBetterAuthUserRepositoryClass {
104
+ }
105
+ vi.mock("../../db/auth-user-repository.js", () => ({
106
+ BetterAuthUserRepository: MockBetterAuthUserRepositoryClass,
107
+ }));
108
+ class MockDrizzleUserRoleRepositoryClass {
109
+ isPlatformAdmin = vi.fn().mockResolvedValue(false);
110
+ listRolesByUser = vi.fn().mockResolvedValue([]);
111
+ getTenantIdByUserId = vi.fn().mockResolvedValue(null);
112
+ grantRole = vi.fn();
113
+ revokeRole = vi.fn().mockResolvedValue(false);
114
+ listUsersByRole = vi.fn().mockResolvedValue([]);
115
+ }
116
+ vi.mock("../../auth/user-role-repository.js", () => ({
117
+ DrizzleUserRoleRepository: MockDrizzleUserRoleRepositoryClass,
118
+ }));
119
+ // Mock fleet deps (only imported when fleet is enabled)
120
+ class MockFleetManagerClass {
121
+ __brand = "fleet-manager";
122
+ }
123
+ vi.mock("../../fleet/fleet-manager.js", () => ({
124
+ FleetManager: MockFleetManagerClass,
125
+ }));
126
+ class MockProfileStoreClass {
127
+ __brand = "profile-store";
128
+ }
129
+ vi.mock("../../fleet/profile-store.js", () => ({
130
+ ProfileStore: MockProfileStoreClass,
131
+ }));
132
+ class MockProxyManagerClass {
133
+ __brand = "proxy-manager";
134
+ }
135
+ vi.mock("../../proxy/manager.js", () => ({
136
+ ProxyManager: MockProxyManagerClass,
137
+ }));
138
+ class MockDrizzleServiceKeyRepositoryClass {
139
+ __brand = "service-key-repo";
140
+ }
141
+ vi.mock("../../gateway/service-key-repository.js", () => ({
142
+ DrizzleServiceKeyRepository: MockDrizzleServiceKeyRepositoryClass,
143
+ }));
144
+ class MockDockerClass {
145
+ __brand = "docker";
146
+ }
147
+ vi.mock("dockerode", () => ({
148
+ default: MockDockerClass,
149
+ }));
150
+ // Mock crypto deps
151
+ class MockDrizzleCryptoChargeRepositoryClass {
152
+ __brand = "charge-repo";
153
+ }
154
+ vi.mock("../../billing/crypto/charge-store.js", () => ({
155
+ DrizzleCryptoChargeRepository: MockDrizzleCryptoChargeRepositoryClass,
156
+ }));
157
+ class MockDrizzleWebhookSeenRepositoryClass {
158
+ __brand = "webhook-seen-repo";
159
+ }
160
+ vi.mock("../../billing/drizzle-webhook-seen-repository.js", () => ({
161
+ DrizzleWebhookSeenRepository: MockDrizzleWebhookSeenRepositoryClass,
162
+ }));
163
+ // Mock stripe deps
164
+ class MockStripeClass {
165
+ __brand = "stripe-client";
166
+ }
167
+ vi.mock("stripe", () => ({
168
+ default: MockStripeClass,
169
+ }));
170
+ class MockDrizzleTenantCustomerRepositoryClass {
171
+ __brand = "tenant-customer-repo";
172
+ }
173
+ vi.mock("../../billing/stripe/tenant-store.js", () => ({
174
+ DrizzleTenantCustomerRepository: MockDrizzleTenantCustomerRepositoryClass,
175
+ }));
176
+ vi.mock("../../billing/stripe/credit-prices.js", () => ({
177
+ loadCreditPriceMap: vi.fn(() => new Map()),
178
+ }));
179
+ class MockStripePaymentProcessorClass {
180
+ __brand = "stripe-processor";
181
+ handleWebhook = vi.fn();
182
+ }
183
+ vi.mock("../../billing/stripe/stripe-payment-processor.js", () => ({
184
+ StripePaymentProcessor: MockStripePaymentProcessorClass,
185
+ }));
186
+ // ---------------------------------------------------------------------------
187
+ // Helpers
188
+ // ---------------------------------------------------------------------------
189
+ function baseBootConfig(overrides) {
190
+ return {
191
+ slug: "test-product",
192
+ databaseUrl: "postgres://localhost:5432/testdb",
193
+ provisionSecret: "test-secret",
194
+ features: {
195
+ fleet: false,
196
+ crypto: false,
197
+ stripe: false,
198
+ gateway: false,
199
+ hotPool: false,
200
+ },
201
+ ...overrides,
202
+ };
203
+ }
204
+ // ---------------------------------------------------------------------------
205
+ // Tests
206
+ // ---------------------------------------------------------------------------
207
+ describe("buildContainer", () => {
208
+ // Dynamic import so mocks are registered before the module loads
209
+ async function loadBuildContainer() {
210
+ const mod = await import("../container.js");
211
+ return mod.buildContainer;
212
+ }
213
+ it("throws on empty databaseUrl", async () => {
214
+ const buildContainer = await loadBuildContainer();
215
+ const config = baseBootConfig({ databaseUrl: "" });
216
+ await expect(buildContainer(config)).rejects.toThrow("databaseUrl is required");
217
+ });
218
+ it("creates pool with correct connectionString", async () => {
219
+ const buildContainer = await loadBuildContainer();
220
+ const config = baseBootConfig();
221
+ poolConstructorCalls.length = 0;
222
+ const container = await buildContainer(config);
223
+ expect(poolConstructorCalls).toContainEqual({
224
+ connectionString: "postgres://localhost:5432/testdb",
225
+ });
226
+ expect(container.pool).toBeDefined();
227
+ });
228
+ it("calls platformBoot with correct slug", async () => {
229
+ const buildContainer = await loadBuildContainer();
230
+ const config = baseBootConfig({ slug: "paperclip" });
231
+ await buildContainer(config);
232
+ expect(mockPlatformBoot).toHaveBeenCalledWith(expect.objectContaining({ slug: "paperclip" }));
233
+ });
234
+ it("seeds system accounts after creating ledger", async () => {
235
+ const buildContainer = await loadBuildContainer();
236
+ const config = baseBootConfig();
237
+ await buildContainer(config);
238
+ expect(mockSeedSystemAccounts).toHaveBeenCalled();
239
+ });
240
+ it("core services are always present", async () => {
241
+ const buildContainer = await loadBuildContainer();
242
+ const config = baseBootConfig();
243
+ const container = await buildContainer(config);
244
+ expect(container.db).toBeDefined();
245
+ expect(container.pool).toBeDefined();
246
+ expect(container.productConfig).toBeDefined();
247
+ expect(container.creditLedger).toBeDefined();
248
+ expect(container.orgMemberRepo).toBeDefined();
249
+ expect(container.orgService).toBeDefined();
250
+ expect(container.userRoleRepo).toBeDefined();
251
+ });
252
+ it("returns null for all disabled features", async () => {
253
+ const buildContainer = await loadBuildContainer();
254
+ const config = baseBootConfig();
255
+ const container = await buildContainer(config);
256
+ expect(container.fleet).toBeNull();
257
+ expect(container.crypto).toBeNull();
258
+ expect(container.stripe).toBeNull();
259
+ expect(container.gateway).toBeNull();
260
+ expect(container.hotPool).toBeNull();
261
+ });
262
+ it("builds fleet services when feature is enabled", async () => {
263
+ const buildContainer = await loadBuildContainer();
264
+ const config = baseBootConfig({
265
+ features: { fleet: true, crypto: false, stripe: false, gateway: false, hotPool: false },
266
+ });
267
+ const container = await buildContainer(config);
268
+ expect(container.fleet).not.toBeNull();
269
+ expect(container.fleet?.manager).toBeDefined();
270
+ expect(container.fleet?.docker).toBeDefined();
271
+ expect(container.fleet?.proxy).toBeDefined();
272
+ expect(container.fleet?.profileStore).toBeDefined();
273
+ expect(container.fleet?.serviceKeyRepo).toBeDefined();
274
+ });
275
+ it("builds crypto services when feature is enabled", async () => {
276
+ const buildContainer = await loadBuildContainer();
277
+ const config = baseBootConfig({
278
+ features: { fleet: false, crypto: true, stripe: false, gateway: false, hotPool: false },
279
+ });
280
+ const container = await buildContainer(config);
281
+ expect(container.crypto).not.toBeNull();
282
+ expect(container.crypto?.chargeRepo).toBeDefined();
283
+ expect(container.crypto?.webhookSeenRepo).toBeDefined();
284
+ });
285
+ it("builds stripe services when feature is enabled and key provided", async () => {
286
+ const buildContainer = await loadBuildContainer();
287
+ const config = baseBootConfig({
288
+ features: { fleet: false, crypto: false, stripe: true, gateway: false, hotPool: false },
289
+ stripeSecretKey: "sk_test_123",
290
+ stripeWebhookSecret: "whsec_test_456",
291
+ });
292
+ const container = await buildContainer(config);
293
+ expect(container.stripe).not.toBeNull();
294
+ expect(container.stripe?.stripe).toBeDefined();
295
+ expect(container.stripe?.webhookSecret).toBe("whsec_test_456");
296
+ expect(container.stripe?.customerRepo).toBeDefined();
297
+ expect(container.stripe?.processor).toBeDefined();
298
+ });
299
+ it("returns null stripe when feature enabled but no secret key", async () => {
300
+ const buildContainer = await loadBuildContainer();
301
+ const config = baseBootConfig({
302
+ features: { fleet: false, crypto: false, stripe: true, gateway: false, hotPool: false },
303
+ // stripeSecretKey intentionally omitted
304
+ });
305
+ const container = await buildContainer(config);
306
+ expect(container.stripe).toBeNull();
307
+ });
308
+ it("builds gateway services when feature is enabled", async () => {
309
+ const buildContainer = await loadBuildContainer();
310
+ const config = baseBootConfig({
311
+ features: { fleet: false, crypto: false, stripe: false, gateway: true, hotPool: false },
312
+ });
313
+ const container = await buildContainer(config);
314
+ expect(container.gateway).not.toBeNull();
315
+ expect(container.gateway?.serviceKeyRepo).toBeDefined();
316
+ });
317
+ it("runs migrations before building services", async () => {
318
+ const buildContainer = await loadBuildContainer();
319
+ const config = baseBootConfig();
320
+ await buildContainer(config);
321
+ expect(mockMigrate).toHaveBeenCalled();
322
+ // Migration should be called before platformBoot
323
+ const migrateOrder = mockMigrate.mock.invocationCallOrder[0];
324
+ const bootOrder = mockPlatformBoot.mock.invocationCallOrder[0];
325
+ expect(migrateOrder).toBeLessThan(bootOrder);
326
+ });
327
+ it("constructs OrgService with orgRepo, memberRepo, db, and authUserRepo", async () => {
328
+ const buildContainer = await loadBuildContainer();
329
+ const config = baseBootConfig();
330
+ orgServiceConstructorCalls.length = 0;
331
+ await buildContainer(config);
332
+ expect(orgServiceConstructorCalls.length).toBeGreaterThan(0);
333
+ const [orgRepo, memberRepo, db, options] = orgServiceConstructorCalls[0];
334
+ expect(orgRepo).toBeInstanceOf(MockDrizzleOrgRepositoryClass);
335
+ expect(memberRepo).toBeInstanceOf(MockDrizzleOrgMemberRepositoryClass);
336
+ expect(db).toBe(mockDb);
337
+ expect(options).toEqual(expect.objectContaining({ userRepo: expect.any(MockBetterAuthUserRepositoryClass) }));
338
+ });
339
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,170 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestContainer } from "../test-container.js";
3
+ // ---------------------------------------------------------------------------
4
+ // PlatformContainer interface
5
+ // ---------------------------------------------------------------------------
6
+ describe("PlatformContainer", () => {
7
+ it("allows null feature services", () => {
8
+ const c = createTestContainer();
9
+ expect(c.fleet).toBeNull();
10
+ expect(c.crypto).toBeNull();
11
+ expect(c.stripe).toBeNull();
12
+ expect(c.gateway).toBeNull();
13
+ expect(c.hotPool).toBeNull();
14
+ });
15
+ it("requires core services to be present", () => {
16
+ const c = createTestContainer();
17
+ expect(c.db).toBeDefined();
18
+ expect(c.pool).toBeDefined();
19
+ expect(c.productConfig).toBeDefined();
20
+ expect(c.creditLedger).toBeDefined();
21
+ expect(c.orgMemberRepo).toBeDefined();
22
+ expect(c.orgService).toBeDefined();
23
+ expect(c.userRoleRepo).toBeDefined();
24
+ });
25
+ });
26
+ // ---------------------------------------------------------------------------
27
+ // BootConfig shape
28
+ // ---------------------------------------------------------------------------
29
+ describe("BootConfig", () => {
30
+ it("requires slug and provisionSecret", () => {
31
+ const config = {
32
+ slug: "test-product",
33
+ databaseUrl: "postgres://localhost/test",
34
+ provisionSecret: "s3cret",
35
+ features: {
36
+ fleet: false,
37
+ crypto: false,
38
+ stripe: false,
39
+ gateway: false,
40
+ hotPool: false,
41
+ },
42
+ };
43
+ expect(config.slug).toBe("test-product");
44
+ expect(config.provisionSecret).toBe("s3cret");
45
+ });
46
+ it("accepts optional fields", () => {
47
+ const config = {
48
+ slug: "full",
49
+ databaseUrl: "postgres://localhost/full",
50
+ provisionSecret: "secret",
51
+ host: "127.0.0.1",
52
+ port: 4000,
53
+ features: {
54
+ fleet: true,
55
+ crypto: true,
56
+ stripe: true,
57
+ gateway: true,
58
+ hotPool: true,
59
+ },
60
+ stripeSecretKey: "sk_test_xxx",
61
+ stripeWebhookSecret: "whsec_xxx",
62
+ cryptoServiceKey: "csk_xxx",
63
+ routes: [],
64
+ };
65
+ expect(config.host).toBe("127.0.0.1");
66
+ expect(config.port).toBe(4000);
67
+ expect(config.features.fleet).toBe(true);
68
+ });
69
+ it("FeatureFlags has all five toggles", () => {
70
+ const flags = {
71
+ fleet: true,
72
+ crypto: false,
73
+ stripe: true,
74
+ gateway: false,
75
+ hotPool: true,
76
+ };
77
+ expect(Object.keys(flags)).toHaveLength(5);
78
+ });
79
+ });
80
+ // ---------------------------------------------------------------------------
81
+ // createTestContainer
82
+ // ---------------------------------------------------------------------------
83
+ describe("createTestContainer", () => {
84
+ it("returns valid defaults", () => {
85
+ const c = createTestContainer();
86
+ expect(c.fleet).toBeNull();
87
+ expect(c.crypto).toBeNull();
88
+ expect(c.stripe).toBeNull();
89
+ expect(c.gateway).toBeNull();
90
+ expect(c.hotPool).toBeNull();
91
+ expect(c.productConfig.product).toBeDefined();
92
+ });
93
+ it("allows overrides for core services", () => {
94
+ const customConfig = {
95
+ product: { slug: "custom", name: "Custom" },
96
+ navItems: [],
97
+ domains: [],
98
+ features: null,
99
+ fleet: null,
100
+ billing: null,
101
+ };
102
+ const c = createTestContainer({ productConfig: customConfig });
103
+ expect(c.productConfig.product).toEqual({ slug: "custom", name: "Custom" });
104
+ });
105
+ it("stub ledger methods return sensible defaults", async () => {
106
+ const c = createTestContainer();
107
+ expect(await c.creditLedger.balance("t1")).toBe(0);
108
+ expect(await c.creditLedger.hasReferenceId("ref")).toBe(false);
109
+ expect(await c.creditLedger.history("t1")).toEqual([]);
110
+ expect(await c.creditLedger.tenantsWithBalance()).toEqual([]);
111
+ expect(await c.creditLedger.lifetimeSpendBatch([])).toEqual(new Map());
112
+ });
113
+ it("stub orgMemberRepo methods return sensible defaults", async () => {
114
+ const c = createTestContainer();
115
+ expect(await c.orgMemberRepo.findMember("org1", "u1")).toBeNull();
116
+ expect(await c.orgMemberRepo.listMembers("org1")).toEqual([]);
117
+ expect(await c.orgMemberRepo.countAdminsAndOwners("org1")).toBe(0);
118
+ });
119
+ it("stub userRoleRepo methods return sensible defaults", async () => {
120
+ const c = createTestContainer();
121
+ expect(await c.userRoleRepo.getTenantIdByUserId("u1")).toBeNull();
122
+ expect(await c.userRoleRepo.isPlatformAdmin("u1")).toBe(false);
123
+ expect(await c.userRoleRepo.listRolesByUser("u1")).toEqual([]);
124
+ });
125
+ it("allows enabling feature services via overrides", () => {
126
+ const fleet = {
127
+ manager: {},
128
+ docker: {},
129
+ proxy: {},
130
+ profileStore: {},
131
+ serviceKeyRepo: {},
132
+ };
133
+ const crypto = {
134
+ chargeRepo: {},
135
+ webhookSeenRepo: {},
136
+ };
137
+ const stripe = {
138
+ stripe: {},
139
+ webhookSecret: "whsec_test",
140
+ customerRepo: {},
141
+ processor: {},
142
+ };
143
+ const gateway = {
144
+ serviceKeyRepo: {},
145
+ };
146
+ const hotPool = {
147
+ poolManager: {},
148
+ };
149
+ const c = createTestContainer({ fleet, crypto, stripe, gateway, hotPool });
150
+ expect(c.fleet).not.toBeNull();
151
+ expect(c.crypto).not.toBeNull();
152
+ expect(c.stripe).not.toBeNull();
153
+ expect(c.stripe?.webhookSecret).toBe("whsec_test");
154
+ expect(c.gateway).not.toBeNull();
155
+ expect(c.hotPool).not.toBeNull();
156
+ });
157
+ it("overrides merge without affecting other defaults", () => {
158
+ const c = createTestContainer({ gateway: { serviceKeyRepo: {} } });
159
+ // Overridden field
160
+ expect(c.gateway).not.toBeNull();
161
+ // Other feature services remain null
162
+ expect(c.fleet).toBeNull();
163
+ expect(c.crypto).toBeNull();
164
+ expect(c.stripe).toBeNull();
165
+ expect(c.hotPool).toBeNull();
166
+ // Core services still present
167
+ expect(c.creditLedger).toBeDefined();
168
+ expect(c.orgMemberRepo).toBeDefined();
169
+ });
170
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { gracefulShutdown, startBackgroundServices } from "../lifecycle.js";
3
+ import { createTestContainer } from "../test-container.js";
4
+ describe("startBackgroundServices", () => {
5
+ it("returns a BackgroundHandles object", async () => {
6
+ const container = createTestContainer();
7
+ const handles = await startBackgroundServices(container);
8
+ expect(handles).toHaveProperty("intervals");
9
+ expect(handles).toHaveProperty("unsubscribes");
10
+ expect(Array.isArray(handles.intervals)).toBe(true);
11
+ expect(Array.isArray(handles.unsubscribes)).toBe(true);
12
+ });
13
+ it("calls proxy.start when fleet is enabled", async () => {
14
+ const startFn = vi.fn().mockResolvedValue(undefined);
15
+ const container = createTestContainer({
16
+ fleet: {
17
+ manager: {},
18
+ docker: {},
19
+ proxy: {
20
+ start: startFn,
21
+ addRoute: async () => { },
22
+ removeRoute: () => { },
23
+ getRoutes: () => [],
24
+ },
25
+ profileStore: { list: async () => [] },
26
+ serviceKeyRepo: {},
27
+ },
28
+ });
29
+ await startBackgroundServices(container);
30
+ expect(startFn).toHaveBeenCalledOnce();
31
+ });
32
+ it("does not throw when proxy.start fails", async () => {
33
+ const container = createTestContainer({
34
+ fleet: {
35
+ manager: {},
36
+ docker: {},
37
+ proxy: {
38
+ start: async () => {
39
+ throw new Error("proxy start failed");
40
+ },
41
+ addRoute: async () => { },
42
+ removeRoute: () => { },
43
+ getRoutes: () => [],
44
+ },
45
+ profileStore: { list: async () => [] },
46
+ serviceKeyRepo: {},
47
+ },
48
+ });
49
+ // Should not throw
50
+ const handles = await startBackgroundServices(container);
51
+ expect(handles).toBeDefined();
52
+ });
53
+ });
54
+ describe("gracefulShutdown", () => {
55
+ it("clears all intervals", async () => {
56
+ const container = createTestContainer();
57
+ const clearSpy = vi.spyOn(global, "clearInterval");
58
+ const interval1 = setInterval(() => { }, 10000);
59
+ const interval2 = setInterval(() => { }, 10000);
60
+ const handles = {
61
+ intervals: [interval1, interval2],
62
+ unsubscribes: [],
63
+ };
64
+ await gracefulShutdown(container, handles);
65
+ expect(clearSpy).toHaveBeenCalledWith(interval1);
66
+ expect(clearSpy).toHaveBeenCalledWith(interval2);
67
+ clearSpy.mockRestore();
68
+ });
69
+ it("calls all unsubscribe functions", async () => {
70
+ const container = createTestContainer();
71
+ const unsub1 = vi.fn();
72
+ const unsub2 = vi.fn();
73
+ const handles = {
74
+ intervals: [],
75
+ unsubscribes: [unsub1, unsub2],
76
+ };
77
+ await gracefulShutdown(container, handles);
78
+ expect(unsub1).toHaveBeenCalledOnce();
79
+ expect(unsub2).toHaveBeenCalledOnce();
80
+ });
81
+ it("calls pool.end()", async () => {
82
+ const endFn = vi.fn().mockResolvedValue(undefined);
83
+ const container = createTestContainer({
84
+ pool: { end: endFn },
85
+ });
86
+ const handles = { intervals: [], unsubscribes: [] };
87
+ await gracefulShutdown(container, handles);
88
+ expect(endFn).toHaveBeenCalledOnce();
89
+ });
90
+ });
@@ -0,0 +1 @@
1
+ export {};