@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.
- package/README.md +42 -3
- package/dist/composables/readExpectedJson.d.ts +158 -0
- package/dist/composables/useFormSubmission.d.ts +5 -1
- package/dist/composables/useSitePhiPosture.d.ts +71 -0
- package/dist/fields/DcsFormDate.vue.d.ts +2 -2
- package/dist/fields/DcsFormSelect.vue.d.ts +2 -2
- package/dist/fields/DcsFormText.vue.d.ts +2 -2
- package/dist/fields/DcsFormTextarea.vue.d.ts +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1014 -486
- 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 +98 -14
- package/src/__tests__/missing-form-error-surfacing.test.ts +124 -0
- 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 +46 -5
- package/src/__tests__/visible-if.test.ts +17 -4
- package/src/composables/readExpectedJson.ts +360 -0
- package/src/composables/useFormSubmission.ts +66 -13
- 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
|
@@ -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
|
-
<
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
+
})
|