PromptVault/tests/js/stubs/inertia.js

159 lines
5.3 KiB
JavaScript
Raw Permalink Normal View History

2026-10-26 20:12:16 +00:00
/**
* Test double for `@inertiajs/vue3` (see SPECS.md §9).
*
* Implements just the Inertia surface PromptVault uses — `Link`, `Head`,
* `router`, `useForm`, `usePage` — and records every interaction on
* `inertiaMock` so tests can assert against it.
*
* Register in each test file that mounts an Inertia-dependent component
* (adjust the relative path to this file):
*
* vi.mock('@inertiajs/vue3', async () => import('../stubs/inertia.js'));
*
* Behavior notes:
* - `router.*` calls are recorded as `{ method, url, data, options }` in
* `inertiaMock.routerCalls`.
* - `useForm` returns a reactive form whose `post`/`put` apply `transform()`
* (if set) and record into `inertiaMock.formSubmissions`.
* - Submissions complete synchronously by default. Set
* `inertiaMock.deferSubmissions = true` to inspect the form while
* `processing === true`, then call `resolvePendingSubmissions()`.
* - Set `inertiaMock.failNext = true` to make the next form submission fail:
* the stub populates `form.errors` and `page.props.errors` (mirroring
* Inertia's validation-error behavior) and invokes the `onError` callback,
* which is where the component's `form.clearErrors()` runs.
*/
import { defineComponent, h, reactive } from 'vue';
export const inertiaMock = {
/** Recorded `router.*` calls: `{ method, url, data, options }`. */
routerCalls: [],
/** Recorded form submissions: `{ method, url, data, options }`. */
formSubmissions: [],
/** Every form created via `useForm()` (for direct assertions). */
forms: [],
/** `Head` props recorded per mount: `{ title }`. */
headCalls: [],
/** The shared Inertia page object returned by `usePage()`. */
page: reactive({ props: { errors: {}, visit: { url: '/', fullUrl: '/' } } }),
/** When true, the next submission fails (fires the `onError` callback). */
failNext: false,
/** When true, submissions stay pending until `resolvePendingSubmissions()`. */
deferSubmissions: false,
/** Pending submission completions (internal bookkeeping). */
pending: [],
};
export function resetInertiaMock() {
inertiaMock.routerCalls.length = 0;
inertiaMock.formSubmissions.length = 0;
inertiaMock.forms.length = 0;
inertiaMock.headCalls.length = 0;
inertiaMock.page.props.errors = {};
inertiaMock.failNext = false;
inertiaMock.deferSubmissions = false;
inertiaMock.pending.length = 0;
}
/** Complete every deferred form submission (runs `onSuccess`/`onError`). */
export function resolvePendingSubmissions() {
const pending = [...inertiaMock.pending];
inertiaMock.pending.length = 0;
for (const finish of pending) {
finish();
}
}
const record = (method, url, data, options) =>
inertiaMock.routerCalls.push({ method, url, data, options });
export const router = {
get: (url, data = {}, options = {}) => record('get', url, data, options),
post: (url, data = {}, options = {}) => record('post', url, data, options),
put: (url, data = {}, options = {}) => record('put', url, data, options),
delete: (url, data = {}, options = {}) => record('delete', url, data, options),
reload: (options = {}) => record('reload', '', {}, options),
};
/** Renders a plain anchor so tests can inspect `href`/`class`. */
export const Link = defineComponent({
name: 'Link',
props: { href: { type: String, required: true } },
setup(props, { slots, attrs }) {
return () =>
h('a', { ...attrs, href: props.href, 'data-inertia': 'true' }, slots.default ? slots.default() : []);
},
});
/** No-op page-title component; records the title for assertions. */
export const Head = defineComponent({
name: 'Head',
props: { title: { type: String, default: null } },
setup(props) {
inertiaMock.headCalls.push({ title: props.title ?? null });
return () => null;
},
});
export function usePage() {
return inertiaMock.page;
}
export function useForm(initial = {}) {
let transform = null;
const submit = (method, url, options = {}) => {
const payload = transform ? transform() : form;
const data = {};
for (const key of Object.keys(initial)) {
data[key] = payload[key] !== undefined ? payload[key] : form[key];
}
form.processing = true;
inertiaMock.formSubmissions.push({ method, url, data, options });
const finish = () => {
if (inertiaMock.failNext) {
inertiaMock.failNext = false;
form.errors = { name: ['The server rejected the submission.'] };
inertiaMock.page.props.errors = form.errors;
options.onError?.();
} else {
options.onSuccess?.({ data });
}
form.processing = false;
};
if (inertiaMock.deferSubmissions) {
inertiaMock.pending.push(finish);
return;
}
finish();
};
const form = reactive({
...initial,
processing: false,
errors: {},
transform(fn) {
transform = fn;
},
clearErrors() {
form.errors = {};
},
put(url, options = {}) {
submit('put', url, options);
},
post(url, options = {}) {
submit('post', url, options);
},
});
inertiaMock.forms.push(form);
return form;
}