@duffcloudservices/site-forms 0.5.0 → 0.7.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.
@@ -0,0 +1,239 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { flushPromises, mount } from '@vue/test-utils'
3
+ import DcsForm from '../DcsForm.vue'
4
+ import {
5
+ PHI_GUIDANCE_COPY,
6
+ fetchSiteHandlesPhi,
7
+ resetSitePhiPostureCache,
8
+ } from '../composables/useSitePhiPosture'
9
+ import type { PortalFormDefinition } from '../types'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // C-415 — PHI guidance on HIPAA-mode tenants.
13
+ //
14
+ // The defect: a `hipaaMode: true` clinic whose AI chat told visitors not to
15
+ // share health details while its managed contact form — 6 required fields
16
+ // including a free-text `message` — said nothing at all. Measured 2026-07-31 on
17
+ // the served https://kineticenergypt.com/contact: /HIPAA/i, /protected health/i,
18
+ // /do not include/i and /medical information/i all NOT FOUND.
19
+ //
20
+ // The rule these tests must not let rot: the guidance is a function of the
21
+ // PLATFORM verdict and nothing else. Every test below therefore controls only
22
+ // what the compliance endpoint says, and asserts in BOTH directions — a test
23
+ // that passed while the component ignored the tenant flag would prove nothing,
24
+ // which is what `it('is vacuous if the flag is ignored')` pins explicitly.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const GUIDANCE = '[data-form-phi-guidance]'
28
+
29
+ function makeDef(
30
+ overrides: Partial<PortalFormDefinition> = {},
31
+ ): PortalFormDefinition {
32
+ return {
33
+ formId: 'contact',
34
+ submission: { kind: 'lead' },
35
+ // The shape measured on KEPT: split name, email, phone, then two required
36
+ // free-text fields. `message` is the box a patient actually types into.
37
+ fields: [
38
+ { id: 'first-name', type: 'text', label: 'First name', required: true, width: 'half' },
39
+ { id: 'last-name', type: 'text', label: 'Last name', required: true, width: 'half' },
40
+ { id: 'email', type: 'email', label: 'Your email', required: true, width: 'half' },
41
+ { id: 'phone', type: 'tel', label: 'Phone number', required: true, width: 'half' },
42
+ { id: 'subject', type: 'text', label: 'Subject', required: true, phi: true },
43
+ { id: 'message', type: 'textarea', label: 'Your message', required: true, phi: true },
44
+ ],
45
+ ...overrides,
46
+ }
47
+ }
48
+
49
+ /** Installs a fetch that answers the compliance endpoint with `siteHandlesPhi`. */
50
+ function stubCompliance(siteHandlesPhi: boolean | undefined, init: Partial<{ ok: boolean }> = {}) {
51
+ const calls: string[] = []
52
+ const impl = vi.fn(async (url: unknown) => {
53
+ calls.push(String(url))
54
+ return {
55
+ ok: init.ok ?? true,
56
+ status: init.ok === false ? 500 : 200,
57
+ json: async () => ({ siteHandlesPhi }),
58
+ } as unknown as Response
59
+ })
60
+ vi.stubGlobal('fetch', impl)
61
+ return { calls, impl }
62
+ }
63
+
64
+ async function mountForm(props: Record<string, unknown> = {}) {
65
+ const wrapper = mount(DcsForm, {
66
+ props: {
67
+ formId: 'contact',
68
+ siteSlug: 'kept',
69
+ apiBase: 'https://api.example.test/api/v1',
70
+ definitionOverride: makeDef(),
71
+ ...props,
72
+ },
73
+ })
74
+ await flushPromises()
75
+ return wrapper
76
+ }
77
+
78
+ beforeEach(() => {
79
+ resetSitePhiPostureCache()
80
+ })
81
+
82
+ afterEach(() => {
83
+ vi.unstubAllGlobals()
84
+ vi.restoreAllMocks()
85
+ resetSitePhiPostureCache()
86
+ })
87
+
88
+ describe('PHI guidance — the two directions', () => {
89
+ it('RENDERS the guidance when the platform reports the tenant PHI-handling', async () => {
90
+ stubCompliance(true)
91
+ const wrapper = await mountForm()
92
+
93
+ const note = wrapper.find(GUIDANCE)
94
+ expect(note.exists()).toBe(true)
95
+ expect(note.text()).toContain(PHI_GUIDANCE_COPY.lead)
96
+ expect(note.text()).toContain(PHI_GUIDANCE_COPY.detail)
97
+ // The copy the chat already ships on these tenants, so the two surfaces read
98
+ // as one product rather than two teams.
99
+ expect(note.text().toLowerCase()).toContain('personal health details')
100
+ })
101
+
102
+ it('renders NOTHING on a non-PHI tenant', async () => {
103
+ stubCompliance(false)
104
+ const wrapper = await mountForm({ siteSlug: 'mi-handyman' })
105
+
106
+ expect(wrapper.find(GUIDANCE).exists()).toBe(false)
107
+ expect(wrapper.text().toLowerCase()).not.toContain('personal health details')
108
+ })
109
+
110
+ it('is vacuous if the flag is ignored — the same mount must differ on the verdict alone', async () => {
111
+ // The kill test. Nothing changes between these two mounts except the boolean
112
+ // the platform returns, so a component that hardcoded either answer fails
113
+ // one of them. Written as one test on purpose: it is the DIFFERENCE that is
114
+ // the assertion.
115
+ stubCompliance(true)
116
+ const phi = await mountForm()
117
+ resetSitePhiPostureCache()
118
+ vi.unstubAllGlobals()
119
+
120
+ stubCompliance(false)
121
+ const nonPhi = await mountForm()
122
+
123
+ expect(phi.find(GUIDANCE).exists()).toBe(true)
124
+ expect(nonPhi.find(GUIDANCE).exists()).toBe(false)
125
+ expect(phi.find(GUIDANCE).exists()).not.toBe(nonPhi.find(GUIDANCE).exists())
126
+ })
127
+ })
128
+
129
+ describe('PHI guidance — placement', () => {
130
+ it('sits immediately above the free-text field, not at the top of the form', async () => {
131
+ stubCompliance(true)
132
+ const wrapper = await mountForm()
133
+
134
+ const fields = wrapper.find('.dcs-form__fields').element
135
+ const children = Array.from(fields.children)
136
+ const noteIndex = children.findIndex((el) =>
137
+ el.hasAttribute('data-form-phi-guidance'),
138
+ )
139
+ const messageIndex = children.findIndex(
140
+ (el) => el.getAttribute('data-form-field-key') === 'message',
141
+ )
142
+
143
+ expect(noteIndex).toBeGreaterThan(0) // NOT parked at the top
144
+ expect(messageIndex).toBe(noteIndex + 1) // directly above the textarea
145
+ })
146
+
147
+ it('falls back to the top when the form/step has no free-text field', async () => {
148
+ stubCompliance(true)
149
+ const wrapper = await mountForm({
150
+ definitionOverride: makeDef({
151
+ fields: [
152
+ { id: 'name', type: 'text', label: 'Name', required: true },
153
+ { id: 'when', type: 'date', label: 'Preferred date' },
154
+ ],
155
+ }),
156
+ })
157
+
158
+ const children = Array.from(wrapper.find('.dcs-form__fields').element.children)
159
+ expect(children[0]?.hasAttribute('data-form-phi-guidance')).toBe(true)
160
+ })
161
+
162
+ it('anchors to a `message`-role field even when it is not a textarea', async () => {
163
+ stubCompliance(true)
164
+ const wrapper = await mountForm({
165
+ definitionOverride: makeDef({
166
+ fields: [
167
+ { id: 'name', type: 'text', label: 'Name', required: true },
168
+ { id: 'note', type: 'text', role: 'message', label: 'How can we help?' },
169
+ ],
170
+ }),
171
+ })
172
+
173
+ const children = Array.from(wrapper.find('.dcs-form__fields').element.children)
174
+ const noteIndex = children.findIndex((el) =>
175
+ el.hasAttribute('data-form-phi-guidance'),
176
+ )
177
+ expect(children[noteIndex + 1]?.getAttribute('data-form-field-key')).toBe('note')
178
+ })
179
+ })
180
+
181
+ describe('the posture read itself', () => {
182
+ it('calls the slug-scoped compliance route', async () => {
183
+ const { calls } = stubCompliance(true)
184
+ await mountForm()
185
+ expect(calls).toContain(
186
+ 'https://api.example.test/api/v1/sites/kept/forms/compliance',
187
+ )
188
+ })
189
+
190
+ it('falls back to the slug-free route when the build has no baked slug', async () => {
191
+ const { calls } = stubCompliance(true)
192
+ await mountForm({ siteSlug: '' })
193
+ // Never the broken `/sites//forms/...` shape (the kimduffhomes.com 405).
194
+ expect(calls).toContain('https://api.example.test/api/v1/forms/compliance')
195
+ expect(calls.some((u) => u.includes('/sites//'))).toBe(false)
196
+ })
197
+
198
+ it('memoizes one verdict per site so N forms on a page cost ONE round trip', async () => {
199
+ const { impl } = stubCompliance(true)
200
+ const opts = { apiBase: 'https://api.example.test/api/v1', siteSlug: 'kept' }
201
+ const [a, b, c] = await Promise.all([
202
+ fetchSiteHandlesPhi(opts),
203
+ fetchSiteHandlesPhi(opts),
204
+ fetchSiteHandlesPhi(opts),
205
+ ])
206
+ expect([a, b, c]).toEqual([true, true, true])
207
+ expect(impl).toHaveBeenCalledTimes(1)
208
+ })
209
+
210
+ it('reports false — and renders nothing — when the endpoint fails', async () => {
211
+ stubCompliance(true, { ok: false })
212
+ const wrapper = await mountForm()
213
+ expect(wrapper.find(GUIDANCE).exists()).toBe(false)
214
+ })
215
+
216
+ it('reports false when the endpoint is unreachable', async () => {
217
+ vi.stubGlobal(
218
+ 'fetch',
219
+ vi.fn(async () => {
220
+ throw new Error('network down')
221
+ }),
222
+ )
223
+ await expect(
224
+ fetchSiteHandlesPhi({ apiBase: 'https://api.example.test/api/v1', siteSlug: 'kept' }),
225
+ ).resolves.toBe(false)
226
+ })
227
+
228
+ it('treats a body without a boolean verdict as NO verdict, never as yes', async () => {
229
+ // A 200 from an SPA shell / proxy interstitial must not be read as "PHI
230
+ // tenant" — the same class of failure C-301/C-310 hardened the POST against.
231
+ vi.stubGlobal(
232
+ 'fetch',
233
+ vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ siteHandlesPhi: 'true' }) }) as unknown as Response),
234
+ )
235
+ await expect(
236
+ fetchSiteHandlesPhi({ apiBase: 'https://api.example.test/api/v1', siteSlug: 'kept' }),
237
+ ).resolves.toBe(false)
238
+ })
239
+ })
@@ -0,0 +1,425 @@
1
+ /**
2
+ * C-310 — a 2xx with valid JSON is NOT proof of storage. Only the endpoint's own
3
+ * receipt is.
4
+ *
5
+ * The gap this closes (Codex cross-model review §c, 2026-07-26): C-301/C-302
6
+ * hardened TRANSPORT parsing — an HTML shell, an empty body, or an unreadable
7
+ * stream all became visible failures. Anything that parsed as JSON still passed,
8
+ * so `{}`, `[]`, `"ok"` and `{"hello":"world"}` were all reported to the visitor
9
+ * as a stored lead. And `204` was exempt from the body check entirely, even
10
+ * though no guarded endpoint contracts `204`.
11
+ */
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
13
+ import { mount, flushPromises } from '@vue/test-utils'
14
+ import DcsForm from '../DcsForm.vue'
15
+ import { submitFormValues } from '../composables/useFormSubmission'
16
+ import {
17
+ isBookingCancellationReceipt,
18
+ isEstimateReceipt,
19
+ isManagedFormReceipt,
20
+ isPasswordlessVerifyReceipt,
21
+ isSiteContactReceipt,
22
+ readExpectedJson,
23
+ } from '../composables/readExpectedJson'
24
+ import type { DcsFormSubmitError, PortalFormDefinition } from '../types'
25
+
26
+ const def: PortalFormDefinition = {
27
+ formId: 'contact',
28
+ submission: { kind: 'lead' },
29
+ fields: [
30
+ { id: 'name', type: 'text', label: 'Name', required: true },
31
+ { id: 'email', type: 'email', label: 'Email', required: true },
32
+ ],
33
+ }
34
+
35
+ /** `202` + `PublicSiteFormSubmissionResponse` — public_site_forms.go:509-524. */
36
+ const MANAGED_FORM_RECEIPT = {
37
+ id: '07438812886145977836-dd5a5baf3835082c',
38
+ status: 'accepted',
39
+ submittedAt: '2026-07-26T14:52:30Z',
40
+ message: 'Thanks — your form has been received.',
41
+ notificationQueued: true,
42
+ }
43
+
44
+ /** `201` + `ContactFormResponse` — contact_form.go:814-823. */
45
+ const SITE_CONTACT_RECEIPT = {
46
+ id: '3f2504e0-4f89-11d3-9a0c-0305e82c3301',
47
+ status: 'submitted',
48
+ submittedAt: '2026-07-26T14:52:30Z',
49
+ message: 'Thank you for reaching out! A member of the team will respond soon.',
50
+ }
51
+
52
+ /** `201` + `mapSiteVisitorEstimateResponse` — revenue_estimate_visitor.go:1419. */
53
+ const ESTIMATE_RECEIPT = {
54
+ id: '01J8Z9QKX2P7B3M4N5R6S7T8U9',
55
+ siteId: 'iron-oak-contractors',
56
+ customerEmail: 'jane@example.com',
57
+ status: 'pending_ai_review',
58
+ }
59
+
60
+ let consoleError: ReturnType<typeof vi.spyOn>
61
+ beforeEach(() => {
62
+ consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
63
+ })
64
+ afterEach(() => {
65
+ consoleError.mockRestore()
66
+ })
67
+
68
+ /**
69
+ * A fetch stub that answers with a FRESH Response per call.
70
+ *
71
+ * It used to `mockResolvedValue` one Response instance, which was only safe
72
+ * while `<DcsForm>` made exactly one request per mount. C-415 added a
73
+ * mount-time GET of the form-compliance posture, so a shared instance had its
74
+ * body consumed by the GET and the submission then failed with "body already
75
+ * read" — a harness artifact, not a product defect. A real transport hands out
76
+ * a new Response per request; so does this now.
77
+ */
78
+ function respond(body: BodyInit | null, init: ResponseInit): typeof fetch {
79
+ return vi.fn().mockImplementation(async () => new Response(body, init)) as unknown as typeof fetch
80
+ }
81
+
82
+ function jsonResponse(body: unknown, status = 200): Response {
83
+ return new Response(JSON.stringify(body), {
84
+ status,
85
+ headers: { 'content-type': 'application/json' },
86
+ })
87
+ }
88
+
89
+ // ───────────────────────────────────────────────────────────────────────────
90
+ // Transport layer: the removed 204 exemption
91
+ // ───────────────────────────────────────────────────────────────────────────
92
+
93
+ describe('C-310: the 204 exemption is gone', () => {
94
+ // KILL-TEST. On pre-C-310 code `readOkBody` returned `null` for a bodyless 204
95
+ // and `submitFormValues` resolved, so this expectation FAILS on the old tree —
96
+ // which is the whole point of asserting it.
97
+ it('a bodyless 204 is a FAILURE, not a quiet success', async () => {
98
+ const fetchImpl = respond(null, { status: 204 })
99
+ await expect(
100
+ submitFormValues({
101
+ apiBase: 'https://api.example.com/api/v1',
102
+ siteSlug: 'kept',
103
+ fetchImpl,
104
+ payload: { formId: 'contact', values: { name: 'Jane' } },
105
+ }),
106
+ ).rejects.toMatchObject({ status: 204, nonJsonResponse: true })
107
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
108
+ })
109
+
110
+ it('the diagnostic explains WHY 204 is no longer trusted', async () => {
111
+ const response = new Response(null, { status: 204 })
112
+ await expect(readExpectedJson(response, isManagedFormReceipt)).rejects.toThrow(
113
+ 'We could not confirm your message was received',
114
+ )
115
+ const logged = String(consoleError.mock.calls[0][0])
116
+ expect(logged).toContain('empty body')
117
+ expect(logged).toContain('204')
118
+ })
119
+
120
+ it('a 204 that DOES carry a JSON receipt is still accepted', async () => {
121
+ // Belt and braces: the removal is about the EMPTY-body exemption, not about
122
+ // banning the status. If some hop ever answers 204 with a real receipt, the
123
+ // receipt is what decides.
124
+ const response = new Response(JSON.stringify(MANAGED_FORM_RECEIPT), {
125
+ status: 200,
126
+ headers: { 'content-type': 'application/json' },
127
+ })
128
+ await expect(readExpectedJson(response, isManagedFormReceipt)).resolves.toEqual(
129
+ MANAGED_FORM_RECEIPT,
130
+ )
131
+ })
132
+ })
133
+
134
+ // ───────────────────────────────────────────────────────────────────────────
135
+ // Receipt layer: valid JSON that proves nothing
136
+ // ───────────────────────────────────────────────────────────────────────────
137
+
138
+ describe('C-310: valid JSON without a receipt is a FAILURE', () => {
139
+ const notReceipts: Array<[string, unknown]> = [
140
+ ['an empty object', {}],
141
+ ['an empty array', []],
142
+ ['a bare JSON string', 'ok'],
143
+ ['a bare JSON number', 1],
144
+ ['JSON null', null],
145
+ ['an unrelated object', { hello: 'world' }],
146
+ ['a receipt missing its id', { status: 'accepted', submittedAt: '2026-07-26T14:52:30Z' }],
147
+ ['a receipt with a blank id', { id: ' ', status: 'accepted', submittedAt: '2026-07-26T14:52:30Z' }],
148
+ ['a receipt with an unknown status', { id: 'S-1', status: 'maybe', submittedAt: '2026-07-26T14:52:30Z' }],
149
+ ['a receipt missing submittedAt', { id: 'S-1', status: 'accepted' }],
150
+ ['a receipt with an unparseable submittedAt', { id: 'S-1', status: 'accepted', submittedAt: 'soon' }],
151
+ // The exact shape the pre-C-310 test suite asserted as a success. It is not
152
+ // a shape the server can produce; testing against it is how a client drifts
153
+ // away from its own API with every test still green.
154
+ ['the invented pre-C-310 test shape', { status: 'accepted', submissionId: 'S-1' }],
155
+ ]
156
+
157
+ for (const [label, body] of notReceipts) {
158
+ it(`rejects ${label}`, async () => {
159
+ const fetchImpl = respond(JSON.stringify(body), {
160
+ status: 202,
161
+ headers: { 'content-type': 'application/json' },
162
+ })
163
+ await expect(
164
+ submitFormValues({
165
+ apiBase: 'https://api.example.com/api/v1',
166
+ siteSlug: 'kept',
167
+ fetchImpl,
168
+ payload: { formId: 'contact', values: { name: 'Jane' } },
169
+ }),
170
+ ).rejects.toMatchObject({ status: 202, unconfirmedReceipt: true, nonJsonResponse: false })
171
+ // TERMINAL: an unconfirmable submission must never be retried, or a lead
172
+ // that WAS stored gets stored twice.
173
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
174
+ })
175
+ }
176
+
177
+ it('accepts the real managed-form receipt', async () => {
178
+ const result = await submitFormValues({
179
+ apiBase: 'https://api.example.com/api/v1',
180
+ siteSlug: 'kept',
181
+ fetchImpl: respond(JSON.stringify(MANAGED_FORM_RECEIPT), {
182
+ status: 202,
183
+ headers: { 'content-type': 'application/json' },
184
+ }),
185
+ payload: { formId: 'contact', values: { name: 'Jane' } },
186
+ })
187
+ expect(result.response).toEqual(MANAGED_FORM_RECEIPT)
188
+ expect(consoleError).not.toHaveBeenCalled()
189
+ })
190
+
191
+ it('separates a receipt failure from a transport failure on the error object', async () => {
192
+ // The two have different remedies: `nonJsonResponse` means "check routing",
193
+ // `unconfirmedReceipt` means "check the endpoint contract".
194
+ const html = respond('<!doctype html><html></html>', {
195
+ status: 200,
196
+ headers: { 'content-type': 'text/html' },
197
+ })
198
+ await expect(
199
+ submitFormValues({
200
+ apiBase: 'https://api.example.com/api/v1',
201
+ siteSlug: 'kept',
202
+ fetchImpl: html,
203
+ payload: { formId: 'contact', values: { name: 'Jane' } },
204
+ }),
205
+ ).rejects.toMatchObject({ nonJsonResponse: true, unconfirmedReceipt: false })
206
+ })
207
+ })
208
+
209
+ // ───────────────────────────────────────────────────────────────────────────
210
+ // C-319: the spam-quarantine shapes are the GENUINE receipts
211
+ // ───────────────────────────────────────────────────────────────────────────
212
+
213
+ describe("C-319: a quarantine response is the route's genuine receipt", () => {
214
+ // C-310 pinned a deliberate WIDENING here: `isLegacySpamOkReceipt` accepted a
215
+ // bare `{"status":"ok"}` because two of the five spam-quarantine paths
216
+ // answered that instead of their endpoint's real receipt, and rejecting it
217
+ // would have shown a visible failure to every quarantined submitter —
218
+ // including an AI false-positive victim whose message really was stored.
219
+ //
220
+ // C-319 removed the cause, so the widening is gone with it. All five
221
+ // quarantine paths now answer the route's genuine success receipt, built by
222
+ // the same writer the success path uses (server spam_receipts.go) from the
223
+ // row C-307 stores. These tests are the inverse of the ones they replace: the
224
+ // legacy shape must now FAIL, and the real quarantine receipts must PASS.
225
+
226
+ it('managed forms: the legacy 202 {"status":"ok"} is no longer a receipt', async () => {
227
+ await expect(
228
+ submitFormValues({
229
+ apiBase: 'https://api.example.com/api/v1',
230
+ siteSlug: 'kept',
231
+ fetchImpl: respond(JSON.stringify({ status: 'ok' }), {
232
+ status: 202,
233
+ headers: { 'content-type': 'application/json' },
234
+ }),
235
+ payload: { formId: 'contact', values: { name: 'Jane' } },
236
+ }),
237
+ ).rejects.toThrow()
238
+ })
239
+
240
+ it('managed forms: a quarantined submission still resolves as success', async () => {
241
+ // What the server now answers a spam-classified managed-form submission
242
+ // with — indistinguishable from a genuine 202, because it IS one.
243
+ const result = await submitFormValues({
244
+ apiBase: 'https://api.example.com/api/v1',
245
+ siteSlug: 'kept',
246
+ fetchImpl: respond(JSON.stringify(MANAGED_FORM_RECEIPT), {
247
+ status: 202,
248
+ headers: { 'content-type': 'application/json' },
249
+ }),
250
+ payload: { formId: 'contact', values: { name: 'Buy cheap SEO' } },
251
+ })
252
+ expect(result.response).toEqual(MANAGED_FORM_RECEIPT)
253
+ expect(consoleError).not.toHaveBeenCalled()
254
+ })
255
+
256
+ it('managed forms: the DcsForm UI shows the success state for a quarantined spammer', async () => {
257
+ const originalFetch = globalThis.fetch
258
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = respond(
259
+ JSON.stringify(MANAGED_FORM_RECEIPT),
260
+ { status: 202, headers: { 'content-type': 'application/json' } },
261
+ )
262
+
263
+ const wrapper = mount(DcsForm, {
264
+ props: { formId: 'contact', siteSlug: 'kept', definitionOverride: def, apiBase: '' },
265
+ })
266
+ await wrapper.find('[data-form-field-key="name"] input').setValue('Buy cheap SEO')
267
+ await wrapper.find('[data-form-field-key="email"] input').setValue('spam@example.com')
268
+ await wrapper.find('form').trigger('submit')
269
+ await flushPromises()
270
+
271
+ expect(wrapper.emitted('submit-error')).toBeUndefined()
272
+ expect(wrapper.emitted('submit-success')?.length).toBe(1)
273
+
274
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
275
+ })
276
+
277
+ it('site contact: the legacy {"status":"ok"} no longer satisfies the predicate', () => {
278
+ expect(isSiteContactReceipt({ status: 'ok' })).toBe(false)
279
+ expect(isManagedFormReceipt({ status: 'ok' })).toBe(false)
280
+ })
281
+
282
+ it('site contact: a quarantined submission answers the genuine 201 receipt', async () => {
283
+ await expect(
284
+ readExpectedJson(jsonResponse(SITE_CONTACT_RECEIPT, 201), isSiteContactReceipt),
285
+ ).resolves.toEqual(SITE_CONTACT_RECEIPT)
286
+ })
287
+
288
+ it('the estimate honeypot decoy stays indistinguishable from a genuine estimate', () => {
289
+ // The honeypot answers a 201 built by the same writer and row constructor a
290
+ // genuine estimate uses, so `id` is a real ULID and the predicate passes.
291
+ // If it did not, a bot could detect the honeypot from the client's behaviour.
292
+ expect(isEstimateReceipt({ ...ESTIMATE_RECEIPT, id: '01J8ZDECOY0000000000000000' })).toBe(true)
293
+ })
294
+ })
295
+
296
+ // ───────────────────────────────────────────────────────────────────────────
297
+ // Per-endpoint predicates (exported for the site backports)
298
+ // ───────────────────────────────────────────────────────────────────────────
299
+
300
+ describe('C-310: per-endpoint receipt predicates', () => {
301
+ it('site contact requires an id and a contract status', async () => {
302
+ await expect(
303
+ readExpectedJson(jsonResponse(SITE_CONTACT_RECEIPT, 201), isSiteContactReceipt),
304
+ ).resolves.toEqual(SITE_CONTACT_RECEIPT)
305
+ expect(isSiteContactReceipt({ id: 'c-1', status: 'processing' })).toBe(true)
306
+ expect(isSiteContactReceipt({ id: 'c-1', status: 'accepted' })).toBe(false)
307
+ expect(isSiteContactReceipt({ status: 'submitted' })).toBe(false)
308
+ })
309
+
310
+ it('estimate requires the returned estimate id', async () => {
311
+ await expect(
312
+ readExpectedJson(jsonResponse(ESTIMATE_RECEIPT, 201), isEstimateReceipt),
313
+ ).resolves.toEqual(ESTIMATE_RECEIPT)
314
+ expect(isEstimateReceipt({ siteId: 'iron-oak-contractors' })).toBe(false)
315
+ expect(isEstimateReceipt({ id: '' })).toBe(false)
316
+ })
317
+
318
+ it('passwordless verification requires success === true', async () => {
319
+ await expect(
320
+ readExpectedJson(
321
+ jsonResponse({ success: true, redirectTo: 'https://example.com/account' }),
322
+ isPasswordlessVerifyReceipt,
323
+ ),
324
+ ).resolves.toMatchObject({ success: true })
325
+ expect(isPasswordlessVerifyReceipt({ success: false })).toBe(false)
326
+ expect(isPasswordlessVerifyReceipt({ success: 'true' })).toBe(false)
327
+ expect(isPasswordlessVerifyReceipt({ redirectTo: '/account' })).toBe(false)
328
+ })
329
+
330
+ it('booking cancellation requires success === true', async () => {
331
+ await expect(
332
+ readExpectedJson(
333
+ jsonResponse({ success: true, message: 'Booking cancelled successfully' }),
334
+ isBookingCancellationReceipt,
335
+ ),
336
+ ).resolves.toMatchObject({ success: true })
337
+ expect(isBookingCancellationReceipt({ success: false })).toBe(false)
338
+ expect(isBookingCancellationReceipt({ message: 'Booking cancelled successfully' })).toBe(false)
339
+ })
340
+ })
341
+
342
+ // ───────────────────────────────────────────────────────────────────────────
343
+ // A submission POST must never follow a redirect
344
+ // ───────────────────────────────────────────────────────────────────────────
345
+
346
+ describe('C-310: submission POSTs never follow redirects', () => {
347
+ it("sets redirect:'error' on the JSON POST", async () => {
348
+ const fetchImpl = respond(JSON.stringify(MANAGED_FORM_RECEIPT), {
349
+ status: 202,
350
+ headers: { 'content-type': 'application/json' },
351
+ })
352
+ await submitFormValues({
353
+ apiBase: 'https://api.example.com/api/v1',
354
+ siteSlug: 'kept',
355
+ fetchImpl,
356
+ payload: { formId: 'contact', values: { name: 'Jane' } },
357
+ })
358
+ const [, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
359
+ expect((init as RequestInit).redirect).toBe('error')
360
+ })
361
+
362
+ it("sets redirect:'error' on the multipart POST", async () => {
363
+ const fetchImpl = respond(JSON.stringify(MANAGED_FORM_RECEIPT), {
364
+ status: 202,
365
+ headers: { 'content-type': 'application/json' },
366
+ })
367
+ await submitFormValues({
368
+ apiBase: 'https://api.example.com/api/v1',
369
+ siteSlug: 'kept',
370
+ fetchImpl,
371
+ payload: {
372
+ formId: 'contact',
373
+ values: { photo: new File(['x'], 'x.png', { type: 'image/png' }) },
374
+ },
375
+ })
376
+ const [, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
377
+ expect((init as RequestInit).redirect).toBe('error')
378
+ })
379
+
380
+ it('surfaces a rejected redirect as a visible failure', async () => {
381
+ // A 301/302 also rewrites the POST into a GET, so following one silently
382
+ // drops the submission body. `redirect:'error'` makes fetch reject instead.
383
+ const fetchImpl = vi
384
+ .fn()
385
+ .mockRejectedValue(new TypeError('Failed to fetch')) as unknown as typeof fetch
386
+ await expect(
387
+ submitFormValues({
388
+ apiBase: 'https://api.example.com/api/v1',
389
+ siteSlug: 'kept',
390
+ fetchImpl,
391
+ retries: 0,
392
+ payload: { formId: 'contact', values: { name: 'Jane' } },
393
+ }),
394
+ ).rejects.toMatchObject({ payload: { formId: 'contact' } })
395
+ })
396
+ })
397
+
398
+ // ───────────────────────────────────────────────────────────────────────────
399
+ // UI level
400
+ // ───────────────────────────────────────────────────────────────────────────
401
+
402
+ describe('C-310: the DcsForm UI never shows success without a receipt', () => {
403
+ it('a 202 {} is a visible failure, not a success state', async () => {
404
+ const originalFetch = globalThis.fetch
405
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = respond('{}', {
406
+ status: 202,
407
+ headers: { 'content-type': 'application/json' },
408
+ })
409
+
410
+ const wrapper = mount(DcsForm, {
411
+ props: { formId: 'contact', siteSlug: 'kept', definitionOverride: def, apiBase: '' },
412
+ })
413
+ await wrapper.find('[data-form-field-key="name"] input').setValue('Jane')
414
+ await wrapper.find('[data-form-field-key="email"] input').setValue('jane@example.com')
415
+ await wrapper.find('form').trigger('submit')
416
+ await flushPromises()
417
+
418
+ expect(wrapper.emitted('submit-success')).toBeUndefined()
419
+ const emitted = wrapper.emitted('submit-error')?.[0]?.[0] as DcsFormSubmitError
420
+ expect(emitted.unconfirmedReceipt).toBe(true)
421
+ expect(wrapper.text()).toContain('We could not confirm your message was received')
422
+
423
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
424
+ })
425
+ })