create-ailk 0.1.1 → 0.2.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 (40) hide show
  1. package/component-catalog.md +175 -14
  2. package/dist/cli.js +0 -0
  3. package/package.json +14 -15
  4. package/templates/apps/api/CLAUDE.md +4 -1
  5. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  6. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +11 -0
  7. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +127 -0
  8. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
  9. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
  10. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
  11. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +11 -0
  12. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
  13. package/templates/apps/api/src/routes/project-listings/index.ts +15 -0
  14. package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
  15. package/templates/apps/api/src/routes/project-listings/start.ts +8 -0
  16. package/templates/apps/api/src/server.ts +14 -0
  17. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  18. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  19. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
  20. package/templates/apps/api/src/services/consent.ts +236 -0
  21. package/templates/apps/api/src/services/flow-engine.ts +18 -0
  22. package/templates/apps/api/src/services/listing-promotion.ts +342 -0
  23. package/templates/apps/api/src/services/project-listing-decision.ts +21 -1
  24. package/templates/apps/api/src/services/project-listings.ts +373 -25
  25. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  26. package/templates/apps/web/jest.config.cjs +6 -0
  27. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  28. package/templates/apps/web/lib/site-brand.tsx +4 -1
  29. package/templates/apps/web/lib/site-theme.ts +74 -0
  30. package/templates/apps/web/package.json +1 -0
  31. package/templates/content/_site.mdx +12 -0
  32. package/templates/database/CHANGELOG.md +61 -0
  33. package/templates/database/inbox/schema.prisma +98 -0
  34. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  35. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  36. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  37. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  38. package/templates/database/package.json +1 -1
  39. package/templates/database/scripts/db-generate-locked.sh +0 -0
  40. package/templates/package.json +1 -1
@@ -0,0 +1,684 @@
1
+ /**
2
+ * @file listing-promotion.test.ts
3
+ * @description Unit tests for the listing-promotion service (#5295; ADR 0009
4
+ * D3 service-layer pattern), mirroring `lead-promotion.test.ts`.
5
+ *
6
+ * External dependency boundary mocked:
7
+ * - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts`
8
+ * manual mock (`jest.mock("../../lib/prisma.js")`).
9
+ *
10
+ * `prisma.projectListing.findMany` is given a REALISTIC filtering
11
+ * implementation over an in-memory fixture "table" rather than a static
12
+ * `mockResolvedValue`, so the two security properties this service exists for
13
+ * actually get exercised against what the service passes to the DB. A bug that
14
+ * widened the `siteId` match set, or that loosened the email comparison, would
15
+ * surface the foreign row here rather than only in an argument-shape assertion.
16
+ *
17
+ * `tenant-context.ts`'s `listTenancy` runs REAL — it is a pure read over the
18
+ * mocked `prisma.membership.findMany` — so the "resolve via the sole
19
+ * tenant-context assembler" contract is covered rather than re-stubbed.
20
+ */
21
+
22
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
23
+ jest.mock("../../lib/prisma.js");
24
+
25
+ import { prisma } from "../../lib/prisma.js";
26
+ import { promoteListingsForVerifiedUser } from "../listing-promotion";
27
+ import type { PromoteListingsResult } from "../listing-promotion";
28
+
29
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
30
+
31
+ const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
32
+ const OWN_ORG_ID = "org_00000000-0000-7000-8000-0000000000b1";
33
+ const OWN_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c1";
34
+ // OTHER_WORKSPACE_ID belongs to a different organization than OWN_ORG_ID —
35
+ // the tenant-safety fixture (never a workspace of OWN_ORG_ID).
36
+ const OTHER_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c2";
37
+ // A SECOND workspace of the user's OWN organization — the per-site grain of the
38
+ // ambiguity refusal needs two of the caller's own sites to be observable.
39
+ const SECOND_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c3";
40
+ const USER_EMAIL = "founder@example.com";
41
+
42
+ type FakeListingRow = {
43
+ id: string;
44
+ founderEmail: string;
45
+ siteId: string;
46
+ ownerUserId: string | null;
47
+ /** Defaults to `requested` — a submitted, claimable row. */
48
+ status?: string;
49
+ };
50
+
51
+ /** Shapes prisma.membership.findMany's mocked return — what listTenancy reads. */
52
+ function ownMembership() {
53
+ return [
54
+ {
55
+ role: "owner",
56
+ organization: {
57
+ id: OWN_ORG_ID,
58
+ name: "Acme",
59
+ slug: "acme",
60
+ workspaces: [{ id: OWN_WORKSPACE_ID, name: "Acme HQ", slug: "acme-hq" }],
61
+ },
62
+ },
63
+ ];
64
+ }
65
+
66
+ function verifiedUser(overrides?: Partial<{ emailVerified: boolean; email: string }>) {
67
+ return {
68
+ id: USER_ID,
69
+ email: overrides?.email ?? USER_EMAIL,
70
+ emailVerified: overrides?.emailVerified ?? true,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Wires projectListing.findMany / updateMany to a realistic in-memory "table"
76
+ * filtered by the `where` the service passes.
77
+ *
78
+ * `founderEmail` is compared with `===` here deliberately: the service passes a
79
+ * plain string (EXACT equality), not `{ equals, mode: "insensitive" }`. Prisma
80
+ * compiles an insensitive `equals` to `ILIKE`, which would make `_` and `%` in
81
+ * the caller's own address LIKE wildcards — so if the service ever switched to
82
+ * that form, this fixture would no longer model the DB and the
83
+ * wildcard-capture test below would stop being meaningful. The fixture asserts
84
+ * the form it models.
85
+ */
86
+ function seedListingTable(rows: FakeListingRow[]) {
87
+ const table = rows.map((r) => ({ ...r }));
88
+
89
+ (prisma.projectListing.findMany as jest.Mock).mockImplementation(
90
+ async (args: {
91
+ where: {
92
+ founderEmail: unknown;
93
+ siteId: { in: string[] };
94
+ ownerUserId: null;
95
+ status?: { in?: string[] };
96
+ };
97
+ select: { id: true; siteId: true };
98
+ }) => {
99
+ const { founderEmail, siteId, status } = args.where;
100
+ if (typeof founderEmail !== "string") {
101
+ throw new Error(
102
+ "listing-promotion must compare founderEmail by exact string equality; " +
103
+ "a filter object (e.g. mode: 'insensitive') compiles to ILIKE and makes " +
104
+ "`_`/`%` in the caller's own address wildcards",
105
+ );
106
+ }
107
+ // A candidate read with no status term would make an anonymous,
108
+ // quota-free draft claimable; model the filter so its absence fails.
109
+ if (!Array.isArray(status?.in)) {
110
+ throw new Error(
111
+ "the candidate read must constrain status; a draft is created anonymously with no quota, so a claimable draft is a planted-row vector",
112
+ );
113
+ }
114
+ const claimable = status.in;
115
+ return table
116
+ .filter(
117
+ (row) =>
118
+ row.founderEmail === founderEmail &&
119
+ siteId.in.includes(row.siteId) &&
120
+ row.ownerUserId === null &&
121
+ claimable.includes(row.status ?? "requested"),
122
+ )
123
+ .map((row) => ({ id: row.id, siteId: row.siteId }));
124
+ },
125
+ );
126
+
127
+ /**
128
+ * Models the WRITE's own `ownerUserId: null` predicate, not just its id list.
129
+ * Without that the mock would happily overwrite a row already linked to
130
+ * somebody else, hiding the compare-and-swap the service relies on to survive
131
+ * two concurrent reconciliation runs.
132
+ */
133
+ (prisma.projectListing.updateMany as jest.Mock).mockImplementation(
134
+ async (args: {
135
+ where: { id: { in: string[] }; ownerUserId: null };
136
+ data: { ownerUserId: string; ownerLinkedAt: Date };
137
+ }) => {
138
+ if (args.where.ownerUserId !== null) {
139
+ throw new Error(
140
+ "the owner-link write must repeat `ownerUserId: null`; without it a concurrent run transfers a row away from the user who already claimed it",
141
+ );
142
+ }
143
+ let count = 0;
144
+ for (const row of table) {
145
+ if (args.where.id.in.includes(row.id) && row.ownerUserId === null) {
146
+ row.ownerUserId = args.data.ownerUserId;
147
+ count += 1;
148
+ }
149
+ }
150
+ return { count };
151
+ },
152
+ );
153
+
154
+ return table;
155
+ }
156
+
157
+ describe("promoteListingsForVerifiedUser", () => {
158
+ beforeEach(() => {
159
+ jest.clearAllMocks();
160
+ });
161
+
162
+ // ── The happy path: all three conditions hold ────────────────────────────
163
+ it("links an un-linked listing whose founderEmail is the verified user's own, in the user's own workspace", async () => {
164
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
165
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
166
+ const table = seedListingTable([
167
+ {
168
+ id: "prjl_00000000-0000-7000-8000-0000000000d1",
169
+ founderEmail: USER_EMAIL,
170
+ siteId: OWN_WORKSPACE_ID,
171
+ ownerUserId: null,
172
+ },
173
+ ]);
174
+
175
+ const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
176
+
177
+ expect(result).toEqual<PromoteListingsResult>({
178
+ ok: true,
179
+ count: 1,
180
+ promoted: [
181
+ {
182
+ listingId: "prjl_00000000-0000-7000-8000-0000000000d1",
183
+ siteId: OWN_WORKSPACE_ID,
184
+ },
185
+ ],
186
+ });
187
+ expect(prisma.projectListing.findMany).toHaveBeenCalledWith({
188
+ where: {
189
+ founderEmail: USER_EMAIL,
190
+ siteId: { in: [OWN_WORKSPACE_ID] },
191
+ ownerUserId: null,
192
+ status: { in: ["requested", "rejected", "active"] },
193
+ },
194
+ select: { id: true, siteId: true },
195
+ });
196
+ expect(prisma.projectListing.updateMany).toHaveBeenCalledWith({
197
+ where: {
198
+ id: { in: ["prjl_00000000-0000-7000-8000-0000000000d1"] },
199
+ // Repeated on the write, so the update is a compare-and-swap.
200
+ ownerUserId: null,
201
+ },
202
+ data: { ownerUserId: USER_ID, ownerLinkedAt: expect.any(Date) },
203
+ });
204
+ expect(table[0]?.ownerUserId).toBe(USER_ID);
205
+ });
206
+
207
+ // ── SECURITY PROPERTY 1 ──────────────────────────────────────────────────
208
+ it("an UNVERIFIED email never produces a link, even when it MATCHES founderEmail exactly", async () => {
209
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(
210
+ verifiedUser({ emailVerified: false }),
211
+ );
212
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
213
+ // The listing's founderEmail is byte-identical to the caller's address, and
214
+ // it sits in a workspace the caller owns. Only verification is missing.
215
+ const table = seedListingTable([
216
+ {
217
+ id: "prjl_unverified_match",
218
+ founderEmail: USER_EMAIL,
219
+ siteId: OWN_WORKSPACE_ID,
220
+ ownerUserId: null,
221
+ },
222
+ ]);
223
+
224
+ const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
225
+
226
+ expect(result).toEqual<PromoteListingsResult>({
227
+ ok: true,
228
+ count: 0,
229
+ promoted: [],
230
+ });
231
+ // The gate is evaluated BEFORE any tenancy or listing read happens at all.
232
+ expect(prisma.membership.findMany).not.toHaveBeenCalled();
233
+ expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
234
+ expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
235
+ expect(table[0]?.ownerUserId).toBeNull();
236
+ });
237
+
238
+ // ── SECURITY PROPERTY 2 ──────────────────────────────────────────────────
239
+ it("a matching, VERIFIED email in a DIFFERENT tenant never produces a link", async () => {
240
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
241
+ // The user belongs ONLY to OWN_ORG_ID / OWN_WORKSPACE_ID.
242
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
243
+ const table = seedListingTable([
244
+ {
245
+ id: "prjl_cross_tenant",
246
+ founderEmail: USER_EMAIL, // the caller's own verified address
247
+ siteId: OTHER_WORKSPACE_ID, // a DIFFERENT organization's workspace
248
+ ownerUserId: null,
249
+ },
250
+ ]);
251
+
252
+ const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
253
+
254
+ expect(result).toEqual<PromoteListingsResult>({
255
+ ok: true,
256
+ count: 0,
257
+ promoted: [],
258
+ });
259
+ expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
260
+ expect(table.find((r) => r.id === "prjl_cross_tenant")?.ownerUserId).toBeNull();
261
+ // The query itself never widens siteId.in to the foreign workspace.
262
+ expect(prisma.projectListing.findMany).toHaveBeenCalledWith({
263
+ where: {
264
+ founderEmail: USER_EMAIL,
265
+ siteId: { in: [OWN_WORKSPACE_ID] },
266
+ ownerUserId: null,
267
+ status: { in: ["requested", "rejected", "active"] },
268
+ },
269
+ select: { id: true, siteId: true },
270
+ });
271
+ });
272
+
273
+ /**
274
+ * The email comparison is EXACT, so a LIKE metacharacter in the caller's own
275
+ * verified address cannot capture a different founder's row.
276
+ *
277
+ * Verified against a live Postgres while building this: with
278
+ * `{ equals, mode: "insensitive" }` Prisma emits `founder_email ILIKE $1`,
279
+ * and a caller whose real address is `a_c@example.com` matched BOTH its own
280
+ * row and a stranger's `abc@example.com` in the same workspace — two rows in,
281
+ * two rows out. This test is the regression lock for that.
282
+ */
283
+ it("a LIKE metacharacter in the caller's own verified address captures no other founder's listing", async () => {
284
+ const WILDCARD_EMAIL = "a_c@example.com";
285
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(
286
+ verifiedUser({ email: WILDCARD_EMAIL }),
287
+ );
288
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
289
+ const table = seedListingTable([
290
+ {
291
+ id: "prjl_mine",
292
+ founderEmail: WILDCARD_EMAIL,
293
+ siteId: OWN_WORKSPACE_ID,
294
+ ownerUserId: null,
295
+ },
296
+ {
297
+ // A DIFFERENT person, in the same workspace, whose address differs from
298
+ // the caller's only where the `_` sits.
299
+ id: "prjl_neighbour",
300
+ founderEmail: "abc@example.com",
301
+ siteId: OWN_WORKSPACE_ID,
302
+ ownerUserId: null,
303
+ },
304
+ ]);
305
+
306
+ const result = (await promoteListingsForVerifiedUser({
307
+ userId: USER_ID,
308
+ })) as Extract<PromoteListingsResult, { ok: true }>;
309
+
310
+ expect(result.count).toBe(1);
311
+ expect(result.promoted).toEqual([
312
+ { listingId: "prjl_mine", siteId: OWN_WORKSPACE_ID },
313
+ ]);
314
+ expect(table.find((r) => r.id === "prjl_neighbour")?.ownerUserId).toBeNull();
315
+ });
316
+
317
+ // ── Two people sharing an address: the link follows the VERIFIED one ──────
318
+ it("two listings sharing a founderEmail are both linked ONLY to the user who verified that address", async () => {
319
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
320
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
321
+ const table = seedListingTable([
322
+ {
323
+ id: "prjl_shared_1",
324
+ founderEmail: USER_EMAIL,
325
+ siteId: OWN_WORKSPACE_ID,
326
+ ownerUserId: null,
327
+ },
328
+ {
329
+ // Already linked to SOMEBODY ELSE — excluded by `ownerUserId: null`, so
330
+ // a later verifier of the same address can never steal it.
331
+ id: "prjl_shared_2",
332
+ founderEmail: USER_EMAIL,
333
+ siteId: OWN_WORKSPACE_ID,
334
+ ownerUserId: "user_00000000-0000-7000-8000-0000000000a2",
335
+ },
336
+ ]);
337
+
338
+ const result = (await promoteListingsForVerifiedUser({
339
+ userId: USER_ID,
340
+ })) as Extract<PromoteListingsResult, { ok: true }>;
341
+
342
+ expect(result.count).toBe(1);
343
+ expect(result.promoted).toEqual([
344
+ { listingId: "prjl_shared_1", siteId: OWN_WORKSPACE_ID },
345
+ ]);
346
+ expect(table.find((r) => r.id === "prjl_shared_2")?.ownerUserId).toBe(
347
+ "user_00000000-0000-7000-8000-0000000000a2",
348
+ );
349
+ });
350
+
351
+ /**
352
+ * THE COUNTEREXAMPLE this service is built to refuse.
353
+ *
354
+ * Two strangers both typed the same address into the anonymous form, so there
355
+ * are TWO un-linked rows carrying it at one site and nothing that says which
356
+ * is whose. Linking both to whoever verifies the address would hand one
357
+ * person the other's whole application — the disclosure the owner link exists
358
+ * to remove, arriving through the promotion path instead of the read path.
359
+ *
360
+ * Found by an independent verifier who reproduced it end to end against a
361
+ * live Postgres before the refusal existed: `count: 2`, and the verifying
362
+ * founder read the stranger's row through `GET /v1/project-listings/me`.
363
+ */
364
+ it("REFUSES to link when TWO un-linked listings share the address at one site", async () => {
365
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
366
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
367
+ const table = seedListingTable([
368
+ {
369
+ id: "prjl_typed_by_founder_a",
370
+ founderEmail: USER_EMAIL,
371
+ siteId: OWN_WORKSPACE_ID,
372
+ ownerUserId: null,
373
+ },
374
+ {
375
+ // A DIFFERENT person who typed the same address. Indistinguishable.
376
+ id: "prjl_typed_by_founder_b",
377
+ founderEmail: USER_EMAIL,
378
+ siteId: OWN_WORKSPACE_ID,
379
+ ownerUserId: null,
380
+ },
381
+ ]);
382
+
383
+ const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
384
+
385
+ expect(result).toEqual<PromoteListingsResult>({
386
+ ok: true,
387
+ count: 0,
388
+ promoted: [],
389
+ });
390
+ expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
391
+ // BOTH rows stay null. Neither founder can read the other's application.
392
+ expect(table.find((r) => r.id === "prjl_typed_by_founder_a")?.ownerUserId).toBeNull();
393
+ expect(table.find((r) => r.id === "prjl_typed_by_founder_b")?.ownerUserId).toBeNull();
394
+ });
395
+
396
+ it("an ambiguous group at one site does not block an unambiguous row at another", async () => {
397
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
398
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue([
399
+ {
400
+ role: "owner",
401
+ organization: {
402
+ id: OWN_ORG_ID,
403
+ name: "Acme",
404
+ slug: "acme",
405
+ workspaces: [
406
+ { id: OWN_WORKSPACE_ID, name: "Acme HQ", slug: "acme-hq" },
407
+ { id: SECOND_WORKSPACE_ID, name: "Acme Two", slug: "acme-two" },
408
+ ],
409
+ },
410
+ },
411
+ ]);
412
+ const table = seedListingTable([
413
+ // Ambiguous pair at the first site — both refused.
414
+ {
415
+ id: "prjl_ambiguous_a",
416
+ founderEmail: USER_EMAIL,
417
+ siteId: OWN_WORKSPACE_ID,
418
+ ownerUserId: null,
419
+ },
420
+ {
421
+ id: "prjl_ambiguous_b",
422
+ founderEmail: USER_EMAIL,
423
+ siteId: OWN_WORKSPACE_ID,
424
+ ownerUserId: null,
425
+ },
426
+ // The only row carrying the address at the SECOND site — attributable.
427
+ {
428
+ id: "prjl_unambiguous",
429
+ founderEmail: USER_EMAIL,
430
+ siteId: SECOND_WORKSPACE_ID,
431
+ ownerUserId: null,
432
+ },
433
+ ]);
434
+
435
+ const result = (await promoteListingsForVerifiedUser({
436
+ userId: USER_ID,
437
+ })) as Extract<PromoteListingsResult, { ok: true }>;
438
+
439
+ expect(result.count).toBe(1);
440
+ expect(result.promoted).toEqual([
441
+ { listingId: "prjl_unambiguous", siteId: SECOND_WORKSPACE_ID },
442
+ ]);
443
+ expect(table.find((r) => r.id === "prjl_ambiguous_a")?.ownerUserId).toBeNull();
444
+ expect(table.find((r) => r.id === "prjl_ambiguous_b")?.ownerUserId).toBeNull();
445
+ expect(table.find((r) => r.id === "prjl_unambiguous")?.ownerUserId).toBe(USER_ID);
446
+ });
447
+
448
+ /**
449
+ * The write repeats `ownerUserId: null`, so a run that loses a race matches 0
450
+ * rows instead of transferring a row away from the user who already claimed
451
+ * it. The candidate read alone cannot establish this: both runs read the row
452
+ * as un-linked before either wrote.
453
+ */
454
+ it("a row claimed between the candidate read and the write is not transferred away", async () => {
455
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
456
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
457
+ const table = seedListingTable([
458
+ {
459
+ id: "prjl_raced",
460
+ founderEmail: USER_EMAIL,
461
+ siteId: OWN_WORKSPACE_ID,
462
+ ownerUserId: null,
463
+ },
464
+ ]);
465
+
466
+ // Simulate the interleaving: another verifier claims the row after this
467
+ // call's candidate read returns it and before its update runs.
468
+ const findMany = prisma.projectListing.findMany as jest.Mock;
469
+ const realFindMany = findMany.getMockImplementation();
470
+ findMany.mockImplementation(async (args: Parameters<typeof realFindMany>[0]) => {
471
+ const rows = await realFindMany!(args);
472
+ const raced = table.find((r) => r.id === "prjl_raced");
473
+ if (raced) raced.ownerUserId = "user_00000000-0000-7000-8000-0000000000a9";
474
+ return rows;
475
+ });
476
+
477
+ const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
478
+
479
+ expect(result).toEqual<PromoteListingsResult>({
480
+ ok: true,
481
+ count: 0,
482
+ promoted: [],
483
+ });
484
+ // The first claimant keeps the row.
485
+ expect(table.find((r) => r.id === "prjl_raced")?.ownerUserId).toBe(
486
+ "user_00000000-0000-7000-8000-0000000000a9",
487
+ );
488
+ });
489
+
490
+ /**
491
+ * The denial-of-link the ambiguity refusal would otherwise open, found by the
492
+ * security reviewer after the refusal landed.
493
+ *
494
+ * `POST /v1/project-listings` is anonymous and the concurrent-listing quota is
495
+ * enforced at SUBMIT, not at start, so anyone with a site's public key — which
496
+ * ships in browser JavaScript by design — can create unbounded `draft` rows
497
+ * carrying any address. One planted draft would be enough to make every
498
+ * genuine row at that site ambiguous, permanently blocking the real founder
499
+ * from ever claiming their own listing. Excluding drafts from candidacy is
500
+ * what stops one anonymous request from doing that.
501
+ */
502
+ it("a planted anonymous DRAFT neither links nor blocks the founder's own submitted row", async () => {
503
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
504
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
505
+ const table = seedListingTable([
506
+ {
507
+ id: "prjl_planted_draft",
508
+ founderEmail: USER_EMAIL,
509
+ siteId: OWN_WORKSPACE_ID,
510
+ ownerUserId: null,
511
+ status: "draft",
512
+ },
513
+ {
514
+ id: "prjl_genuine_submitted",
515
+ founderEmail: USER_EMAIL,
516
+ siteId: OWN_WORKSPACE_ID,
517
+ ownerUserId: null,
518
+ status: "requested",
519
+ },
520
+ ]);
521
+
522
+ const result = (await promoteListingsForVerifiedUser({
523
+ userId: USER_ID,
524
+ })) as Extract<PromoteListingsResult, { ok: true }>;
525
+
526
+ // The draft is not a candidate, so the group is NOT ambiguous and the
527
+ // founder's own submitted row still links.
528
+ expect(result.count).toBe(1);
529
+ expect(result.promoted).toEqual([
530
+ { listingId: "prjl_genuine_submitted", siteId: OWN_WORKSPACE_ID },
531
+ ]);
532
+ expect(table.find((r) => r.id === "prjl_planted_draft")?.ownerUserId).toBeNull();
533
+ });
534
+
535
+ it("a rejected listing is still the founder's own and still links", async () => {
536
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
537
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
538
+ seedListingTable([
539
+ {
540
+ id: "prjl_rejected",
541
+ founderEmail: USER_EMAIL,
542
+ siteId: OWN_WORKSPACE_ID,
543
+ ownerUserId: null,
544
+ status: "rejected",
545
+ },
546
+ ]);
547
+
548
+ const result = (await promoteListingsForVerifiedUser({
549
+ userId: USER_ID,
550
+ })) as Extract<PromoteListingsResult, { ok: true }>;
551
+
552
+ expect(result.count).toBe(1);
553
+ });
554
+
555
+ // ── Case + whitespace: the user side is normalized the way writes are ────
556
+ it("normalizes the user's address the way startProjectListingDraft normalizes founderEmail", async () => {
557
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(
558
+ verifiedUser({ email: " Founder@Example.com " }),
559
+ );
560
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
561
+ seedListingTable([
562
+ {
563
+ id: "prjl_normalized",
564
+ founderEmail: USER_EMAIL,
565
+ siteId: OWN_WORKSPACE_ID,
566
+ ownerUserId: null,
567
+ },
568
+ ]);
569
+
570
+ const result = (await promoteListingsForVerifiedUser({
571
+ userId: USER_ID,
572
+ })) as Extract<PromoteListingsResult, { ok: true }>;
573
+
574
+ expect(result.count).toBe(1);
575
+ expect(prisma.projectListing.findMany).toHaveBeenCalledWith(
576
+ expect.objectContaining({
577
+ where: expect.objectContaining({ founderEmail: USER_EMAIL }),
578
+ }),
579
+ );
580
+ });
581
+
582
+ // ── Idempotent ───────────────────────────────────────────────────────────
583
+ it("a listing linked on the first run is excluded on the second run", async () => {
584
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
585
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
586
+ seedListingTable([
587
+ {
588
+ id: "prjl_first_run",
589
+ founderEmail: USER_EMAIL,
590
+ siteId: OWN_WORKSPACE_ID,
591
+ ownerUserId: null,
592
+ },
593
+ ]);
594
+
595
+ const first = await promoteListingsForVerifiedUser({ userId: USER_ID });
596
+ expect(first).toEqual<PromoteListingsResult>({
597
+ ok: true,
598
+ count: 1,
599
+ promoted: [{ listingId: "prjl_first_run", siteId: OWN_WORKSPACE_ID }],
600
+ });
601
+
602
+ const second = await promoteListingsForVerifiedUser({ userId: USER_ID });
603
+ expect(second).toEqual<PromoteListingsResult>({
604
+ ok: true,
605
+ count: 0,
606
+ promoted: [],
607
+ });
608
+ });
609
+
610
+ // ── No match → count:0 ───────────────────────────────────────────────────
611
+ it("no matching listing → count:0 with no update", async () => {
612
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
613
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
614
+ seedListingTable([]);
615
+
616
+ const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
617
+
618
+ expect(result).toEqual<PromoteListingsResult>({
619
+ ok: true,
620
+ count: 0,
621
+ promoted: [],
622
+ });
623
+ expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
624
+ });
625
+
626
+ // ── No workspaces → no-op, never queries the listing table ───────────────
627
+ it("a user with no workspaces is a no-op — never queries inbox.ProjectListing", async () => {
628
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
629
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
630
+
631
+ const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
632
+
633
+ expect(result).toEqual<PromoteListingsResult>({
634
+ ok: true,
635
+ count: 0,
636
+ promoted: [],
637
+ });
638
+ expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
639
+ });
640
+
641
+ // ── Unknown user → ok:false, user_not_found ──────────────────────────────
642
+ it("an unknown userId returns ok:false with kind user_not_found", async () => {
643
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
644
+
645
+ const result = (await promoteListingsForVerifiedUser({
646
+ userId: "user_does-not-exist",
647
+ })) as Extract<PromoteListingsResult, { ok: false }>;
648
+
649
+ expect(result.ok).toBe(false);
650
+ expect(result.error.kind).toBe("user_not_found");
651
+ });
652
+
653
+ // ── A DB failure is a result, never a throw ──────────────────────────────
654
+ it("a DB failure returns ok:false internal_error rather than throwing", async () => {
655
+ (prisma.user.findUnique as jest.Mock).mockRejectedValue(
656
+ new Error("connection terminated"),
657
+ );
658
+
659
+ const result = (await promoteListingsForVerifiedUser({
660
+ userId: USER_ID,
661
+ })) as Extract<PromoteListingsResult, { ok: false }>;
662
+
663
+ expect(result.ok).toBe(false);
664
+ expect(result.error.kind).toBe("internal_error");
665
+ });
666
+
667
+ // ── PII non-leak — the result never carries the user's raw email ─────────
668
+ it("PII non-leak — the result never carries the founder's raw email", async () => {
669
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
670
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
671
+ seedListingTable([
672
+ {
673
+ id: "prjl_pii",
674
+ founderEmail: USER_EMAIL,
675
+ siteId: OWN_WORKSPACE_ID,
676
+ ownerUserId: null,
677
+ },
678
+ ]);
679
+
680
+ const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
681
+
682
+ expect(JSON.stringify(result)).not.toContain(USER_EMAIL);
683
+ });
684
+ });