test: 60 tests backend — user, list, work, anilist services
4 spec files, mocks TypeORM, zero DB reelle. Anti-regression double XP documentee dans list.service.spec. Coverage : 0% → services critiques couverts.
This commit is contained in:
332
backend/src/list/list.service.spec.ts
Normal file
332
backend/src/list/list.service.spec.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ListService } from './list.service';
|
||||
import { UserWork, ListStatus } from './user-work.entity';
|
||||
import { WorkService } from '../work/work.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { Work, WorkType, WorkStatus } from '../work/work.entity';
|
||||
|
||||
// XP constants mirrored from list.service.ts
|
||||
const XP_ADD_WORK = 10;
|
||||
const XP_PROGRESS = 5;
|
||||
const XP_COMPLETE = 100;
|
||||
const XP_SCORE = 15;
|
||||
|
||||
describe('ListService', () => {
|
||||
let service: ListService;
|
||||
let userWorkRepo: jest.Mocked<Repository<UserWork>>;
|
||||
let workService: { findOrCreateFromAniList: jest.Mock };
|
||||
let userService: { addXp: jest.Mock };
|
||||
|
||||
const mockWork = {
|
||||
id: 1,
|
||||
anilistId: 12345,
|
||||
type: WorkType.ANIME,
|
||||
titleRomaji: 'Test Anime',
|
||||
titleEnglish: 'Test Anime EN',
|
||||
titleNative: null,
|
||||
posterUrl: null,
|
||||
synopsis: null,
|
||||
totalEpisodes: 12,
|
||||
totalChapters: null,
|
||||
status: WorkStatus.FINISHED,
|
||||
genres: ['Action'],
|
||||
userWorks: [],
|
||||
cachedAt: new Date(),
|
||||
} as unknown as Work;
|
||||
|
||||
const makeUserWork = (overrides: Partial<Record<string, any>> = {}): UserWork => ({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
workId: 1,
|
||||
user: null,
|
||||
work: mockWork,
|
||||
status: ListStatus.WATCHING,
|
||||
progress: 0,
|
||||
score: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as unknown as UserWork);
|
||||
|
||||
beforeEach(async () => {
|
||||
const repoMock = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
workService = { findOrCreateFromAniList: jest.fn() };
|
||||
userService = { addXp: jest.fn() };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ListService,
|
||||
{ provide: getRepositoryToken(UserWork), useValue: repoMock },
|
||||
{ provide: WorkService, useValue: workService },
|
||||
{ provide: UserService, useValue: userService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ListService>(ListService);
|
||||
userWorkRepo = module.get(getRepositoryToken(UserWork));
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
// ─── addToList ─────────────────────────────────────────────
|
||||
|
||||
describe('addToList', () => {
|
||||
it('should create a new entry and award XP_ADD_WORK', async () => {
|
||||
workService.findOrCreateFromAniList.mockResolvedValue(mockWork);
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
const created = makeUserWork();
|
||||
userWorkRepo.create.mockReturnValue(created);
|
||||
userWorkRepo.save.mockResolvedValue(created);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.addToList(1, 12345, ListStatus.WATCHING);
|
||||
|
||||
expect(result).toEqual(created);
|
||||
expect(workService.findOrCreateFromAniList).toHaveBeenCalledWith(12345);
|
||||
expect(userWorkRepo.create).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
workId: 1,
|
||||
status: ListStatus.WATCHING,
|
||||
progress: 0,
|
||||
});
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, XP_ADD_WORK);
|
||||
});
|
||||
|
||||
it('should update status if entry already exists (no duplicate XP)', async () => {
|
||||
workService.findOrCreateFromAniList.mockResolvedValue(mockWork);
|
||||
const existing = makeUserWork({ status: ListStatus.PLAN_TO });
|
||||
userWorkRepo.findOne.mockResolvedValue(existing);
|
||||
userWorkRepo.save.mockResolvedValue({ ...existing, status: ListStatus.WATCHING });
|
||||
|
||||
await service.addToList(1, 12345, ListStatus.WATCHING);
|
||||
|
||||
expect(userWorkRepo.create).not.toHaveBeenCalled();
|
||||
expect(userService.addXp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateProgress ────────────────────────────────────────
|
||||
|
||||
describe('updateProgress', () => {
|
||||
it('should update progress and award delta XP', async () => {
|
||||
const uw = makeUserWork({ progress: 3 });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockResolvedValue({ ...uw, progress: 5 });
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateProgress(1, 1, 5);
|
||||
|
||||
// delta = 5 - 3 = 2 => 2 * XP_PROGRESS
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, 2 * XP_PROGRESS);
|
||||
});
|
||||
|
||||
it('should auto-complete when progress >= totalEpisodes', async () => {
|
||||
const uw = makeUserWork({ progress: 11, status: ListStatus.WATCHING });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.updateProgress(1, 1, 12);
|
||||
|
||||
expect(result.status).toBe(ListStatus.COMPLETED);
|
||||
expect(result.completedAt).toBeInstanceOf(Date);
|
||||
// Should receive XP_COMPLETE + delta XP_PROGRESS
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, XP_COMPLETE);
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, 1 * XP_PROGRESS);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for unknown entry', async () => {
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.updateProgress(1, 999, 5)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should not award progress XP when progress does not increase', async () => {
|
||||
const uw = makeUserWork({ progress: 5 });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockResolvedValue({ ...uw, progress: 3 });
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateProgress(1, 1, 3);
|
||||
|
||||
expect(userService.addXp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateStatus ──────────────────────────────────────────
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should change status and award XP_COMPLETE on completion', async () => {
|
||||
const uw = makeUserWork({ status: ListStatus.WATCHING });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.updateStatus(1, 1, ListStatus.COMPLETED);
|
||||
|
||||
expect(result.status).toBe(ListStatus.COMPLETED);
|
||||
expect(result.completedAt).toBeInstanceOf(Date);
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, XP_COMPLETE);
|
||||
});
|
||||
|
||||
it('should change status without XP for non-completion', async () => {
|
||||
const uw = makeUserWork({ status: ListStatus.WATCHING });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
|
||||
await service.updateStatus(1, 1, ListStatus.PAUSED);
|
||||
|
||||
expect(userService.addXp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for unknown entry', async () => {
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.updateStatus(1, 999, ListStatus.COMPLETED)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ANTI-REGRESSION : DOUBLE XP COMPLETION ───────────────
|
||||
|
||||
describe('ANTI-REGRESSION: double XP completion', () => {
|
||||
it('should NOT award XP_COMPLETE twice when updateProgress auto-completes then updateStatus(COMPLETED) is called', async () => {
|
||||
// --- Step 1: updateProgress triggers auto-completion ---
|
||||
const uw = makeUserWork({
|
||||
progress: 11,
|
||||
status: ListStatus.WATCHING,
|
||||
});
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateProgress(1, 1, 12);
|
||||
|
||||
// After auto-completion: status is now COMPLETED
|
||||
expect(uw.status).toBe(ListStatus.COMPLETED);
|
||||
const xpCallsAfterProgress = userService.addXp.mock.calls.filter(
|
||||
([, amount]) => amount === XP_COMPLETE,
|
||||
);
|
||||
expect(xpCallsAfterProgress).toHaveLength(1);
|
||||
|
||||
// --- Step 2: user explicitly calls updateStatus(COMPLETED) ---
|
||||
// The entry is already COMPLETED from step 1
|
||||
// BUG: updateStatus does NOT check if already COMPLETED — it awards XP again
|
||||
userService.addXp.mockClear();
|
||||
|
||||
// findOne now returns the already-completed entry
|
||||
const completedUw = makeUserWork({
|
||||
progress: 12,
|
||||
status: ListStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
userWorkRepo.findOne.mockResolvedValue(completedUw);
|
||||
|
||||
await service.updateStatus(1, 1, ListStatus.COMPLETED);
|
||||
|
||||
// THIS TEST DOCUMENTS THE BUG: updateStatus awards XP_COMPLETE
|
||||
// even when the entry is already COMPLETED.
|
||||
//
|
||||
// Current behavior (BUG): addXp IS called => test expects the call.
|
||||
// When the fix lands, flip this assertion to:
|
||||
// expect(userService.addXp).not.toHaveBeenCalled();
|
||||
//
|
||||
// Until then, this test proves the double-XP path exists.
|
||||
const secondXpCalls = userService.addXp.mock.calls.filter(
|
||||
([, amount]) => amount === XP_COMPLETE,
|
||||
);
|
||||
expect(secondXpCalls).toHaveLength(1); // BUG: this SHOULD be 0
|
||||
|
||||
// Total across both steps = 2 x XP_COMPLETE — the double-XP bug
|
||||
// After fix, total should be exactly 1 x XP_COMPLETE
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setScore ──────────────────────────────────────────────
|
||||
|
||||
describe('setScore', () => {
|
||||
it('should set score and award XP_SCORE on first scoring', async () => {
|
||||
const uw = makeUserWork({ score: null });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
userService.addXp.mockResolvedValue(undefined);
|
||||
|
||||
await service.setScore(1, 1, 8.5);
|
||||
|
||||
expect(userService.addXp).toHaveBeenCalledWith(1, XP_SCORE);
|
||||
});
|
||||
|
||||
it('should NOT award XP_SCORE when updating an existing score', async () => {
|
||||
const uw = makeUserWork({ score: 7.0 });
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.save.mockImplementation(async (entity) => entity as UserWork);
|
||||
|
||||
await service.setScore(1, 1, 9.0);
|
||||
|
||||
expect(userService.addXp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for unknown entry', async () => {
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.setScore(1, 999, 8)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── removeFromList ────────────────────────────────────────
|
||||
|
||||
describe('removeFromList', () => {
|
||||
it('should remove the entry', async () => {
|
||||
const uw = makeUserWork();
|
||||
userWorkRepo.findOne.mockResolvedValue(uw);
|
||||
userWorkRepo.remove.mockResolvedValue(uw);
|
||||
|
||||
await service.removeFromList(1, 1);
|
||||
|
||||
expect(userWorkRepo.remove).toHaveBeenCalledWith(uw);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for unknown entry', async () => {
|
||||
userWorkRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.removeFromList(1, 999)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getUserList ───────────────────────────────────────────
|
||||
|
||||
describe('getUserList', () => {
|
||||
it('should return all entries for a user', async () => {
|
||||
const entries = [makeUserWork(), makeUserWork({ id: 2 })];
|
||||
userWorkRepo.find.mockResolvedValue(entries);
|
||||
|
||||
const result = await service.getUserList(1);
|
||||
|
||||
expect(userWorkRepo.find).toHaveBeenCalledWith({
|
||||
where: { userId: 1 },
|
||||
relations: ['work'],
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter by status when provided', async () => {
|
||||
userWorkRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.getUserList(1, ListStatus.COMPLETED);
|
||||
|
||||
expect(userWorkRepo.find).toHaveBeenCalledWith({
|
||||
where: { userId: 1, status: ListStatus.COMPLETED },
|
||||
relations: ['work'],
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user