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