Files
CASAN/backend/test/services.test.ts
T
thanhnvandClaude Opus 4.8 36a4812ef3 refactor(structure): promote app to repo root + remove redundant workspace cruft
Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.

- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
  .specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
  active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
  launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
  tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
  README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
  artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
  - .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
    working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
    -> packages/casan-harness/... (.specify/logs state kept)
  - .claude/launch.json, .gitea/*-runbook.md: path prefixes
  - CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
  - policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.

Full gate from the new root: PASS=64 FAIL=0 SKIP=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:26:36 +09:00

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