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
@@ -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);
});
});
});