create-ailk 0.23.0 → 0.24.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.
- package/README.md +27 -14
- package/dist/add-module.js +6 -1
- package/dist/add-route.js +7 -1
- package/dist/derive-architecture.js +17 -0
- package/dist/lib/scaffold-render-sweep.d.ts +33 -0
- package/dist/lib/scaffold-render-sweep.js +45 -0
- package/dist/module-architecture.js +17 -0
- package/dist/sync-routes.d.ts +30 -4
- package/dist/sync-routes.js +271 -18
- package/package.json +3 -3
- package/templates/.env.example +4 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +25 -11
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +333 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/leads/index.ts +364 -298
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
- package/templates/apps/api/src/server.ts +25 -0
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
- package/templates/apps/api/src/services/offerings/connection.ts +83 -3
- package/templates/apps/api/src/services/offerings/index.ts +6 -6
- package/templates/apps/api/src/services/person.ts +23 -0
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/mcp/CLAUDE.md +11 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/web/CLAUDE.md +12 -3
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
- package/templates/apps/web/lib/magnet-routes.ts +3 -1
- package/templates/apps/web/lib/newsletter.actions.ts +9 -6
- package/templates/database/CHANGELOG.md +20 -0
- package/templates/database/__tests__/seed.test.ts +33 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +27 -0
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
|
@@ -50,7 +50,7 @@ jest.mock("../../../lib/prisma.js", () => ({
|
|
|
50
50
|
},
|
|
51
51
|
user: { findUnique: jest.fn() },
|
|
52
52
|
projectListing: { findMany: jest.fn() },
|
|
53
|
-
waitlistScore: {
|
|
53
|
+
waitlistScore: { groupBy: jest.fn() },
|
|
54
54
|
},
|
|
55
55
|
}));
|
|
56
56
|
|
|
@@ -258,9 +258,16 @@ function installFixtureMocks(): void {
|
|
|
258
258
|
},
|
|
259
259
|
);
|
|
260
260
|
|
|
261
|
-
(prisma.waitlistScore.
|
|
262
|
-
({ where }: { where: { siteId: string; experimentKey: string } }) =>
|
|
263
|
-
Promise.resolve(
|
|
261
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mockImplementation(
|
|
262
|
+
({ where }: { where: { siteId: string; experimentKey: { in: string[] } } }) =>
|
|
263
|
+
Promise.resolve(
|
|
264
|
+
where.experimentKey.in
|
|
265
|
+
.filter((experimentKey) => (WAITLIST_COUNTS[where.siteId]?.[experimentKey] ?? 0) > 0)
|
|
266
|
+
.map((experimentKey) => ({
|
|
267
|
+
experimentKey,
|
|
268
|
+
_count: { _all: WAITLIST_COUNTS[where.siteId]![experimentKey] },
|
|
269
|
+
})),
|
|
270
|
+
),
|
|
264
271
|
);
|
|
265
272
|
}
|
|
266
273
|
|
|
@@ -378,7 +385,7 @@ describe("GET /v1/project-listings/me — the empty case is not a refusal", () =
|
|
|
378
385
|
it("no waitlist count is read when the caller owns nothing", async () => {
|
|
379
386
|
await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
|
|
380
387
|
|
|
381
|
-
expect(prisma.waitlistScore.
|
|
388
|
+
expect(prisma.waitlistScore.groupBy).not.toHaveBeenCalled();
|
|
382
389
|
});
|
|
383
390
|
});
|
|
384
391
|
|
|
@@ -419,13 +426,17 @@ describe("GET /v1/project-listings/me — the response shape", () => {
|
|
|
419
426
|
// One shared site id would have reported 0 for one of them.
|
|
420
427
|
expect(byId["prjl_ada_one"]).toBe(7);
|
|
421
428
|
expect(byId["prjl_ada_two"]).toBe(3);
|
|
422
|
-
expect(prisma.waitlistScore.
|
|
429
|
+
expect(prisma.waitlistScore.groupBy).toHaveBeenCalledWith({
|
|
430
|
+
by: ["experimentKey"],
|
|
423
431
|
// #6258 AC-6 — the fenced read now filters the CONSTRAINED column beside
|
|
424
432
|
// `siteId`; the two are equal by the workspace_site_agree CHECK.
|
|
425
|
-
where: { siteId: SITE_A, workspaceId: SITE_A, experimentKey: "atlas-waitlist" },
|
|
433
|
+
where: { siteId: SITE_A, workspaceId: SITE_A, experimentKey: { in: ["atlas-waitlist"] } },
|
|
434
|
+
_count: { _all: true },
|
|
426
435
|
});
|
|
427
|
-
expect(prisma.waitlistScore.
|
|
428
|
-
|
|
436
|
+
expect(prisma.waitlistScore.groupBy).toHaveBeenCalledWith({
|
|
437
|
+
by: ["experimentKey"],
|
|
438
|
+
where: { siteId: SITE_B, workspaceId: SITE_B, experimentKey: { in: ["atlas-two-waitlist"] } },
|
|
439
|
+
_count: { _all: true },
|
|
429
440
|
});
|
|
430
441
|
});
|
|
431
442
|
|
|
@@ -61,7 +61,7 @@ jest.mock("../../../lib/prisma.js", () => ({
|
|
|
61
61
|
projectListing: { findMany: jest.fn() },
|
|
62
62
|
siteListingConfig: { findUnique: jest.fn() },
|
|
63
63
|
waitlistExperiment: { findMany: jest.fn() },
|
|
64
|
-
waitlistScore: {
|
|
64
|
+
waitlistScore: { groupBy: jest.fn() },
|
|
65
65
|
},
|
|
66
66
|
}));
|
|
67
67
|
|
|
@@ -236,9 +236,13 @@ function installFixtureMocks(): void {
|
|
|
236
236
|
// count is keyed on the `experimentKey` the listing already holds, so the
|
|
237
237
|
// id-to-key hop this route used to make is gone. A test below asserts it
|
|
238
238
|
// stays gone.
|
|
239
|
-
(prisma.waitlistScore.
|
|
240
|
-
({ where }: { where: { experimentKey: string } }) =>
|
|
241
|
-
Promise.resolve(
|
|
239
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mockImplementation(
|
|
240
|
+
({ where }: { where: { experimentKey: { in: string[] } } }) =>
|
|
241
|
+
Promise.resolve(
|
|
242
|
+
where.experimentKey.in
|
|
243
|
+
.filter((experimentKey) => (COUNTS_BY_KEY[experimentKey] ?? 0) > 0)
|
|
244
|
+
.map((experimentKey) => ({ experimentKey, _count: { _all: COUNTS_BY_KEY[experimentKey] } })),
|
|
245
|
+
),
|
|
242
246
|
);
|
|
243
247
|
}
|
|
244
248
|
|
|
@@ -413,10 +417,17 @@ describe("GET /v1/project-listings/public", () => {
|
|
|
413
417
|
|
|
414
418
|
it("counts the waitlist through the listing's own experimentKey, in one hop", async () => {
|
|
415
419
|
await app.inject({ method: "GET", url: `/v1/project-listings/public?siteKey=${SITE_KEY}` });
|
|
416
|
-
expect(prisma.waitlistScore.
|
|
420
|
+
expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
|
|
421
|
+
expect(prisma.waitlistScore.groupBy).toHaveBeenCalledWith({
|
|
422
|
+
by: ["experimentKey"],
|
|
417
423
|
// #6258 AC-6 — the fenced read now filters the CONSTRAINED column
|
|
418
424
|
// beside `siteId`; the two are equal by the workspace_site_agree CHECK.
|
|
419
|
-
where: {
|
|
425
|
+
where: {
|
|
426
|
+
siteId: SITE,
|
|
427
|
+
workspaceId: SITE,
|
|
428
|
+
experimentKey: { in: expect.arrayContaining(["exp-newest"]) },
|
|
429
|
+
},
|
|
430
|
+
_count: { _all: true },
|
|
420
431
|
});
|
|
421
432
|
// The id-to-key translation read is gone with #5036 D6d.
|
|
422
433
|
expect(prisma.waitlistExperiment.findMany).not.toHaveBeenCalled();
|
|
@@ -885,7 +885,7 @@ describe("a captured draft answers its listicle's Application fields (#7521 AC-2
|
|
|
885
885
|
({ where }: { where: Record<string, unknown> }) =>
|
|
886
886
|
Promise.resolve(flows.filter((f) => matches(f, where))),
|
|
887
887
|
);
|
|
888
|
-
(prisma.waitlistScore.
|
|
888
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([]);
|
|
889
889
|
app = Fastify();
|
|
890
890
|
await app.register(startProjectListingRoute);
|
|
891
891
|
await app.register(patchProjectListingDraftRoute);
|
|
@@ -179,6 +179,7 @@ describe("AC-1 — completed stamps the row once, guarded, beside the legacy tas
|
|
|
179
179
|
});
|
|
180
180
|
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
|
181
181
|
where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
|
|
182
|
+
select: { id: true },
|
|
182
183
|
});
|
|
183
184
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
184
185
|
});
|
|
@@ -200,6 +200,7 @@ function expectHandledAsToday() {
|
|
|
200
200
|
expect(prisma.callback.updateMany).toHaveBeenCalledTimes(1);
|
|
201
201
|
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
|
202
202
|
where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
|
|
203
|
+
select: { id: true },
|
|
203
204
|
});
|
|
204
205
|
expect(foreignSiteLogs()).toHaveLength(0);
|
|
205
206
|
}
|
|
@@ -1279,6 +1279,7 @@ async function upsertBillingIdentity(
|
|
|
1279
1279
|
billingProvider: "stripe",
|
|
1280
1280
|
billingCustomerId: customerId,
|
|
1281
1281
|
},
|
|
1282
|
+
select: { id: true },
|
|
1282
1283
|
});
|
|
1283
1284
|
|
|
1284
1285
|
if (existingByBilling) {
|
|
@@ -1424,6 +1425,7 @@ async function upsertOrgBillingIdentity(
|
|
|
1424
1425
|
billingProvider: "stripe",
|
|
1425
1426
|
billingCustomerId: customerId,
|
|
1426
1427
|
},
|
|
1428
|
+
select: { id: true },
|
|
1427
1429
|
});
|
|
1428
1430
|
|
|
1429
1431
|
if (existingByBilling && existingByBilling.id === organizationId) {
|
|
@@ -246,6 +246,11 @@ const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
|
|
|
246
246
|
// #8171 D8 — the workspace's Requests read (the inbound member rows), the
|
|
247
247
|
// SAME scope.
|
|
248
248
|
const WORKSPACES_REQUESTS = "./routes/workspaces/requests.js";
|
|
249
|
+
// #9254 DD5 — the workspace's Callbacks read (request-and-confirm rows), the
|
|
250
|
+
// SAME scope.
|
|
251
|
+
const WORKSPACES_CALLBACKS = "./routes/workspaces/callbacks.js";
|
|
252
|
+
// #9287 DD2 — the owner's Start, Get and Extend trial, the SAME scope.
|
|
253
|
+
const WORKSPACES_TRIAL = "./routes/workspaces/trial.js";
|
|
249
254
|
// #7877 D11 — the member reads: auth only, under the SAME module predicate.
|
|
250
255
|
const MEMBER_PIECES = "./routes/member/pieces.js";
|
|
251
256
|
const MEMBER_MESSAGES = "./routes/member/messages.js";
|
|
@@ -372,6 +377,16 @@ const SLICE_PLUGINS = {
|
|
|
372
377
|
specifier: WORKSPACES_REQUESTS,
|
|
373
378
|
load: () => import(WORKSPACES_REQUESTS),
|
|
374
379
|
},
|
|
380
|
+
// #9254 DD5 — the Callbacks read, the SAME scope.
|
|
381
|
+
workspaceCallbackRoutes: {
|
|
382
|
+
specifier: WORKSPACES_CALLBACKS,
|
|
383
|
+
load: () => import(WORKSPACES_CALLBACKS),
|
|
384
|
+
},
|
|
385
|
+
// #9287 DD2 — the trial routes, the SAME scope.
|
|
386
|
+
workspaceTrialRoutes: {
|
|
387
|
+
specifier: WORKSPACES_TRIAL,
|
|
388
|
+
load: () => import(WORKSPACES_TRIAL),
|
|
389
|
+
},
|
|
375
390
|
workspacePlanRoutes: {
|
|
376
391
|
specifier: WORKSPACES_PLAN,
|
|
377
392
|
load: () => import(WORKSPACES_PLAN),
|
|
@@ -1340,6 +1355,16 @@ export async function build() {
|
|
|
1340
1355
|
await tenantScope.register(
|
|
1341
1356
|
await sliceRoutePlugin("workspaceRequestRoutes"),
|
|
1342
1357
|
);
|
|
1358
|
+
// #9254 DD5 — the Callbacks read: the request-and-confirm rows the
|
|
1359
|
+
// Inbox lists under Interested, behind `inbox-read`.
|
|
1360
|
+
await tenantScope.register(
|
|
1361
|
+
await sliceRoutePlugin("workspaceCallbackRoutes"),
|
|
1362
|
+
);
|
|
1363
|
+
// #9287 DD2 — Start, Get and Extend trial, behind `billing-manage`
|
|
1364
|
+
// (the owner alone; never an agent).
|
|
1365
|
+
await tenantScope.register(
|
|
1366
|
+
await sliceRoutePlugin("workspaceTrialRoutes"),
|
|
1367
|
+
);
|
|
1343
1368
|
await tenantScope.register(
|
|
1344
1369
|
await sliceRoutePlugin("workspacePlanRoutes"),
|
|
1345
1370
|
);
|
|
@@ -29,7 +29,16 @@ jest.mock("@working-theory/observability", () => ({
|
|
|
29
29
|
}));
|
|
30
30
|
|
|
31
31
|
import { prisma } from "../../lib/prisma.js";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
ABANDON_CANDIDATE_PAGE_SIZE,
|
|
34
|
+
runAbandonEmailSweep,
|
|
35
|
+
} from "../abandon-email";
|
|
36
|
+
|
|
37
|
+
// `flowSession.updateMany` — the dark-mode batch stamp (#9249) — is not on
|
|
38
|
+
// the shared double; installed here the way lead-routing.test.ts installs
|
|
39
|
+
// `lead.count`.
|
|
40
|
+
const mockFlowSessionUpdateMany = jest.fn();
|
|
41
|
+
Object.assign(prisma.flowSession, { updateMany: mockFlowSessionUpdateMany });
|
|
33
42
|
|
|
34
43
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
35
44
|
|
|
@@ -183,15 +192,16 @@ describe("runAbandonEmailSweep", () => {
|
|
|
183
192
|
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue([
|
|
184
193
|
sessionRow(),
|
|
185
194
|
]);
|
|
186
|
-
|
|
195
|
+
mockFlowSessionUpdateMany.mockResolvedValue({ count: 1 });
|
|
187
196
|
|
|
188
197
|
const result = await runAbandonEmailSweep({ now: NOW });
|
|
189
198
|
|
|
190
199
|
expect(mockSendAbandonEmail).not.toHaveBeenCalled();
|
|
191
|
-
expect(
|
|
192
|
-
where: { id: SESSION_ID },
|
|
200
|
+
expect(mockFlowSessionUpdateMany).toHaveBeenCalledWith({
|
|
201
|
+
where: { id: { in: [SESSION_ID] }, abandonEmailStatus: null },
|
|
193
202
|
data: { abandonEmailStatus: "dark", abandonEmailAt: NOW },
|
|
194
203
|
});
|
|
204
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
195
205
|
expect(result).toMatchObject({
|
|
196
206
|
ok: true,
|
|
197
207
|
summary: {
|
|
@@ -205,6 +215,113 @@ describe("runAbandonEmailSweep", () => {
|
|
|
205
215
|
});
|
|
206
216
|
});
|
|
207
217
|
|
|
218
|
+
// ── #9249 — dark mode stamps the whole candidate set in ONE updateMany ──
|
|
219
|
+
|
|
220
|
+
it("dark mode stamps N≥3 candidates with exactly one flowSession.updateMany, never a per-row update", async () => {
|
|
221
|
+
const ids = ["flsess_a", "flsess_b", "flsess_c"];
|
|
222
|
+
(prisma.flowDefinition.findMany as jest.Mock).mockResolvedValue([
|
|
223
|
+
flowDefRow({
|
|
224
|
+
abandonEmail: {
|
|
225
|
+
mode: "dark",
|
|
226
|
+
delayMinutes: 30,
|
|
227
|
+
copy: { subject: en("Still interested?"), body: en("Come back!") },
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
]);
|
|
231
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue(
|
|
232
|
+
ids.map((id) => sessionRow({ id })),
|
|
233
|
+
);
|
|
234
|
+
mockFlowSessionUpdateMany.mockResolvedValue({ count: ids.length });
|
|
235
|
+
|
|
236
|
+
const result = await runAbandonEmailSweep({ now: NOW });
|
|
237
|
+
|
|
238
|
+
expect(mockFlowSessionUpdateMany).toHaveBeenCalledTimes(1);
|
|
239
|
+
expect(mockFlowSessionUpdateMany).toHaveBeenCalledWith({
|
|
240
|
+
// The `abandonEmailStatus: null` guard keeps a row stamped concurrently
|
|
241
|
+
// from being re-stamped — the same term the candidate read selects on.
|
|
242
|
+
where: { id: { in: ids }, abandonEmailStatus: null },
|
|
243
|
+
data: { abandonEmailStatus: "dark", abandonEmailAt: NOW },
|
|
244
|
+
});
|
|
245
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
246
|
+
expect(mockSendAbandonEmail).not.toHaveBeenCalled();
|
|
247
|
+
// Per-candidate audit logging is unchanged — one line per session.
|
|
248
|
+
expect(
|
|
249
|
+
mockLoggerInfo.mock.calls.filter(
|
|
250
|
+
([fields]) => fields.outcome === "dark" && fields.mode === "dark",
|
|
251
|
+
),
|
|
252
|
+
).toHaveLength(ids.length);
|
|
253
|
+
// Summary counters report the same numbers as the per-row stamps did.
|
|
254
|
+
expect(result).toEqual({
|
|
255
|
+
ok: true,
|
|
256
|
+
summary: { scanned: 3, selected: 3, dark: 3, sent: 0, failed: 0, skipped: 0 },
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("dark mode with no candidates issues no updateMany at all", async () => {
|
|
261
|
+
(prisma.flowDefinition.findMany as jest.Mock).mockResolvedValue([
|
|
262
|
+
flowDefRow({
|
|
263
|
+
abandonEmail: {
|
|
264
|
+
mode: "dark",
|
|
265
|
+
delayMinutes: 30,
|
|
266
|
+
copy: { subject: en("Still interested?"), body: en("Come back!") },
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
]);
|
|
270
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue([]);
|
|
271
|
+
|
|
272
|
+
const result = await runAbandonEmailSweep({ now: NOW });
|
|
273
|
+
|
|
274
|
+
expect(mockFlowSessionUpdateMany).not.toHaveBeenCalled();
|
|
275
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
276
|
+
expect(result).toEqual({
|
|
277
|
+
ok: true,
|
|
278
|
+
summary: { scanned: 0, selected: 0, dark: 0, sent: 0, failed: 0, skipped: 0 },
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── #9249 — the candidate read is paged ──────────────────────────────────
|
|
283
|
+
|
|
284
|
+
it("pages the candidate read with take: ABANDON_CANDIDATE_PAGE_SIZE and processes exactly one page per run", async () => {
|
|
285
|
+
expect(ABANDON_CANDIDATE_PAGE_SIZE).toBeGreaterThan(0);
|
|
286
|
+
(prisma.flowDefinition.findMany as jest.Mock).mockResolvedValue([
|
|
287
|
+
flowDefRow({
|
|
288
|
+
abandonEmail: {
|
|
289
|
+
mode: "dark",
|
|
290
|
+
delayMinutes: 30,
|
|
291
|
+
copy: { subject: en("Still interested?"), body: en("Come back!") },
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
294
|
+
]);
|
|
295
|
+
// The database honours `take`: a run with more candidates than the page
|
|
296
|
+
// sees exactly one page of them. The rest stay `abandonEmailStatus: null`
|
|
297
|
+
// and are selected by the next run.
|
|
298
|
+
const page = Array.from({ length: ABANDON_CANDIDATE_PAGE_SIZE }, (_, i) =>
|
|
299
|
+
sessionRow({ id: `flsess_${i}` }),
|
|
300
|
+
);
|
|
301
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue(page);
|
|
302
|
+
mockFlowSessionUpdateMany.mockResolvedValue({ count: page.length });
|
|
303
|
+
|
|
304
|
+
const result = await runAbandonEmailSweep({ now: NOW });
|
|
305
|
+
|
|
306
|
+
expect(prisma.flowSession.findMany).toHaveBeenCalledTimes(1);
|
|
307
|
+
expect(prisma.flowSession.findMany).toHaveBeenCalledWith(
|
|
308
|
+
expect.objectContaining({ take: ABANDON_CANDIDATE_PAGE_SIZE }),
|
|
309
|
+
);
|
|
310
|
+
// No second read within the run — the remainder is the next run's.
|
|
311
|
+
expect(mockFlowSessionUpdateMany).toHaveBeenCalledTimes(1);
|
|
312
|
+
expect(mockFlowSessionUpdateMany.mock.calls[0][0].where.id.in).toHaveLength(
|
|
313
|
+
ABANDON_CANDIDATE_PAGE_SIZE,
|
|
314
|
+
);
|
|
315
|
+
expect(result).toMatchObject({
|
|
316
|
+
ok: true,
|
|
317
|
+
summary: {
|
|
318
|
+
scanned: ABANDON_CANDIDATE_PAGE_SIZE,
|
|
319
|
+
selected: ABANDON_CANDIDATE_PAGE_SIZE,
|
|
320
|
+
dark: ABANDON_CANDIDATE_PAGE_SIZE,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
208
325
|
// ── AC2 — enabled mode: selected + sent ──────────────────────────────────
|
|
209
326
|
|
|
210
327
|
it("enabled mode calls sendAbandonEmail and stamps abandonEmailStatus:'sent'", async () => {
|
|
@@ -144,6 +144,16 @@ describe("fulfillDeliverable", () => {
|
|
|
144
144
|
);
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
+
it("selects only the id on the legacy DeliverableAsset lookup, never the bytes blob (#9248)", async () => {
|
|
148
|
+
await fulfillDeliverable(baseInput({ segment: "small" }));
|
|
149
|
+
|
|
150
|
+
// `DeliverableAsset.bytes` is the file itself; this read exists only to
|
|
151
|
+
// learn the row's id, so the query must name that one column.
|
|
152
|
+
const [[args]] = (prisma.deliverableAsset.findUnique as jest.Mock).mock
|
|
153
|
+
.calls as [[{ select?: Record<string, unknown> }]];
|
|
154
|
+
expect(args.select).toEqual({ id: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
147
157
|
it("resolves the OTHER segment's entry when segment differs (two-segment divergence, AC1)", async () => {
|
|
148
158
|
(prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue({
|
|
149
159
|
id: "dasset_00000000-0000-7000-8000-0000000000a2",
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
setOwnPersonContact,
|
|
56
56
|
setPersonPlanOffering,
|
|
57
57
|
stampPersonUser,
|
|
58
|
+
stripeCustomerDashboardUrl,
|
|
58
59
|
updatePersonContact,
|
|
59
60
|
} from "../person";
|
|
60
61
|
|
|
@@ -1174,3 +1175,30 @@ describe("resolveMemberPersonId releases a held plan on a fresh link only (#8793
|
|
|
1174
1175
|
expect(log.warn).not.toHaveBeenCalled();
|
|
1175
1176
|
});
|
|
1176
1177
|
});
|
|
1178
|
+
|
|
1179
|
+
describe("stripeCustomerDashboardUrl (#9349 DD5, AC-9)", () => {
|
|
1180
|
+
it("builds the live and the test Dashboard links", () => {
|
|
1181
|
+
expect(stripeCustomerDashboardUrl("cus_ABC123", "live")).toBe(
|
|
1182
|
+
"https://dashboard.stripe.com/customers/cus_ABC123",
|
|
1183
|
+
);
|
|
1184
|
+
expect(stripeCustomerDashboardUrl("cus_ABC123", "test")).toBe(
|
|
1185
|
+
"https://dashboard.stripe.com/test/customers/cus_ABC123",
|
|
1186
|
+
);
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it("refuses an id outside the cus_ grammar or over 255 characters", () => {
|
|
1190
|
+
for (const id of [
|
|
1191
|
+
"cus_a/../../settings",
|
|
1192
|
+
"cus_",
|
|
1193
|
+
"acct_1",
|
|
1194
|
+
"cus_a?x=1",
|
|
1195
|
+
"cus_a#x",
|
|
1196
|
+
`cus_${"a".repeat(252)}`,
|
|
1197
|
+
]) {
|
|
1198
|
+
expect(stripeCustomerDashboardUrl(id, "live")).toBeNull();
|
|
1199
|
+
}
|
|
1200
|
+
expect(
|
|
1201
|
+
stripeCustomerDashboardUrl(`cus_${"a".repeat(251)}`, "live"),
|
|
1202
|
+
).not.toBeNull();
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
@@ -200,6 +200,8 @@ const ENV_KEYS = [
|
|
|
200
200
|
"AILK_LEAD_NOTIFICATION_TO",
|
|
201
201
|
"AILK_LEAD_NOTIFICATION_FROM",
|
|
202
202
|
"AILK_DELIVERABLE_EMAIL_FROM",
|
|
203
|
+
"AILK_ENABLED_MODULES",
|
|
204
|
+
"STRIPE_ENV_ORGANIZATION_ID",
|
|
203
205
|
] as const;
|
|
204
206
|
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
205
207
|
|
|
@@ -208,6 +210,10 @@ beforeEach(async () => {
|
|
|
208
210
|
saved = {};
|
|
209
211
|
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
210
212
|
process.env.DATABASE_URL = "postgres://stub";
|
|
213
|
+
// With the multi-tenant module and no STRIPE_ENV_ORGANIZATION_ID nobody owns the
|
|
214
|
+
// environment key (#9285 AC-8), so "no connection" stays no connection here.
|
|
215
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant";
|
|
216
|
+
delete process.env.STRIPE_ENV_ORGANIZATION_ID;
|
|
211
217
|
delete process.env.NODE_ENV;
|
|
212
218
|
delete process.env.AILK_LICENSE_KEY;
|
|
213
219
|
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* @file project-listings.counts.test.ts
|
|
4
|
+
*
|
|
5
|
+
* #9243 — `attachWaitlistCounts` counts waitlist scores with ONE
|
|
6
|
+
* `waitlistScore.groupBy` per call, however many distinct experiment keys the
|
|
7
|
+
* rows carry, and `publicProjectListings` — the anonymous page-render read —
|
|
8
|
+
* bounds its `findMany` with a `take`. Both are the AC evidence for audit HIGH
|
|
9
|
+
* #1 (docs/audits/query-efficiency-audit-2026-10-19.md): the per-key
|
|
10
|
+
* `count()` loop and the unbounded public read.
|
|
11
|
+
*
|
|
12
|
+
* Mock pattern follows `project-listings.fence.test.ts` (jest.fn() doubles on
|
|
13
|
+
* `lib/prisma.js`).
|
|
14
|
+
*/
|
|
15
|
+
import { LISTING_DIRECTORY_MAX_ENTRIES } from "@working-theory/validation";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
listProjectListings,
|
|
19
|
+
publicProjectListings,
|
|
20
|
+
} from "../project-listings.js";
|
|
21
|
+
|
|
22
|
+
// Boundary mocks — `project-listings.ts`'s import chain reaches the Resend
|
|
23
|
+
// client and the licence check; neither is exercised by the reads under test.
|
|
24
|
+
jest.mock("@working-theory/email", () => ({
|
|
25
|
+
sendLeadNotification: jest
|
|
26
|
+
.fn()
|
|
27
|
+
.mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
28
|
+
sendDeliverableEmail: jest
|
|
29
|
+
.fn()
|
|
30
|
+
.mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
jest.mock("@working-theory/license", () => ({
|
|
34
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
35
|
+
tier: "free",
|
|
36
|
+
plan: null,
|
|
37
|
+
features: [],
|
|
38
|
+
expiresAt: null,
|
|
39
|
+
revoked: false,
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
44
|
+
jest.mock("../../lib/prisma.js", () => ({
|
|
45
|
+
prisma: {
|
|
46
|
+
projectListing: { findMany: jest.fn() },
|
|
47
|
+
siteListingConfig: { findUnique: jest.fn() },
|
|
48
|
+
waitlistScore: { count: jest.fn(), groupBy: jest.fn() },
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
import { prisma } from "../../lib/prisma.js";
|
|
53
|
+
|
|
54
|
+
const SITE = "wksp_00000000-0000-7000-8000-000000000001";
|
|
55
|
+
const EXPERIMENT_A = "ailk-founder-diagnostic";
|
|
56
|
+
const EXPERIMENT_B = "ailk-agency-waitlist";
|
|
57
|
+
const EXPERIMENT_C = "ailk-studio-waitlist";
|
|
58
|
+
|
|
59
|
+
function listingRow(
|
|
60
|
+
id: string,
|
|
61
|
+
experimentKey: string | null,
|
|
62
|
+
): Record<string, unknown> {
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
siteId: SITE,
|
|
66
|
+
workspaceId: SITE,
|
|
67
|
+
leadId: null,
|
|
68
|
+
slug: `slug-${id}`,
|
|
69
|
+
name: "Acme",
|
|
70
|
+
headline: null,
|
|
71
|
+
oneLiner: null,
|
|
72
|
+
problem: null,
|
|
73
|
+
solution: null,
|
|
74
|
+
url: null,
|
|
75
|
+
product: null,
|
|
76
|
+
commitment: null,
|
|
77
|
+
teamComposition: null,
|
|
78
|
+
faqs: null,
|
|
79
|
+
anythingElse: null,
|
|
80
|
+
address: null,
|
|
81
|
+
addressLine1: null,
|
|
82
|
+
addressLine2: null,
|
|
83
|
+
addressCity: null,
|
|
84
|
+
addressRegion: null,
|
|
85
|
+
addressPostalCode: null,
|
|
86
|
+
addressCountry: null,
|
|
87
|
+
questions: null,
|
|
88
|
+
siteAnswers: null,
|
|
89
|
+
windowDays: null,
|
|
90
|
+
founderName: null,
|
|
91
|
+
founderFirstName: null,
|
|
92
|
+
founderLastName: null,
|
|
93
|
+
founderEmail: null,
|
|
94
|
+
founderPhone: null,
|
|
95
|
+
founderCompany: null,
|
|
96
|
+
emailVerifiedAt: null,
|
|
97
|
+
logoMarkUrl: null,
|
|
98
|
+
logoWordmarkUrl: null,
|
|
99
|
+
featured: false,
|
|
100
|
+
status: "active",
|
|
101
|
+
consentAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
102
|
+
decidedAt: new Date("2026-09-02T00:00:00.000Z"),
|
|
103
|
+
decidedBy: null,
|
|
104
|
+
issueUrl: null,
|
|
105
|
+
experimentKey,
|
|
106
|
+
windowEndsAt: new Date("2099-01-01T00:00:00.000Z"),
|
|
107
|
+
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
108
|
+
updatedAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The shape `prisma.waitlistScore.groupBy({ by: ["experimentKey"], _count: { _all: true } })` returns. */
|
|
113
|
+
function grouped(
|
|
114
|
+
experimentKey: string,
|
|
115
|
+
count: number,
|
|
116
|
+
): { experimentKey: string; _count: { _all: number } } {
|
|
117
|
+
return { experimentKey, _count: { _all: count } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
jest.clearAllMocks();
|
|
122
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
123
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([]);
|
|
124
|
+
(prisma.waitlistScore.count as jest.Mock).mockResolvedValue(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("attachWaitlistCounts — one waitlistScore query per call (#9243 AC-1)", () => {
|
|
128
|
+
it("three distinct experiment keys across four rows → exactly ONE groupBy, no count()", async () => {
|
|
129
|
+
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
|
|
130
|
+
listingRow("plst_a", EXPERIMENT_A),
|
|
131
|
+
listingRow("plst_b", EXPERIMENT_B),
|
|
132
|
+
listingRow("plst_c", EXPERIMENT_C),
|
|
133
|
+
listingRow("plst_d", EXPERIMENT_A),
|
|
134
|
+
]);
|
|
135
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([
|
|
136
|
+
grouped(EXPERIMENT_A, 5),
|
|
137
|
+
grouped(EXPERIMENT_B, 2),
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
const rows = await listProjectListings(SITE);
|
|
141
|
+
|
|
142
|
+
expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
|
|
144
|
+
expect(
|
|
145
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mock.calls[0]![0],
|
|
146
|
+
).toEqual({
|
|
147
|
+
by: ["experimentKey"],
|
|
148
|
+
// fence: workspaceId === siteId beside siteId, by value (#6258 AC-6),
|
|
149
|
+
// and every DISTINCT key in one `in` — no per-key fan-out.
|
|
150
|
+
where: {
|
|
151
|
+
siteId: SITE,
|
|
152
|
+
workspaceId: SITE,
|
|
153
|
+
experimentKey: { in: [EXPERIMENT_A, EXPERIMENT_B, EXPERIMENT_C] },
|
|
154
|
+
},
|
|
155
|
+
_count: { _all: true },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const countsById = new Map(rows.map((r) => [r.id, r.waitlistCount]));
|
|
159
|
+
expect(countsById.get("plst_a")).toBe(5);
|
|
160
|
+
expect(countsById.get("plst_d")).toBe(5);
|
|
161
|
+
expect(countsById.get("plst_b")).toBe(2);
|
|
162
|
+
// A key with ZERO score rows is absent from a GROUP BY result; it reads 0,
|
|
163
|
+
// exactly the value the per-key `count()` produced for it.
|
|
164
|
+
expect(countsById.get("plst_c")).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("no listing carries an experimentKey → no waitlistScore read is issued at all", async () => {
|
|
168
|
+
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
|
|
169
|
+
listingRow("plst_a", null),
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
const rows = await listProjectListings(SITE);
|
|
173
|
+
|
|
174
|
+
expect(prisma.waitlistScore.groupBy).not.toHaveBeenCalled();
|
|
175
|
+
expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
|
|
176
|
+
expect(rows[0]!.waitlistCount).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("publicProjectListings — the anonymous read is bounded (#9243 AC-2)", () => {
|
|
181
|
+
it("passes a take; more active listings than the bound returns exactly the bound", async () => {
|
|
182
|
+
const overBound = Array.from(
|
|
183
|
+
{ length: LISTING_DIRECTORY_MAX_ENTRIES + 1 },
|
|
184
|
+
(_, i) => listingRow(`plst_${i}`, EXPERIMENT_A),
|
|
185
|
+
);
|
|
186
|
+
// Honour `take` the way the database would, so an unbounded read is the
|
|
187
|
+
// failure this test sees rather than one the double hides.
|
|
188
|
+
(prisma.projectListing.findMany as jest.Mock).mockImplementation(
|
|
189
|
+
({ take }: { take?: number }) =>
|
|
190
|
+
Promise.resolve(overBound.slice(0, take)),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const result = await publicProjectListings(SITE);
|
|
194
|
+
|
|
195
|
+
expect(
|
|
196
|
+
(prisma.projectListing.findMany as jest.Mock).mock.calls[0]![0].take,
|
|
197
|
+
).toBe(LISTING_DIRECTORY_MAX_ENTRIES);
|
|
198
|
+
expect(result).toHaveLength(LISTING_DIRECTORY_MAX_ENTRIES);
|
|
199
|
+
// Still one count query for the whole bounded page.
|
|
200
|
+
expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
|
|
201
|
+
});
|
|
202
|
+
});
|