@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.
- package/README.md +42 -3
- package/dist/composables/readExpectedJson.d.ts +158 -0
- package/dist/composables/useSitePhiPosture.d.ts +71 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +753 -588
- package/dist/index.js.map +1 -1
- package/dist/site-forms.css +1 -1
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
- package/src/DcsForm.vue +61 -10
- package/src/__tests__/missing-form-error-surfacing.test.ts +7 -2
- package/src/__tests__/non-json-response.test.ts +282 -0
- package/src/__tests__/phi-guidance.test.ts +239 -0
- package/src/__tests__/receipt-validation.test.ts +425 -0
- package/src/__tests__/submission.test.ts +23 -6
- package/src/__tests__/visible-if.test.ts +17 -4
- package/src/composables/readExpectedJson.ts +360 -0
- package/src/composables/useFormSubmission.ts +55 -9
- package/src/composables/useSitePhiPosture.ts +123 -0
- package/src/index.ts +29 -0
- package/src/style.css +25 -0
- package/src/types.ts +16 -0
package/dist/site-forms.css
CHANGED
|
@@ -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
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
|
-
<
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
+
})
|