create-ailk 0.19.0 → 0.20.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 +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file consent-ledger.test.ts
|
|
3
|
+
* @description The consent ledger's writers (#8438 D3) — `recordCookieChoice`,
|
|
4
|
+
* `recordPersonConsent`, `linkConsentVisitor`, the unsubscribe token, and the
|
|
5
|
+
* `source` contract of `recordConsent`.
|
|
6
|
+
*
|
|
7
|
+
* External dependency boundaries mocked:
|
|
8
|
+
* - @working-theory/database (via moduleNameMapper -> prisma-client.js mock;
|
|
9
|
+
* prisma singleton via __mocks__/prisma.ts)
|
|
10
|
+
*
|
|
11
|
+
* The ledger is modelled in memory: `$transaction` runs its callback on the
|
|
12
|
+
* mocked client, `consentGrant.createMany` appends, and `$queryRaw` answers
|
|
13
|
+
* the way the store's `DISTINCT ON` queries would — newest row per purpose —
|
|
14
|
+
* so the "writes only when the answer changes" rule is exercised end to end.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// 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.
|
|
18
|
+
jest.mock("../../lib/prisma.js");
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
PLATFORM_CONSENT_CONFIG,
|
|
22
|
+
resolveConsentStatement,
|
|
23
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
24
|
+
siteConsentConfigSchema,
|
|
25
|
+
} from "@working-theory/validation";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
armConsentLedger,
|
|
29
|
+
type LedgerRow,
|
|
30
|
+
type LinkRow,
|
|
31
|
+
} from "../../lib/__tests__/consent-ledger-double.js";
|
|
32
|
+
import { prisma } from "../../lib/prisma.js";
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
ConsentLinkSecretMissing,
|
|
36
|
+
consentLinkSecretConfigured,
|
|
37
|
+
consentUnsubscribeToken,
|
|
38
|
+
linkConsentVisitor,
|
|
39
|
+
recordConsent,
|
|
40
|
+
recordCookieChoice,
|
|
41
|
+
recordPersonConsent,
|
|
42
|
+
verifyConsentUnsubscribeToken,
|
|
43
|
+
} from "../consent";
|
|
44
|
+
|
|
45
|
+
const WS = "wksp_01920000-0000-7000-8000-0000000000a1";
|
|
46
|
+
const PERSON = "person_01920000-0000-7000-8000-0000000000a1";
|
|
47
|
+
const VISITOR = "0192a000-0000-4000-8000-0000000000a1";
|
|
48
|
+
|
|
49
|
+
type Row = LedgerRow;
|
|
50
|
+
|
|
51
|
+
const CONFIG = siteConsentConfigSchema.parse({
|
|
52
|
+
policies: { terms: "/terms", privacy: "/privacy" },
|
|
53
|
+
purposes: [
|
|
54
|
+
{
|
|
55
|
+
id: "service_terms",
|
|
56
|
+
required: true,
|
|
57
|
+
kind: "agreement",
|
|
58
|
+
policy: "terms",
|
|
59
|
+
label: { en: "You agree." },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "product_news",
|
|
63
|
+
kind: "preference",
|
|
64
|
+
area: "communications",
|
|
65
|
+
label: { en: "Product news." },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "share_data",
|
|
69
|
+
kind: "consent",
|
|
70
|
+
area: "data",
|
|
71
|
+
label: { en: "Share usage data." },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "analytics_cookies",
|
|
75
|
+
kind: "consent",
|
|
76
|
+
area: "cookies",
|
|
77
|
+
label: { en: "Analytics cookies." },
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** The in-memory ledger every case appends to (`lib/__tests__/consent-ledger-double.ts`). */
|
|
83
|
+
let ledger: Row[] = [];
|
|
84
|
+
let links: LinkRow[] = [];
|
|
85
|
+
|
|
86
|
+
function newest(rows: Row[], purposeId?: string): Row | null {
|
|
87
|
+
const matching = rows.filter((r) => purposeId === undefined || r.purposeId === purposeId);
|
|
88
|
+
return matching.length === 0 ? null : matching[matching.length - 1]!;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
jest.clearAllMocks();
|
|
93
|
+
const double = armConsentLedger(prisma);
|
|
94
|
+
ledger = double.ledger;
|
|
95
|
+
links = double.links;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("recordConsent carries the ledger fields (#8438 D3)", () => {
|
|
99
|
+
it("stamps personId, source, sourceRef and the purpose's policy version on every row", async () => {
|
|
100
|
+
await recordConsent({
|
|
101
|
+
siteId: WS,
|
|
102
|
+
subjectType: "lead",
|
|
103
|
+
subjectId: "lead_1",
|
|
104
|
+
personId: PERSON,
|
|
105
|
+
source: "magnet",
|
|
106
|
+
sourceRef: "free-guide",
|
|
107
|
+
locale: "en",
|
|
108
|
+
config: CONFIG,
|
|
109
|
+
consents: { service_terms: true, share_data: false },
|
|
110
|
+
});
|
|
111
|
+
expect(ledger).toHaveLength(2);
|
|
112
|
+
expect(ledger[0]).toMatchObject({
|
|
113
|
+
personId: PERSON,
|
|
114
|
+
source: "magnet",
|
|
115
|
+
sourceRef: "free-guide",
|
|
116
|
+
policyVersion: 1,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("is a type error to write a row with no source", async () => {
|
|
121
|
+
await recordConsent(
|
|
122
|
+
// @ts-expect-error -- `source` is required: every writer must say where the answer came from.
|
|
123
|
+
{
|
|
124
|
+
siteId: WS,
|
|
125
|
+
subjectType: "lead",
|
|
126
|
+
subjectId: "lead_1",
|
|
127
|
+
personId: null,
|
|
128
|
+
locale: "en",
|
|
129
|
+
config: CONFIG,
|
|
130
|
+
consents: { service_terms: true },
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("recordPersonConsent (#8438 D3)", () => {
|
|
137
|
+
const base = {
|
|
138
|
+
workspaceId: WS,
|
|
139
|
+
personId: PERSON,
|
|
140
|
+
locale: "en",
|
|
141
|
+
config: CONFIG,
|
|
142
|
+
subject: { subjectType: "person", subjectId: PERSON },
|
|
143
|
+
};
|
|
144
|
+
const settings = (purposeId: string, granted: boolean) =>
|
|
145
|
+
recordPersonConsent({
|
|
146
|
+
...base,
|
|
147
|
+
purposeId,
|
|
148
|
+
granted,
|
|
149
|
+
source: "account_settings",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("grant -> withdraw -> grant leaves three rows, newest granted", async () => {
|
|
153
|
+
await settings("share_data", true);
|
|
154
|
+
await settings("share_data", false);
|
|
155
|
+
await settings("share_data", true);
|
|
156
|
+
expect(ledger.map((r) => r.granted)).toEqual([true, false, true]);
|
|
157
|
+
expect(newest(ledger, "share_data")?.source).toBe("account_settings");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("writes nothing for a repeat of the same answer", async () => {
|
|
161
|
+
await settings("share_data", true);
|
|
162
|
+
const again = await settings("share_data", true);
|
|
163
|
+
expect(again).toEqual({ ok: true, changed: false });
|
|
164
|
+
expect(ledger).toHaveLength(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("treats a preference as on until withdrawn: granting it first writes nothing", async () => {
|
|
168
|
+
expect(await settings("product_news", true)).toEqual({
|
|
169
|
+
ok: true,
|
|
170
|
+
changed: false,
|
|
171
|
+
});
|
|
172
|
+
expect(ledger).toHaveLength(0);
|
|
173
|
+
expect(await settings("product_news", false)).toEqual({
|
|
174
|
+
ok: true,
|
|
175
|
+
changed: true,
|
|
176
|
+
});
|
|
177
|
+
expect(ledger).toHaveLength(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("records the statement the registry resolves, never one from the caller", async () => {
|
|
181
|
+
await settings("share_data", true);
|
|
182
|
+
const purpose = CONFIG.purposes.find((p) => p.id === "share_data")!;
|
|
183
|
+
expect(ledger[0]?.["statement"]).toBe(
|
|
184
|
+
resolveConsentStatement(purpose, "en").statement,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("asks an agreement once per policy version and refuses to withdraw it", async () => {
|
|
189
|
+
expect(await settings("service_terms", true)).toEqual({
|
|
190
|
+
ok: true,
|
|
191
|
+
changed: true,
|
|
192
|
+
});
|
|
193
|
+
expect(await settings("service_terms", true)).toEqual({
|
|
194
|
+
ok: true,
|
|
195
|
+
changed: false,
|
|
196
|
+
});
|
|
197
|
+
expect(await settings("service_terms", false)).toEqual({
|
|
198
|
+
ok: false,
|
|
199
|
+
code: "agreement_not_withdrawable",
|
|
200
|
+
});
|
|
201
|
+
expect(ledger).toHaveLength(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("re-asks an agreement when its policy version is bumped", async () => {
|
|
205
|
+
await settings("service_terms", true);
|
|
206
|
+
const bumped = siteConsentConfigSchema.parse({
|
|
207
|
+
...CONFIG,
|
|
208
|
+
purposes: CONFIG.purposes.map((p) =>
|
|
209
|
+
p.id === "service_terms" ? { ...p, policyVersion: 2 } : p,
|
|
210
|
+
),
|
|
211
|
+
});
|
|
212
|
+
const result = await recordPersonConsent({
|
|
213
|
+
...base,
|
|
214
|
+
config: bumped,
|
|
215
|
+
purposeId: "service_terms",
|
|
216
|
+
granted: true,
|
|
217
|
+
source: "account_settings",
|
|
218
|
+
});
|
|
219
|
+
expect(result).toEqual({ ok: true, changed: true });
|
|
220
|
+
expect(newest(ledger, "service_terms")?.policyVersion).toBe(2);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("refuses an unknown purpose and the cookie purpose", async () => {
|
|
224
|
+
expect(await settings("nope", true)).toEqual({
|
|
225
|
+
ok: false,
|
|
226
|
+
code: "purpose_unknown",
|
|
227
|
+
});
|
|
228
|
+
expect(await settings("analytics_cookies", true)).toEqual({
|
|
229
|
+
ok: false,
|
|
230
|
+
code: "cookie_purpose",
|
|
231
|
+
});
|
|
232
|
+
expect(ledger).toHaveLength(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("takes the (subject, purpose) advisory lock before it reads", async () => {
|
|
236
|
+
await settings("share_data", true);
|
|
237
|
+
const lock = (prisma.$executeRaw as jest.Mock).mock.calls[0]!;
|
|
238
|
+
expect(String(lock[0].join("?"))).toContain("pg_advisory_xact_lock");
|
|
239
|
+
expect(lock[1]).toBe(`ailk:consent:${WS}:person:${PERSON}:share_data`);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("an unsubscribe withdraws a grant an anonymous form made, and only once", async () => {
|
|
243
|
+
// A magnet row the person never confirmed themself: own reading says
|
|
244
|
+
// 'not granted', any-source says granted.
|
|
245
|
+
ledger.push({
|
|
246
|
+
id: "cnsnt_00000000-0000-7000-8000-0000000000f1",
|
|
247
|
+
workspaceId: WS,
|
|
248
|
+
subjectType: "lead",
|
|
249
|
+
subjectId: "lead_1",
|
|
250
|
+
personId: PERSON,
|
|
251
|
+
purposeId: "share_data",
|
|
252
|
+
granted: true,
|
|
253
|
+
source: "magnet",
|
|
254
|
+
sourceRef: "free-guide",
|
|
255
|
+
policyVersion: 1,
|
|
256
|
+
locale: "en",
|
|
257
|
+
statement: "Share usage data.",
|
|
258
|
+
createdAt: new Date("2026-10-12T00:00:00Z"),
|
|
259
|
+
});
|
|
260
|
+
const first = await recordPersonConsent({
|
|
261
|
+
...base,
|
|
262
|
+
purposeId: "share_data",
|
|
263
|
+
granted: false,
|
|
264
|
+
source: "email_unsubscribe",
|
|
265
|
+
});
|
|
266
|
+
const second = await recordPersonConsent({
|
|
267
|
+
...base,
|
|
268
|
+
purposeId: "share_data",
|
|
269
|
+
granted: false,
|
|
270
|
+
source: "email_unsubscribe",
|
|
271
|
+
});
|
|
272
|
+
expect(first).toEqual({ ok: true, changed: true });
|
|
273
|
+
expect(second).toEqual({ ok: true, changed: false });
|
|
274
|
+
expect(ledger.filter((r) => r.source === "email_unsubscribe")).toHaveLength(
|
|
275
|
+
1,
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("an unsubscribe still withdraws an own grant that a newer anonymous decline hides", async () => {
|
|
280
|
+
await settings("share_data", true);
|
|
281
|
+
ledger.push({
|
|
282
|
+
id: "cnsnt_00000000-0000-7000-8000-0000000000f2",
|
|
283
|
+
workspaceId: WS,
|
|
284
|
+
subjectType: "lead",
|
|
285
|
+
subjectId: "lead_2",
|
|
286
|
+
personId: PERSON,
|
|
287
|
+
purposeId: "share_data",
|
|
288
|
+
granted: false,
|
|
289
|
+
source: "magnet",
|
|
290
|
+
sourceRef: "free-guide",
|
|
291
|
+
policyVersion: 1,
|
|
292
|
+
locale: "en",
|
|
293
|
+
statement: "Share usage data.",
|
|
294
|
+
createdAt: new Date(Date.now() + 60_000),
|
|
295
|
+
});
|
|
296
|
+
const result = await recordPersonConsent({
|
|
297
|
+
...base,
|
|
298
|
+
purposeId: "share_data",
|
|
299
|
+
granted: false,
|
|
300
|
+
source: "email_unsubscribe",
|
|
301
|
+
});
|
|
302
|
+
expect(result).toEqual({ ok: true, changed: true });
|
|
303
|
+
expect(ledger.filter((r) => r.source === "email_unsubscribe")).toHaveLength(
|
|
304
|
+
1,
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("writes the owner's platform consents against subject user with no Person", async () => {
|
|
309
|
+
const result = await recordPersonConsent({
|
|
310
|
+
workspaceId: WS,
|
|
311
|
+
personId: null,
|
|
312
|
+
purposeId: "wt_product_data",
|
|
313
|
+
granted: true,
|
|
314
|
+
source: "account_settings",
|
|
315
|
+
locale: "en",
|
|
316
|
+
config: PLATFORM_CONSENT_CONFIG,
|
|
317
|
+
subject: { subjectType: "user", subjectId: "user_1" },
|
|
318
|
+
});
|
|
319
|
+
expect(result).toEqual({ ok: true, changed: true });
|
|
320
|
+
expect(ledger[0]).toMatchObject({ subjectType: "user", personId: null });
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("recordCookieChoice (#8438 D3)", () => {
|
|
325
|
+
const choice = (c: "accepted" | "declined") =>
|
|
326
|
+
recordCookieChoice(
|
|
327
|
+
{ workspaceId: WS, visitorId: VISITOR, choice: c, locale: "en" },
|
|
328
|
+
CONFIG,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
it("writes one visitor row stamped with the banner source and the registry statement", async () => {
|
|
332
|
+
expect(await choice("accepted")).toEqual({ recorded: true });
|
|
333
|
+
expect(ledger).toHaveLength(1);
|
|
334
|
+
expect(ledger[0]).toMatchObject({
|
|
335
|
+
subjectType: "visitor",
|
|
336
|
+
subjectId: VISITOR,
|
|
337
|
+
purposeId: "analytics_cookies",
|
|
338
|
+
granted: true,
|
|
339
|
+
source: "cookie_banner",
|
|
340
|
+
policyVersion: 1,
|
|
341
|
+
personId: null,
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("logs a change of choice and not a replay", async () => {
|
|
346
|
+
await choice("accepted");
|
|
347
|
+
expect(await choice("accepted")).toEqual({ recorded: false });
|
|
348
|
+
expect(await choice("declined")).toEqual({ recorded: true });
|
|
349
|
+
expect(ledger.map((r) => r.granted)).toEqual([true, false]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("writes nothing when the registry declares no cookie purpose", async () => {
|
|
353
|
+
const none = siteConsentConfigSchema.parse({
|
|
354
|
+
policies: {},
|
|
355
|
+
purposes: [{ id: "a", label: { en: "a" } }],
|
|
356
|
+
});
|
|
357
|
+
expect(
|
|
358
|
+
await recordCookieChoice(
|
|
359
|
+
{
|
|
360
|
+
workspaceId: WS,
|
|
361
|
+
visitorId: VISITOR,
|
|
362
|
+
choice: "accepted",
|
|
363
|
+
locale: "en",
|
|
364
|
+
},
|
|
365
|
+
none,
|
|
366
|
+
),
|
|
367
|
+
).toEqual({ recorded: false });
|
|
368
|
+
expect(ledger).toHaveLength(0);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("uses the platform default registry when none is passed", async () => {
|
|
372
|
+
expect(
|
|
373
|
+
await recordCookieChoice({
|
|
374
|
+
workspaceId: WS,
|
|
375
|
+
visitorId: VISITOR,
|
|
376
|
+
choice: "declined",
|
|
377
|
+
locale: "en",
|
|
378
|
+
}),
|
|
379
|
+
).toEqual({ recorded: true });
|
|
380
|
+
expect(ledger[0]?.["purposeId"]).toBe(
|
|
381
|
+
SITE_CONSENT_CONFIG_DEFAULTS.purposes.find((p) => p.area === "cookies")
|
|
382
|
+
?.id,
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("linkConsentVisitor (#8438 C5)", () => {
|
|
388
|
+
it("inserts and ignores a duplicate", async () => {
|
|
389
|
+
(prisma.consentVisitorLink.createMany as jest.Mock).mockResolvedValue({
|
|
390
|
+
count: 1,
|
|
391
|
+
});
|
|
392
|
+
await linkConsentVisitor({
|
|
393
|
+
workspaceId: WS,
|
|
394
|
+
visitorId: VISITOR,
|
|
395
|
+
personId: PERSON,
|
|
396
|
+
source: "magnet",
|
|
397
|
+
});
|
|
398
|
+
expect(prisma.consentVisitorLink.createMany).toHaveBeenCalledWith({
|
|
399
|
+
data: [
|
|
400
|
+
{
|
|
401
|
+
workspaceId: WS,
|
|
402
|
+
visitorId: VISITOR,
|
|
403
|
+
personId: PERSON,
|
|
404
|
+
source: "magnet",
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
skipDuplicates: true,
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("never throws: a failure is swallowed so a link cannot fail a lead", async () => {
|
|
412
|
+
(prisma.consentVisitorLink.createMany as jest.Mock).mockRejectedValue(
|
|
413
|
+
new Error("down"),
|
|
414
|
+
);
|
|
415
|
+
await expect(
|
|
416
|
+
linkConsentVisitor({
|
|
417
|
+
workspaceId: WS,
|
|
418
|
+
visitorId: VISITOR,
|
|
419
|
+
personId: PERSON,
|
|
420
|
+
source: "api",
|
|
421
|
+
}),
|
|
422
|
+
).resolves.toBeUndefined();
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe("the unsubscribe token (#8438 D3, SEC-8438-9)", () => {
|
|
427
|
+
const SECRET = "x".repeat(40);
|
|
428
|
+
const claims = {
|
|
429
|
+
workspaceId: WS,
|
|
430
|
+
personId: PERSON,
|
|
431
|
+
purposeId: "product_news",
|
|
432
|
+
};
|
|
433
|
+
let saved: string | undefined;
|
|
434
|
+
|
|
435
|
+
beforeEach(() => {
|
|
436
|
+
saved = process.env["AILK_CONSENT_LINK_SECRET"];
|
|
437
|
+
process.env["AILK_CONSENT_LINK_SECRET"] = SECRET;
|
|
438
|
+
});
|
|
439
|
+
afterEach(() => {
|
|
440
|
+
if (saved === undefined) delete process.env["AILK_CONSENT_LINK_SECRET"];
|
|
441
|
+
else process.env["AILK_CONSENT_LINK_SECRET"] = saved;
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("round-trips and has the v1.<payload>.<mac> shape", () => {
|
|
445
|
+
const token = consentUnsubscribeToken(claims);
|
|
446
|
+
expect(token).toMatch(/^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
|
447
|
+
expect(verifyConsentUnsubscribeToken(token)).toEqual(claims);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("refuses a token with one flipped character", () => {
|
|
451
|
+
const token = consentUnsubscribeToken(claims);
|
|
452
|
+
const flipped = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A");
|
|
453
|
+
expect(verifyConsentUnsubscribeToken(flipped)).toBeNull();
|
|
454
|
+
const body = token.split(".");
|
|
455
|
+
body[1] = body[1]!.slice(0, -1) + (body[1]!.endsWith("A") ? "B" : "A");
|
|
456
|
+
expect(verifyConsentUnsubscribeToken(body.join("."))).toBeNull();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("refuses a token older than 400 days, and a future-dated one", () => {
|
|
460
|
+
const now = new Date("2026-10-12T00:00:00Z");
|
|
461
|
+
const old = consentUnsubscribeToken(
|
|
462
|
+
claims,
|
|
463
|
+
new Date(now.getTime() - 401 * 86_400_000),
|
|
464
|
+
);
|
|
465
|
+
const fresh = consentUnsubscribeToken(
|
|
466
|
+
claims,
|
|
467
|
+
new Date(now.getTime() - 399 * 86_400_000),
|
|
468
|
+
);
|
|
469
|
+
const future = consentUnsubscribeToken(
|
|
470
|
+
claims,
|
|
471
|
+
new Date(now.getTime() + 3_600_000),
|
|
472
|
+
);
|
|
473
|
+
expect(verifyConsentUnsubscribeToken(old, now)).toBeNull();
|
|
474
|
+
expect(verifyConsentUnsubscribeToken(fresh, now)).toEqual(claims);
|
|
475
|
+
expect(verifyConsentUnsubscribeToken(future, now)).toBeNull();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("refuses a wrong version, garbage, and a token signed with another secret", () => {
|
|
479
|
+
const token = consentUnsubscribeToken(claims);
|
|
480
|
+
expect(
|
|
481
|
+
verifyConsentUnsubscribeToken(token.replace(/^v1/, "v2")),
|
|
482
|
+
).toBeNull();
|
|
483
|
+
expect(verifyConsentUnsubscribeToken("nonsense")).toBeNull();
|
|
484
|
+
expect(verifyConsentUnsubscribeToken("a.b.c")).toBeNull();
|
|
485
|
+
process.env["AILK_CONSENT_LINK_SECRET"] = "y".repeat(40);
|
|
486
|
+
expect(verifyConsentUnsubscribeToken(token)).toBeNull();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("throws on mint, and refuses on verify, with the secret unset or under 32 bytes", () => {
|
|
490
|
+
const token = consentUnsubscribeToken(claims);
|
|
491
|
+
delete process.env["AILK_CONSENT_LINK_SECRET"];
|
|
492
|
+
expect(consentLinkSecretConfigured()).toBe(false);
|
|
493
|
+
expect(() => consentUnsubscribeToken(claims)).toThrow(
|
|
494
|
+
ConsentLinkSecretMissing,
|
|
495
|
+
);
|
|
496
|
+
expect(verifyConsentUnsubscribeToken(token)).toBeNull();
|
|
497
|
+
process.env["AILK_CONSENT_LINK_SECRET"] = "short";
|
|
498
|
+
expect(consentLinkSecretConfigured()).toBe(false);
|
|
499
|
+
expect(() => consentUnsubscribeToken(claims)).toThrow(
|
|
500
|
+
ConsentLinkSecretMissing,
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
@@ -38,7 +38,12 @@ import {
|
|
|
38
38
|
} from "../consent";
|
|
39
39
|
|
|
40
40
|
const SITE_ID = "site-1";
|
|
41
|
-
const SUBJECT = {
|
|
41
|
+
const SUBJECT = {
|
|
42
|
+
subjectType: "project_listing",
|
|
43
|
+
subjectId: "prjl_00000000-0000-7000-8000-000000000001",
|
|
44
|
+
personId: null,
|
|
45
|
+
source: "api" as const,
|
|
46
|
+
};
|
|
42
47
|
|
|
43
48
|
/** Two purposes, one of them bilingual — the fixture every locale case reads from. */
|
|
44
49
|
const CONFIG = siteConsentConfigSchema.parse({
|
|
@@ -220,6 +225,10 @@ describe("recordLegacyConsent (#5275 D11)", () => {
|
|
|
220
225
|
locale: UNRECOVERABLE_CONSENT_LOCALE,
|
|
221
226
|
statement: UNRECOVERABLE_CONSENT_STATEMENT,
|
|
222
227
|
statementHash: consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT),
|
|
228
|
+
// #8438 D2 — the ledger fields: no Person, no magnet slug, and the
|
|
229
|
+
// registry's policy version in force.
|
|
230
|
+
sourceRef: null,
|
|
231
|
+
policyVersion: 1,
|
|
223
232
|
});
|
|
224
233
|
expect(rows[0]!["purposeId"]).toBe("service_terms");
|
|
225
234
|
expect(rows[0]!["locale"]).toBe("und");
|
|
@@ -266,13 +275,19 @@ describe("consentConfigForSite", () => {
|
|
|
266
275
|
});
|
|
267
276
|
|
|
268
277
|
describe("requiredConsentUnmet (#5275, security review Medium 1)", () => {
|
|
269
|
-
const config = {
|
|
278
|
+
const config = siteConsentConfigSchema.parse({
|
|
270
279
|
policies: {},
|
|
271
280
|
purposes: [
|
|
272
281
|
{ id: "service_terms", required: true, label: { en: "a" } },
|
|
273
282
|
{ id: "marketing", required: false, label: { en: "b" } },
|
|
274
283
|
],
|
|
275
|
-
};
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("does not demand a sign-up agreement of a form that cannot show it (#8438 D13)", () => {
|
|
287
|
+
expect(
|
|
288
|
+
requiredConsentUnmet(SITE_CONSENT_CONFIG_DEFAULTS, { service_terms: true }),
|
|
289
|
+
).toBeUndefined();
|
|
290
|
+
});
|
|
276
291
|
|
|
277
292
|
it("names a required purpose that is declined OR absent, and passes when every required one is true", () => {
|
|
278
293
|
expect(requiredConsentUnmet(config, { service_terms: false })).toBe("service_terms");
|
|
@@ -1014,12 +1014,121 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1014
1014
|
expect(createData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
|
|
1015
1015
|
});
|
|
1016
1016
|
|
|
1017
|
-
|
|
1017
|
+
// ── #8435 D5 (AC-3) — subscribers and callbacks link to their Person ─────
|
|
1018
|
+
const LINKED = {
|
|
1019
|
+
ok: true as const,
|
|
1020
|
+
value: { personId: MOCK_PERSON_ID as never, created: false, billing: "unchanged" as const },
|
|
1021
|
+
};
|
|
1022
|
+
const findSpies: jest.SpyInstance[] = [];
|
|
1023
|
+
const spyFind = () => {
|
|
1024
|
+
const spy = jest.spyOn(personService, "findOrCreatePerson");
|
|
1025
|
+
findSpies.push(spy);
|
|
1026
|
+
return spy;
|
|
1027
|
+
};
|
|
1028
|
+
afterEach(() => {
|
|
1029
|
+
while (findSpies.length > 0) findSpies.pop()?.mockRestore();
|
|
1030
|
+
});
|
|
1031
|
+
const subscriberData = () =>
|
|
1032
|
+
jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<string, unknown>;
|
|
1033
|
+
const callbackData = () =>
|
|
1034
|
+
jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<string, unknown>;
|
|
1035
|
+
|
|
1036
|
+
it("a subscribe calls findOrCreatePerson(siteId, { email }) once and writes that personId", async () => {
|
|
1018
1037
|
(prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
|
|
1019
|
-
|
|
1038
|
+
const find = spyFind().mockResolvedValue(LINKED);
|
|
1020
1039
|
await routeLead(subscribeInput, sourceMeta);
|
|
1021
|
-
|
|
1040
|
+
expect(find).toHaveBeenCalledTimes(1);
|
|
1041
|
+
expect(find).toHaveBeenCalledWith("site-abc", { email: "subscriber@example.com" });
|
|
1042
|
+
expect(subscriberData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it("a subscribe whose Person is invalid_email writes personId: null and one person-skipped warn with no email", async () => {
|
|
1046
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
|
|
1047
|
+
jest
|
|
1048
|
+
.spyOn(personService, "findOrCreatePerson")
|
|
1049
|
+
.mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
|
|
1050
|
+
const result = await routeLead(subscribeInput, sourceMeta);
|
|
1051
|
+
expect(result).toMatchObject({ ok: true, id: "subscr_x", persisted: "db" });
|
|
1052
|
+
expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1053
|
+
const skipped = warn.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-skipped");
|
|
1054
|
+
expect(skipped).toHaveLength(1);
|
|
1055
|
+
expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("a subscribe whose Person step fails (internal_error or a throw) answers exactly like a linked one", async () => {
|
|
1059
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
|
|
1060
|
+
const find = spyFind().mockResolvedValue(LINKED);
|
|
1061
|
+
const linked = await routeLead(subscribeInput, sourceMeta);
|
|
1062
|
+
jest.mocked(prisma.subscriber.create).mockClear();
|
|
1063
|
+
|
|
1064
|
+
find.mockResolvedValueOnce({ ok: false, error: { kind: "internal_error" } });
|
|
1065
|
+
const failed = await routeLead(subscribeInput, sourceMeta);
|
|
1066
|
+
expect(failed).toEqual(linked);
|
|
1067
|
+
expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1068
|
+
expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({ event: "person-failed", siteId: "site-abc" });
|
|
1069
|
+
jest.mocked(prisma.subscriber.create).mockClear();
|
|
1070
|
+
|
|
1071
|
+
find.mockRejectedValueOnce(new Error("boom"));
|
|
1072
|
+
const thrown = await routeLead(subscribeInput, sourceMeta);
|
|
1073
|
+
expect(thrown).toEqual(linked);
|
|
1074
|
+
expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("a duplicate subscribe (P2002) makes no second Person call", async () => {
|
|
1078
|
+
(prisma.subscriber.create as jest.Mock).mockRejectedValue(Object.assign(new Error("dup"), { code: "P2002" }));
|
|
1079
|
+
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({ id: "subscr_existing" });
|
|
1080
|
+
const find = spyFind().mockResolvedValue(LINKED);
|
|
1081
|
+
const result = await routeLead(subscribeInput, sourceMeta);
|
|
1082
|
+
expect(result).toMatchObject({ ok: true, id: "subscr_existing" });
|
|
1083
|
+
expect(find).toHaveBeenCalledTimes(1);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it("a callback with payload.email and a spaced phone links its Person, carrying the E.164 telephone", async () => {
|
|
1087
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
|
|
1088
|
+
const find = spyFind().mockResolvedValue(LINKED);
|
|
1089
|
+
await routeLead(
|
|
1090
|
+
{ ...callbackInput, phone: "+1 415 555 0123", payload: { email: "c@x.io" } },
|
|
1091
|
+
sourceMeta,
|
|
1092
|
+
);
|
|
1093
|
+
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1094
|
+
email: "c@x.io",
|
|
1095
|
+
name: "Alice",
|
|
1096
|
+
contact: { telephone: "+14155550123" },
|
|
1097
|
+
});
|
|
1098
|
+
expect(callbackData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it.each([
|
|
1102
|
+
["no payload.email", { notes: "urgent" }],
|
|
1103
|
+
["a numeric payload.email", { email: 12345 }],
|
|
1104
|
+
])("a callback with %s makes no person delegate call and writes no personId key", async (_label, payload) => {
|
|
1105
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
|
|
1106
|
+
await routeLead({ ...callbackInput, payload }, sourceMeta);
|
|
1022
1107
|
expect(personCalls()).toBe(0);
|
|
1108
|
+
expect(Object.keys(callbackData())).not.toContain("personId");
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
// ── #8435 D5 (AC-4) — a submit's explicit contact ────────────────────────
|
|
1112
|
+
it("a submit with options.contact.name passes that name over payload.name, and the normalized contact", async () => {
|
|
1113
|
+
const find = spyFind().mockResolvedValue(LINKED);
|
|
1114
|
+
await routeLead(
|
|
1115
|
+
{ ...leadInput, payload: { name: "Bob" } },
|
|
1116
|
+
sourceMeta,
|
|
1117
|
+
{ contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" } },
|
|
1118
|
+
);
|
|
1119
|
+
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1120
|
+
email: "test@example.com",
|
|
1121
|
+
name: "Ann",
|
|
1122
|
+
contact: { givenName: "Ann", telephone: "+14155550123" },
|
|
1123
|
+
});
|
|
1124
|
+
// the lead's payload is the raw answers, unchanged
|
|
1125
|
+
expect(createData()["payload"]).toEqual({ name: "Bob" });
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("a submit with no options.contact passes payload.name and no contact, as before", async () => {
|
|
1129
|
+
const find = spyFind().mockResolvedValue(LINKED);
|
|
1130
|
+
await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta);
|
|
1131
|
+
expect(find).toHaveBeenCalledWith("site-abc", { email: "test@example.com", name: "Bob" });
|
|
1023
1132
|
});
|
|
1024
1133
|
|
|
1025
1134
|
it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
|
|
@@ -1060,7 +1169,9 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1060
1169
|
|
|
1061
1170
|
const failed = await routeLead(leadInput, sourceMeta);
|
|
1062
1171
|
|
|
1063
|
-
|
|
1172
|
+
// The answer is the same as a successful capture, apart from the internal
|
|
1173
|
+
// `personId` (#8438 D11) the route never puts on a wire.
|
|
1174
|
+
expect(failed).toEqual({ ...ok, personId: null });
|
|
1064
1175
|
expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db", email: { ok: true } });
|
|
1065
1176
|
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1066
1177
|
const lines = error.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-failed");
|