@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.
@@ -1 +1 @@
1
- .dcs-form{display:grid;gap:1.5rem;width:100%}.dcs-form__header{display:grid;gap:.5rem}.dcs-form__title{margin:0;font-size:clamp(1.875rem,2vw + 1.25rem,2.5rem);font-weight:700;line-height:1.1}.dcs-form__description{margin:0;font-size:1rem;line-height:1.6}.dcs-form__progress{font-size:.95rem}.dcs-form__fields{display:grid;gap:1rem}.dcs-form-field{display:grid;gap:.5rem}.dcs-form-field__label{display:inline-flex;align-items:center;gap:.2rem;font-size:.95rem;font-weight:600;line-height:1.4}.dcs-form-field__required{font-weight:700}.dcs-form-input{width:100%;min-height:3rem;padding:.75rem .95rem;font:inherit;line-height:1.5;color:inherit;background:#fffffff5;border:1px solid rgba(15,23,42,.16);border-radius:.875rem;box-shadow:0 1px 2px #0f172a14;transition:border-color .15s ease,box-shadow .15s ease,background-color .15s ease}.dcs-form-input::placeholder{color:#475569bf}.dcs-form-input:hover{border-color:#2563eb61}.dcs-form-input:focus,.dcs-form-input:focus-visible{outline:none;border-color:#2563ebbf;box-shadow:0 0 0 4px #3b82f62e}.dcs-form-input[aria-invalid=true]{border-color:#dc2626b8;box-shadow:0 0 0 4px #f871712e}.dcs-form-textarea{min-height:10rem;resize:vertical}.dcs-form-file{min-height:3.25rem;padding:.5rem .75rem;cursor:pointer}.dcs-form-file::file-selector-button{margin:0 .85rem 0 0;border:0;border-radius:9999px;padding:.65rem 1rem;font:inherit;font-weight:700;line-height:1;color:#fff;background:linear-gradient(135deg,#1d4ed8,#2563eb);box-shadow:0 8px 18px #2563eb33;cursor:pointer;transition:transform .15s ease,box-shadow .15s ease,opacity .15s ease}.dcs-form-file:hover::file-selector-button{transform:translateY(-1px);box-shadow:0 10px 22px #2563eb42}.dcs-form-file:disabled,.dcs-form-file:disabled::file-selector-button{cursor:not-allowed;opacity:.7}.dcs-form-file__status{margin:-.15rem 0 0;font-size:.78rem;font-weight:700;letter-spacing:.03em;color:#475569d1;text-transform:uppercase}.dcs-form-file-preview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:.85rem}.dcs-form-file-preview{position:relative;overflow:hidden;display:grid;gap:.65rem;padding:.65rem;border:1px solid rgba(15,23,42,.12);border-radius:1rem;background:linear-gradient(180deg,#fffffff0,#f8fafce6),radial-gradient(circle at top left,rgba(37,99,235,.1),transparent 40%);box-shadow:0 12px 28px #0f172a1a}.dcs-form-file-preview__image,.dcs-form-file-preview__placeholder{width:100%;aspect-ratio:4 / 3;border-radius:.75rem;background:#0f172a14}.dcs-form-file-preview__image{object-fit:cover}.dcs-form-file-preview__placeholder{display:grid;place-items:center;font-size:.8rem;font-weight:800;letter-spacing:.08em;color:#475569cc;text-transform:uppercase}.dcs-form-file-preview__meta{min-width:0;display:grid;gap:.1rem}.dcs-form-file-preview__name{overflow:hidden;font-size:.85rem;font-weight:700;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.dcs-form-file-preview__size{font-size:.75rem;color:#475569db}.dcs-form-file-preview__remove{appearance:none;justify-self:start;border:1px solid rgba(185,28,28,.2);border-radius:9999px;padding:.4rem .75rem;font:inherit;font-size:.75rem;font-weight:800;line-height:1;color:#991b1b;background:#fef2f2f2;cursor:pointer;transition:transform .15s ease,border-color .15s ease,background-color .15s ease}.dcs-form-file-preview__remove:hover{transform:translateY(-1px);border-color:#b91c1c6b;background:#fff}.dcs-form-select{appearance:none}.dcs-form-field__help,.dcs-form-field__error,.dcs-form__submit-error{margin:0;font-size:.875rem;line-height:1.5}.dcs-form-field__error,.dcs-form__submit-error{color:#b91c1c}.dcs-form__submit-error{padding:.85rem 1rem;border:1px solid rgba(220,38,38,.28);border-radius:.875rem;background:#fef2f2eb}.dcs-form__actions{display:flex;flex-wrap:wrap;gap:.75rem}.dcs-form__btn{appearance:none;border:0;border-radius:9999px;padding:.85rem 1.5rem;font:inherit;font-weight:700;line-height:1;cursor:pointer;transition:transform .15s ease,box-shadow .15s ease,opacity .15s ease}.dcs-form__btn:hover{transform:translateY(-1px)}.dcs-form__btn:focus,.dcs-form__btn:focus-visible{outline:none;box-shadow:0 0 0 4px #3b82f638}.dcs-form__btn:disabled{opacity:.7;cursor:not-allowed;transform:none}.dcs-form__btn--submit,.dcs-form__btn--next{color:#fff;background:linear-gradient(135deg,#1d4ed8,#2563eb);box-shadow:0 10px 24px #2563eb38}.dcs-form__btn--prev{color:#0f172a;background:#ffffffeb;box-shadow:0 8px 20px #0f172a1f}.dcs-form-checkbox-group,.dcs-form-radio-group{display:grid;gap:.75rem}.dcs-form-checkbox,.dcs-form-radio,.dcs-form-checkbox-single{display:flex;align-items:flex-start;gap:.65rem}.dcs-form-checkbox-single input,.dcs-form-checkbox input,.dcs-form-radio input{margin-top:.2rem}.dcs-form-section{display:grid;gap:.4rem}.dcs-form-section__heading,.dcs-form-section__help,.dcs-form--missing p,.dcs-form--success p{margin:0}@media(min-width:768px){.dcs-form__fields{grid-template-columns:repeat(2,minmax(0,1fr));gap:1.25rem}.dcs-form-field--width-half{grid-column:span 1}.dcs-form-field--width-full,.dcs-form-field--width-auto,.dcs-form-field--textarea,.dcs-form-field--checkbox,.dcs-form-field--checkbox-group,.dcs-form-field--radio,.dcs-form-field--section-heading,.dcs-form-field--html-block,.dcs-form-field--file{grid-column:1 / -1}}
1
+ .dcs-form{display:grid;gap:1.5rem;width:100%}.dcs-form__header{display:grid;gap:.5rem}.dcs-form__title{margin:0;font-size:clamp(1.875rem,2vw + 1.25rem,2.5rem);font-weight:700;line-height:1.1}.dcs-form__description{margin:0;font-size:1rem;line-height:1.6}.dcs-form__progress{font-size:.95rem}.dcs-form__fields{display:grid;gap:1rem}.dcs-form__phi-guidance{margin:0;padding:.7rem .9rem;font-size:.9rem;line-height:1.55;color:inherit;background:#0f172a0b;border:1px solid rgba(15,23,42,.1);border-left:3px solid rgba(37,99,235,.6);border-radius:.625rem}.dcs-form__phi-guidance strong{font-weight:650}.dcs-form-field{display:grid;gap:.5rem}.dcs-form-field__label{display:inline-flex;align-items:center;gap:.2rem;font-size:.95rem;font-weight:600;line-height:1.4}.dcs-form-field__required{font-weight:700}.dcs-form-input{width:100%;min-height:3rem;padding:.75rem .95rem;font:inherit;line-height:1.5;color:inherit;background:#fffffff5;border:1px solid rgba(15,23,42,.16);border-radius:.875rem;box-shadow:0 1px 2px #0f172a14;transition:border-color .15s ease,box-shadow .15s ease,background-color .15s ease}.dcs-form-input::placeholder{color:#475569bf}.dcs-form-input:hover{border-color:#2563eb61}.dcs-form-input:focus,.dcs-form-input:focus-visible{outline:none;border-color:#2563ebbf;box-shadow:0 0 0 4px #3b82f62e}.dcs-form-input[aria-invalid=true]{border-color:#dc2626b8;box-shadow:0 0 0 4px #f871712e}.dcs-form-textarea{min-height:10rem;resize:vertical}.dcs-form-file{min-height:3.25rem;padding:.5rem .75rem;cursor:pointer}.dcs-form-file::file-selector-button{margin:0 .85rem 0 0;border:0;border-radius:9999px;padding:.65rem 1rem;font:inherit;font-weight:700;line-height:1;color:#fff;background:linear-gradient(135deg,#1d4ed8,#2563eb);box-shadow:0 8px 18px #2563eb33;cursor:pointer;transition:transform .15s ease,box-shadow .15s ease,opacity .15s ease}.dcs-form-file:hover::file-selector-button{transform:translateY(-1px);box-shadow:0 10px 22px #2563eb42}.dcs-form-file:disabled,.dcs-form-file:disabled::file-selector-button{cursor:not-allowed;opacity:.7}.dcs-form-file__status{margin:-.15rem 0 0;font-size:.78rem;font-weight:700;letter-spacing:.03em;color:#475569d1;text-transform:uppercase}.dcs-form-file-preview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:.85rem}.dcs-form-file-preview{position:relative;overflow:hidden;display:grid;gap:.65rem;padding:.65rem;border:1px solid rgba(15,23,42,.12);border-radius:1rem;background:linear-gradient(180deg,#fffffff0,#f8fafce6),radial-gradient(circle at top left,rgba(37,99,235,.1),transparent 40%);box-shadow:0 12px 28px #0f172a1a}.dcs-form-file-preview__image,.dcs-form-file-preview__placeholder{width:100%;aspect-ratio:4 / 3;border-radius:.75rem;background:#0f172a14}.dcs-form-file-preview__image{object-fit:cover}.dcs-form-file-preview__placeholder{display:grid;place-items:center;font-size:.8rem;font-weight:800;letter-spacing:.08em;color:#475569cc;text-transform:uppercase}.dcs-form-file-preview__meta{min-width:0;display:grid;gap:.1rem}.dcs-form-file-preview__name{overflow:hidden;font-size:.85rem;font-weight:700;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.dcs-form-file-preview__size{font-size:.75rem;color:#475569db}.dcs-form-file-preview__remove{appearance:none;justify-self:start;border:1px solid rgba(185,28,28,.2);border-radius:9999px;padding:.4rem .75rem;font:inherit;font-size:.75rem;font-weight:800;line-height:1;color:#991b1b;background:#fef2f2f2;cursor:pointer;transition:transform .15s ease,border-color .15s ease,background-color .15s ease}.dcs-form-file-preview__remove:hover{transform:translateY(-1px);border-color:#b91c1c6b;background:#fff}.dcs-form-select{appearance:none}.dcs-form-field__help,.dcs-form-field__error,.dcs-form__submit-error{margin:0;font-size:.875rem;line-height:1.5}.dcs-form-field__error,.dcs-form__submit-error{color:#b91c1c}.dcs-form__submit-error{padding:.85rem 1rem;border:1px solid rgba(220,38,38,.28);border-radius:.875rem;background:#fef2f2eb}.dcs-form__actions{display:flex;flex-wrap:wrap;gap:.75rem}.dcs-form__btn{appearance:none;border:0;border-radius:9999px;padding:.85rem 1.5rem;font:inherit;font-weight:700;line-height:1;cursor:pointer;transition:transform .15s ease,box-shadow .15s ease,opacity .15s ease}.dcs-form__btn:hover{transform:translateY(-1px)}.dcs-form__btn:focus,.dcs-form__btn:focus-visible{outline:none;box-shadow:0 0 0 4px #3b82f638}.dcs-form__btn:disabled{opacity:.7;cursor:not-allowed;transform:none}.dcs-form__btn--submit,.dcs-form__btn--next{color:#fff;background:linear-gradient(135deg,#1d4ed8,#2563eb);box-shadow:0 10px 24px #2563eb38}.dcs-form__btn--prev{color:#0f172a;background:#ffffffeb;box-shadow:0 8px 20px #0f172a1f}.dcs-form-checkbox-group,.dcs-form-radio-group{display:grid;gap:.75rem}.dcs-form-checkbox,.dcs-form-radio,.dcs-form-checkbox-single{display:flex;align-items:flex-start;gap:.65rem}.dcs-form-checkbox-single input,.dcs-form-checkbox input,.dcs-form-radio input{margin-top:.2rem}.dcs-form-section{display:grid;gap:.4rem}.dcs-form-section__heading,.dcs-form-section__help,.dcs-form--missing p,.dcs-form--success p{margin:0}@media(min-width:768px){.dcs-form__fields{grid-template-columns:repeat(2,minmax(0,1fr));gap:1.25rem}.dcs-form-field--width-half{grid-column:span 1}.dcs-form__phi-guidance,.dcs-form-field--width-full,.dcs-form-field--width-auto,.dcs-form-field--textarea,.dcs-form-field--checkbox,.dcs-form-field--checkbox-group,.dcs-form-field--radio,.dcs-form-field--section-heading,.dcs-form-field--html-block,.dcs-form-field--file{grid-column:1 / -1}}
package/dist/types.d.ts CHANGED
@@ -113,4 +113,20 @@ export interface DcsFormSubmitError {
113
113
  payload: DcsFormSubmitPayload;
114
114
  error: Error;
115
115
  status?: number;
116
+ /**
117
+ * True when the server answered 2xx but the body was NOT JSON — i.e. the POST
118
+ * was answered by something that is not the DCS API (SPA shell, proxy
119
+ * interstitial, gateway page) and the submission was NOT stored. Additive and
120
+ * optional: the pre-existing error contract is unchanged (C-301).
121
+ */
122
+ nonJsonResponse?: boolean;
123
+ /**
124
+ * True when the server answered 2xx with valid JSON that is NOT the endpoint's
125
+ * receipt (C-310). Distinct from `nonJsonResponse` because the remedies differ:
126
+ * that one means "the POST was answered by something else — check routing",
127
+ * this one means "the API answered, but with nothing that proves a row exists
128
+ * — check the endpoint contract against the receipt predicate". Additive and
129
+ * optional; the pre-existing error contract is unchanged.
130
+ */
131
+ unconfirmedReceipt?: boolean;
116
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/site-forms",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Shared <DcsForm/> runtime for DCS customer sites — renders managed form definitions from .dcs/forms/<formId>.yaml",
5
5
  "type": "module",
6
6
  "files": [
package/src/DcsForm.vue CHANGED
@@ -9,6 +9,10 @@ import type {
9
9
  import { useUiSound } from '@dcs/ui/sound'
10
10
  import { useDcsForm } from './composables/useDcsForm'
11
11
  import { submitFormValues } from './composables/useFormSubmission'
12
+ import {
13
+ PHI_GUIDANCE_COPY,
14
+ fetchSiteHandlesPhi,
15
+ } from './composables/useSitePhiPosture'
12
16
  import { warnIfInvalid } from './schema/validate'
13
17
  import { loadFormDefinitions } from './loaders/yaml'
14
18
 
@@ -180,6 +184,41 @@ function fieldComponent(field: PortalFormField) {
180
184
  }
181
185
  }
182
186
 
187
+ // ── PHI guidance (C-415) ────────────────────────────────────────────────────
188
+ // Default-ON for HIPAA-mode tenants (Q-393(b)); there is no per-site opt-in
189
+ // prop, because a site-local switch could disagree with the tenant's real HIPAA
190
+ // posture and the whole point of the C-352 predicate is that exactly one thing
191
+ // decides. Starts false and flips only when the platform says so, so nothing
192
+ // renders on the 11 non-PHI sites in the fleet.
193
+ const siteHandlesPhi = ref(false)
194
+
195
+ const showPhiGuidance = computed(
196
+ () => siteHandlesPhi.value && !!definition.value,
197
+ )
198
+
199
+ /**
200
+ * Where the guidance sits: immediately above the first FREE-TEXT field the
201
+ * visitor will actually type prose into. Guidance parked at the top of a long
202
+ * form is guidance nobody reads; this puts it in the line of sight of the box
203
+ * that can carry PHI (on KEPT's contact form, the required `message` textarea).
204
+ * Falls back to the top of the visible fields when a form/step has no free-text
205
+ * field — a HIPAA-mode tenant's structured intake is still health information.
206
+ */
207
+ const phiGuidanceIndex = computed(() => {
208
+ const idx = form.visibleFields.value.findIndex(
209
+ (f) =>
210
+ f.type === 'textarea' || f.role === 'message' || f.role === 'summary',
211
+ )
212
+ return idx >= 0 ? idx : 0
213
+ })
214
+
215
+ onMounted(async () => {
216
+ siteHandlesPhi.value = await fetchSiteHandlesPhi({
217
+ apiBase: apiBase.value,
218
+ siteSlug: resolvedSiteSlug.value,
219
+ })
220
+ })
221
+
183
222
  const formEl = ref<HTMLFormElement | null>(null)
184
223
 
185
224
  defineExpose({
@@ -281,17 +320,29 @@ onMounted(() => {
281
320
  </div>
282
321
 
283
322
  <div class="dcs-form__fields">
284
- <component
285
- :is="fieldComponent(field)"
286
- v-for="field in form.visibleFields.value"
323
+ <template
324
+ v-for="(field, index) in form.visibleFields.value"
287
325
  :key="field.id"
288
- :field="field"
289
- :attachment-policy="safeDefinition.attachmentPolicy"
290
- :model-value="form.values[field.id]"
291
- :error="form.errors.value[field.id]"
292
- @update:model-value="(v: unknown) => form.setValue(field.id, v)"
293
- @blur="form.touch(field.id)"
294
- />
326
+ >
327
+ <p
328
+ v-if="showPhiGuidance && index === phiGuidanceIndex"
329
+ class="dcs-form__phi-guidance"
330
+ data-form-phi-guidance
331
+ role="note"
332
+ >
333
+ <strong>{{ PHI_GUIDANCE_COPY.lead }}</strong>
334
+ {{ PHI_GUIDANCE_COPY.detail }}
335
+ </p>
336
+ <component
337
+ :is="fieldComponent(field)"
338
+ :field="field"
339
+ :attachment-policy="safeDefinition.attachmentPolicy"
340
+ :model-value="form.values[field.id]"
341
+ :error="form.errors.value[field.id]"
342
+ @update:model-value="(v: unknown) => form.setValue(field.id, v)"
343
+ @blur="form.touch(field.id)"
344
+ />
345
+ </template>
295
346
  </div>
296
347
 
297
348
  <div v-if="form.submitError.value" class="dcs-form__submit-error" role="alert">
@@ -65,8 +65,13 @@ describe('missing/misconfigured server form (Lamphere W6 repro)', () => {
65
65
  await wrapper.find('form').trigger('submit')
66
66
  await flushPromises()
67
67
 
68
- // 4xx must not be retried — one POST only.
69
- expect(fetchMock).toHaveBeenCalledTimes(1)
68
+ // 4xx must not be retried — one POST only. Counted over POSTs specifically:
69
+ // the mount also issues the C-415 form-compliance GET, and conflating the
70
+ // two would let a genuine retry regression hide behind the posture read.
71
+ const posts = fetchMock.mock.calls.filter(
72
+ ([, init]) => (init as RequestInit | undefined)?.method === 'POST',
73
+ )
74
+ expect(posts).toHaveLength(1)
70
75
 
71
76
  // The failure is loud: a rendered, role="alert" error block.
72
77
  const alert = wrapper.find('.dcs-form__submit-error')
@@ -0,0 +1,282 @@
1
+ /**
2
+ * C-301 — a 2xx response whose body is NOT JSON must surface as a VISIBLE
3
+ * FAILURE, never as a successful submission.
4
+ *
5
+ * The defect: `safeJson()` returned `null` on any parse failure, so a POST that
6
+ * landed on a static host's SPA shell (HTTP 200 `text/html`) was indistinguishable
7
+ * from a quiet success. `<DcsForm>` showed the success state, played the success
8
+ * chime and emitted `submit-success` — while NOTHING was stored. Silent lead loss.
9
+ *
10
+ * These tests use real `Response` objects (not hand-rolled doubles) so the
11
+ * content-type header and the single-use body stream behave exactly as they do in
12
+ * a browser.
13
+ */
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
15
+ import { mount, flushPromises } from '@vue/test-utils'
16
+ import DcsForm from '../DcsForm.vue'
17
+ import { submitFormValues } from '../composables/useFormSubmission'
18
+ import type { DcsFormSubmitError, PortalFormDefinition } from '../types'
19
+
20
+ const def: PortalFormDefinition = {
21
+ formId: 'contact',
22
+ submission: { kind: 'lead' },
23
+ fields: [
24
+ { id: 'name', type: 'text', label: 'Name', required: true },
25
+ { id: 'email', type: 'email', label: 'Email', required: true },
26
+ ],
27
+ }
28
+
29
+ const SPA_SHELL =
30
+ '<!DOCTYPE html>\n<html lang="en">\n <head><title>Site</title></head>\n <body><div id="app"></div></body>\n</html>'
31
+
32
+ /**
33
+ * The REAL managed-form success shape: `202` + `PublicSiteFormSubmissionResponse`
34
+ * (types.gen.ts:14845), written at
35
+ * server/internal/handlers/public_site_forms.go:509-524.
36
+ */
37
+ const MANAGED_FORM_RECEIPT = {
38
+ id: '07438812886145977836-dd5a5baf3835082c',
39
+ status: 'accepted',
40
+ submittedAt: '2026-07-26T14:52:30Z',
41
+ message: 'Thanks — your form has been received.',
42
+ notificationQueued: true,
43
+ }
44
+
45
+ let consoleError: ReturnType<typeof vi.spyOn>
46
+ beforeEach(() => {
47
+ consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
48
+ })
49
+ afterEach(() => {
50
+ consoleError.mockRestore()
51
+ })
52
+
53
+ function respond(body: BodyInit | null, init: ResponseInit): typeof fetch {
54
+ return vi.fn().mockResolvedValue(new Response(body, init)) as unknown as typeof fetch
55
+ }
56
+
57
+ describe('C-301: non-JSON 2xx body is a failure, not a success', () => {
58
+ it('rejects a 200 text/html SPA-shell body (the lead-loss shape)', async () => {
59
+ const fetchImpl = respond(SPA_SHELL, {
60
+ status: 200,
61
+ headers: { 'content-type': 'text/html; charset=utf-8' },
62
+ })
63
+
64
+ await expect(
65
+ submitFormValues({
66
+ apiBase: '',
67
+ siteSlug: 'kept',
68
+ fetchImpl,
69
+ payload: { formId: 'contact', values: { name: 'Jane' } },
70
+ }),
71
+ ).rejects.toMatchObject({
72
+ status: 200,
73
+ nonJsonResponse: true,
74
+ })
75
+
76
+ // TERMINAL: never retried, so a lead can never be double-stored.
77
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
78
+ // The diagnostic names the real cause for whoever reads the console.
79
+ expect(consoleError).toHaveBeenCalledTimes(1)
80
+ expect(String(consoleError.mock.calls[0][0])).toContain('did not reach the DCS API')
81
+ })
82
+
83
+ it('surfaces the failure in the DcsForm UI and never shows the success state', async () => {
84
+ const originalFetch = globalThis.fetch
85
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = respond(SPA_SHELL, {
86
+ status: 200,
87
+ headers: { 'content-type': 'text/html' },
88
+ })
89
+
90
+ const wrapper = mount(DcsForm, {
91
+ props: { formId: 'contact', siteSlug: 'kept', definitionOverride: def, apiBase: '' },
92
+ })
93
+ await wrapper.find('[data-form-field-key="name"] input').setValue('Jane')
94
+ await wrapper.find('[data-form-field-key="email"] input').setValue('jane@example.com')
95
+ await wrapper.find('form').trigger('submit')
96
+ await flushPromises()
97
+
98
+ expect(wrapper.emitted('submit-success')).toBeUndefined()
99
+ expect(wrapper.emitted('submit-error')?.length).toBe(1)
100
+ const emitted = wrapper.emitted('submit-error')?.[0]?.[0] as DcsFormSubmitError
101
+ expect(emitted.nonJsonResponse).toBe(true)
102
+ // Visitor-visible failure text, and no success confirmation anywhere.
103
+ expect(wrapper.text()).toContain('We could not confirm your message was received')
104
+ expect(wrapper.html()).not.toContain('data-form-success')
105
+
106
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
107
+ })
108
+
109
+ it('rejects a 200 text/plain gateway message', async () => {
110
+ await expect(
111
+ submitFormValues({
112
+ apiBase: 'https://api.example.com/api/v1',
113
+ siteSlug: '',
114
+ fetchImpl: respond('Unable to resolve site from request', {
115
+ status: 200,
116
+ headers: { 'content-type': 'text/plain' },
117
+ }),
118
+ payload: { formId: 'contact', values: { name: 'Jane' } },
119
+ }),
120
+ ).rejects.toMatchObject({ nonJsonResponse: true })
121
+ })
122
+
123
+ it('rejects an HTML body even when the content-type claims JSON', async () => {
124
+ await expect(
125
+ submitFormValues({
126
+ apiBase: 'https://api.example.com/api/v1',
127
+ siteSlug: 'kept',
128
+ fetchImpl: respond(SPA_SHELL, {
129
+ status: 200,
130
+ headers: { 'content-type': 'application/json' },
131
+ }),
132
+ payload: { formId: 'contact', values: { name: 'Jane' } },
133
+ }),
134
+ ).rejects.toMatchObject({ nonJsonResponse: true })
135
+ })
136
+
137
+ // C-310: these two previously asserted `{status:'accepted', submissionId:'S-1'}`
138
+ // and `{status:'accepted'}` — neither is a shape the server can produce. The
139
+ // real contract is `PublicSiteFormSubmissionResponse` (id + status +
140
+ // submittedAt, types.gen.ts:14845), written at public_site_forms.go:509.
141
+ // Testing against an invented shape is how a client drifts away from its own
142
+ // API without any test going red.
143
+ it('UNCHANGED: a REAL managed-form receipt still resolves with the parsed response', async () => {
144
+ const receipt = MANAGED_FORM_RECEIPT
145
+ const result = await submitFormValues({
146
+ apiBase: 'https://api.example.com/api/v1',
147
+ siteSlug: 'kept',
148
+ fetchImpl: respond(JSON.stringify(receipt), {
149
+ status: 202,
150
+ headers: { 'content-type': 'application/json' },
151
+ }),
152
+ payload: { formId: 'contact', values: { name: 'Jane' } },
153
+ })
154
+ expect(result.response).toEqual(receipt)
155
+ expect(consoleError).not.toHaveBeenCalled()
156
+ })
157
+
158
+ it('UNCHANGED: a JSON receipt with no content-type header still resolves', async () => {
159
+ const result = await submitFormValues({
160
+ apiBase: 'https://api.example.com/api/v1',
161
+ siteSlug: 'kept',
162
+ fetchImpl: vi.fn().mockResolvedValue({
163
+ ok: true,
164
+ status: 202,
165
+ text: async () => JSON.stringify(MANAGED_FORM_RECEIPT),
166
+ headers: { get: () => null },
167
+ }) as unknown as typeof fetch,
168
+ payload: { formId: 'contact', values: { name: 'Jane' } },
169
+ })
170
+ expect(result.response).toEqual(MANAGED_FORM_RECEIPT)
171
+ })
172
+
173
+ // C-310 KILL-TEST for the removed 204 exemption. This case INVERTS: it used to
174
+ // assert "an empty 204 body still resolves as a quiet success" and passes on
175
+ // pre-C-310 code only in that inverted form. `204` proves the RESPONDER chose
176
+ // to send nothing; it proves nothing about whether the DCS API ran. All five
177
+ // guarded endpoints return a JSON receipt, so a bodyless 204 on a submission
178
+ // POST means something other than the API answered.
179
+ it('C-310: an empty 204 is a FAILURE — no guarded endpoint contracts 204', async () => {
180
+ const fetchImpl = respond(null, { status: 204 })
181
+ await expect(
182
+ submitFormValues({
183
+ apiBase: 'https://api.example.com/api/v1',
184
+ siteSlug: 'kept',
185
+ fetchImpl,
186
+ payload: { formId: 'contact', values: { name: 'Jane' } },
187
+ }),
188
+ ).rejects.toMatchObject({ status: 204, nonJsonResponse: true })
189
+ // TERMINAL: an unconfirmable submission must never be retried.
190
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
191
+ expect(String(consoleError.mock.calls[0][0])).toContain('empty body')
192
+ })
193
+
194
+ // C-302 adversarial review (2026-07-26), caught before 0.6.0 was published.
195
+ // This case previously asserted "empty 200 = quiet success" — the inverse of
196
+ // what it should be. The C-301 sweep found misrouted fleet POSTs answering
197
+ // with EMPTY bodies, so that was the one lead-loss shape this guard was blind
198
+ // to. Emptiness is now trusted only when the STATUS says so (204).
199
+ it('C-302: an empty 200 body is a FAILURE — no DCS endpoint returns one', async () => {
200
+ const fetchImpl = respond('', { status: 200, headers: { 'content-type': 'text/html' } })
201
+ await expect(
202
+ submitFormValues({
203
+ apiBase: 'https://api.example.com/api/v1',
204
+ siteSlug: 'kept',
205
+ fetchImpl,
206
+ payload: { formId: 'contact', values: { name: 'Jane' } },
207
+ }),
208
+ ).rejects.toMatchObject({ status: 200, nonJsonResponse: true })
209
+ // TERMINAL: an unconfirmable submission must never be retried.
210
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
211
+ })
212
+
213
+ it('C-302: a body-read rejection is a FAILURE, never a claimed success', async () => {
214
+ // Connection dropped after headers, mid-body — the canonical flaky-mobile
215
+ // case. The old guard swallowed this into '' and reported "sent".
216
+ const fetchImpl = vi.fn().mockResolvedValue({
217
+ ok: true,
218
+ status: 200,
219
+ text: async () => {
220
+ throw new TypeError('network error')
221
+ },
222
+ headers: { get: () => 'application/json' },
223
+ }) as unknown as typeof fetch
224
+
225
+ await expect(
226
+ submitFormValues({
227
+ apiBase: 'https://api.example.com/api/v1',
228
+ siteSlug: 'kept',
229
+ fetchImpl,
230
+ payload: { formId: 'contact', values: { name: 'Jane' } },
231
+ }),
232
+ ).rejects.toMatchObject({ status: 200, nonJsonResponse: true })
233
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
234
+ })
235
+
236
+ it('UNCHANGED: a 4xx JSON error keeps the existing error contract (no retry)', async () => {
237
+ const fetchImpl = respond(JSON.stringify({ message: 'Missing required fields: email' }), {
238
+ status: 400,
239
+ headers: { 'content-type': 'application/json' },
240
+ })
241
+ await expect(
242
+ submitFormValues({
243
+ apiBase: 'https://api.example.com/api/v1',
244
+ siteSlug: 'kept',
245
+ fetchImpl,
246
+ payload: { formId: 'contact', values: {} },
247
+ }),
248
+ ).rejects.toMatchObject({ status: 400 })
249
+
250
+ try {
251
+ await submitFormValues({
252
+ apiBase: 'https://api.example.com/api/v1',
253
+ siteSlug: 'kept',
254
+ fetchImpl: respond(JSON.stringify({ message: 'nope' }), {
255
+ status: 400,
256
+ headers: { 'content-type': 'application/json' },
257
+ }),
258
+ payload: { formId: 'contact', values: {} },
259
+ })
260
+ expect.unreachable('should have thrown')
261
+ } catch (e) {
262
+ const err = e as DcsFormSubmitError
263
+ expect(err.error.message).toContain('Submission failed (400)')
264
+ expect(err.nonJsonResponse).toBeUndefined()
265
+ }
266
+ })
267
+
268
+ it('UNCHANGED: a 5xx still retries and then fails', async () => {
269
+ const fetchImpl = vi
270
+ .fn()
271
+ .mockResolvedValue(new Response('boom', { status: 503 })) as unknown as typeof fetch
272
+ await expect(
273
+ submitFormValues({
274
+ apiBase: 'https://api.example.com/api/v1',
275
+ siteSlug: 'kept',
276
+ fetchImpl,
277
+ payload: { formId: 'contact', values: { name: 'Jane' } },
278
+ }),
279
+ ).rejects.toMatchObject({ status: 503 })
280
+ expect(fetchImpl).toHaveBeenCalledTimes(2)
281
+ })
282
+ })