diff --git a/backend/src/list/list.service.spec.ts b/backend/src/list/list.service.spec.ts new file mode 100644 index 0000000..656f456 --- /dev/null +++ b/backend/src/list/list.service.spec.ts @@ -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>; + 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> = {}): 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); + 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' }, + }); + }); + }); +}); diff --git a/backend/src/user/user.service.spec.ts b/backend/src/user/user.service.spec.ts new file mode 100644 index 0000000..4138a3c --- /dev/null +++ b/backend/src/user/user.service.spec.ts @@ -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; + +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); + 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'); + }); + }); +}); diff --git a/backend/src/work/anilist.service.spec.ts b/backend/src/work/anilist.service.spec.ts new file mode 100644 index 0000000..d7d14cf --- /dev/null +++ b/backend/src/work/anilist.service.spec.ts @@ -0,0 +1,300 @@ +/// +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); + }); + + // ----------------------------------------------------------------------- + // 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...'); + }); + }); +}); diff --git a/backend/src/work/work.service.spec.ts b/backend/src/work/work.service.spec.ts new file mode 100644 index 0000000..a1ab864 --- /dev/null +++ b/backend/src/work/work.service.spec.ts @@ -0,0 +1,216 @@ +/// +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 => ({ + 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, 'findOne' | 'create' | 'save'>>; + let anilist: jest.Mocked>; + + 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; + 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); + }); + }); +});