fix(catalog): route PSA VIN scope/main-group categories to drill, not empty leaf
VIN-decoded Peugeot/Citroën scope categories ("mekanik", "kaporta", …)
carry json-vin-main-groups.action / json-vin-illustrations.action link
paths, but getCategoryWithPartsInner's isPsaParent gate only recognised
the catalog-browse paths (psa:: / json-illustrations.action). So every
VIN PSA parent fell through to the leaf path, fetched no parts, and
rendered an empty panel — the largest current "0 parça" cluster
(~20-30 empty parts_panel_viewed/day across all Peugeot/Citroën models;
serkan filazi's complaint among them).
The drill already works end-to-end (getChildren → fetchSubGroupsByPath →
fetchVinMainGroups/fetchVinIllustrations → vin-image-board parts); only
the gate was out of sync with PL24Service.isPsaVin*Path. Verified live on
prod: scope "mekanik" → 6 main-groups → "motor" → 10 illustrations →
12 OEM parts + schema + 15 hotspots.
Also surface a retryable loadError (matching the pl24/emex group-node
branches) when a PSA parent drill comes back empty, instead of a
misleading empty grid. +2 regression tests pin the dispatch to the
PL24Service.isPsaVin* helpers so it can't silently drift again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -207,10 +207,92 @@ describe("CategoriesService", () => {
|
||||
expect(service.getChildren).toHaveBeenCalledWith("c1");
|
||||
expect(partsCatalogsService.fetchParts).not.toHaveBeenCalled();
|
||||
expect(result.parts).toEqual([]);
|
||||
expect(result.children).toEqual(discovered);
|
||||
expect((result as { children?: unknown }).children).toEqual(discovered);
|
||||
// The wrapper attaches the server-resolved ancestor trail (id+name only).
|
||||
expect(result.ancestors).toEqual([{ id: "root", name: "Kök" }]);
|
||||
});
|
||||
|
||||
// Regression guard for the PSA "0 parça" bug: VIN-decoded Peugeot/Citroën
|
||||
// scope categories carry a json-vin-main-groups.action linkPath (and the
|
||||
// next level json-vin-illustrations.action). These are PARENTS — they must
|
||||
// route to getChildren, not fall through to the leaf path and render empty.
|
||||
// The dispatch must stay in sync with PL24Service.isPsaVin*Path; this test
|
||||
// fails loudly if it drifts again.
|
||||
it("routes a PSA VIN scope (json-vin-main-groups.action) to children, not an empty leaf", async () => {
|
||||
const category = {
|
||||
id: "psa1",
|
||||
name: "mekanik",
|
||||
nameOriginal: "mekanik",
|
||||
parentId: null,
|
||||
vehicleId: "v1",
|
||||
source: "pl24",
|
||||
linkPath: "/psa/peugeot_parts/json-vin-main-groups.action?scope=_FCT0001&vin=VF3ABCDE",
|
||||
linkWid: null,
|
||||
hasSubgroups: null,
|
||||
hasParts: null,
|
||||
};
|
||||
let selectCall = 0;
|
||||
const db = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
selectCall++;
|
||||
const captured = selectCall;
|
||||
const c: Record<string, any> = {};
|
||||
c.from = vi.fn().mockReturnValue(c);
|
||||
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||
c.limit = vi.fn().mockReturnValue([category]);
|
||||
return c;
|
||||
}),
|
||||
execute: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const { service } = createService(db);
|
||||
const discovered = [{ id: "mg1", name: "motor", children: undefined }];
|
||||
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
|
||||
|
||||
const result = await service.getCategoryWithParts("psa1");
|
||||
|
||||
expect(service.getChildren).toHaveBeenCalledWith("psa1");
|
||||
expect(result.parts).toEqual([]);
|
||||
expect((result as { children?: unknown }).children).toEqual(discovered);
|
||||
});
|
||||
|
||||
it("surfaces loadError (not an empty grid) when a PSA VIN main-group drill comes back empty", async () => {
|
||||
const category = {
|
||||
id: "psa2",
|
||||
name: "motor",
|
||||
nameOriginal: "motor",
|
||||
parentId: "psa1",
|
||||
vehicleId: "v1",
|
||||
source: "pl24",
|
||||
linkPath: "/psa/peugeot_parts/json-vin-illustrations.action?mainGroup=P8&vin=VF3ABCDE",
|
||||
linkWid: null,
|
||||
hasSubgroups: null,
|
||||
hasParts: null,
|
||||
};
|
||||
let selectCall = 0;
|
||||
const db = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
selectCall++;
|
||||
const captured = selectCall;
|
||||
const c: Record<string, any> = {};
|
||||
c.from = vi.fn().mockReturnValue(c);
|
||||
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||
c.limit = vi.fn().mockReturnValue([category]);
|
||||
return c;
|
||||
}),
|
||||
execute: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const { service } = createService(db);
|
||||
// Drill comes back empty (transient upstream failure).
|
||||
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
|
||||
|
||||
const result = await service.getCategoryWithParts("psa2");
|
||||
|
||||
expect(service.getChildren).toHaveBeenCalledWith("psa2");
|
||||
expect((result as { loadError?: boolean }).loadError).toBe(true);
|
||||
expect((result as { children?: unknown }).children).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAncestors", () => {
|
||||
|
||||
@@ -918,15 +918,29 @@ export class CategoriesService {
|
||||
}
|
||||
|
||||
// PSA parent nodes — always fetch children on-demand:
|
||||
// • psa:: scope paths (top-level scopes)
|
||||
// • json-illustrations.action paths (mid-level main groups → illustration lists)
|
||||
// • psa:: scope paths (catalog-browse top-level scopes)
|
||||
// • json-illustrations.action (catalog-browse main group → illustrations)
|
||||
// • json-vin-main-groups.action (VIN-indexed scope → main groups)
|
||||
// • json-vin-illustrations.action (VIN-indexed main group → illustrations)
|
||||
// The two VIN-indexed levels were MISSING here, so VIN-decoded Peugeot/Citroën
|
||||
// scope categories ("mekanik", "kaporta", "ekipmanlar"…) fell through every
|
||||
// parent branch to the leaf path, fetched no parts (the main-groups endpoint
|
||||
// returns groups, not parts) and rendered an empty panel — the single biggest
|
||||
// "0 parça" complaint. The drill itself works: getChildren →
|
||||
// fetchSubGroupsByPath → fetchVinMainGroups / fetchVinIllustrations. These four
|
||||
// predicates must stay in sync with PL24Service.isPsaPath / isPsaIllusPath /
|
||||
// isPsaVinMainGroupsPath / isPsaVinIllusPath; the leaf level
|
||||
// (vin-image-board.action / image-board.action) correctly stays a leaf below.
|
||||
const psaLink = category.linkPath ?? "";
|
||||
const isPsaParent =
|
||||
category.linkPath?.startsWith("psa::") ||
|
||||
(category.linkPath?.includes("/psa/") &&
|
||||
category.linkPath?.includes("json-illustrations.action"));
|
||||
psaLink.startsWith("psa::") ||
|
||||
(psaLink.includes("/psa/") &&
|
||||
(psaLink.includes("json-illustrations.action") ||
|
||||
psaLink.includes("json-vin-main-groups.action") ||
|
||||
psaLink.includes("json-vin-illustrations.action")));
|
||||
if (isPsaParent && category.vehicleId) {
|
||||
const psaChildren = await this.getChildren(categoryId);
|
||||
return {
|
||||
const base = {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: category.nameOriginal || null,
|
||||
@@ -934,8 +948,14 @@ export class CategoriesService {
|
||||
parts: [],
|
||||
schemaPics: [],
|
||||
hotspots: [],
|
||||
children: psaChildren,
|
||||
};
|
||||
// A PSA scope / main group is never a parts leaf. If the on-demand drill
|
||||
// comes back empty the upstream snapshot failed transiently — surface a
|
||||
// retryable load error (matching the pl24/emex group-node branches below)
|
||||
// instead of a misleading empty subcategory grid.
|
||||
return psaChildren.length > 0
|
||||
? { ...base, children: psaChildren }
|
||||
: { ...base, loadError: true };
|
||||
}
|
||||
|
||||
// parts-catalogs parent group — the groups2 API marked this node as having
|
||||
|
||||
Reference in New Issue
Block a user