Skip to content

Commit 81e6263

Browse files
committed
pretty
1 parent e70c83e commit 81e6263

4 files changed

Lines changed: 555 additions & 4 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{"total": {"lines":{"total":200,"covered":183,"skipped":0,"pct":91.5},"statements":{"total":212,"covered":192,"skipped":0,"pct":90.56},"functions":{"total":48,"covered":46,"skipped":0,"pct":95.83},"branches":{"total":84,"covered":62,"skipped":0,"pct":73.8},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}}
2+
,"/home/alfagun74/git/phalcode/gamevault/gamevault-backend/src/modules/metadata/providers/igdb/igdb.metadata-provider.service.ts": {"lines":{"total":90,"covered":84,"skipped":0,"pct":93.33},"functions":{"total":30,"covered":30,"skipped":0,"pct":100},"statements":{"total":94,"covered":86,"skipped":0,"pct":91.48},"branches":{"total":45,"covered":25,"skipped":0,"pct":55.55}}
3+
,"/home/alfagun74/git/phalcode/gamevault/gamevault-backend/src/modules/web-ui/web-ui.service.ts": {"lines":{"total":110,"covered":99,"skipped":0,"pct":90},"functions":{"total":18,"covered":16,"skipped":0,"pct":88.88},"statements":{"total":118,"covered":106,"skipped":0,"pct":89.83},"branches":{"total":39,"covered":37,"skipped":0,"pct":94.87}}
4+
}

jest.config.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,7 @@ const config: Config = {
4040
"!**/auth/guards/refresh-token.guard.ts", // Trivial super.canActivate() wrapper
4141

4242
// --- Metadata providers ---
43-
"!**/metadata/providers/igdb/**", // IGDB provider - calls external IGDB API
4443
"!**/metadata/providers/rawg-legacy/**", // RAWG legacy provider - deprecated external API
45-
46-
// --- Web UI ---
47-
"!**/web-ui/**", // Frontend bundle service - filesystem + network heavy
4844
],
4945
coverageDirectory: "./coverage",
5046
testEnvironment: "node",
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { NotFoundException } from "@nestjs/common";
2+
3+
import { igdb, twitchAccessToken } from "@phalcode/ts-igdb-client";
4+
import { IgdbMetadataProviderService } from "./igdb.metadata-provider.service";
5+
6+
jest.mock("../../../../configuration", () => ({
7+
__esModule: true,
8+
default: {
9+
METADATA: {
10+
IGDB: {
11+
ENABLED: true,
12+
REQUEST_INTERVAL_MS: 0,
13+
PRIORITY: 10,
14+
CLIENT_ID: "test-client-id",
15+
CLIENT_SECRET: "test-client-secret",
16+
},
17+
},
18+
TESTING: { MOCK_FILES: true },
19+
},
20+
}));
21+
22+
jest.mock("@phalcode/ts-igdb-client", () => ({
23+
fields: jest.fn((value) => value),
24+
where: jest.fn((...value) => value),
25+
whereIn: jest.fn((...value) => value),
26+
search: jest.fn((value) => value),
27+
twitchAccessToken: jest.fn(),
28+
igdb: jest.fn(),
29+
proto: {},
30+
}));
31+
32+
jest.mock("../../../../logging", () => ({
33+
__esModule: true,
34+
default: {
35+
log: jest.fn(),
36+
error: jest.fn(),
37+
warn: jest.fn(),
38+
debug: jest.fn(),
39+
},
40+
logGamevaultGame: jest.fn(),
41+
logGamevaultUser: jest.fn(),
42+
logMedia: jest.fn(),
43+
logMetadata: jest.fn(),
44+
logMetadataProvider: jest.fn(),
45+
logProgress: jest.fn(),
46+
}));
47+
48+
describe("IgdbMetadataProviderService", () => {
49+
let service: IgdbMetadataProviderService;
50+
let mockMetadataService: any;
51+
let mockMediaService: any;
52+
53+
const gamesData = [
54+
{
55+
id: 101,
56+
url: "https://igdb.com/games/test-game",
57+
name: "Test Game",
58+
summary: "Summary",
59+
storyline: "Storyline",
60+
first_release_date: 1_700_000_000,
61+
total_rating: 87.4,
62+
game_status: { status: "Early Access" },
63+
websites: [{ url: "https://example.com" }],
64+
screenshots: [
65+
{ url: "//images.igdb.com/igdb/image/upload/t_thumb/shot" },
66+
],
67+
artworks: [{ url: "//images.igdb.com/igdb/image/upload/t_thumb/art" }],
68+
videos: [
69+
{ name: "Official Trailer", video_id: "trailer123" },
70+
{ name: "Gameplay Demo", video_id: "gameplay123" },
71+
],
72+
cover: { url: "//images.igdb.com/igdb/image/upload/t_thumb/cover" },
73+
involved_companies: [
74+
{
75+
developer: true,
76+
publisher: false,
77+
company: { id: 1, name: "Dev Studio" },
78+
},
79+
{
80+
developer: false,
81+
publisher: true,
82+
company: { id: 2, name: "Pub House" },
83+
},
84+
],
85+
genres: [{ id: 11, name: "RPG" }],
86+
keywords: [{ id: 21, name: "Fantasy" }],
87+
themes: [{ id: 31, name: "Dark" }],
88+
age_ratings: [
89+
{
90+
rating_category: { rating: "M" },
91+
},
92+
],
93+
},
94+
];
95+
96+
beforeEach(() => {
97+
mockMetadataService = {
98+
registerProvider: jest.fn(),
99+
};
100+
101+
mockMediaService = {
102+
downloadByUrl: jest
103+
.fn()
104+
.mockImplementation((url: string) => Promise.resolve(`saved:${url}`)),
105+
};
106+
107+
service = new IgdbMetadataProviderService(
108+
mockMetadataService,
109+
{} as any,
110+
{} as any,
111+
{} as any,
112+
{} as any,
113+
{} as any,
114+
mockMediaService,
115+
);
116+
117+
(twitchAccessToken as jest.Mock).mockResolvedValue("oauth-token");
118+
119+
(igdb as jest.Mock).mockImplementation(() => ({
120+
request: (resource: string) => {
121+
const execute = jest.fn();
122+
if (resource === "games") {
123+
execute.mockResolvedValue({ data: gamesData });
124+
} else if (resource === "game_time_to_beats") {
125+
execute.mockResolvedValue({ data: [{ normally: 7_200 }] });
126+
} else {
127+
execute.mockResolvedValue({ data: [] });
128+
}
129+
130+
return {
131+
pipe: () => ({ execute }),
132+
};
133+
},
134+
}));
135+
});
136+
137+
afterEach(() => jest.restoreAllMocks());
138+
139+
describe("onModuleInit", () => {
140+
it("should register provider when credentials are present", async () => {
141+
await service.onModuleInit();
142+
expect(mockMetadataService.registerProvider).toHaveBeenCalledWith(
143+
service,
144+
);
145+
});
146+
});
147+
148+
describe("search", () => {
149+
it("should search by id and name and map minimal metadata", async () => {
150+
const result = await service.search("101");
151+
152+
expect(result.length).toBeGreaterThan(0);
153+
expect(result[0]).toEqual(
154+
expect.objectContaining({
155+
provider_slug: "igdb",
156+
provider_data_id: "101",
157+
title: "Test Game",
158+
}),
159+
);
160+
expect(result[0].cover_url).toContain("https://");
161+
expect(result[0].cover_url).toContain("t_cover_big_2x");
162+
});
163+
164+
it("should search by name only when query is not a number", async () => {
165+
const result = await service.search("test game");
166+
expect(result[0].provider_data_id).toBe("101");
167+
});
168+
});
169+
170+
describe("getByProviderDataIdOrFail", () => {
171+
it("should map full metadata including media and age rating", async () => {
172+
const metadata = await service.getByProviderDataIdOrFail("101");
173+
174+
expect(metadata.provider_slug).toBe("igdb");
175+
expect(metadata.provider_data_id).toBe("101");
176+
expect(metadata.age_rating).toBe(17);
177+
expect(metadata.average_playtime).toBe(120);
178+
expect(metadata.title).toBe("Test Game");
179+
expect(metadata.description).toContain("Summary");
180+
expect(metadata.description).toContain("Storyline");
181+
expect(metadata.url_trailers).toEqual([
182+
"https://www.youtube.com/watch?v=trailer123",
183+
]);
184+
expect(metadata.url_gameplays).toEqual([
185+
"https://www.youtube.com/watch?v=gameplay123",
186+
]);
187+
expect(metadata.developers[0].name).toBe("Dev Studio");
188+
expect(metadata.publishers[0].name).toBe("Pub House");
189+
expect(mockMediaService.downloadByUrl).toHaveBeenCalledTimes(2);
190+
});
191+
192+
it("should throw NotFoundException when game does not exist", async () => {
193+
(igdb as jest.Mock).mockImplementationOnce(() => ({
194+
request: (resource: string) => ({
195+
pipe: () => ({
196+
execute: jest.fn().mockResolvedValue({
197+
data: resource === "games" ? [] : [],
198+
}),
199+
}),
200+
}),
201+
}));
202+
203+
await expect(service.getByProviderDataIdOrFail("999")).rejects.toThrow(
204+
NotFoundException,
205+
);
206+
});
207+
});
208+
209+
describe("fallback behavior", () => {
210+
it("should return undefined average_playtime when fetching playtime fails", async () => {
211+
(igdb as jest.Mock).mockImplementation(() => ({
212+
request: (resource: string) => {
213+
if (resource === "games") {
214+
return {
215+
pipe: () => ({
216+
execute: jest.fn().mockResolvedValue({ data: gamesData }),
217+
}),
218+
};
219+
}
220+
return {
221+
pipe: () => ({
222+
execute: jest.fn().mockRejectedValue(new Error("boom")),
223+
}),
224+
};
225+
},
226+
}));
227+
228+
const metadata = await service.getByProviderDataIdOrFail("101");
229+
expect(metadata.average_playtime).toBeUndefined();
230+
});
231+
232+
it("should return undefined image when media download fails", async () => {
233+
mockMediaService.downloadByUrl.mockRejectedValueOnce(
234+
new Error("download failed"),
235+
);
236+
237+
const metadata = await service.getByProviderDataIdOrFail("101");
238+
expect(metadata.cover).toBeUndefined();
239+
});
240+
241+
it("should return undefined age rating if ratings cannot be mapped", async () => {
242+
const gameWithoutMappedRatings = {
243+
...gamesData[0],
244+
age_ratings: [{ rating_category: { rating: "UNKNOWN_RATING" } }],
245+
};
246+
247+
(igdb as jest.Mock).mockImplementationOnce(() => ({
248+
request: (resource: string) => {
249+
if (resource === "games") {
250+
return {
251+
pipe: () => ({
252+
execute: jest
253+
.fn()
254+
.mockResolvedValue({ data: [gameWithoutMappedRatings] }),
255+
}),
256+
};
257+
}
258+
return {
259+
pipe: () => ({
260+
execute: jest
261+
.fn()
262+
.mockResolvedValue({ data: [{ normally: 3_600 }] }),
263+
}),
264+
};
265+
},
266+
}));
267+
268+
const metadata = await service.getByProviderDataIdOrFail("101");
269+
expect(metadata.age_rating).toBeUndefined();
270+
});
271+
});
272+
});

0 commit comments

Comments
 (0)