@duffcloudservices/site-forms 0.4.2 → 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.4.2",
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
@@ -6,8 +6,13 @@ import type {
6
6
  PortalFormDefinition,
7
7
  PortalFormField,
8
8
  } from './types'
9
+ import { useUiSound } from '@dcs/ui/sound'
9
10
  import { useDcsForm } from './composables/useDcsForm'
10
11
  import { submitFormValues } from './composables/useFormSubmission'
12
+ import {
13
+ PHI_GUIDANCE_COPY,
14
+ fetchSiteHandlesPhi,
15
+ } from './composables/useSitePhiPosture'
11
16
  import { warnIfInvalid } from './schema/validate'
12
17
  import { loadFormDefinitions } from './loaders/yaml'
13
18
 
@@ -91,6 +96,17 @@ const safeDefinition = computed<PortalFormDefinition>(
91
96
 
92
97
  const form = useDcsForm({ definition: safeDefinition.value })
93
98
 
99
+ // First-party form-success chime (dcs-product-sound). Synthesis lives entirely in
100
+ // the shared engine — this component owns no AudioContext. Calling useUiSound() here
101
+ // also binds the engine's gesture-unlock listeners, so the user's submit click
102
+ // (pointerdown/keydown) resumes the AudioContext synchronously in the gesture;
103
+ // the chime that fires after the async submit resolves then plays on a running
104
+ // context (autoplay-safe by construction). It stays SILENT by default: customer
105
+ // sites are silent-by-default (they never seed sound via configureSoundDefaults),
106
+ // and the engine additionally hard-silences inside the portal editor iframe. The
107
+ // chime activates only once a site opts into sound.
108
+ const { play: playUiSound } = useUiSound()
109
+
94
110
  // If the resolved definition changes (e.g. preview iframe updates),
95
111
  // re-create derived state by resetting.
96
112
  watch(
@@ -98,13 +114,31 @@ watch(
98
114
  () => form.reset(),
99
115
  )
100
116
 
101
- const apiBase = computed(
102
- () =>
117
+ const apiBase = computed(() => {
118
+ const configured =
103
119
  props.apiBase ??
104
120
  (import.meta as unknown as { env?: { VITE_DCS_PUBLIC_API?: string } }).env
105
121
  ?.VITE_DCS_PUBLIC_API ??
106
- '',
107
- )
122
+ ''
123
+ if (configured) return configured
124
+ return defaultPublicApiBase()
125
+ })
126
+
127
+ /**
128
+ * Fallback API base when neither the `api-base` prop nor VITE_DCS_PUBLIC_API
129
+ * is set. A customer apex does not route `/api/v1/sites/*` (an empty base
130
+ * produced a same-origin POST the static host rejects with a 405), so default
131
+ * to the shared public API host; localhost keeps the relative base for the
132
+ * dev proxy.
133
+ */
134
+ function defaultPublicApiBase(): string {
135
+ if (typeof window === 'undefined') return ''
136
+ const host = window.location.hostname.toLowerCase()
137
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1') {
138
+ return '/api/v1'
139
+ }
140
+ return 'https://api.duffcloudservices.com/api/v1'
141
+ }
108
142
 
109
143
  const resolvedSiteSlug = computed(
110
144
  () =>
@@ -150,6 +184,41 @@ function fieldComponent(field: PortalFormField) {
150
184
  }
151
185
  }
152
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
+
153
222
  const formEl = ref<HTMLFormElement | null>(null)
154
223
 
155
224
  defineExpose({
@@ -182,6 +251,9 @@ async function onSubmit(e: Event): Promise<void> {
182
251
  payload,
183
252
  })
184
253
  form.submitted.value = true
254
+ // Success confirmed by the server (never on optimistic fire): play only after
255
+ // the submission resolves. Inert unless the site has enabled sound.
256
+ playUiSound('notification.success')
185
257
  emit('submit-success', result)
186
258
  } catch (err) {
187
259
  const e2 = err as DcsFormSubmitError
@@ -248,17 +320,29 @@ onMounted(() => {
248
320
  </div>
249
321
 
250
322
  <div class="dcs-form__fields">
251
- <component
252
- :is="fieldComponent(field)"
253
- v-for="field in form.visibleFields.value"
323
+ <template
324
+ v-for="(field, index) in form.visibleFields.value"
254
325
  :key="field.id"
255
- :field="field"
256
- :attachment-policy="safeDefinition.attachmentPolicy"
257
- :model-value="form.values[field.id]"
258
- :error="form.errors.value[field.id]"
259
- @update:model-value="(v: unknown) => form.setValue(field.id, v)"
260
- @blur="form.touch(field.id)"
261
- />
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>
262
346
  </div>
263
347
 
264
348
  <div v-if="form.submitError.value" class="dcs-form__submit-error" role="alert">
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import DcsForm from '../DcsForm.vue'
4
+ import type { PortalFormDefinition } from '../types'
5
+
6
+ /**
7
+ * C-155 verification — the Lamphere W6 "silent submit failure".
8
+ *
9
+ * Original finding (.docs/archive/plans/lamphere-reunion-redesign/plan.md,
10
+ * Learnings): prod `Features` lacked `contactForm` and the `PortalSiteForms`
11
+ * row was never applied — "the form silently accepted-then-reset with no
12
+ * user-visible error".
13
+ *
14
+ * Today both of those misconfigurations make the server's public submission
15
+ * handler return 404 "Form not found" (server/internal/handlers/
16
+ * public_site_forms.go — ErrSiteNotFound / ErrFormNotFound /
17
+ * ErrFormFeatureDisabled all map to http.StatusNotFound). This suite proves
18
+ * the client surfaces that 404 loudly: the submit error renders in
19
+ * `.dcs-form__submit-error` with role="alert", the form does NOT flip to the
20
+ * success state, and the user's field values are NOT reset.
21
+ */
22
+
23
+ const def: PortalFormDefinition = {
24
+ formId: 'contact',
25
+ submission: { kind: 'lead' },
26
+ fields: [
27
+ { id: 'name', type: 'text', label: 'Name', required: true },
28
+ { id: 'email', type: 'email', label: 'Email', required: true },
29
+ ],
30
+ }
31
+
32
+ const originalFetch = globalThis.fetch
33
+
34
+ afterEach(() => {
35
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
36
+ })
37
+
38
+ describe('missing/misconfigured server form (Lamphere W6 repro)', () => {
39
+ it('surfaces a visible role=alert error on 404 and does not reset or fake success', async () => {
40
+ // The Lamphere shape: the site repo HAS a valid local YAML definition
41
+ // (the form renders and validates fine), but the server side was never
42
+ // provisioned — POST returns 404 "Form not found".
43
+ const fetchMock = vi.fn().mockResolvedValue({
44
+ ok: false,
45
+ status: 404,
46
+ text: async () => '{"error":"Form not found"}',
47
+ })
48
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch =
49
+ fetchMock as unknown as typeof fetch
50
+
51
+ const wrapper = mount(DcsForm, {
52
+ props: {
53
+ formId: 'contact',
54
+ siteSlug: 'lamphere-class-2005',
55
+ definitionOverride: def,
56
+ apiBase: 'https://api.example.com',
57
+ },
58
+ })
59
+
60
+ await wrapper.find('[data-form-field-key="name"] input').setValue('Jane')
61
+ await wrapper
62
+ .find('[data-form-field-key="email"] input')
63
+ .setValue('jane@example.com')
64
+
65
+ await wrapper.find('form').trigger('submit')
66
+ await flushPromises()
67
+
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)
75
+
76
+ // The failure is loud: a rendered, role="alert" error block.
77
+ const alert = wrapper.find('.dcs-form__submit-error')
78
+ expect(alert.exists()).toBe(true)
79
+ expect(alert.attributes('role')).toBe('alert')
80
+ expect(alert.text()).toContain('Submission failed (404)')
81
+
82
+ // And machine-visible: submit-error emitted with the 404, no submit-success.
83
+ const errEvents = wrapper.emitted('submit-error')
84
+ expect(errEvents?.length).toBe(1)
85
+ expect((errEvents![0][0] as { status?: number }).status).toBe(404)
86
+ expect(wrapper.emitted('submit-success')).toBeUndefined()
87
+
88
+ // No fake success state ("accepted") ...
89
+ expect(wrapper.find('.dcs-form--success').exists()).toBe(false)
90
+
91
+ // ... and no reset: the user's values survive the failed submit.
92
+ expect(
93
+ (wrapper.find('[data-form-field-key="name"] input').element as HTMLInputElement)
94
+ .value,
95
+ ).toBe('Jane')
96
+ expect(
97
+ (wrapper.find('[data-form-field-key="email"] input').element as HTMLInputElement)
98
+ .value,
99
+ ).toBe('jane@example.com')
100
+
101
+ // The submit button is re-enabled for a retry, still on the form view.
102
+ expect(wrapper.find('form').exists()).toBe(true)
103
+ expect(
104
+ (wrapper.find('.dcs-form__btn--submit').element as HTMLButtonElement).disabled,
105
+ ).toBe(false)
106
+ })
107
+
108
+ it('renders a visible not-configured state when the local definition is missing', async () => {
109
+ // The other misconfiguration direction: no local YAML at all. The
110
+ // component renders an explicit missing state instead of an empty form.
111
+ const wrapper = mount(DcsForm, {
112
+ props: {
113
+ formId: 'ghost-form',
114
+ siteSlug: 'lamphere-class-2005',
115
+ formsModules: {},
116
+ },
117
+ })
118
+
119
+ const missing = wrapper.find('.dcs-form--missing')
120
+ expect(missing.exists()).toBe(true)
121
+ expect(missing.text()).toContain('is not configured')
122
+ expect(wrapper.find('form').exists()).toBe(false)
123
+ })
124
+ })
@@ -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
+ })