|
| 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