Compare commits

..

4 Commits

Author SHA1 Message Date
f83b07863d Merge branch 'sprint/sakuin/setup-jest'
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 25s
2026-04-05 07:30:25 +02:00
e68c8d1f19 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.
2026-04-05 07:29:37 +02:00
2504c3756d feat: setup Jest backend — config + deps + scripts test/watch/cov 2026-04-05 07:25:46 +02:00
1059d4ae17 fix: prevent double XP on completion — guard already-completed status 2026-04-05 07:19:44 +02:00
8 changed files with 4550 additions and 3 deletions

15
backend/jest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Config } from 'jest';
const config: Config = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
};
export default config;

3480
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,10 @@
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- migration:generate src/migrations/$npm_config_name -d src/config/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/data-source.ts"
"migration:revert": "npm run typeorm -- migration:revert -d src/config/data-source.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
},
"dependencies": {
"@nestjs/common": "^11.1.17",
@@ -30,9 +33,13 @@
"devDependencies": {
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9",
"@nestjs/testing": "^11.1.18",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/node": "^25.5.0",
"jest": "^30.3.0",
"ts-jest": "^29.4.9",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.2"

View 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' },
});
});
});
});

View File

@@ -97,9 +97,10 @@ export class ListService {
});
if (!uw) throw new NotFoundException('Entry not found');
const wasAlreadyCompleted = uw.status === ListStatus.COMPLETED;
uw.status = status;
if (status === ListStatus.COMPLETED) {
uw.completedAt = new Date();
if (status === ListStatus.COMPLETED && !wasAlreadyCompleted) {
uw.completedAt = uw.completedAt || new Date();
await this.userService.addXp(userId, XP_COMPLETE);
}

View File

@@ -0,0 +1,196 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { User } from './user.entity';
const mockRepo = () => ({
findOne: jest.fn(),
findOneByOrFail: jest.fn(),
create: jest.fn(),
save: jest.fn(),
});
type MockRepository = ReturnType<typeof mockRepo>;
describe('UserService', () => {
let service: UserService;
let repo: MockRepository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{ provide: getRepositoryToken(User), useFactory: mockRepo },
],
}).compile();
service = module.get<UserService>(UserService);
repo = module.get(getRepositoryToken(User));
});
// --- calculateLevel (private, accessed via bracket notation) ---
describe('calculateLevel', () => {
it('returns 1 for 0 XP', () => {
expect(service['calculateLevel'](0)).toBe(1);
});
it('returns 1 for 100 XP (below first threshold)', () => {
expect(service['calculateLevel'](100)).toBe(1);
});
it('returns 1 for 499 XP (just below threshold)', () => {
expect(service['calculateLevel'](499)).toBe(1);
});
it('returns 2 for exactly 500 XP', () => {
expect(service['calculateLevel'](500)).toBe(2);
});
it('returns 2 for 999 XP', () => {
expect(service['calculateLevel'](999)).toBe(2);
});
it('returns 3 for 1000 XP', () => {
expect(service['calculateLevel'](1000)).toBe(3);
});
it('returns 11 for 5000 XP', () => {
expect(service['calculateLevel'](5000)).toBe(11);
});
});
// --- addXp ---
describe('addXp', () => {
it('adds XP and recalculates level', async () => {
const user = { id: 1, xp: 200, level: 1 } as User;
repo.findOneByOrFail.mockResolvedValue(user);
repo.save.mockImplementation((u) => Promise.resolve(u));
const result = await service.addXp(1, 300);
expect(result.xp).toBe(500);
expect(result.level).toBe(2);
expect(repo.save).toHaveBeenCalledWith(user);
});
it('keeps level 1 when XP stays below 500', async () => {
const user = { id: 1, xp: 50, level: 1 } as User;
repo.findOneByOrFail.mockResolvedValue(user);
repo.save.mockImplementation((u) => Promise.resolve(u));
const result = await service.addXp(1, 100);
expect(result.xp).toBe(150);
expect(result.level).toBe(1);
});
it('jumps multiple levels on large XP gain', async () => {
const user = { id: 1, xp: 0, level: 1 } as User;
repo.findOneByOrFail.mockResolvedValue(user);
repo.save.mockImplementation((u) => Promise.resolve(u));
const result = await service.addXp(1, 1500);
expect(result.xp).toBe(1500);
expect(result.level).toBe(4);
});
});
// --- findOrCreate ---
describe('findOrCreate', () => {
const profile = { id: 'oauth-42', nickname: 'tetard', avatar: 'https://img/a.png' };
it('returns existing user when found and unchanged', async () => {
const existing = {
id: 1,
superOauthId: 'oauth-42',
username: 'tetard',
avatarUrl: 'https://img/a.png',
} as User;
repo.findOne.mockResolvedValue(existing);
const result = await service.findOrCreate(profile);
expect(result).toBe(existing);
expect(repo.save).not.toHaveBeenCalled();
expect(repo.create).not.toHaveBeenCalled();
});
it('creates a new user when none exists', async () => {
const created = {
id: 1,
superOauthId: 'oauth-42',
username: 'tetard',
avatarUrl: 'https://img/a.png',
} as User;
repo.findOne.mockResolvedValue(null);
repo.create.mockReturnValue(created);
repo.save.mockResolvedValue(created);
const result = await service.findOrCreate(profile);
expect(repo.create).toHaveBeenCalledWith({
superOauthId: 'oauth-42',
username: 'tetard',
avatarUrl: 'https://img/a.png',
});
expect(repo.save).toHaveBeenCalledWith(created);
expect(result).toBe(created);
});
it('updates username when it changed upstream', async () => {
const existing = {
id: 1,
superOauthId: 'oauth-42',
username: 'old-name',
avatarUrl: 'https://img/a.png',
} as User;
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((u) => Promise.resolve(u));
const result = await service.findOrCreate(profile);
expect(result.username).toBe('tetard');
expect(repo.save).toHaveBeenCalled();
});
it('updates avatar when it changed upstream', async () => {
const existing = {
id: 1,
superOauthId: 'oauth-42',
username: 'tetard',
avatarUrl: 'https://img/old.png',
} as User;
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((u) => Promise.resolve(u));
const result = await service.findOrCreate({ ...profile, avatar: 'https://img/new.png' });
expect(result.avatarUrl).toBe('https://img/new.png');
expect(repo.save).toHaveBeenCalled();
});
it('keeps existing avatar when upstream avatar is undefined', async () => {
const existing = {
id: 1,
superOauthId: 'oauth-42',
username: 'tetard',
avatarUrl: 'https://img/a.png',
} as User;
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((u) => Promise.resolve(u));
const result = await service.findOrCreate({ id: 'oauth-42', nickname: 'new-name' });
expect(result.avatarUrl).toBe('https://img/a.png');
});
});
});

View File

@@ -0,0 +1,300 @@
/// <reference types="jest" />
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { AniListService, AniListMedia } from './anilist.service';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const ANIME_FIXTURE: AniListMedia = {
id: 21,
type: 'ANIME',
title: { romaji: 'One Punch Man', english: 'One-Punch Man', native: 'ワンパンマン' },
coverImage: { large: 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21-YCDoj1EkAxFn.jpg' },
description: 'Saitama has a rather peculiar hobby...',
episodes: 12,
chapters: null,
status: 'FINISHED',
genres: ['Action', 'Comedy', 'Sci-Fi'],
};
const MANGA_FIXTURE: AniListMedia = {
id: 30013,
type: 'MANGA',
title: { romaji: 'One Piece', english: 'One Piece', native: 'ONE PIECE' },
coverImage: { large: 'https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx30013.jpg' },
description: 'Gold Roger, the King of the Pirates...',
episodes: null,
chapters: 1120,
status: 'RELEASING',
genres: ['Action', 'Adventure', 'Comedy'],
};
function makeSearchResponse(media: AniListMedia[], total = media.length, hasNextPage = false) {
return {
data: {
Page: {
media,
pageInfo: { total, hasNextPage },
},
},
};
}
function makeGetByIdResponse(media: AniListMedia) {
return { data: { Media: media } };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mockFetchOk(body: unknown): jest.Mock {
return jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(body),
} as unknown as Response);
}
function mockFetchError(status: number, body = ''): jest.Mock {
return jest.fn().mockResolvedValue({
ok: false,
status,
text: () => Promise.resolve(body),
} as unknown as Response);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AniListService', () => {
let service: AniListService;
let originalFetch: typeof globalThis.fetch;
beforeAll(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AniListService,
{
provide: ConfigService,
useValue: {
get: (key: string, fallback: string) =>
key === 'ANILIST_API_URL' ? 'https://graphql.anilist.co' : fallback,
},
},
],
}).compile();
service = module.get<AniListService>(AniListService);
});
// -----------------------------------------------------------------------
// search()
// -----------------------------------------------------------------------
describe('search()', () => {
it('should return parsed anime results', async () => {
const payload = makeSearchResponse([ANIME_FIXTURE], 1, false);
globalThis.fetch = mockFetchOk(payload);
const result = await service.search('One Punch', 'ANIME');
expect(result.media).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.hasNextPage).toBe(false);
const media = result.media[0];
expect(media.id).toBe(21);
expect(media.type).toBe('ANIME');
expect(media.title.romaji).toBe('One Punch Man');
expect(media.title.english).toBe('One-Punch Man');
expect(media.title.native).toBe('ワンパンマン');
expect(media.episodes).toBe(12);
expect(media.chapters).toBeNull();
expect(media.coverImage?.large).toContain('anilist');
expect(media.genres).toEqual(['Action', 'Comedy', 'Sci-Fi']);
});
it('should return parsed manga results', async () => {
const payload = makeSearchResponse([MANGA_FIXTURE], 42, true);
globalThis.fetch = mockFetchOk(payload);
const result = await service.search('One Piece', 'MANGA');
expect(result.media).toHaveLength(1);
expect(result.total).toBe(42);
expect(result.hasNextPage).toBe(true);
const media = result.media[0];
expect(media.type).toBe('MANGA');
expect(media.chapters).toBe(1120);
expect(media.episodes).toBeNull();
});
it('should search without type filter', async () => {
const payload = makeSearchResponse([ANIME_FIXTURE, MANGA_FIXTURE], 2);
globalThis.fetch = mockFetchOk(payload);
const result = await service.search('One');
expect(result.media).toHaveLength(2);
// verify the variables sent to the API don't include type
const fetchMock = globalThis.fetch as jest.Mock;
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.variables).not.toHaveProperty('type');
expect(body.variables.search).toBe('One');
});
it('should forward page and perPage parameters', async () => {
const payload = makeSearchResponse([], 0);
globalThis.fetch = mockFetchOk(payload);
await service.search('test', 'ANIME', 3, 10);
const fetchMock = globalThis.fetch as jest.Mock;
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.variables.page).toBe(3);
expect(body.variables.perPage).toBe(10);
});
it('should POST to the configured API URL', async () => {
const payload = makeSearchResponse([]);
globalThis.fetch = mockFetchOk(payload);
await service.search('test');
const fetchMock = globalThis.fetch as jest.Mock;
expect(fetchMock).toHaveBeenCalledWith(
'https://graphql.anilist.co',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
);
});
});
// -----------------------------------------------------------------------
// getById()
// -----------------------------------------------------------------------
describe('getById()', () => {
it('should return a single media by id', async () => {
const payload = makeGetByIdResponse(ANIME_FIXTURE);
globalThis.fetch = mockFetchOk(payload);
const media = await service.getById(21);
expect(media).not.toBeNull();
expect(media!.id).toBe(21);
expect(media!.title.romaji).toBe('One Punch Man');
});
it('should return null when API errors', async () => {
globalThis.fetch = mockFetchError(404, 'Not Found');
const media = await service.getById(999999);
expect(media).toBeNull();
});
it('should return null when fetch rejects (network error)', async () => {
globalThis.fetch = jest.fn().mockRejectedValue(new Error('network down'));
const media = await service.getById(1);
expect(media).toBeNull();
});
});
// -----------------------------------------------------------------------
// Error handling
// -----------------------------------------------------------------------
describe('error handling', () => {
it('should throw on HTTP 500', async () => {
globalThis.fetch = mockFetchError(500, 'Internal Server Error');
await expect(service.search('test')).rejects.toThrow('AniList API error: 500');
});
it('should throw on HTTP 429 rate limit', async () => {
globalThis.fetch = mockFetchError(429, 'Too Many Requests');
await expect(service.search('test')).rejects.toThrow('AniList API error: 429');
});
it('should throw on HTTP 404', async () => {
globalThis.fetch = mockFetchError(404, 'Not Found');
await expect(service.search('test')).rejects.toThrow('AniList API error: 404');
});
it('should propagate network errors from fetch', async () => {
globalThis.fetch = jest.fn().mockRejectedValue(new TypeError('fetch failed'));
await expect(service.search('test')).rejects.toThrow('fetch failed');
});
});
// -----------------------------------------------------------------------
// Response field parsing
// -----------------------------------------------------------------------
describe('field parsing', () => {
it('should handle null optional fields', async () => {
const sparse: AniListMedia = {
id: 100,
type: 'ANIME',
title: { romaji: 'Unknown', english: null, native: null },
coverImage: null,
description: null,
episodes: null,
chapters: null,
status: null,
genres: [],
};
const payload = makeSearchResponse([sparse]);
globalThis.fetch = mockFetchOk(payload);
const result = await service.search('unknown');
const media = result.media[0];
expect(media.title.english).toBeNull();
expect(media.title.native).toBeNull();
expect(media.coverImage).toBeNull();
expect(media.description).toBeNull();
expect(media.episodes).toBeNull();
expect(media.chapters).toBeNull();
expect(media.status).toBeNull();
expect(media.genres).toEqual([]);
});
it('should preserve all genres', async () => {
const payload = makeSearchResponse([ANIME_FIXTURE]);
globalThis.fetch = mockFetchOk(payload);
const result = await service.search('opm');
expect(result.media[0].genres).toEqual(['Action', 'Comedy', 'Sci-Fi']);
});
it('should preserve description text', async () => {
const payload = makeSearchResponse([MANGA_FIXTURE]);
globalThis.fetch = mockFetchOk(payload);
const result = await service.search('op');
expect(result.media[0].description).toBe('Gold Roger, the King of the Pirates...');
});
});
});

View File

@@ -0,0 +1,216 @@
/// <reference types="jest" />
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkService } from './work.service';
import { Work, WorkType, WorkStatus } from './work.entity';
import { AniListService, AniListMedia } from './anilist.service';
const mockRepository = () => ({
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
});
const mockAniListService = () => ({
search: jest.fn(),
getById: jest.fn(),
});
const buildMedia = (overrides: Partial<AniListMedia> = {}): AniListMedia => ({
id: 1,
type: 'ANIME',
title: { romaji: 'Shingeki no Kyojin', english: 'Attack on Titan', native: '進撃の巨人' },
coverImage: { large: 'https://img.anilist.co/cover.jpg' },
description: 'Humanity fights titans.',
episodes: 25,
chapters: null,
status: 'FINISHED',
genres: ['Action', 'Drama'],
...overrides,
});
describe('WorkService', () => {
let service: WorkService;
let workRepo: jest.Mocked<Pick<Repository<Work>, 'findOne' | 'create' | 'save'>>;
let anilist: jest.Mocked<Pick<AniListService, 'search' | 'getById'>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkService,
{ provide: getRepositoryToken(Work), useFactory: mockRepository },
{ provide: AniListService, useFactory: mockAniListService },
],
}).compile();
service = module.get(WorkService);
workRepo = module.get(getRepositoryToken(Work));
anilist = module.get(AniListService);
});
// ------------------------------------------------------------------
// findOrCreateFromAniList — existing in DB
// ------------------------------------------------------------------
describe('findOrCreateFromAniList (existing)', () => {
it('should return the existing work without calling AniList', async () => {
const existingWork = { id: 42, anilistId: 1, titleRomaji: 'SNK' } as Work;
workRepo.findOne.mockResolvedValue(existingWork);
const result = await service.findOrCreateFromAniList(1);
expect(result).toBe(existingWork);
expect(workRepo.findOne).toHaveBeenCalledWith({ where: { anilistId: 1 } });
expect(anilist.getById).not.toHaveBeenCalled();
expect(workRepo.save).not.toHaveBeenCalled();
});
});
// ------------------------------------------------------------------
// findOrCreateFromAniList — fetch + create
// ------------------------------------------------------------------
describe('findOrCreateFromAniList (create)', () => {
it('should fetch from AniList and persist a new ANIME work', async () => {
workRepo.findOne.mockResolvedValue(null);
const media = buildMedia();
anilist.getById.mockResolvedValue(media);
const created = { id: 1, anilistId: media.id } as Work;
workRepo.create.mockReturnValue(created);
workRepo.save.mockResolvedValue(created);
const result = await service.findOrCreateFromAniList(media.id);
expect(anilist.getById).toHaveBeenCalledWith(media.id);
expect(workRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
anilistId: media.id,
type: WorkType.ANIME,
titleRomaji: 'Shingeki no Kyojin',
titleEnglish: 'Attack on Titan',
titleNative: '進撃の巨人',
posterUrl: 'https://img.anilist.co/cover.jpg',
synopsis: 'Humanity fights titans.',
totalEpisodes: 25,
totalChapters: undefined,
status: WorkStatus.FINISHED,
genres: ['Action', 'Drama'],
}),
);
expect(workRepo.save).toHaveBeenCalledWith(created);
expect(result).toBe(created);
});
it('should map MANGA type correctly', async () => {
workRepo.findOne.mockResolvedValue(null);
const media = buildMedia({ type: 'MANGA', episodes: null, chapters: 139 });
anilist.getById.mockResolvedValue(media);
workRepo.create.mockReturnValue({} as Work);
workRepo.save.mockResolvedValue({} as Work);
await service.findOrCreateFromAniList(media.id);
expect(workRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
type: WorkType.MANGA,
totalEpisodes: undefined,
totalChapters: 139,
}),
);
});
it('should throw when AniList returns null', async () => {
workRepo.findOne.mockResolvedValue(null);
anilist.getById.mockResolvedValue(null);
await expect(service.findOrCreateFromAniList(999)).rejects.toThrow(
'AniList media 999 not found',
);
});
});
// ------------------------------------------------------------------
// mapStatus — all branches
// ------------------------------------------------------------------
describe('mapStatus (via findOrCreateFromAniList)', () => {
// Helper: trigger findOrCreate and capture the status passed to create()
const captureStatus = async (anilistStatus: string | null) => {
workRepo.findOne.mockResolvedValue(null);
anilist.getById.mockResolvedValue(buildMedia({ status: anilistStatus }));
workRepo.create.mockReturnValue({} as Work);
workRepo.save.mockResolvedValue({} as Work);
await service.findOrCreateFromAniList(1);
const arg = workRepo.create.mock.calls[0][0] as Partial<Work>;
return arg.status;
};
it.each([
['RELEASING', WorkStatus.RELEASING],
['FINISHED', WorkStatus.FINISHED],
['NOT_YET_RELEASED', WorkStatus.NOT_YET_RELEASED],
['CANCELLED', WorkStatus.CANCELLED],
['HIATUS', WorkStatus.HIATUS],
])('should map "%s" to WorkStatus.%s', async (input: string, expected: WorkStatus) => {
expect(await captureStatus(input)).toBe(expected);
});
it('should return undefined for null status', async () => {
expect(await captureStatus(null)).toBeUndefined();
});
it('should return undefined for unknown status', async () => {
expect(await captureStatus('UNKNOWN_VALUE')).toBeUndefined();
});
});
// ------------------------------------------------------------------
// Optional fields — null handling
// ------------------------------------------------------------------
describe('optional fields handling', () => {
it('should set optional fields to undefined when AniList returns null', async () => {
workRepo.findOne.mockResolvedValue(null);
const media = buildMedia({
title: { romaji: 'Test', english: null, native: null },
coverImage: null,
description: null,
episodes: null,
chapters: null,
status: null,
});
anilist.getById.mockResolvedValue(media);
workRepo.create.mockReturnValue({} as Work);
workRepo.save.mockResolvedValue({} as Work);
await service.findOrCreateFromAniList(media.id);
expect(workRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
titleEnglish: undefined,
titleNative: undefined,
posterUrl: undefined,
synopsis: undefined,
totalEpisodes: undefined,
totalChapters: undefined,
status: undefined,
}),
);
});
});
// ------------------------------------------------------------------
// search — delegates to AniListService
// ------------------------------------------------------------------
describe('search', () => {
it('should delegate to anilist.search with correct args', async () => {
const expected = { media: [], total: 0, hasNextPage: false };
anilist.search.mockResolvedValue(expected);
const result = await service.search('naruto', 'ANIME', 2);
expect(anilist.search).toHaveBeenCalledWith('naruto', 'ANIME', 2);
expect(result).toBe(expected);
});
});
});