468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import request from 'supertest';
|
|
import { createTestApp, loginToken } from './helpers.js';
|
|
|
|
// ─── Auth guard ───────────────────────────────────────────────────────────────
|
|
|
|
test('unauthenticated requests are rejected with 401', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
await request(app.getHttpServer()).get('/api/v1/objectives').expect(401);
|
|
await request(app.getHttpServer()).get('/api/v1/objectives/1').expect(401);
|
|
await request(app.getHttpServer()).get('/api/v1/key-results/1').expect(401);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
// ─── GET /objectives (list) ───────────────────────────────────────────────────
|
|
|
|
test('GET /objectives employee sees only own objectives', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
assert.equal(res.body.success, true);
|
|
assert.ok(Array.isArray(res.body.data));
|
|
// Every returned objective must belong to employee (id=3)
|
|
assert.ok(res.body.data.every((o: { ownerId: number }) => o.ownerId === 3));
|
|
assert.equal(res.body.meta.total, res.body.data.length);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('GET /objectives manager sees all objectives', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const managerToken = await loginToken(app, 'manager');
|
|
const employeeToken = await loginToken(app, 'employee');
|
|
const managerRes = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${managerToken}`)
|
|
.expect(200);
|
|
const employeeRes = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.expect(200);
|
|
assert.ok(managerRes.body.data.length > employeeRes.body.data.length);
|
|
assert.equal(managerRes.body.data.length, 3);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('GET /objectives?quarter= filters to matching quarter only', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
const matching = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives?quarter=Q2/2026')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
const empty = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives?quarter=Q1/1900')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
assert.ok(matching.body.data.every((o: { quarter: string }) => o.quarter === 'Q2/2026'));
|
|
assert.equal(empty.body.data.length, 0);
|
|
assert.equal(empty.body.meta.total, 0);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
// ─── GET /objectives/:id ──────────────────────────────────────────────────────
|
|
|
|
test('GET /objectives/:id returns objective with computedProgress and relations', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives/1')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
const data = res.body.data;
|
|
assert.equal(data.id, 1);
|
|
assert.equal(typeof data.computedProgress, 'number');
|
|
// KR1=33, KR2=60 → avg = Math.round(46.5) = 47
|
|
assert.equal(data.computedProgress, 47);
|
|
assert.ok(data.owner !== undefined);
|
|
assert.ok(Array.isArray(data.keyResults));
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('GET /objectives/:id returns 404 for non-existent objective', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/objectives/999999')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(404);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('GET /objectives/:id returns 403 when employee accesses another owner objective', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
// Objective 3 belongs to manager (id=2), not employee (id=3)
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/objectives/3')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(403);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
// ─── POST /objectives ─────────────────────────────────────────────────────────
|
|
|
|
test('POST /objectives returns 400 for empty title', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ title: '', ownerId: 3, quarter: 'Q1/2026' })
|
|
.expect(400);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('POST /objectives returns 400 for invalid quarter format', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
// DTO requires Q[1-4]/YYYY format
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ title: 'Valid title', ownerId: 3, quarter: '2026-Q2' })
|
|
.expect(400);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('POST /objectives manager creates objective and receives it with NOT_STARTED status', async () => {
|
|
const { app, prisma } = await createTestApp();
|
|
let createdId: number | undefined;
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
const res = await request(app.getHttpServer())
|
|
.post('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ title: 'E2E created objective', ownerId: 3, quarter: 'Q1/2099' })
|
|
.expect(201);
|
|
createdId = res.body.data.id;
|
|
assert.equal(res.body.data.status, 'NOT_STARTED');
|
|
assert.equal(res.body.data.ownerId, 3);
|
|
assert.ok(typeof createdId === 'number');
|
|
} finally {
|
|
if (createdId) await prisma.objective.delete({ where: { id: createdId } });
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('POST /objectives returns 403 when employee creates for another user', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
// employee (id=3) trying to create objective for employee2 (id=4)
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ title: 'Sneaky objective', ownerId: 4, quarter: 'Q1/2099' })
|
|
.expect(403);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('POST /objectives returns 404 for non-existent owner', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ title: 'Ghost owner objective', ownerId: 999999, quarter: 'Q1/2099' })
|
|
.expect(404);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
// ─── GET /key-results/:id ─────────────────────────────────────────────────────
|
|
|
|
test('GET /key-results/:id returns KR with nested objective', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/v1/key-results/1')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
assert.equal(res.body.success, true);
|
|
assert.equal(res.body.data.id, 1);
|
|
assert.ok(res.body.data.objective !== undefined);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('GET /key-results/:id returns 404 for non-existent KR', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/key-results/999999')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(404);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('GET /key-results/:id returns 403 when employee reads another owner KR', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
// KR 3 belongs to objective 2 (ownerId=employee2/id=4)
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/key-results/3')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(403);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
// ─── POST /key-results ────────────────────────────────────────────────────────
|
|
|
|
test('POST /key-results employee creates KR on own objective', async () => {
|
|
const { app, prisma } = await createTestApp();
|
|
let createdId: number | undefined;
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
const res = await request(app.getHttpServer())
|
|
.post('/api/v1/key-results')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({
|
|
objectiveId: 1,
|
|
title: 'E2E created key result',
|
|
progress: 0,
|
|
startValue: 0,
|
|
targetValue: 5,
|
|
deadline: '2099-12-31',
|
|
})
|
|
.expect(201);
|
|
createdId = res.body.data.id;
|
|
assert.equal(res.body.data.progress, 0);
|
|
assert.equal(res.body.data.objectiveId, 1);
|
|
} finally {
|
|
if (createdId) await prisma.keyResult.delete({ where: { id: createdId } });
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('POST /key-results returns 403 when employee creates KR on another owner objective', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
// Objective 3 belongs to manager (id=2)
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/key-results')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({
|
|
objectiveId: 3,
|
|
title: 'Sneaky KR',
|
|
progress: 0,
|
|
startValue: 0,
|
|
targetValue: 1,
|
|
deadline: '2099-12-31',
|
|
})
|
|
.expect(403);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
// ─── PATCH /key-results/:id/progress ─────────────────────────────────────────
|
|
|
|
test('PATCH /key-results/:id/progress updates progress and returns updated KR', async () => {
|
|
const { app, prisma } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
const res = await request(app.getHttpServer())
|
|
.patch('/api/v1/key-results/2/progress')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ progress: 70, comment: 'E2E progress update' })
|
|
.expect(200);
|
|
assert.equal(res.body.data.progress, 70);
|
|
} finally {
|
|
await prisma.keyResult.update({ where: { id: 2 }, data: { progress: 60 } });
|
|
await prisma.progressUpdate.deleteMany({ where: { keyResultId: 2, progress: 70 } });
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('PATCH /key-results/:id/progress returns 400 for out-of-range values', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
await request(app.getHttpServer())
|
|
.patch('/api/v1/key-results/1/progress')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ progress: -1 })
|
|
.expect(400);
|
|
await request(app.getHttpServer())
|
|
.patch('/api/v1/key-results/1/progress')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ progress: 101 })
|
|
.expect(400);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('PATCH /key-results/:id/progress returns 403 when employee updates another owner KR', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
// KR 3 belongs to employee2's objective
|
|
await request(app.getHttpServer())
|
|
.patch('/api/v1/key-results/3/progress')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ progress: 50 })
|
|
.expect(403);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('PATCH /key-results/:id/progress returns 404 for non-existent KR', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
await request(app.getHttpServer())
|
|
.patch('/api/v1/key-results/999999/progress')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ progress: 50 })
|
|
.expect(404);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
// ─── Golden regression ────────────────────────────────────────────────────────
|
|
|
|
test('golden: manager objective list does not drift', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
const canonical = JSON.stringify(
|
|
{
|
|
success: response.body.success,
|
|
data: response.body.data.map((o: { id: number; title: string; quarter: string; status: string; keyResults: { id: number; title: string; progress: number }[] }) => ({
|
|
id: o.id,
|
|
title: o.title,
|
|
quarter: o.quarter,
|
|
status: o.status,
|
|
keyResults: o.keyResults.map((kr) => ({ id: kr.id, title: kr.title, progress: kr.progress })),
|
|
})),
|
|
meta: response.body.meta,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
const fixture = await fs.readFile(path.resolve('test/golden/objectives.manager.json'), 'utf8');
|
|
assert.equal(`${canonical}\n`, fixture, 'Manager objective list response drifted from golden fixture');
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('golden: employee objective list does not drift', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
const canonical = JSON.stringify(
|
|
{
|
|
success: response.body.success,
|
|
data: response.body.data.map((o: { id: number; title: string; quarter: string; status: string; keyResults: { id: number; title: string; progress: number }[] }) => ({
|
|
id: o.id,
|
|
title: o.title,
|
|
quarter: o.quarter,
|
|
status: o.status,
|
|
keyResults: o.keyResults.map((kr) => ({ id: kr.id, title: kr.title, progress: kr.progress })),
|
|
})),
|
|
meta: response.body.meta,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
const fixture = await fs.readFile(path.resolve('test/golden/objectives.employee.json'), 'utf8');
|
|
assert.equal(`${canonical}\n`, fixture, 'Employee objective list response drifted from golden fixture');
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test('golden: objective detail (GET /objectives/1) does not drift', async () => {
|
|
const { app } = await createTestApp();
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives/1')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
const data = response.body.data;
|
|
const canonical = JSON.stringify(
|
|
{
|
|
success: response.body.success,
|
|
data: {
|
|
id: data.id,
|
|
title: data.title,
|
|
quarter: data.quarter,
|
|
status: data.status,
|
|
computedProgress: data.computedProgress,
|
|
keyResults: data.keyResults.map((kr: { id: number; title: string; progress: number }) => ({
|
|
id: kr.id,
|
|
title: kr.title,
|
|
progress: kr.progress,
|
|
})),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
const fixture = await fs.readFile(path.resolve('test/golden/objective-detail.json'), 'utf8');
|
|
assert.equal(`${canonical}\n`, fixture, 'Objective detail response drifted from golden fixture');
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|