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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user