Add unit/component tests for proclamation admin forms

Server tests cover load content stripping per role and upsert/delete
role enforcement. Client tests verify field rendering, pre-fill on
edit, content visibility per role, and submit button dirty-state.

Switched selects from bind:value/value to selected attribute pattern
to work around Svelte 5 select value reactivity in test env.
This commit is contained in:
NeilClack
2026-07-07 22:27:19 -05:00
parent 9ddad9d834
commit f7d255b744
3 changed files with 318 additions and 8 deletions
@@ -108,14 +108,17 @@
<select
id="field-solfeggioFrequencyId"
name="solfeggioFrequencyId"
bind:value={solfeggioFrequencyId}
onchange={(e) => solfeggioFrequencyId = (e.target as HTMLSelectElement).value}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.solfeggioFrequencyId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Frequency --</option>
{#each relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
<option value={opt.value}>{opt.label}</option>
<option
value={opt.value}
selected={String(opt.value) === solfeggioFrequencyId}
>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.solfeggioFrequencyId}
@@ -133,7 +136,6 @@
<select
id="field-parentCategoryId"
name="parentCategoryId"
value={parentCategoryId}
onchange={handleParentChange}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.parentCategoryId
? 'border-red-400'
@@ -141,7 +143,10 @@
>
<option value="">-- Select Category --</option>
{#each parentOptions as cat (String(cat.id))}
<option value={cat.id}>{cat.name}</option>
<option
value={cat.id}
selected={String(cat.id) === parentCategoryId}
>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.parentCategoryId}
@@ -159,14 +164,17 @@
<select
id="field-categoryId"
name="categoryId"
bind:value={categoryId}
onchange={(e) => categoryId = (e.target as HTMLSelectElement).value}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.categoryId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Sub-Category --</option>
{#each childOptions as cat (String(cat.id))}
<option value={cat.id}>{cat.name}</option>
<option
value={cat.id}
selected={String(cat.id) === categoryId}
>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.categoryId}
@@ -184,14 +192,17 @@
<select
id="field-subscriptionLevelId"
name="subscriptionLevelId"
bind:value={subscriptionLevelId}
onchange={(e) => subscriptionLevelId = (e.target as HTMLSelectElement).value}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.subscriptionLevelId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Level --</option>
{#each relationOptionsData.subscriptionLevelId ?? [] as opt (String(opt.value))}
<option value={opt.value}>{opt.label}</option>
<option
value={opt.value}
selected={String(opt.value) === subscriptionLevelId}
>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.subscriptionLevelId}
@@ -0,0 +1,164 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
vi.mock('$app/forms', () => ({
enhance: () => () => {}
}));
vi.mock('$lib/components/tiptap/TipTap.svelte', () => ({
default: ($anchor: Element) => {
const div = document.createElement('div');
div.setAttribute('data-testid', 'tiptap-mock');
$anchor.replaceWith(div);
}
}));
import ProclamationForm from './ProclamationForm.svelte';
const relationOptionsData = {
solfeggioFrequencyId: [
{ label: 'UT - 396', value: 1 },
{ label: 'RE - 417', value: 2 }
],
subscriptionLevelId: [
{ label: 'Free', value: 1 },
{ label: 'Premium', value: 2 }
]
};
const categories = [
{ id: 1, name: 'Healing', parentId: null },
{ id: 2, name: 'Manifestation', parentId: null },
{ id: 3, name: 'Physical Healing', parentId: 1 },
{ id: 4, name: 'Emotional Healing', parentId: 1 }
];
describe('ProclamationForm', () => {
it('renders all basic fields in create mode', async () => {
render(ProclamationForm, {
relationOptionsData,
categories,
isSuper: true
});
await expect
.element(page.getByRole('textbox', { name: 'Name' }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Frequency$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Category$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Sub-Category$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Subscription Level$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Save' }))
.toBeInTheDocument();
});
it('pre-fills name input when editing an existing record', async () => {
render(ProclamationForm, {
record: {
id: 'proc-1',
name: 'Test Proclamation',
solfeggioFrequencyId: 2,
subscriptionLevelId: 2,
categoryId: 3,
parentCategoryId: 1
},
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await expect
.element(page.getByRole('textbox', { name: 'Name' }))
.toHaveValue('Test Proclamation');
});
it('pre-fills select values when editing an existing record', async () => {
render(ProclamationForm, {
record: {
id: 'proc-1',
name: 'Test Proclamation',
solfeggioFrequencyId: 2,
subscriptionLevelId: 2,
categoryId: 3,
parentCategoryId: 1
},
relationOptionsData,
categories,
isSuper: true
});
const freqEl = (await page
.getByRole('combobox', { name: /^Frequency$/ })
.element()) as HTMLSelectElement;
const levelEl = (await page
.getByRole('combobox', { name: /^Subscription Level$/ })
.element()) as HTMLSelectElement;
expect(freqEl.value).toBe('2');
expect(levelEl.value).toBe('2');
});
it('shows content field when isSuper is true', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test', content: '<p>Secret</p>' },
relationOptionsData,
categories,
isSuper: true
});
await expect.element(page.getByText('Content')).toBeInTheDocument();
await expect.element(page.getByTestId('tiptap-mock')).toBeInTheDocument();
});
it('hides content field when isSuper is false', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test', content: '<p>Secret</p>' },
relationOptionsData,
categories,
isSuper: false
});
await expect.element(page.getByText('Content')).not.toBeInTheDocument();
});
it('disables submit button when editing and not dirty', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test' },
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await expect
.element(page.getByRole('button', { name: 'Update' }))
.toBeDisabled();
});
it('enables submit button when editing and user modifies a field', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test' },
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await page.getByRole('textbox', { name: 'Name' }).fill('New Name');
await expect
.element(page.getByRole('button', { name: 'Update' }))
.toBeEnabled();
});
});
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser, createFormRequest } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamation: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
},
proclamationCategory: {
findMany: vi.fn()
}
}
}));
vi.mock('$lib/svelte-admin/crud', () => ({
loadRecords: vi.fn(),
deleteRecord: vi.fn(),
superFormErrorsToFieldErrors: vi.fn(() => ({ error: '', fieldErrors: {} }))
}));
import { prisma } from '$lib/db';
import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud';
import { load, actions } from './+page.server';
const mockLoadRecords = loadRecords as ReturnType<typeof vi.fn>;
const mockDeleteRecord = deleteRecord as ReturnType<typeof vi.fn>;
const mockSuperFormErrorsToFieldErrors = superFormErrorsToFieldErrors as ReturnType<typeof vi.fn>;
const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
const mockFindUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
const mockCreate = prisma.proclamation.create as ReturnType<typeof vi.fn>;
const mockUpdate = prisma.proclamation.update as ReturnType<typeof vi.fn>;
describe('proclamations admin page server', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('load', () => {
const recordsWithContent = [
{ id: '1', name: 'A', content: '<p>secret</p>' },
{ id: '2', name: 'B', content: '<p>secret2</p>' }
];
const relationOptionsData = {};
const categories = [{ id: 1, name: 'Healing', parentId: null }];
it('strips content for non-Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const result = await load({ locals } as any);
expect(result).toEqual({
records: [
{ id: '1', name: 'A' },
{ id: '2', name: 'B' }
],
relationOptionsData,
categories
});
});
it('keeps content for Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await load({ locals } as any);
expect(result).toEqual({
records: recordsWithContent,
relationOptionsData,
categories
});
});
});
describe('upsert action', () => {
it('rejects unauthenticated requests', async () => {
const locals = createLocals();
const req = createFormRequest({ name: 'test' });
const result = await actions.upsert({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'Unauthorized' });
});
it('rejects non-Super users', async () => {
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const req = createFormRequest({ name: 'test' });
const result = await actions.upsert({ request: req, locals } as any);
expect(result).toEqual({
success: false,
error: 'Only Super admins can edit proclamations'
});
});
});
describe('delete action', () => {
it('rejects non-Super users', async () => {
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const req = new Request('http://localhost', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: '1' })
});
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({
success: false,
error: 'Only Super admins can delete proclamations'
});
});
it('delegates to deleteRecord for Super users', async () => {
mockDeleteRecord.mockResolvedValue({ success: true });
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const req = new Request('http://localhost', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: '1' })
});
const result = await actions.delete({ request: req, locals } as any);
expect(mockDeleteRecord).toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
});
});