- Fix VALID_STATUSES fallback to use uppercase enum value - Add TODO comment on InviteListItem cast pending type regeneration - Guard revoke action against null id (returns fail 400) - Add request: to delete action mock events for Sentry consistency - Add expiresAt forwarding test for create action - Add null-id guard test for revoke action Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
6.4 KiB
TypeScript
200 lines
6.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
vi.mock('$env/dynamic/private', () => ({
|
|
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
|
}));
|
|
|
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
|
|
|
import { load, actions } from './+page.server';
|
|
import { createApiClient } from '$lib/shared/api.server';
|
|
|
|
function mockApi(methods: Partial<Record<'GET' | 'PUT' | 'DELETE', ReturnType<typeof vi.fn>>>) {
|
|
vi.mocked(createApiClient).mockReturnValue(methods as ReturnType<typeof createApiClient>);
|
|
}
|
|
|
|
// ─── load() ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('admin/users/[id] load()', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
function makeEvent(permissions: string[] = ['ADMIN']) {
|
|
return {
|
|
params: { id: 'user-123' },
|
|
fetch: vi.fn() as unknown as typeof fetch,
|
|
locals: { user: { groups: [{ permissions }] } },
|
|
request: new Request('http://localhost/admin/users/user-123'),
|
|
url: new URL('http://localhost/admin/users/user-123')
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any;
|
|
}
|
|
|
|
it('throws 403 when the user lacks the ADMIN permission', async () => {
|
|
let thrown: unknown;
|
|
try {
|
|
await load(makeEvent(['READ_ALL']));
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
|
|
expect((thrown as { status: number }).status).toBe(403);
|
|
});
|
|
|
|
it('throws 404 when the backend returns non-ok for the user lookup', async () => {
|
|
const mockGet = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ response: { ok: false, status: 404 }, data: undefined })
|
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] });
|
|
mockApi({ GET: mockGet });
|
|
|
|
let thrown: unknown;
|
|
try {
|
|
await load(makeEvent());
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
|
|
expect((thrown as { status: number }).status).toBe(404);
|
|
});
|
|
|
|
it('returns editUser and groups on success', async () => {
|
|
const editUser = { id: 'user-123', email: 'max@example.com', firstName: 'Max' };
|
|
const groups = [
|
|
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
|
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
|
];
|
|
const mockGet = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ response: { ok: true }, data: editUser })
|
|
.mockResolvedValueOnce({ response: { ok: true }, data: groups });
|
|
mockApi({ GET: mockGet });
|
|
|
|
const result = await load(makeEvent());
|
|
|
|
expect(result).toMatchObject({
|
|
editUser: expect.objectContaining({ id: 'user-123' }),
|
|
groups: expect.arrayContaining([expect.objectContaining({ id: 'g-1' })])
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── update action ────────────────────────────────────────────────────────────
|
|
|
|
describe('admin/users/[id] update action', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
function makeUpdateRequest(fields: Record<string, string | string[]> = {}) {
|
|
const fd = new FormData();
|
|
const defaults: Record<string, string> = {
|
|
firstName: 'Max',
|
|
lastName: 'Mustermann',
|
|
email: 'max@example.com'
|
|
};
|
|
for (const [k, v] of Object.entries({ ...defaults, ...fields })) {
|
|
if (Array.isArray(v)) {
|
|
v.forEach((item) => fd.append(k, item));
|
|
} else {
|
|
fd.append(k, v);
|
|
}
|
|
}
|
|
return new Request('http://localhost', { method: 'POST', body: fd });
|
|
}
|
|
|
|
function makeEvent(request: Request) {
|
|
return {
|
|
params: { id: 'user-123' },
|
|
request,
|
|
fetch: vi.fn() as unknown as typeof fetch
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any;
|
|
}
|
|
|
|
it('calls PUT /api/users/{id} and returns success: true on 200', async () => {
|
|
const mockPut = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
|
|
mockApi({ PUT: mockPut });
|
|
|
|
const result = await actions.update(makeEvent(makeUpdateRequest()));
|
|
|
|
expect(mockPut).toHaveBeenCalledWith(
|
|
'/api/users/{id}',
|
|
expect.objectContaining({ params: { path: { id: 'user-123' } } })
|
|
);
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it('returns fail with backend error code when PUT returns non-OK', async () => {
|
|
const mockPut = vi
|
|
.fn()
|
|
.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } });
|
|
mockApi({ PUT: mockPut });
|
|
|
|
const result = await actions.update(makeEvent(makeUpdateRequest()));
|
|
|
|
expect(result).toMatchObject({ status: 403 });
|
|
});
|
|
|
|
it('returns fail with generic message when error body has no code field', async () => {
|
|
const mockPut = vi
|
|
.fn()
|
|
.mockResolvedValue({ response: { ok: false, status: 500 }, error: null });
|
|
mockApi({ PUT: mockPut });
|
|
|
|
const result = await actions.update(makeEvent(makeUpdateRequest()));
|
|
|
|
expect(result).toMatchObject({ status: 500 });
|
|
});
|
|
|
|
it('returns fail without calling backend when passwords do not match', async () => {
|
|
const mockPut = vi.fn();
|
|
mockApi({ PUT: mockPut });
|
|
|
|
const result = await actions.update(
|
|
makeEvent(makeUpdateRequest({ newPassword: 'abc', confirmPassword: 'xyz' }))
|
|
);
|
|
|
|
expect(mockPut).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({ status: 400 });
|
|
});
|
|
});
|
|
|
|
// ─── delete action ────────────────────────────────────────────────────────────
|
|
|
|
describe('admin/users/[id] delete action', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
function makeEvent() {
|
|
return {
|
|
params: { id: 'user-123' },
|
|
fetch: vi.fn() as unknown as typeof fetch,
|
|
request: new Request('http://localhost/admin/users/user-123')
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any;
|
|
}
|
|
|
|
it('redirects to /admin/users on successful delete', async () => {
|
|
const mockDelete = vi.fn().mockResolvedValue({ response: { ok: true } });
|
|
mockApi({ DELETE: mockDelete });
|
|
|
|
let redirectLocation: string | null = null;
|
|
try {
|
|
await actions.delete(makeEvent());
|
|
} catch (e: unknown) {
|
|
const r = e as { location?: string };
|
|
redirectLocation = r.location ?? null;
|
|
}
|
|
|
|
expect(redirectLocation).toBe('/admin/users');
|
|
});
|
|
|
|
it('returns fail when delete returns non-OK', async () => {
|
|
const mockDelete = vi
|
|
.fn()
|
|
.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } });
|
|
mockApi({ DELETE: mockDelete });
|
|
|
|
const result = await actions.delete(makeEvent());
|
|
|
|
expect(result).toMatchObject({ status: 403 });
|
|
});
|
|
});
|