fix(pl24): model listesi giriş noktasını catmeta'dan çöz
Prod'da browse onarması Peugeot'yu taşıdı (64 bayat satır → 39 güncel model) ama Volvo'da "upstream returned no models" ile korumalı dala düştü. Sebep: `BACKEND_MODEL_PATH`'te p5volvo yok ve yedek keşif yedi bilinen yolu deniyor — Volvo'nunki `/extern/vehicles/models`, yani ÇOĞUL "vehicles", listede yok. Volvo/Polestar browse bu yüzden sıfır model tohumluyor ve emekli P4 listesini sunmaya devam ediyordu. Otoritatif kaynak her P5 backend'inin kendi `/extern/catmeta` yanıtındaki `data.catalogEntryPoint.path`. Canlı doğrulandı: p5volvo → /p5volvo/extern/vehicles/models (60 model: XC90, V40…) p5psa → /p5psa/extern/vehicle/catalogs İki değişiklik: - p5psa ve p5volvo `BACKEND_MODEL_PATH`'e sabitlendi (sıfır ek istek). p5psa zaten çalışıyordu ama her çağrıda beş boşa probe isteğiyle yeniden keşfediliyordu. - `resolveModelPathFromCatmeta()`: haritada olmayan backend artık yedi kör probe yerine tek otoritatif catmeta çağrısı yapıyor. Sonuç backend başına 30 gün cache'leniyor (olumsuz sonuç dahil), yani backend ömrü boyunca tek istek; PL24 bir ucu taşırsa kendiliğinden düzeliyor. Hata durumunda null → eski yedeğe düşer. 8 yeni test. api 638 test geçiyor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
127
apps/api/src/integrations/pl24/pl24-model-path.spec.ts
Normal file
127
apps/api/src/integrations/pl24/pl24-model-path.spec.ts
Normal file
@@ -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<string, string>,
|
||||
) => Promise<string | null>;
|
||||
baseUrl: string;
|
||||
language: string;
|
||||
redis: {
|
||||
get: (k: string) => Promise<string | null>;
|
||||
set: (k: string, v: string, ttl?: number) => Promise<unknown>;
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>,
|
||||
): Promise<string | null> {
|
||||
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}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user