feat(mvp): phase 8 - integration, testing & deployment

Fix all build/type/lint errors (zod 3.25 compat wrapper, Svelte 5 fixes),
write 115 unit tests across 10 test files, expand seed script with demo
data, update Docker config with migration on startup.
This commit is contained in:
2026-03-24 22:09:17 +03:00
parent 0bd30c5e17
commit e6b50fb4f1
36 changed files with 1634 additions and 99 deletions
@@ -117,7 +117,7 @@
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value="" disabled>Select...</option>
{#each entityOptions as option}
{#each entityOptions as option (option.id)}
<option value={option.id}>{option.name}</option>
{/each}
</select>
@@ -142,7 +142,7 @@
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value="" disabled>Select...</option>
{#each targetOptions as option}
{#each targetOptions as option (option.id)}
<option value={option.id}>{option.name}</option>
{/each}
</select>
+1 -1
View File
@@ -136,7 +136,7 @@
bind:value={$form.healthcheckDefaults}
rows="4"
class="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground"
placeholder='{"interval": 300, "timeout": 5000, "method": "GET"}'
placeholder={'{"interval": 300, "timeout": 5000, "method": "GET"}'}
></textarea>
{#if $errors.healthcheckDefaults}<span class="text-xs text-destructive">{$errors.healthcheckDefaults}</span>{/if}
</div>
+1 -1
View File
@@ -104,7 +104,7 @@
class="rounded border border-input bg-background px-2 py-0.5 text-xs text-foreground"
>
<option value="" disabled>Select group</option>
{#each groups.filter((g) => !user.groups.some((ug) => ug.id === g.id)) as group}
{#each groups.filter((g) => !user.groups.some((ug) => ug.id === g.id)) as group (group.id)}
<option value={group.id}>{group.name}</option>
{/each}
</select>
+1 -1
View File
@@ -105,7 +105,7 @@
iconType={$form.iconType ?? 'lucide'}
iconValue={$form.icon ?? ''}
onchange={(type, value) => {
$form.iconType = type;
$form.iconType = type as typeof $form.iconType;
$form.icon = value;
}}
/>
@@ -58,7 +58,7 @@
</filter>
</defs>
{#each blobs as blob, i}
{#each blobs as blob (blob.hueOffset)}
<circle
cx="{blob.x}%"
cy="{blob.y}%"
+1 -1
View File
@@ -94,7 +94,7 @@
<div
class="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{#each bgOptions as opt}
{#each bgOptions as opt (opt.value)}
<button
type="button"
onclick={() => {
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../prisma.js', () => ({
prisma: {
app: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
},
appStatus: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn()
}
}
}));
import { prisma } from '../../prisma.js';
import * as appService from '../appService.js';
const mockApp = prisma.app as unknown as {
findMany: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
const mockAppStatus = prisma.appStatus as unknown as {
create: ReturnType<typeof vi.fn>;
};
describe('appService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('findAll', () => {
it('returns all apps', async () => {
const apps = [
{ id: '1', name: 'App1', statuses: [] },
{ id: '2', name: 'App2', statuses: [] }
];
mockApp.findMany.mockResolvedValue(apps);
const result = await appService.findAll();
expect(result).toEqual(apps);
expect(mockApp.findMany).toHaveBeenCalledWith({
where: {},
orderBy: { name: 'asc' },
include: { statuses: { orderBy: { checkedAt: 'desc' }, take: 1 } }
});
});
it('filters by category', async () => {
mockApp.findMany.mockResolvedValue([]);
await appService.findAll({ category: 'media' });
expect(mockApp.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { category: 'media' }
})
);
});
it('filters by search term', async () => {
mockApp.findMany.mockResolvedValue([]);
await appService.findAll({ search: 'grafana' });
const call = mockApp.findMany.mock.calls[0][0];
expect(call.where.OR).toBeDefined();
expect(call.where.OR).toHaveLength(3);
});
});
describe('findById', () => {
it('returns app when found', async () => {
const app = { id: '1', name: 'App', statuses: [], createdBy: null };
mockApp.findUnique.mockResolvedValue(app);
const result = await appService.findById('1');
expect(result).toEqual(app);
});
it('throws when not found', async () => {
mockApp.findUnique.mockResolvedValue(null);
await expect(appService.findById('missing')).rejects.toThrow('App not found');
});
});
describe('create', () => {
it('creates app with required fields', async () => {
const input = { name: 'New App', url: 'https://app.local' };
const created = { id: '1', ...input };
mockApp.create.mockResolvedValue(created);
const result = await appService.create(input);
expect(result.id).toBe('1');
expect(mockApp.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: 'New App',
url: 'https://app.local',
healthcheckEnabled: false,
healthcheckInterval: 300
})
});
});
});
describe('update', () => {
it('updates specified fields', async () => {
mockApp.findUnique.mockResolvedValue({ id: '1' });
mockApp.update.mockResolvedValue({ id: '1', name: 'Updated' });
const result = await appService.update('1', { name: 'Updated' });
expect(mockApp.update).toHaveBeenCalledWith({
where: { id: '1' },
data: { name: 'Updated' }
});
expect(result.name).toBe('Updated');
});
});
describe('remove', () => {
it('deletes app', async () => {
mockApp.findUnique.mockResolvedValue({ id: '1' });
mockApp.delete.mockResolvedValue({});
await appService.remove('1');
expect(mockApp.delete).toHaveBeenCalledWith({ where: { id: '1' } });
});
});
describe('recordStatus', () => {
it('creates a status record', async () => {
const status = { id: 's1', appId: '1', status: 'online', responseTime: 150 };
mockAppStatus.create.mockResolvedValue(status);
const result = await appService.recordStatus('1', 'online', 150);
expect(result).toEqual(status);
});
});
describe('getCategories', () => {
it('returns unique categories', async () => {
mockApp.findMany.mockResolvedValue([
{ category: 'Media' },
{ category: 'Monitoring' }
]);
const result = await appService.getCategories();
expect(result).toEqual(['Media', 'Monitoring']);
});
});
});
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock prisma before importing authService
vi.mock('../../prisma.js', () => ({
prisma: {
user: {
update: vi.fn(),
findUnique: vi.fn()
}
}
}));
// Set JWT_SECRET for tests
process.env.JWT_SECRET = 'test-secret-key-for-unit-tests';
import {
hashPassword,
verifyPassword,
signAccessToken,
verifyAccessToken,
generateRefreshToken,
getRefreshTokenExpiry,
rotateTokens
} from '../authService.js';
import { prisma } from '../../prisma.js';
describe('authService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('hashPassword / verifyPassword', () => {
it('hashes a password and verifies it correctly', async () => {
const password = 'mySecurePassword123';
const hash = await hashPassword(password);
expect(hash).not.toBe(password);
expect(hash.length).toBeGreaterThan(0);
const isValid = await verifyPassword(password, hash);
expect(isValid).toBe(true);
});
it('rejects wrong password', async () => {
const hash = await hashPassword('correct-password');
const isValid = await verifyPassword('wrong-password', hash);
expect(isValid).toBe(false);
});
});
describe('signAccessToken / verifyAccessToken', () => {
it('signs and verifies a token', () => {
const payload = { userId: 'usr-1', email: 'test@test.com', role: 'user' };
const token = signAccessToken(payload);
expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3);
const decoded = verifyAccessToken(token);
expect(decoded.userId).toBe('usr-1');
expect(decoded.email).toBe('test@test.com');
expect(decoded.role).toBe('user');
});
it('throws for invalid token', () => {
expect(() => verifyAccessToken('invalid.token.value')).toThrow(
'Invalid or expired access token'
);
});
});
describe('generateRefreshToken', () => {
it('generates a hex string', () => {
const token = generateRefreshToken();
expect(typeof token).toBe('string');
expect(token.length).toBe(96); // 48 bytes * 2 hex chars
expect(/^[0-9a-f]+$/.test(token)).toBe(true);
});
it('generates unique tokens', () => {
const token1 = generateRefreshToken();
const token2 = generateRefreshToken();
expect(token1).not.toBe(token2);
});
});
describe('getRefreshTokenExpiry', () => {
it('returns a future date', () => {
const expiry = getRefreshTokenExpiry();
expect(expiry.getTime()).toBeGreaterThan(Date.now());
});
it('defaults to 7 days from now', () => {
const expiry = getRefreshTokenExpiry();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
const diff = expiry.getTime() - Date.now();
// Allow 10 seconds tolerance
expect(diff).toBeGreaterThan(sevenDaysMs - 10000);
expect(diff).toBeLessThan(sevenDaysMs + 10000);
});
});
describe('rotateTokens', () => {
it('generates new token pair and saves refresh token', async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const result = await rotateTokens('usr-1', 'test@test.com', 'user');
expect(result.accessToken).toBeTruthy();
expect(result.refreshToken).toBeTruthy();
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../prisma.js', () => ({
prisma: {
board: {
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn()
},
section: {
findUnique: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
},
widget: {
findUnique: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));
import { prisma } from '../../prisma.js';
import * as boardService from '../boardService.js';
const mockBoard = prisma.board as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockSection = prisma.section as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockWidget = prisma.widget as unknown as Record<string, ReturnType<typeof vi.fn>>;
describe('boardService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('findAllBoards', () => {
it('returns all boards', async () => {
const boards = [{ id: '1', name: 'Main', _count: { sections: 2 } }];
mockBoard.findMany.mockResolvedValue(boards);
const result = await boardService.findAllBoards();
expect(result).toEqual(boards);
});
});
describe('findBoardById', () => {
it('returns board with sections and widgets', async () => {
const board = { id: '1', name: 'Main', sections: [] };
mockBoard.findUnique.mockResolvedValue(board);
const result = await boardService.findBoardById('1');
expect(result.name).toBe('Main');
});
it('throws when not found', async () => {
mockBoard.findUnique.mockResolvedValue(null);
await expect(boardService.findBoardById('missing')).rejects.toThrow('Board not found');
});
});
describe('createBoard', () => {
it('creates a board', async () => {
mockBoard.create.mockResolvedValue({ id: '1', name: 'New Board' });
const result = await boardService.createBoard({ name: 'New Board' });
expect(result.name).toBe('New Board');
});
it('unsets other defaults when creating a default board', async () => {
mockBoard.updateMany.mockResolvedValue({ count: 1 });
mockBoard.create.mockResolvedValue({ id: '1', name: 'Default', isDefault: true });
await boardService.createBoard({ name: 'Default', isDefault: true });
expect(mockBoard.updateMany).toHaveBeenCalledWith({
where: { isDefault: true },
data: { isDefault: false }
});
});
});
describe('updateBoard', () => {
it('updates board fields', async () => {
mockBoard.findUnique.mockResolvedValue({ id: '1' });
mockBoard.update.mockResolvedValue({ id: '1', name: 'Updated' });
const result = await boardService.updateBoard('1', { name: 'Updated' });
expect(result.name).toBe('Updated');
});
});
describe('removeBoard', () => {
it('deletes a board', async () => {
mockBoard.findUnique.mockResolvedValue({ id: '1' });
mockBoard.delete.mockResolvedValue({});
await boardService.removeBoard('1');
expect(mockBoard.delete).toHaveBeenCalledWith({ where: { id: '1' } });
});
});
describe('createSection', () => {
it('creates a section with auto-calculated order', async () => {
mockSection.findFirst.mockResolvedValue({ order: 2 });
mockSection.create.mockResolvedValue({
id: 's1',
title: 'Media',
order: 3
});
const result = await boardService.createSection({
boardId: 'b1',
title: 'Media'
});
expect(result.order).toBe(3);
});
it('starts order at 0 for first section', async () => {
mockSection.findFirst.mockResolvedValue(null);
mockSection.create.mockResolvedValue({
id: 's1',
title: 'First',
order: 0
});
await boardService.createSection({ boardId: 'b1', title: 'First' });
expect(mockSection.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ order: 0 })
})
);
});
});
describe('createWidget', () => {
it('creates a widget', async () => {
mockWidget.findFirst.mockResolvedValue(null);
mockWidget.create.mockResolvedValue({
id: 'w1',
type: 'app',
order: 0
});
const result = await boardService.createWidget({
sectionId: 's1',
type: 'app'
});
expect(result.type).toBe('app');
});
});
describe('removeWidget', () => {
it('deletes a widget', async () => {
mockWidget.findUnique.mockResolvedValue({ id: 'w1' });
mockWidget.delete.mockResolvedValue({});
await boardService.removeWidget('w1');
expect(mockWidget.delete).toHaveBeenCalledWith({ where: { id: 'w1' } });
});
});
});
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../prisma.js', () => ({
prisma: {
group: {
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
},
userGroup: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
deleteMany: vi.fn()
}
}
}));
import { prisma } from '../../prisma.js';
import * as groupService from '../groupService.js';
const mockGroup = prisma.group as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockUserGroup = prisma.userGroup as unknown as Record<string, ReturnType<typeof vi.fn>>;
describe('groupService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('findAll', () => {
it('returns all groups', async () => {
const groups = [{ id: '1', name: 'Devs', _count: { users: 2 } }];
mockGroup.findMany.mockResolvedValue(groups);
const result = await groupService.findAll();
expect(result).toEqual(groups);
});
});
describe('findById', () => {
it('returns group when found', async () => {
const group = { id: '1', name: 'Devs' };
mockGroup.findUnique.mockResolvedValue(group);
const result = await groupService.findById('1');
expect(result).toEqual(group);
});
it('throws when not found', async () => {
mockGroup.findUnique.mockResolvedValue(null);
await expect(groupService.findById('missing')).rejects.toThrow('Group not found');
});
});
describe('create', () => {
it('creates a group', async () => {
mockGroup.findUnique.mockResolvedValue(null);
mockGroup.create.mockResolvedValue({ id: '1', name: 'New Group' });
const result = await groupService.create({ name: 'New Group' });
expect(result.name).toBe('New Group');
});
it('throws on duplicate name', async () => {
mockGroup.findUnique.mockResolvedValue({ id: '1', name: 'Existing' });
await expect(groupService.create({ name: 'Existing' })).rejects.toThrow(
'already exists'
);
});
});
describe('update', () => {
it('updates a group', async () => {
mockGroup.findUnique.mockResolvedValue({ id: '1', name: 'Old' });
mockGroup.findFirst.mockResolvedValue(null);
mockGroup.update.mockResolvedValue({ id: '1', name: 'Updated' });
const result = await groupService.update('1', { name: 'Updated' });
expect(result.name).toBe('Updated');
});
});
describe('addUser', () => {
it('adds user to group', async () => {
mockUserGroup.findUnique.mockResolvedValue(null);
mockUserGroup.create.mockResolvedValue({ id: 'ug1', userId: 'u1', groupId: 'g1' });
const result = await groupService.addUser('g1', 'u1');
expect(result.userId).toBe('u1');
});
it('returns existing membership if already a member', async () => {
const existing = { id: 'ug1', userId: 'u1', groupId: 'g1' };
mockUserGroup.findUnique.mockResolvedValue(existing);
const result = await groupService.addUser('g1', 'u1');
expect(result).toEqual(existing);
expect(mockUserGroup.create).not.toHaveBeenCalled();
});
});
describe('removeUser', () => {
it('removes user from group', async () => {
mockUserGroup.deleteMany.mockResolvedValue({ count: 1 });
await groupService.removeUser('g1', 'u1');
expect(mockUserGroup.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1', groupId: 'g1' }
});
});
});
describe('addUserToDefaultGroups', () => {
it('adds user to all default groups', async () => {
mockGroup.findMany.mockResolvedValue([
{ id: 'g1', name: 'Default1', isDefault: true },
{ id: 'g2', name: 'Default2', isDefault: true }
]);
mockUserGroup.findUnique.mockResolvedValue(null);
mockUserGroup.create.mockImplementation(({ data }: { data: { userId: string; groupId: string } }) =>
Promise.resolve({ id: `ug-${data.groupId}`, ...data })
);
const results = await groupService.addUserToDefaultGroups('u1');
expect(results).toHaveLength(2);
});
});
});
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../prisma.js', () => ({
prisma: {
user: { findUnique: vi.fn() },
permission: {
findFirst: vi.fn(),
findMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn()
},
userGroup: { findMany: vi.fn() }
}
}));
import { prisma } from '../../prisma.js';
import * as permissionService from '../permissionService.js';
const mockUser = prisma.user as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockPermission = prisma.permission as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockUserGroup = prisma.userGroup as unknown as Record<string, ReturnType<typeof vi.fn>>;
describe('permissionService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('checkPermission', () => {
it('grants full access to admins', async () => {
mockUser.findUnique.mockResolvedValue({ role: 'admin' });
const result = await permissionService.checkPermission(
'board',
'b1',
'admin-user',
'edit'
);
expect(result.hasPermission).toBe(true);
expect(result.effectiveLevel).toBe('admin');
expect(result.source).toBe('admin');
});
it('checks direct user permission', async () => {
mockUser.findUnique.mockResolvedValue({ role: 'user' });
mockPermission.findFirst.mockResolvedValue({ level: 'edit' });
const result = await permissionService.checkPermission(
'board',
'b1',
'user1',
'view'
);
expect(result.hasPermission).toBe(true);
expect(result.effectiveLevel).toBe('edit');
expect(result.source).toBe('user');
});
it('denies when user permission is insufficient', async () => {
mockUser.findUnique.mockResolvedValue({ role: 'user' });
mockPermission.findFirst.mockResolvedValue({ level: 'view' });
const result = await permissionService.checkPermission(
'board',
'b1',
'user1',
'admin'
);
expect(result.hasPermission).toBe(false);
});
it('falls back to group permissions', async () => {
mockUser.findUnique.mockResolvedValue({ role: 'user' });
mockPermission.findFirst.mockResolvedValue(null);
mockUserGroup.findMany.mockResolvedValue([{ groupId: 'g1' }]);
mockPermission.findMany.mockResolvedValue([{ level: 'edit' }]);
const result = await permissionService.checkPermission(
'board',
'b1',
'user1',
'view'
);
expect(result.hasPermission).toBe(true);
expect(result.source).toBe('group');
});
it('denies when no permission found', async () => {
mockUser.findUnique.mockResolvedValue({ role: 'user' });
mockPermission.findFirst.mockResolvedValue(null);
mockUserGroup.findMany.mockResolvedValue([]);
const result = await permissionService.checkPermission(
'board',
'b1',
'user1',
'view'
);
expect(result.hasPermission).toBe(false);
expect(result.effectiveLevel).toBeNull();
expect(result.source).toBeNull();
});
});
describe('grantPermission', () => {
it('upserts a permission', async () => {
const perm = {
entityType: 'board' as const,
entityId: 'b1',
targetType: 'user' as const,
targetId: 'u1',
level: 'edit' as const
};
mockPermission.upsert.mockResolvedValue({ id: 'p1', ...perm });
const result = await permissionService.grantPermission(perm);
expect(result.level).toBe('edit');
});
});
describe('revokePermission', () => {
it('deletes matching permissions', async () => {
mockPermission.deleteMany.mockResolvedValue({ count: 1 });
await permissionService.revokePermission('board', 'b1', 'user', 'u1');
expect(mockPermission.deleteMany).toHaveBeenCalledWith({
where: {
entityType: 'board',
entityId: 'b1',
targetType: 'user',
targetId: 'u1'
}
});
});
});
describe('getPermissionsForEntity', () => {
it('returns permissions for an entity', async () => {
const perms = [{ id: 'p1', level: 'view' }];
mockPermission.findMany.mockResolvedValue(perms);
const result = await permissionService.getPermissionsForEntity('board', 'b1');
expect(result).toEqual(perms);
});
});
});
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../prisma.js', () => ({
prisma: {
user: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn()
},
userGroup: {
findMany: vi.fn()
}
}
}));
vi.mock('../authService.js', () => ({
hashPassword: vi.fn((pw: string) => Promise.resolve(`hashed-${pw}`))
}));
import { prisma } from '../../prisma.js';
import * as userService from '../userService.js';
const mockUser = prisma.user as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockUserGroup = prisma.userGroup as unknown as Record<string, ReturnType<typeof vi.fn>>;
describe('userService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('findAll', () => {
it('returns all users', async () => {
const users = [{ id: '1', email: 'a@b.com', displayName: 'User' }];
mockUser.findMany.mockResolvedValue(users);
const result = await userService.findAll();
expect(result).toEqual(users);
});
});
describe('findById', () => {
it('returns user when found', async () => {
const user = { id: '1', email: 'a@b.com' };
mockUser.findUnique.mockResolvedValue(user);
const result = await userService.findById('1');
expect(result).toEqual(user);
});
it('throws when not found', async () => {
mockUser.findUnique.mockResolvedValue(null);
await expect(userService.findById('missing')).rejects.toThrow('User not found');
});
});
describe('findByEmail', () => {
it('returns user with password field', async () => {
const user = { id: '1', email: 'a@b.com', password: 'hash' };
mockUser.findUnique.mockResolvedValue(user);
const result = await userService.findByEmail('a@b.com');
expect(result?.password).toBe('hash');
});
it('returns null when not found', async () => {
mockUser.findUnique.mockResolvedValue(null);
const result = await userService.findByEmail('nobody@test.com');
expect(result).toBeNull();
});
});
describe('create', () => {
it('creates a user with hashed password', async () => {
mockUser.findUnique.mockResolvedValue(null);
mockUser.create.mockResolvedValue({
id: '1',
email: 'new@test.com',
displayName: 'New'
});
const result = await userService.create({
email: 'new@test.com',
password: 'secret',
displayName: 'New'
});
expect(result.email).toBe('new@test.com');
expect(mockUser.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
password: 'hashed-secret'
})
})
);
});
it('throws on duplicate email', async () => {
mockUser.findUnique.mockResolvedValue({ id: '1' });
await expect(
userService.create({
email: 'existing@test.com',
displayName: 'Dup'
})
).rejects.toThrow('already exists');
});
});
describe('update', () => {
it('updates user fields', async () => {
mockUser.findUnique.mockResolvedValue({ id: '1' });
mockUser.update.mockResolvedValue({ id: '1', displayName: 'Updated' });
const result = await userService.update('1', { displayName: 'Updated' });
expect(result.displayName).toBe('Updated');
});
});
describe('remove', () => {
it('deletes user', async () => {
mockUser.findUnique.mockResolvedValue({ id: '1' });
mockUser.delete.mockResolvedValue({});
await userService.remove('1');
expect(mockUser.delete).toHaveBeenCalledWith({ where: { id: '1' } });
});
});
describe('getUserGroups', () => {
it('returns user group memberships', async () => {
mockUserGroup.findMany.mockResolvedValue([
{ group: { id: 'g1', name: 'Devs' } }
]);
const result = await userService.getUserGroups('u1');
expect(result).toEqual([{ id: 'g1', name: 'Devs' }]);
});
});
describe('count', () => {
it('returns user count', async () => {
mockUser.count.mockResolvedValue(42);
const result = await userService.count();
expect(result).toBe(42);
});
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
export function signAccessToken(payload: JwtPayload): string {
return jwt.sign(payload, getJwtSecret(), {
expiresIn: getJwtExpiry()
expiresIn: getJwtExpiry() as string & jwt.SignOptions['expiresIn']
});
}
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { success, error, paginated } from '../response.js';
describe('response envelope', () => {
describe('success', () => {
it('wraps data in success response', () => {
const result = success({ id: '1', name: 'Test' });
expect(result).toEqual({
success: true,
data: { id: '1', name: 'Test' },
error: null
});
});
it('includes meta when provided', () => {
const result = success([1, 2, 3], { total: 10, page: 1, limit: 3 });
expect(result.success).toBe(true);
expect(result.meta).toEqual({ total: 10, page: 1, limit: 3 });
});
it('omits meta when not provided', () => {
const result = success('data');
expect(result.meta).toBeUndefined();
});
});
describe('error', () => {
it('wraps message in error response', () => {
const result = error('Something went wrong');
expect(result).toEqual({
success: false,
data: null,
error: 'Something went wrong'
});
});
});
describe('paginated', () => {
it('wraps data with pagination meta', () => {
const items = [{ id: '1' }, { id: '2' }];
const result = paginated(items, 50, 1, 10);
expect(result).toEqual({
success: true,
data: items,
error: null,
meta: { total: 50, page: 1, limit: 10 }
});
});
});
});
+2 -2
View File
@@ -35,14 +35,14 @@ class ThemeStore {
primarySaturation = $state(70);
backgroundType = $state<BackgroundType>('mesh');
#systemPreference: 'dark' | 'light' = 'dark';
resolvedMode = $derived<'dark' | 'light'>(
this.mode === 'system' ? this.#systemPreference : this.mode
);
isDark = $derived(this.resolvedMode === 'dark');
#systemPreference: 'dark' | 'light' = 'dark';
constructor() {
if (typeof window !== 'undefined') {
this.mode = getStoredValue<ThemeMode>(THEME_STORAGE_KEY, 'system');
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { cn } from '../cn.js';
describe('cn', () => {
it('merges class names', () => {
expect(cn('foo', 'bar')).toBe('foo bar');
});
it('handles conditional classes', () => {
const isHidden = false;
expect(cn('base', isHidden && 'hidden', 'end')).toBe('base end');
});
it('merges tailwind classes with deduplication', () => {
expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4');
});
it('handles undefined and null', () => {
expect(cn('base', undefined, null, 'end')).toBe('base end');
});
it('returns empty string for no inputs', () => {
expect(cn()).toBe('');
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest';
import {
UserRole,
AuthMode,
WidgetType,
IconType,
PermissionLevel,
PERMISSION_HIERARCHY,
EntityType,
TargetType,
HealthcheckMethod,
AppStatusValue,
DEFAULTS
} from '../constants.js';
describe('constants', () => {
describe('UserRole', () => {
it('defines admin and user roles', () => {
expect(UserRole.ADMIN).toBe('admin');
expect(UserRole.USER).toBe('user');
});
});
describe('AuthMode', () => {
it('defines all auth modes', () => {
expect(AuthMode.LOCAL).toBe('local');
expect(AuthMode.OAUTH).toBe('oauth');
expect(AuthMode.BOTH).toBe('both');
});
});
describe('WidgetType', () => {
it('defines all widget types', () => {
expect(WidgetType.APP).toBe('app');
expect(WidgetType.BOOKMARK).toBe('bookmark');
expect(WidgetType.NOTE).toBe('note');
expect(WidgetType.EMBED).toBe('embed');
expect(WidgetType.STATUS).toBe('status');
});
});
describe('IconType', () => {
it('defines all icon types', () => {
expect(IconType.LUCIDE).toBe('lucide');
expect(IconType.SIMPLE).toBe('simple');
expect(IconType.URL).toBe('url');
expect(IconType.EMOJI).toBe('emoji');
});
});
describe('PermissionLevel', () => {
it('defines all permission levels', () => {
expect(PermissionLevel.VIEW).toBe('view');
expect(PermissionLevel.EDIT).toBe('edit');
expect(PermissionLevel.ADMIN).toBe('admin');
});
});
describe('PERMISSION_HIERARCHY', () => {
it('assigns increasing values for higher permissions', () => {
expect(PERMISSION_HIERARCHY[PermissionLevel.VIEW]).toBeLessThan(
PERMISSION_HIERARCHY[PermissionLevel.EDIT]
);
expect(PERMISSION_HIERARCHY[PermissionLevel.EDIT]).toBeLessThan(
PERMISSION_HIERARCHY[PermissionLevel.ADMIN]
);
});
});
describe('EntityType', () => {
it('defines entity types', () => {
expect(EntityType.BOARD).toBe('board');
expect(EntityType.APP).toBe('app');
});
});
describe('TargetType', () => {
it('defines target types', () => {
expect(TargetType.USER).toBe('user');
expect(TargetType.GROUP).toBe('group');
});
});
describe('HealthcheckMethod', () => {
it('defines methods', () => {
expect(HealthcheckMethod.GET).toBe('GET');
expect(HealthcheckMethod.HEAD).toBe('HEAD');
});
});
describe('AppStatusValue', () => {
it('defines all status values', () => {
expect(AppStatusValue.ONLINE).toBe('online');
expect(AppStatusValue.OFFLINE).toBe('offline');
expect(AppStatusValue.DEGRADED).toBe('degraded');
expect(AppStatusValue.UNKNOWN).toBe('unknown');
});
});
describe('DEFAULTS', () => {
it('contains expected default values', () => {
expect(DEFAULTS.HEALTHCHECK_INTERVAL).toBe(300);
expect(DEFAULTS.HEALTHCHECK_TIMEOUT).toBe(5000);
expect(DEFAULTS.JWT_EXPIRY).toBe('15m');
expect(DEFAULTS.REFRESH_TOKEN_EXPIRY_DAYS).toBe(7);
expect(DEFAULTS.SYSTEM_SETTINGS_ID).toBe('singleton');
});
});
});
+330
View File
@@ -0,0 +1,330 @@
import { describe, it, expect } from 'vitest';
import {
loginSchema,
registerSchema,
createUserSchema,
updateUserSchema,
createGroupSchema,
updateGroupSchema,
createAppSchema,
updateAppSchema,
createBoardSchema,
updateBoardSchema,
createSectionSchema,
updateSectionSchema,
createWidgetSchema,
updateWidgetSchema,
createPermissionSchema,
updateSystemSettingsSchema
} from '../validators.js';
describe('validators', () => {
describe('loginSchema', () => {
it('accepts valid login data', () => {
const result = loginSchema.safeParse({
email: 'user@example.com',
password: 'password123'
});
expect(result.success).toBe(true);
});
it('rejects invalid email', () => {
const result = loginSchema.safeParse({
email: 'not-an-email',
password: 'password123'
});
expect(result.success).toBe(false);
});
it('rejects empty password', () => {
const result = loginSchema.safeParse({
email: 'user@example.com',
password: ''
});
expect(result.success).toBe(false);
});
});
describe('registerSchema', () => {
it('accepts valid registration data', () => {
const result = registerSchema.safeParse({
email: 'user@example.com',
password: 'password123',
displayName: 'Test User'
});
expect(result.success).toBe(true);
});
it('rejects short password', () => {
const result = registerSchema.safeParse({
email: 'user@example.com',
password: '12345',
displayName: 'Test'
});
expect(result.success).toBe(false);
});
it('rejects empty display name', () => {
const result = registerSchema.safeParse({
email: 'user@example.com',
password: 'password123',
displayName: ''
});
expect(result.success).toBe(false);
});
});
describe('createUserSchema', () => {
it('accepts valid user with minimal fields', () => {
const result = createUserSchema.safeParse({
email: 'admin@test.com',
displayName: 'Admin'
});
expect(result.success).toBe(true);
});
it('accepts valid user with all fields', () => {
const result = createUserSchema.safeParse({
email: 'admin@test.com',
password: 'secret123',
displayName: 'Admin User',
role: 'admin',
authProvider: 'local'
});
expect(result.success).toBe(true);
});
it('rejects invalid role', () => {
const result = createUserSchema.safeParse({
email: 'admin@test.com',
displayName: 'Admin',
role: 'superadmin'
});
expect(result.success).toBe(false);
});
});
describe('updateUserSchema', () => {
it('accepts partial update', () => {
const result = updateUserSchema.safeParse({
displayName: 'New Name'
});
expect(result.success).toBe(true);
});
it('accepts empty object', () => {
const result = updateUserSchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts nullable avatarUrl', () => {
const result = updateUserSchema.safeParse({
avatarUrl: null
});
expect(result.success).toBe(true);
});
});
describe('createGroupSchema', () => {
it('accepts valid group', () => {
const result = createGroupSchema.safeParse({
name: 'Developers'
});
expect(result.success).toBe(true);
});
it('rejects empty name', () => {
const result = createGroupSchema.safeParse({
name: ''
});
expect(result.success).toBe(false);
});
});
describe('updateGroupSchema', () => {
it('accepts partial update', () => {
const result = updateGroupSchema.safeParse({
isDefault: true
});
expect(result.success).toBe(true);
});
});
describe('createAppSchema', () => {
it('accepts valid app', () => {
const result = createAppSchema.safeParse({
name: 'Grafana',
url: 'https://grafana.local:3000'
});
expect(result.success).toBe(true);
});
it('rejects invalid URL', () => {
const result = createAppSchema.safeParse({
name: 'Bad App',
url: 'not-a-url'
});
expect(result.success).toBe(false);
});
it('accepts valid healthcheck config', () => {
const result = createAppSchema.safeParse({
name: 'App',
url: 'https://app.local',
healthcheckEnabled: true,
healthcheckInterval: 60,
healthcheckMethod: 'GET',
healthcheckExpectedStatus: 200,
healthcheckTimeout: 5000
});
expect(result.success).toBe(true);
});
it('rejects too-short healthcheck interval', () => {
const result = createAppSchema.safeParse({
name: 'App',
url: 'https://app.local',
healthcheckInterval: 10
});
expect(result.success).toBe(false);
});
});
describe('updateAppSchema', () => {
it('accepts partial update', () => {
const result = updateAppSchema.safeParse({ name: 'Updated' });
expect(result.success).toBe(true);
});
it('accepts nullable fields', () => {
const result = updateAppSchema.safeParse({
icon: null,
description: null,
category: null
});
expect(result.success).toBe(true);
});
});
describe('createBoardSchema', () => {
it('accepts valid board', () => {
const result = createBoardSchema.safeParse({
name: 'My Dashboard'
});
expect(result.success).toBe(true);
});
it('rejects missing name', () => {
const result = createBoardSchema.safeParse({});
expect(result.success).toBe(false);
});
});
describe('updateBoardSchema', () => {
it('accepts empty update', () => {
const result = updateBoardSchema.safeParse({});
expect(result.success).toBe(true);
});
});
describe('createSectionSchema', () => {
it('accepts valid section', () => {
const result = createSectionSchema.safeParse({
boardId: 'clr12345678901234567890123',
title: 'Media'
});
expect(result.success).toBe(true);
});
it('rejects missing boardId', () => {
const result = createSectionSchema.safeParse({
title: 'Media'
});
expect(result.success).toBe(false);
});
});
describe('updateSectionSchema', () => {
it('accepts partial update', () => {
const result = updateSectionSchema.safeParse({
order: 5
});
expect(result.success).toBe(true);
});
});
describe('createWidgetSchema', () => {
it('accepts valid widget', () => {
const result = createWidgetSchema.safeParse({
sectionId: 'clr12345678901234567890123',
type: 'app'
});
expect(result.success).toBe(true);
});
it('rejects invalid type', () => {
const result = createWidgetSchema.safeParse({
sectionId: 'clr12345678901234567890123',
type: 'invalid'
});
expect(result.success).toBe(false);
});
});
describe('updateWidgetSchema', () => {
it('accepts partial update', () => {
const result = updateWidgetSchema.safeParse({
order: 3
});
expect(result.success).toBe(true);
});
});
describe('createPermissionSchema', () => {
it('accepts valid permission', () => {
const result = createPermissionSchema.safeParse({
entityType: 'board',
entityId: 'clr12345678901234567890123',
targetType: 'user',
targetId: 'clr12345678901234567890123',
level: 'view'
});
expect(result.success).toBe(true);
});
it('rejects invalid level', () => {
const result = createPermissionSchema.safeParse({
entityType: 'board',
entityId: 'clr12345678901234567890123',
targetType: 'user',
targetId: 'clr12345678901234567890123',
level: 'superadmin'
});
expect(result.success).toBe(false);
});
});
describe('updateSystemSettingsSchema', () => {
it('accepts valid settings', () => {
const result = updateSystemSettingsSchema.safeParse({
authMode: 'local',
registrationEnabled: true,
defaultTheme: 'dark',
defaultPrimaryColor: '#6366f1'
});
expect(result.success).toBe(true);
});
it('rejects invalid hex color', () => {
const result = updateSystemSettingsSchema.safeParse({
defaultPrimaryColor: 'red'
});
expect(result.success).toBe(false);
});
it('accepts empty update', () => {
const result = updateSystemSettingsSchema.safeParse({});
expect(result.success).toBe(true);
});
});
});
+1
View File
@@ -1 +1,2 @@
export { cn } from './cn.js';
export { zod } from './zod-adapter.js';
+23
View File
@@ -0,0 +1,23 @@
/**
* Wrapper for sveltekit-superforms zod adapter with relaxed type constraints.
*
* Zod 3.25+ changed type inference for z.object(), making it incompatible
* with the ZodObjectType constraint in sveltekit-superforms v2.
* This wrapper accepts any z.ZodType and delegates to the real zod adapter.
*/
import { zod as zodOriginal, type ValidationAdapter } from 'sveltekit-superforms/adapters';
import type { z } from 'zod';
/**
* Type-safe zod adapter that works with zod 3.25+.
* Accepts any ZodObject and returns a properly typed ValidationAdapter.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function zod<T extends z.ZodType<any, any, any>>(
schema: T,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: any
): ValidationAdapter<z.output<T>, z.input<T>> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return zodOriginal(schema as any, options) as any;
}