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:
300
backend/src/work/anilist.service.spec.ts
Normal file
300
backend/src/work/anilist.service.spec.ts
Normal 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...');
|
||||
});
|
||||
});
|
||||
});
|
||||
216
backend/src/work/work.service.spec.ts
Normal file
216
backend/src/work/work.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user