test(invites): import real +page.server module via vi.mock env
Replace hand-copied load/action replicas with direct imports of the real module. Mock $env/dynamic/private so the tests cover the actual production code paths, not a duplicate that can drift. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,159 +1,71 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
// Test the load and create action logic in isolation without importing the actual module
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
// (which depends on SvelteKit env and $types at runtime).
|
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
||||||
// We replicate the exact logic here to test the branching behaviour.
|
}));
|
||||||
|
|
||||||
const API_URL = 'http://localhost:8080';
|
import { load, actions } from './+page.server';
|
||||||
|
|
||||||
interface InviteListItem {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
id: string;
|
type AnyFetch = (...args: any[]) => any;
|
||||||
code: string;
|
|
||||||
displayCode: string;
|
|
||||||
label?: string;
|
|
||||||
useCount: number;
|
|
||||||
maxUses?: number;
|
|
||||||
expiresAt?: string;
|
|
||||||
revoked: boolean;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
shareableUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserGroup {
|
function mockResponse(ok: boolean, body: unknown, status = 200) {
|
||||||
id: string;
|
return {
|
||||||
name: string;
|
ok,
|
||||||
permissions: string[];
|
status,
|
||||||
}
|
json: async () => body,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
interface MockResponse {
|
headers: new Headers({ 'content-type': 'application/json' })
|
||||||
ok: boolean;
|
} as unknown as Response;
|
||||||
status?: number;
|
|
||||||
json: () => Promise<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFn(
|
|
||||||
status: string,
|
|
||||||
fetchImpl: (url: string) => Promise<MockResponse>
|
|
||||||
): Promise<{
|
|
||||||
invites: InviteListItem[];
|
|
||||||
status: string;
|
|
||||||
loadError: string | null;
|
|
||||||
groups: UserGroup[];
|
|
||||||
groupsLoadError: string | null;
|
|
||||||
}> {
|
|
||||||
const [invitesRes, groupsRes] = await Promise.all([
|
|
||||||
fetchImpl(`${API_URL}/api/invites?status=${encodeURIComponent(status)}`),
|
|
||||||
fetchImpl(`${API_URL}/api/groups`)
|
|
||||||
]);
|
|
||||||
|
|
||||||
let invites: InviteListItem[] = [];
|
|
||||||
let loadError: string | null = null;
|
|
||||||
if (!invitesRes.ok) {
|
|
||||||
const body = (await invitesRes.json()) as { code?: string } | null;
|
|
||||||
loadError = body?.code ?? 'INTERNAL_ERROR';
|
|
||||||
} else {
|
|
||||||
invites = (await invitesRes.json()) as InviteListItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let groups: UserGroup[] = [];
|
|
||||||
let groupsLoadError: string | null = null;
|
|
||||||
if (!groupsRes.ok) {
|
|
||||||
const body = (await groupsRes.json()) as { code?: string } | null;
|
|
||||||
groupsLoadError = body?.code ?? 'INTERNAL_ERROR';
|
|
||||||
} else {
|
|
||||||
const raw = (await groupsRes.json()) as UserGroup[];
|
|
||||||
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { invites, status, loadError, groups, groupsLoadError };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createActionFn(
|
|
||||||
formData: FormData,
|
|
||||||
fetchImpl: (url: string, init: RequestInit) => Promise<MockResponse>
|
|
||||||
): Promise<{ ok: boolean; body: unknown }> {
|
|
||||||
const label = (formData.get('label') as string) || undefined;
|
|
||||||
const maxUsesRaw = formData.get('maxUses') as string;
|
|
||||||
const maxUses = maxUsesRaw ? parseInt(maxUsesRaw, 10) : undefined;
|
|
||||||
const prefillFirstName = (formData.get('prefillFirstName') as string) || undefined;
|
|
||||||
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
|
||||||
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
|
||||||
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
|
||||||
const groupIds = formData.getAll('groupIds') as string[];
|
|
||||||
|
|
||||||
const res = await fetchImpl(`${API_URL}/api/invites`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
label,
|
|
||||||
maxUses,
|
|
||||||
prefillFirstName,
|
|
||||||
prefillLastName,
|
|
||||||
prefillEmail,
|
|
||||||
expiresAt,
|
|
||||||
groupIds
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await res.json();
|
|
||||||
return { ok: res.ok, body };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('admin/invites load()', () => {
|
describe('admin/invites load()', () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const mockFetch = vi.fn<AnyFetch>();
|
||||||
const mockFetch = vi.fn<any>();
|
|
||||||
|
|
||||||
beforeEach(() => mockFetch.mockReset());
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
function event(status = 'active') {
|
||||||
|
return {
|
||||||
|
url: new URL(`http://localhost/admin/invites?status=${status}`),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
it('returns groups array alongside invites when both succeed', async () => {
|
it('returns groups array alongside invites when both succeed', async () => {
|
||||||
const invites: InviteListItem[] = [];
|
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||||
const groups: UserGroup[] = [
|
mockResponse(true, [
|
||||||
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
||||||
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
||||||
];
|
])
|
||||||
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce({ ok: true, json: async () => invites })
|
|
||||||
.mockResolvedValueOnce({ ok: true, json: async () => groups });
|
|
||||||
|
|
||||||
const result = await loadFn(
|
|
||||||
'active',
|
|
||||||
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const result = await load(event());
|
||||||
|
|
||||||
expect(result.groups).toHaveLength(2);
|
expect(result.groups).toHaveLength(2);
|
||||||
expect(result.groupsLoadError).toBeNull();
|
expect(result.groupsLoadError).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns groups sorted alphabetically by name', async () => {
|
it('returns groups sorted alphabetically by name', async () => {
|
||||||
const groups: UserGroup[] = [
|
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||||
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
mockResponse(true, [
|
||||||
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
||||||
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
||||||
];
|
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
||||||
|
])
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
|
||||||
.mockResolvedValueOnce({ ok: true, json: async () => groups });
|
|
||||||
|
|
||||||
const result = await loadFn(
|
|
||||||
'active',
|
|
||||||
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const result = await load(event());
|
||||||
|
|
||||||
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'FORBIDDEN' }) });
|
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
||||||
|
|
||||||
const result = await loadFn(
|
const result = await load(event());
|
||||||
'active',
|
|
||||||
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.groups).toEqual([]);
|
expect(result.groups).toEqual([]);
|
||||||
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
||||||
@@ -161,23 +73,20 @@ describe('admin/invites load()', () => {
|
|||||||
|
|
||||||
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
.mockResolvedValueOnce({ ok: false, json: async () => null });
|
.mockResolvedValueOnce(mockResponse(false, null, 500));
|
||||||
|
|
||||||
const result = await loadFn(
|
const result = await load(event());
|
||||||
'active',
|
|
||||||
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
.mockResolvedValueOnce({ ok: true, json: async () => [] });
|
.mockResolvedValueOnce(mockResponse(true, []));
|
||||||
|
|
||||||
await loadFn('active', mockFetch as unknown as (url: string) => Promise<MockResponse>);
|
await load(event());
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
|
||||||
@@ -186,25 +95,32 @@ describe('admin/invites load()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('admin/invites create action', () => {
|
describe('admin/invites create action', () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const mockFetch = vi.fn<AnyFetch>();
|
||||||
const mockFetch = vi.fn<any>();
|
|
||||||
|
|
||||||
beforeEach(() => mockFetch.mockReset());
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
const successBody = {
|
||||||
|
id: 'inv-1',
|
||||||
|
code: 'ABCDE12345',
|
||||||
|
displayCode: 'ABCDE-12345',
|
||||||
|
status: 'active',
|
||||||
|
revoked: false,
|
||||||
|
useCount: 0,
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
shareableUrl: 'http://localhost/register?code=ABCDE12345'
|
||||||
|
};
|
||||||
|
|
||||||
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('groupIds', 'g-1');
|
fd.append('groupIds', 'g-1');
|
||||||
fd.append('groupIds', 'g-2');
|
fd.append('groupIds', 'g-2');
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
await actions.create({
|
||||||
ok: true,
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
json: async () => ({ id: 'inv-1', code: 'ABCDE12345' })
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
});
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
await createActionFn(
|
|
||||||
fd,
|
|
||||||
mockFetch as unknown as (url: string, init: RequestInit) => Promise<MockResponse>
|
|
||||||
);
|
|
||||||
|
|
||||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
const sent = JSON.parse(init.body as string);
|
const sent = JSON.parse(init.body as string);
|
||||||
@@ -213,16 +129,13 @@ describe('admin/invites create action', () => {
|
|||||||
|
|
||||||
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
await actions.create({
|
||||||
ok: true,
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
json: async () => ({ id: 'inv-1', code: 'ABCDE12345' })
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
});
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
await createActionFn(
|
|
||||||
fd,
|
|
||||||
mockFetch as unknown as (url: string, init: RequestInit) => Promise<MockResponse>
|
|
||||||
);
|
|
||||||
|
|
||||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
const sent = JSON.parse(init.body as string);
|
const sent = JSON.parse(init.body as string);
|
||||||
|
|||||||
Reference in New Issue
Block a user