Files
CASAN/apps/okr/backend/test/e2e.test.ts
T
2026-07-11 15:56:31 +09:00

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