diff --git a/apps/api/src/integrations/pl24/pl24-model-path.spec.ts b/apps/api/src/integrations/pl24/pl24-model-path.spec.ts new file mode 100644 index 0000000..ba6901d --- /dev/null +++ b/apps/api/src/integrations/pl24/pl24-model-path.spec.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PL24Service } from "./pl24.service"; + +/** + * Model-list entry point resolution (plv2.md, bulgu p5core-08). + * + * `BACKEND_MODEL_PATH` eksik kaldığında eski davranış yedi bilinen yolu sırayla + * denemekti: paylaşılan günlük bütçeden çağrı başına yedi isteğe kadar, ve + * listede olmayan bir yol için sessiz sıfır sonuç. Volvo tam olarak buydu — + * `/extern/vehicles/models` (çoğul "vehicles") listede yok, dolayısıyla + * Volvo/Polestar browse sıfır model tohumlayıp emekli P4 listesini sunmaya + * devam ediyordu. + * + * Otoritatif kaynak her P5 backend'inin kendi `/extern/catmeta` yanıtındaki + * `data.catalogEntryPoint.path`. Canlı doğrulama (2026-09-20): + * p5volvo → /p5volvo/extern/vehicles/models (60 model) + * p5psa → /p5psa/extern/vehicle/catalogs + */ + +type Resolver = { + resolveModelPathFromCatmeta: ( + base: string, + svc: string, + headers: Record, + ) => Promise; + baseUrl: string; + language: string; + redis: { + get: (k: string) => Promise; + set: (k: string, v: string, ttl?: number) => Promise; + }; + logger: { log: (m: string) => void; warn: (m: string) => void }; +}; + +function makeService(opts: { + cached?: string | null; + catmeta?: unknown; + status?: number; + throws?: boolean; +}) { + const sets: Array<[string, string]> = []; + const svc = Object.create(PL24Service.prototype) as unknown as Resolver; + svc.baseUrl = "https://pl24.test"; + svc.language = "tr"; + svc.redis = { + get: vi.fn(async () => opts.cached ?? null), + set: vi.fn(async (k: string, v: string) => { + sets.push([k, v]); + return undefined; + }), + }; + svc.logger = { log: vi.fn(), warn: vi.fn() }; + const fetchMock = vi.fn(async () => { + if (opts.throws) throw new Error("network down"); + return { + ok: (opts.status ?? 200) < 400, + status: opts.status ?? 200, + json: async () => opts.catmeta ?? {}, + }; + }); + vi.stubGlobal("fetch", fetchMock); + return { svc, sets, fetchMock }; +} + +const volvoMeta = { + data: { + catalogEntryPoint: { + wid: "modelsTable", + path: "/p5volvo/extern/vehicles/models?lang=en&serviceName=volvo_parts", + }, + }, +}; + +describe("resolveModelPathFromCatmeta", () => { + beforeEach(() => vi.unstubAllGlobals()); + + it("catmeta'daki giriş noktasını backend'e göreli yola indirger", async () => { + const { svc } = makeService({ catmeta: volvoMeta }); + const out = await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {}); + expect(out).toBe("/extern/vehicles/models"); + }); + + it("çözülen yolu 30 gün cache'ler", async () => { + const { svc, sets } = makeService({ catmeta: volvoMeta }); + await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {}); + expect(sets[0][0]).toBe("pl24:modelpath:p5volvo"); + expect(sets[0][1]).toBe("/extern/vehicles/models"); + }); + + it("cache'lenmiş yol için upstream'e hiç gitmez", async () => { + const { svc, fetchMock } = makeService({ cached: "/extern/vehicles/models" }); + const out = await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {}); + expect(out).toBe("/extern/vehicles/models"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("olumsuz sonuç da cache'lenir — her browse'da yeniden denenmez", async () => { + const { svc, fetchMock } = makeService({ cached: "none" }); + expect(await svc.resolveModelPathFromCatmeta("/p5x", "x_parts", {})).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("catmeta yoksa veya şekil beklenmedikse null döner", async () => { + const { svc, sets } = makeService({ catmeta: { data: {} } }); + expect(await svc.resolveModelPathFromCatmeta("/p5x", "x_parts", {})).toBeNull(); + expect(sets[0][1]).toBe("none"); + }); + + it("HTTP hatasında null döner", async () => { + const { svc } = makeService({ status: 403, catmeta: volvoMeta }); + expect(await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {})).toBeNull(); + }); + + it("ağ hatası fırlatmaz", async () => { + const { svc } = makeService({ throws: true }); + await expect( + svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {}), + ).resolves.toBeNull(); + }); + + it("/extern/ ile başlamayan yolu kabul etmez", async () => { + const { svc } = makeService({ + catmeta: { data: { catalogEntryPoint: { path: "https://baska.site/kotu" } } }, + }); + expect(await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {})).toBeNull(); + }); +}); diff --git a/apps/api/src/integrations/pl24/pl24.service.ts b/apps/api/src/integrations/pl24/pl24.service.ts index 3bf511d..ca1f545 100644 --- a/apps/api/src/integrations/pl24/pl24.service.ts +++ b/apps/api/src/integrations/pl24/pl24.service.ts @@ -1137,6 +1137,78 @@ export class PL24Service { // ==================== PRIVATE: Response parsers ==================== + /** + * Ask a P5 backend where its own model list lives, instead of guessing. + * + * Every P5 backend serves `/extern/catmeta`, whose `data.catalogEntryPoint.path` + * is the authoritative browse entry point — e.g. p5psa answers + * `/p5psa/extern/vehicle/catalogs`, p5volvo `/p5volvo/extern/vehicles/models`. + * The old behaviour, when `BACKEND_MODEL_PATH` had no entry, was to fire the + * seven known paths in turn and keep whichever returned rows. That costs up to + * seven upstream requests per call on a shared daily budget, and it silently + * fails for any backend whose path is not already on the list — which is how + * Volvo/Polestar browse ended up seeding zero models while still serving the + * retired P4 listing. + * + * The resolved path is cached per backend (30 days) so this costs one request + * for a backend's whole lifetime, and it self-heals if PL24 moves an endpoint. + * Returns null on any failure; the caller then falls back as before. + */ + private async resolveModelPathFromCatmeta( + catalogBase: string, + serviceName: string, + headers: Record, + ): Promise { + const cacheKey = `pl24:modelpath:${catalogBase.replace(/^\//, "")}`; + try { + const cached = await this.redis.get(cacheKey); + if (cached) return cached === "none" ? null : cached; + } catch { + // Redis down — resolve live rather than failing the listing. + } + + let resolved: string | null = null; + try { + const url = `${this.baseUrl}${catalogBase}/extern/catmeta?serviceName=${serviceName}&country=DE&lang=${this.language}`; + const res = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(15000) }); + if (res.ok) { + const meta = (await res.json()) as { + data?: { catalogEntryPoint?: { path?: string } }; + }; + const full = meta.data?.catalogEntryPoint?.path; + if (full) { + // catmeta returns the absolute path with query string + // ("/p5volvo/extern/vehicles/models?lang=en&serviceName=…"); the caller + // appends its own lang/serviceName, so keep only the backend-relative + // path segment. + const withoutQuery = full.split("?")[0]; + const relative = withoutQuery.startsWith(catalogBase) + ? withoutQuery.slice(catalogBase.length) + : withoutQuery; + if (relative.startsWith("/extern/")) resolved = relative; + } + } + } catch (err) { + this.logger.warn( + `fetchVehicleList: catmeta lookup failed for ${serviceName}: ${(err as Error).message}`, + ); + } + + if (resolved) { + this.logger.log( + `fetchVehicleList: ${serviceName} model path resolved from catmeta → ${resolved}`, + ); + } + try { + // Cache the negative too, so a backend without a usable catmeta does not + // re-probe on every browse. + await this.redis.set(cacheKey, resolved ?? "none", 30 * 86_400); + } catch { + // best effort + } + return resolved; + } + /** * Parse vehicle data from directAccess response. */ @@ -2226,11 +2298,22 @@ export class PL24Service { p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki p5man: "/extern/model/categories", // MAN trucks + // Pinned 2026-09-20 from each backend's own catmeta `catalogEntryPoint` + // (see resolveModelPathFromCatmeta). Before this, p5psa needed five wasted + // probe requests to rediscover its path on every call, and p5volvo found + // nothing at all — none of the seven guessed paths matches its plural + // `/extern/vehicles/models`, so Volvo/Polestar browse silently seeded zero + // models and kept serving the dead P4 listing. + p5psa: "/extern/vehicle/catalogs", // Peugeot, Citroën, DS, psa_opel, psa_vauxhall + p5volvo: "/extern/vehicles/models", // Volvo, Polestar — NOTE: plural "vehicles" }; // catalogBase is like "/p5vwag" — strip leading slash for map lookup const backendKey = catalogBase.replace(/^\//, ""); - const modelPath = BACKEND_MODEL_PATH[backendKey] ?? "/extern/vehicle/modelfamilies"; + const modelPath = + BACKEND_MODEL_PATH[backendKey] ?? + (await this.resolveModelPathFromCatmeta(catalogBase, serviceName, headers)) ?? + "/extern/vehicle/modelfamilies"; const url = `${this.baseUrl}${catalogBase}${modelPath}?lang=${this.language}&serviceName=${serviceName}`;