404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { ForbiddenException, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
|
import { AuthService } from '../src/auth/auth.service.js';
|
|
import type { JwtUser } from '../src/common/auth.types.js';
|
|
import { KeyResultsService } from '../src/key-results/key-results.service.js';
|
|
import { ObjectivesService } from '../src/objectives/objectives.service.js';
|
|
import { PrismaService } from '../src/prisma/prisma.service.js';
|
|
|
|
// Seeded user identities (from prisma/seed.ts insertion order)
|
|
const managerUser: JwtUser = { sub: 2, email: 'manager@okr.local', role: 'MANAGER', name: 'Nguyen Van Manager' };
|
|
const employeeUser: JwtUser = { sub: 3, email: 'employee@okr.local', role: 'EMPLOYEE', name: 'Nguyen Van A' };
|
|
const employee2User: JwtUser = { sub: 4, email: 'employee2@okr.local', role: 'EMPLOYEE', name: 'Tran Thi B' };
|
|
|
|
// ─── Auth ───────────────────────────────────────────────────────────────────
|
|
|
|
test('AuthService rejects invalid password with UnauthorizedException', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new AuthService(prisma, new JwtService({ secret: 'test-secret' }));
|
|
try {
|
|
await assert.rejects(() => service.login('employee', 'wrong-password'), UnauthorizedException);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('AuthService rejects non-existent user with UnauthorizedException', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new AuthService(prisma, new JwtService({ secret: 'test-secret' }));
|
|
try {
|
|
await assert.rejects(() => service.login('nobody@ghost.local', 'Password@123'), UnauthorizedException);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('AuthService accepts correct credentials and returns signed JWT', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new AuthService(prisma, new JwtService({ secret: 'test-secret' }));
|
|
try {
|
|
const result = await service.login('employee', 'Password@123');
|
|
assert.equal(result.user.email, 'employee@okr.local');
|
|
assert.equal(result.user.role, 'EMPLOYEE');
|
|
assert.ok(result.token.length > 20, 'JWT must be non-trivially long');
|
|
// Token has 3 dot-separated segments
|
|
assert.equal(result.token.split('.').length, 3);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('AuthService accepts login by email as well as username', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new AuthService(prisma, new JwtService({ secret: 'test-secret' }));
|
|
try {
|
|
const byUsername = await service.login('manager', 'Password@123');
|
|
const byEmail = await service.login('manager@okr.local', 'Password@123');
|
|
assert.equal(byUsername.user.id, byEmail.user.id);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
// ─── ObjectivesService.list ──────────────────────────────────────────────────
|
|
|
|
test('ObjectivesService.list employee sees only own objectives', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
try {
|
|
const results = await service.list(employeeUser);
|
|
assert.ok(results.length > 0, 'Employee must have at least one objective');
|
|
assert.ok(
|
|
results.every((o) => o.ownerId === employeeUser.sub),
|
|
'Every returned objective must belong to the employee',
|
|
);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('ObjectivesService.list manager sees all objectives', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
try {
|
|
const managerResults = await service.list(managerUser);
|
|
const employeeResults = await service.list(employeeUser);
|
|
assert.ok(managerResults.length > employeeResults.length, 'Manager must see more objectives than employee');
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('ObjectivesService.list quarter filter narrows results to matching quarter only', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
try {
|
|
const all = await service.list(managerUser);
|
|
const filtered = await service.list(managerUser, 'Q2/2026');
|
|
const empty = await service.list(managerUser, 'Q1/1900');
|
|
|
|
assert.ok(filtered.length <= all.length);
|
|
assert.ok(filtered.every((o) => o.quarter === 'Q2/2026'), 'All filtered results must match the requested quarter');
|
|
assert.equal(empty.length, 0, 'No objectives should match a future non-existent quarter');
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
// ─── ObjectivesService.getById ───────────────────────────────────────────────
|
|
|
|
test('ObjectivesService.getById returns objective with computedProgress', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
try {
|
|
// Objective 1: KR1=33, KR2=60 → avg = Math.round((33+60)/2) = 47
|
|
const result = await service.getById(1, managerUser);
|
|
assert.equal(result.id, 1);
|
|
assert.equal(result.computedProgress, 47);
|
|
assert.ok(result.keyResults.length === 2);
|
|
assert.ok(result.owner !== undefined, 'Owner relation must be loaded');
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('ObjectivesService.getById employee accessing own objective succeeds', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
try {
|
|
// Objective 1 is owned by employee (id=3)
|
|
const result = await service.getById(1, employeeUser);
|
|
assert.equal(result.ownerId, employeeUser.sub);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('ObjectivesService.getById employee accessing another user objective throws ForbiddenException', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
try {
|
|
// Objective 3 belongs to manager (id=2), not employee (id=3)
|
|
await assert.rejects(() => service.getById(3, employeeUser), ForbiddenException);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('ObjectivesService.getById non-existent id throws NotFoundException', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
try {
|
|
await assert.rejects(() => service.getById(999999, managerUser), NotFoundException);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
// ─── ObjectivesService.create ────────────────────────────────────────────────
|
|
|
|
test('ObjectivesService.create sets status to NOT_STARTED and loads relations', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
let createdId: number | undefined;
|
|
try {
|
|
const result = await service.create(
|
|
{ title: 'Test objective for create', ownerId: employeeUser.sub, quarter: 'Q1/2099' },
|
|
managerUser,
|
|
);
|
|
createdId = result.id;
|
|
assert.equal(result.status, 'NOT_STARTED');
|
|
assert.equal(result.ownerId, employeeUser.sub);
|
|
assert.equal(result.quarter, 'Q1/2099');
|
|
assert.ok(result.owner !== undefined, 'Owner relation must be eagerly loaded');
|
|
assert.deepEqual(result.keyResults, [], 'New objective must have no key results');
|
|
} finally {
|
|
if (createdId) await prisma.objective.delete({ where: { id: createdId } });
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('ObjectivesService.create employee creating for another user throws ForbiddenException', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
try {
|
|
await assert.rejects(
|
|
() =>
|
|
service.create(
|
|
{ title: 'Sneaky objective', ownerId: employee2User.sub, quarter: 'Q1/2099' },
|
|
employeeUser,
|
|
),
|
|
ForbiddenException,
|
|
);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('ObjectivesService.create with non-existent owner throws NotFoundException', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new ObjectivesService(prisma);
|
|
try {
|
|
await assert.rejects(
|
|
() => service.create({ title: 'Ghost objective', ownerId: 999999, quarter: 'Q1/2099' }, managerUser),
|
|
NotFoundException,
|
|
);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
// ─── KeyResultsService.getById ───────────────────────────────────────────────
|
|
|
|
test('KeyResultsService.getById returns KR with nested objective and owner', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new KeyResultsService(prisma);
|
|
try {
|
|
// KR 1 belongs to objective 1 which is owned by employee
|
|
const result = await service.getById(1, employeeUser);
|
|
assert.equal(result.id, 1);
|
|
assert.ok(typeof result.progress === 'number');
|
|
assert.ok(result.objective !== undefined, 'Objective relation must be loaded');
|
|
assert.ok(result.objective.owner !== undefined, 'Owner relation must be loaded inside objective');
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('KeyResultsService.getById employee reading another owner KR throws ForbiddenException', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new KeyResultsService(prisma);
|
|
try {
|
|
// KR 3 belongs to objective 2 (ownerId=employee2/id=4), not employee (id=3)
|
|
await assert.rejects(() => service.getById(3, employeeUser), ForbiddenException);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('KeyResultsService.getById non-existent id throws NotFoundException', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new KeyResultsService(prisma);
|
|
try {
|
|
await assert.rejects(() => service.getById(999999, managerUser), NotFoundException);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
// ─── KeyResultsService.updateProgress ────────────────────────────────────────
|
|
|
|
test('KeyResultsService.updateProgress blocks employee updating KR from another owner', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new KeyResultsService(prisma);
|
|
try {
|
|
// KR 3 belongs to employee2's objective — employee cannot update it
|
|
await assert.rejects(
|
|
() => service.updateProgress(3, { progress: 50, comment: 'Not mine' }, employeeUser),
|
|
ForbiddenException,
|
|
);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('KeyResultsService.updateProgress records new progress value and creates ProgressUpdate', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new KeyResultsService(prisma);
|
|
try {
|
|
const updated = await service.updateProgress(1, { progress: 100, comment: 'Completed KR' }, employeeUser);
|
|
assert.equal(updated.progress, 100);
|
|
const progressUpdates = await prisma.progressUpdate.findMany({ where: { keyResultId: 1, progress: 100 } });
|
|
assert.ok(progressUpdates.length > 0, 'A ProgressUpdate record must be created');
|
|
} finally {
|
|
await prisma.keyResult.update({ where: { id: 1 }, data: { progress: 33 } });
|
|
await prisma.progressUpdate.deleteMany({ where: { keyResultId: 1 } });
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('KeyResultsService.updateProgress non-existent KR throws NotFoundException', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new KeyResultsService(prisma);
|
|
try {
|
|
await assert.rejects(
|
|
() => service.updateProgress(999999, { progress: 50, comment: 'ghost' }, managerUser),
|
|
NotFoundException,
|
|
);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
// ─── Status recalculation (full NOT_STARTED → IN_PROGRESS → COMPLETED cycle) ─
|
|
|
|
test('KeyResultsService status recalculation: NOT_STARTED → IN_PROGRESS → COMPLETED → IN_PROGRESS', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new KeyResultsService(prisma);
|
|
let testObjectiveId: number | undefined;
|
|
let testKrId: number | undefined;
|
|
try {
|
|
// Create isolated objective + KR owned by employee
|
|
const obj = await prisma.objective.create({
|
|
data: { title: 'status-cycle-test', ownerId: employeeUser.sub, quarter: 'Q4/2099', status: 'NOT_STARTED' },
|
|
});
|
|
testObjectiveId = obj.id;
|
|
const kr = await prisma.keyResult.create({
|
|
data: {
|
|
objectiveId: obj.id,
|
|
title: 'single KR for status test',
|
|
progress: 0,
|
|
startValue: 0,
|
|
targetValue: 10,
|
|
deadline: new Date('2099-12-31'),
|
|
},
|
|
});
|
|
testKrId = kr.id;
|
|
|
|
// 0% → IN_PROGRESS
|
|
await service.updateProgress(kr.id, { progress: 50, comment: 'halfway' }, employeeUser);
|
|
const afterHalf = await prisma.objective.findUniqueOrThrow({ where: { id: obj.id } });
|
|
assert.equal(afterHalf.status, 'IN_PROGRESS');
|
|
|
|
// 100% → COMPLETED
|
|
await service.updateProgress(kr.id, { progress: 100, comment: 'done' }, employeeUser);
|
|
const afterFull = await prisma.objective.findUniqueOrThrow({ where: { id: obj.id } });
|
|
assert.equal(afterFull.status, 'COMPLETED');
|
|
|
|
// Regression back → IN_PROGRESS
|
|
await service.updateProgress(kr.id, { progress: 80, comment: 'regressed' }, employeeUser);
|
|
const afterRegress = await prisma.objective.findUniqueOrThrow({ where: { id: obj.id } });
|
|
assert.equal(afterRegress.status, 'IN_PROGRESS');
|
|
|
|
// Back to 0 → NOT_STARTED
|
|
await service.updateProgress(kr.id, { progress: 0, comment: 'reset' }, employeeUser);
|
|
const afterZero = await prisma.objective.findUniqueOrThrow({ where: { id: obj.id } });
|
|
assert.equal(afterZero.status, 'NOT_STARTED');
|
|
} finally {
|
|
if (testKrId) await prisma.keyResult.delete({ where: { id: testKrId } });
|
|
if (testObjectiveId) await prisma.objective.delete({ where: { id: testObjectiveId } });
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|
|
|
|
test('KeyResultsService averageProgress: multi-KR completion drives COMPLETED status', async () => {
|
|
const prisma = new PrismaService();
|
|
await prisma.$connect();
|
|
const service = new KeyResultsService(prisma);
|
|
let testObjectiveId: number | undefined;
|
|
let kr1Id: number | undefined;
|
|
let kr2Id: number | undefined;
|
|
try {
|
|
const obj = await prisma.objective.create({
|
|
data: { title: 'multi-kr-test', ownerId: employeeUser.sub, quarter: 'Q4/2099', status: 'NOT_STARTED' },
|
|
});
|
|
testObjectiveId = obj.id;
|
|
const kr1 = await prisma.keyResult.create({
|
|
data: { objectiveId: obj.id, title: 'kr1', progress: 0, startValue: 0, targetValue: 1, deadline: new Date('2099-12-31') },
|
|
});
|
|
kr1Id = kr1.id;
|
|
const kr2 = await prisma.keyResult.create({
|
|
data: { objectiveId: obj.id, title: 'kr2', progress: 0, startValue: 0, targetValue: 1, deadline: new Date('2099-12-31') },
|
|
});
|
|
kr2Id = kr2.id;
|
|
|
|
// Set KR1 to 100%, KR2 stays 0 → avg=50 → IN_PROGRESS
|
|
await service.updateProgress(kr1.id, { progress: 100, comment: '' }, employeeUser);
|
|
const afterFirst = await prisma.objective.findUniqueOrThrow({ where: { id: obj.id } });
|
|
assert.equal(afterFirst.status, 'IN_PROGRESS');
|
|
|
|
// Set KR2 to 100% → avg=100 → COMPLETED
|
|
await service.updateProgress(kr2.id, { progress: 100, comment: '' }, employeeUser);
|
|
const afterBoth = await prisma.objective.findUniqueOrThrow({ where: { id: obj.id } });
|
|
assert.equal(afterBoth.status, 'COMPLETED');
|
|
} finally {
|
|
if (kr1Id) await prisma.keyResult.delete({ where: { id: kr1Id } });
|
|
if (kr2Id) await prisma.keyResult.delete({ where: { id: kr2Id } });
|
|
if (testObjectiveId) await prisma.objective.delete({ where: { id: testObjectiveId } });
|
|
await prisma.$disconnect();
|
|
}
|
|
});
|