@bytebrand/export-cars 1.26.38 → 1.26.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ import { promises as fs } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { get, toNumber, toString } from 'lodash';
4
+ import { ImboImageResponseInterface } from '@bytebrand/chunkable';
5
+ import { DataSchemes } from '@bytebrand/car-schema';
6
+
7
+ import { GoogleXmlBuilder } from '../formats/google-xml.builder';
8
+ import { CriteoXmlItem } from '../interfaces';
9
+ import { FeedGuard, FeedGuardError, LastGood, inspectSourceSnapshot } from './feed-guard';
10
+
11
+ export interface CriteoSourceCar {
12
+ car: DataSchemes.Car;
13
+ images: ImboImageResponseInterface[];
14
+ }
15
+
16
+ export enum CriteoExclusion {
17
+ noId = 'no_id',
18
+ duplicateId = 'duplicate_id',
19
+ filtered = 'filtered',
20
+ notAvailable = 'reserved_or_sold',
21
+ noPrice = 'no_valid_price',
22
+ noImage = 'no_marketing_image',
23
+ }
24
+
25
+ // Online cars flagged like this are not sellable stock (same rule as the AutoUncle feed). Criteo
26
+ // (16.10.2026): only currently marketable stock goes into the full file; a car that leaves it is
27
+ // removed by the next full import.
28
+ const NOT_AVAILABLE = ['selector_offerAvailability_reserved', 'selector_offerAvailability_sold'];
29
+
30
+ // Everything the run needs from the outside world — the Nest exporter wires the real services,
31
+ // tests pass fakes.
32
+ export interface CriteoRunDeps {
33
+ // iterates the base export snapshot chunk by chunk (LoaderService.process)
34
+ forEachChunk: (fn: (cars: CriteoSourceCar[]) => Promise<void>) => Promise<unknown>;
35
+ // existing shared partner filter (images count, Cronicle rules)
36
+ filterCar: (source: CriteoSourceCar) => boolean;
37
+ // CriteoTransformer.transform: null = no real marketing image
38
+ transform: (source: CriteoSourceCar) => Promise<CriteoXmlItem | null>;
39
+ guard: FeedGuard;
40
+ }
41
+
42
+ export interface CriteoRunOptions {
43
+ runId?: string;
44
+ domain: string;
45
+ sourceDirectory: string;
46
+ sourceBaseFileName: string;
47
+ xmlFile: string;
48
+ stateDir: string;
49
+ now?: () => Date;
50
+ }
51
+
52
+ export interface CriteoRunReport {
53
+ runId?: string;
54
+ startedAt: string;
55
+ finishedAt?: string;
56
+ durationMs?: number;
57
+ status: 'published' | 'blocked' | 'failed';
58
+ guard?: { code: string; message: string };
59
+ error?: string;
60
+ xmlFile: string;
61
+ source: { files: number; newestChunkAt: string | null; ageHours: number | null; cars: number };
62
+ items: number;
63
+ excluded: Record<string, number>;
64
+ withRate: number;
65
+ withoutEnergyClass: number;
66
+ // against the ids of the last published file; removed ids are dropped by Criteo's next full import
67
+ ids: { added: number; removed: number; kept: number; removedSample: string[] } | null;
68
+ lastGood: LastGood | null;
69
+ lastGoodAgeHours: number | null;
70
+ stale: boolean;
71
+ }
72
+
73
+ const HOUR_MS = 60 * 60 * 1000;
74
+ const PRICE = /^\d+(\.\d+)? EUR$/;
75
+
76
+ const increment = (counter: Record<string, number>, key: string) => {
77
+ counter[key] = (counter[key] ?? 0) + 1;
78
+ };
79
+
80
+ /**
81
+ * One Criteo catalog run (ADV-319): source check -> map -> temp XML -> count check -> atomic
82
+ * publish -> report. Nothing outside the output directory and `stateDir` is written. A blocked run
83
+ * leaves the last good XML in place and fails the job, so the chained upload step does not run.
84
+ * The XML format, enums and field order stay the legacy Criteo ones.
85
+ */
86
+ export class CriteoFeedRun {
87
+ constructor(private readonly deps: CriteoRunDeps, private readonly options: CriteoRunOptions) { }
88
+
89
+ private now(): Date {
90
+ return this.options.now ? this.options.now() : new Date();
91
+ }
92
+
93
+ private get idsFile(): string {
94
+ return join(this.options.stateDir, 'published-ids.json');
95
+ }
96
+
97
+ async run(): Promise<CriteoRunReport> {
98
+ const { guard } = this.deps;
99
+ const { xmlFile, runId } = this.options;
100
+ const startedAt = this.now();
101
+ const lastGood = await guard.readLastGood();
102
+ const report: CriteoRunReport = {
103
+ runId,
104
+ startedAt: startedAt.toISOString(),
105
+ status: 'failed',
106
+ xmlFile,
107
+ source: { files: 0, newestChunkAt: null, ageHours: null, cars: 0 },
108
+ items: 0,
109
+ excluded: {},
110
+ withRate: 0,
111
+ withoutEnergyClass: 0,
112
+ ids: null,
113
+ lastGood,
114
+ lastGoodAgeHours: guard.staleHours(lastGood, startedAt),
115
+ stale: guard.isStale(lastGood, startedAt),
116
+ };
117
+
118
+ await guard.acquireLock(runId ?? 'local', startedAt);
119
+ const tmpFile = `${xmlFile}.tmp`;
120
+ try {
121
+ const snapshot = await inspectSourceSnapshot(this.options.sourceDirectory, this.options.sourceBaseFileName);
122
+ report.source.files = snapshot.files;
123
+ report.source.newestChunkAt = snapshot.newestMtime?.toISOString() ?? null;
124
+ report.source.ageHours = snapshot.newestMtime
125
+ ? Number(((startedAt.getTime() - snapshot.newestMtime.getTime()) / HOUR_MS).toFixed(2))
126
+ : null;
127
+ guard.checkSource(snapshot, startedAt);
128
+
129
+ await fs.mkdir(dirname(xmlFile), { recursive: true });
130
+ // the builder appends — never onto a leftover temp file
131
+ await fs.rm(tmpFile, { force: true });
132
+ const builder = new GoogleXmlBuilder(tmpFile);
133
+ await builder.buildStart(this.options.domain);
134
+
135
+ const seen = new Set<string>();
136
+ const published: string[] = [];
137
+ await this.deps.forEachChunk(async (cars) => {
138
+ for (const source of cars) {
139
+ report.source.cars += 1;
140
+ const reason = this.exclusion(source, seen);
141
+ if (reason) {
142
+ increment(report.excluded, reason);
143
+ continue;
144
+ }
145
+ const item = await this.deps.transform(source);
146
+ if (!item) {
147
+ increment(report.excluded, CriteoExclusion.noImage);
148
+ continue;
149
+ }
150
+ // the transformer formats the price; a value it could not format is not sent
151
+ if (!PRICE.test(item.price) || !PRICE.test(item.sale_price)) {
152
+ increment(report.excluded, CriteoExclusion.noPrice);
153
+ continue;
154
+ }
155
+ await builder.addItem(item);
156
+ published.push(item.id);
157
+ report.items += 1;
158
+ if (item.installment) {
159
+ report.withRate += 1;
160
+ }
161
+ if (!item.energy_efficiency_class) {
162
+ report.withoutEnergyClass += 1;
163
+ }
164
+ }
165
+ });
166
+ await builder.buildEnd();
167
+
168
+ const counts = { sourceItems: report.source.cars, publishedItems: report.items };
169
+ guard.checkCounts(counts, lastGood);
170
+
171
+ const previousIds = await this.readPublishedIds();
172
+ report.lastGood = await guard.publish(tmpFile, xmlFile, counts, this.now(), runId);
173
+ await this.writePublishedIds(published);
174
+ report.ids = this.diffIds(previousIds, published);
175
+ report.status = 'published';
176
+ report.lastGoodAgeHours = 0;
177
+ report.stale = false;
178
+ return report;
179
+ } catch (error) {
180
+ if (error instanceof FeedGuardError) {
181
+ report.status = 'blocked';
182
+ report.guard = { code: error.code, message: error.message };
183
+ } else {
184
+ report.error = error?.message ?? String(error);
185
+ }
186
+ // keep the rejected file for inspection; the published file stays untouched
187
+ await fs.rename(tmpFile, `${xmlFile}.rejected`).catch(() => undefined);
188
+ throw error;
189
+ } finally {
190
+ await this.writeReport(report, startedAt);
191
+ await guard.releaseLock();
192
+ }
193
+ }
194
+
195
+ private exclusion(source: CriteoSourceCar, seen: Set<string>): CriteoExclusion | null {
196
+ const id = toString(get(source, 'car._id', ''));
197
+ if (!id) {
198
+ return CriteoExclusion.noId;
199
+ }
200
+ if (seen.has(id)) {
201
+ return CriteoExclusion.duplicateId;
202
+ }
203
+ seen.add(id);
204
+ if (!this.deps.filterCar(source)) {
205
+ return CriteoExclusion.filtered;
206
+ }
207
+ if (NOT_AVAILABLE.includes(toString(get(source.car, 'offer.offerAvailability', '')))) {
208
+ return CriteoExclusion.notAvailable;
209
+ }
210
+ const price = toNumber(get(source.car, 'price.currentSalesPrice', null));
211
+ if (!Number.isFinite(price) || price <= 0) {
212
+ return CriteoExclusion.noPrice;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ private async readPublishedIds(): Promise<string[] | null> {
218
+ try {
219
+ return JSON.parse(await fs.readFile(this.idsFile, 'utf8')) as string[];
220
+ } catch (error) {
221
+ if (error?.code === 'ENOENT') {
222
+ return null;
223
+ }
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ private async writePublishedIds(ids: string[]): Promise<void> {
229
+ await fs.mkdir(this.options.stateDir, { recursive: true });
230
+ await fs.writeFile(`${this.idsFile}.tmp`, JSON.stringify(ids), 'utf8');
231
+ await fs.rename(`${this.idsFile}.tmp`, this.idsFile);
232
+ }
233
+
234
+ private diffIds(previous: string[] | null, current: string[]): CriteoRunReport['ids'] {
235
+ if (!previous) {
236
+ return { added: current.length, removed: 0, kept: 0, removedSample: [] };
237
+ }
238
+ const before = new Set(previous);
239
+ const now = new Set(current);
240
+ const removed = previous.filter(id => !now.has(id));
241
+ const kept = current.filter(id => before.has(id)).length;
242
+ return { added: current.length - kept, removed: removed.length, kept, removedSample: removed.slice(0, 20) };
243
+ }
244
+
245
+ private async writeReport(report: CriteoRunReport, startedAt: Date): Promise<void> {
246
+ const finishedAt = this.now();
247
+ report.finishedAt = finishedAt.toISOString();
248
+ report.durationMs = finishedAt.getTime() - startedAt.getTime();
249
+ await fs.mkdir(this.options.stateDir, { recursive: true });
250
+ await fs.writeFile(join(this.options.stateDir, 'last-run-report.json'), JSON.stringify(report, null, 2), 'utf8');
251
+ console.log(`criteo feed report: ${JSON.stringify(report)}`);
252
+ }
253
+ }
@@ -1,4 +1,4 @@
1
- import { escape, get, join, round, toString, trim } from 'lodash';
1
+ import { get, join, round, toString, trim } from 'lodash';
2
2
  import { CarDescriptionFactory, DescriptionTypeEnum } from '@bytebrand/car-description';
3
3
  import { DataSchemes } from '@bytebrand/car-schema';
4
4
  import { TranslatorExtended } from '@bytebrand/schema-tools';
@@ -34,6 +34,11 @@ export class CriteoTransformer {
34
34
  return null;
35
35
  }
36
36
 
37
+ const energyClass = this.getEnergyClass(car);
38
+ const installment = this.getInstallment(car);
39
+
40
+ // Unknown energy class and a missing rate leave their elements out of the item,
41
+ // instead of sending "default" or a "0 EUR" monthly rate.
37
42
  return {
38
43
  id: carId,
39
44
  title: this.getTitle(car),
@@ -47,17 +52,20 @@ export class CriteoTransformer {
47
52
  brand: get(car, 'mainData.make', ''),
48
53
  price: this.getRetailPrice(car),
49
54
  sale_price: this.getPrice(car),
50
- energy_efficiency_class: this.getEnergyClass(car),
55
+ ...(energyClass ? { energy_efficiency_class: energyClass } : {}),
51
56
  color: this.getColor(car),
52
- installment: this.getInstallment(car),
53
- custom_label_0: `${this.getInstallment(car)} EUR mtl.`,
57
+ ...(installment
58
+ ? { installment, custom_label_0: `${installment.monthlyInstallment} EUR mtl.` }
59
+ : {}),
54
60
  };
55
61
  }
56
62
 
63
+ // No HTML escaping here: GoogleXmlBuilder escapes every value for XML.
64
+ // Escaping twice turned "cee'd" into "cee&amp;#39;d".
57
65
  private getTitle(car: DataSchemes.Car): string {
58
66
  const model = toString(get(car, 'mainData.model', ''));
59
67
 
60
- return trim(escape(join([this.getBrand(car), model], ' ')));
68
+ return trim(join([this.getBrand(car), model], ' '));
61
69
  }
62
70
 
63
71
  private async getDescription(car: DataSchemes.Car): Promise<string> {
@@ -143,16 +151,20 @@ export class CriteoTransformer {
143
151
  selector_energyEfficiencyClass_e: 'E',
144
152
  selector_energyEfficiencyClass_f: 'F',
145
153
  selector_energyEfficiencyClass_g: 'G',
146
- selector_unknown: 'default',
147
154
  };
148
155
  // ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
149
- const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
156
+ // Unknown or unmapped class -> undefined, so the element is left out.
157
+ const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass');
150
158
  return get(available, energyClass);
151
159
  }
152
160
 
153
- private getInstallment(car: DataSchemes.Car) {
154
- const monthlyInstallment = round(get(car, 'rate.monthlyInstallment', null));
155
- const paybackPeriod = round(get(car, 'rate.paybackPeriod', null));
161
+ // A rate is sent only when the car has a real one: both values present and above zero.
162
+ private getInstallment(car: DataSchemes.Car): CriteoXmlItem['installment'] | undefined {
163
+ const monthlyInstallment = round(Number(get(car, 'rate.monthlyInstallment')));
164
+ const paybackPeriod = round(Number(get(car, 'rate.paybackPeriod')));
165
+ if (!(monthlyInstallment > 0) || !(paybackPeriod > 0)) {
166
+ return undefined;
167
+ }
156
168
 
157
169
  return {
158
170
  paybackPeriod,
@@ -0,0 +1,365 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ // ADV-319 acceptance: 20 real vehicle cases plus sold / reserved / no image / price change /
3
+ // source outage / duplicate id, and the guard (stale source, count drop, lock, atomic publish).
4
+ // Integration: real files in a temp dir (base export snapshot, output, state), the real
5
+ // CriteoTransformer, GoogleXmlBuilder and FeedGuard; only the marketing image lookup is faked.
6
+ // The 20 cars are anonymized copies of DEV base export cars (05.11.2026): synthetic ids, only the
7
+ // fields the Criteo mapping reads, no dealer data.
8
+ import * as assert from 'node:assert/strict';
9
+ import { promises as fs } from 'fs';
10
+ import { tmpdir } from 'os';
11
+ import { join } from 'path';
12
+
13
+ import { FeedGuard, FeedGuardError, FeedGuardOptions } from '../src/services/feed-guard';
14
+ import { CriteoFeedRun, CriteoRunReport, CriteoSourceCar } from '../src/services/criteo-feed-run';
15
+ import { CriteoTransformer } from '../src/transformers/criteo.transformer';
16
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
17
+ const REAL_CARS: any[] = require('./fixtures/criteo-20-cases.json');
18
+
19
+ const NOW = new Date('2026-11-06T08:00:00Z');
20
+ const HOUR = 60 * 60 * 1000;
21
+ const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
22
+ const imageUrl = (carId: string) => `https://images.autode-dev.de/generated_images/x/${carId}/criteo-Test.jpg`;
23
+
24
+ interface World {
25
+ root: string;
26
+ source: string;
27
+ xml: string;
28
+ state: string;
29
+ noImage: Set<string>;
30
+ }
31
+
32
+ const newWorld = async (): Promise<World> => {
33
+ const root = await fs.mkdtemp(join(tmpdir(), 'criteo-'));
34
+ return {
35
+ root,
36
+ source: join(root, 'cars-export'),
37
+ xml: join(root, 'criteo-export', 'api_export_cars_criteo.xml'),
38
+ state: join(root, 'criteo-state'),
39
+ noImage: new Set(),
40
+ };
41
+ };
42
+
43
+ const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
44
+ const realSources = (): CriteoSourceCar[] => REAL_CARS.map(car => ({ car: clone(car), images: [] }));
45
+
46
+ const writeSource = async (world: World, cars: CriteoSourceCar[], { chunkSize = 6, mtime = NOW, skipChunk = -1 } = {}) => {
47
+ await fs.rm(world.source, { recursive: true, force: true });
48
+ await fs.mkdir(world.source, { recursive: true });
49
+ for (let i = 0; i * chunkSize < cars.length; i++) {
50
+ if (i === skipChunk) {
51
+ continue;
52
+ }
53
+ const file = join(world.source, `cars.${i}.json`);
54
+ await fs.writeFile(file, JSON.stringify(cars.slice(i * chunkSize, (i + 1) * chunkSize)));
55
+ await fs.utimes(file, mtime, mtime);
56
+ }
57
+ };
58
+
59
+ const guardOptions = (world: World, extra: Partial<FeedGuardOptions> = {}): FeedGuardOptions => ({
60
+ stateDir: world.state,
61
+ minSourceItems: 1,
62
+ minPublishedItems: 1,
63
+ maxDropPercent: 20,
64
+ maxSourceAgeHours: 26,
65
+ maxStaleHours: 48,
66
+ acceptDrop: false,
67
+ ...extra,
68
+ });
69
+
70
+ const run = async (world: World, { at = NOW, guard = {} as Partial<FeedGuardOptions> } = {}): Promise<CriteoRunReport> => {
71
+ const transformer = new CriteoTransformer(
72
+ {} as any,
73
+ { config: { isCriteo: true, imageType: 'marketing', domain: 'autode-dev.de' } } as any,
74
+ { getImages: async (car: any) => (world.noImage.has(car._id) ? [] : [imageUrl(car._id)]) } as any,
75
+ );
76
+ const feedRun = new CriteoFeedRun(
77
+ {
78
+ forEachChunk: async (fn) => {
79
+ const names = (await fs.readdir(world.source)).sort((a, b) => Number(a.split('.')[1]) - Number(b.split('.')[1]));
80
+ for (const name of names) {
81
+ await fn(JSON.parse(await fs.readFile(join(world.source, name), 'utf8')));
82
+ }
83
+ },
84
+ filterCar: ({ car }) => (car?.metaData?.imagesCount ?? 0) >= 4,
85
+ transform: source => transformer.transform(source),
86
+ guard: new FeedGuard(guardOptions(world, guard)),
87
+ },
88
+ {
89
+ runId: 'test',
90
+ domain: 'autode-dev.de',
91
+ sourceDirectory: world.source,
92
+ sourceBaseFileName: 'cars',
93
+ xmlFile: world.xml,
94
+ stateDir: world.state,
95
+ now: () => at,
96
+ },
97
+ );
98
+ return feedRun.run();
99
+ };
100
+
101
+ const readReport = async (world: World): Promise<CriteoRunReport> =>
102
+ JSON.parse(await fs.readFile(join(world.state, 'last-run-report.json'), 'utf8'));
103
+
104
+ // id -> field -> value of the published XML
105
+ const readItems = async (file: string): Promise<Map<string, Record<string, string>>> => {
106
+ const xml = await fs.readFile(file, 'utf8');
107
+ const items = new Map<string, Record<string, string>>();
108
+ for (const [, body] of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
109
+ const fields: Record<string, string> = {};
110
+ for (const [, key, value] of body.matchAll(/<g:(\w+)>([\s\S]*?)<\/g:\1>/g)) {
111
+ fields[key] = value.replace(/\s+/g, ' ').trim();
112
+ }
113
+ items.set(fields.id, fields);
114
+ }
115
+ return items;
116
+ };
117
+
118
+ const exists = (file: string) => fs.access(file).then(() => true, () => false);
119
+
120
+ const blocked = async (promise: Promise<unknown>, code: string) => {
121
+ await assert.rejects(promise, (error: any) => error instanceof FeedGuardError && error.code === code);
122
+ };
123
+
124
+ const tests: Array<[string, () => Promise<void>]> = [
125
+ ['20 real cars: every car is published once, in source order, with the legacy field order', async () => {
126
+ const world = await newWorld();
127
+ await writeSource(world, realSources());
128
+ const report = await run(world);
129
+ assert.equal(report.status, 'published');
130
+ assert.equal(report.items, 20);
131
+ assert.deepEqual(report.excluded, {});
132
+ const items = await readItems(world.xml);
133
+ assert.deepEqual([...items.keys()], REAL_CARS.map(car => car._id));
134
+ const xml = await fs.readFile(world.xml, 'utf8');
135
+ assert.ok(xml.startsWith('<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">'));
136
+ assert.ok(xml.trimEnd().endsWith('</channel></rss>'));
137
+ assert.ok(!/object Object|>default<|<g:amount>0 EUR|&amp;#|undefined|NaN/.test(xml));
138
+ const first = xml.match(/<item>([\s\S]*?)<\/item>/)[1];
139
+ assert.deepEqual([...first.matchAll(/<g:(\w+)>/g)].map(m => m[1]).filter(k => !['months', 'amount'].includes(k)), [
140
+ 'id', 'title', 'description', 'google_product_category', 'product_type', 'link', 'image_link', 'condition',
141
+ 'availability', 'brand', 'price', 'sale_price', 'energy_efficiency_class', 'color', 'installment', 'custom_label_0',
142
+ ]);
143
+ }],
144
+ ['20 real cars: id, link, image, price and condition follow the source car', async () => {
145
+ const world = await newWorld();
146
+ await writeSource(world, realSources());
147
+ await run(world);
148
+ const items = await readItems(world.xml);
149
+ for (const car of REAL_CARS) {
150
+ const item = items.get(car._id);
151
+ assert.equal(item.link, `https://www.autode-dev.de/search/vehicle/${car._id}?source=criteo&amp;medium=cpc&amp;campaign=retargeting`);
152
+ assert.equal(item.image_link, imageUrl(car._id));
153
+ assert.equal(item.price, `${car.price.currentSalesPrice} EUR`, car._id);
154
+ assert.equal(item.sale_price, `${car.price.currentSalesPrice} EUR`, car._id);
155
+ assert.equal(item.brand, car.mainData.make);
156
+ assert.equal(item.condition, car.mainData.condition === 'selector_condition_new' ? 'Neu' : 'Gebraucht');
157
+ assert.equal(
158
+ item.availability,
159
+ car.offer.availabilityMode === 'selector_availabilityMode_always' ? 'Sofort' : 'Ab einem bestimmten Datum',
160
+ );
161
+ }
162
+ }],
163
+ ['20 real cars: brand aliases, apostrophe, unknown energy class and missing rates', async () => {
164
+ const world = await newWorld();
165
+ await writeSource(world, realSources());
166
+ const report = await run(world);
167
+ const items = await readItems(world.xml);
168
+ assert.equal(items.get(id(4)).title, 'Mercedes S 580');
169
+ assert.equal(items.get(id(6)).title, 'VW T-Roc');
170
+ assert.equal(items.get(id(12)).title, 'Kia cee&apos;d');
171
+ assert.equal(items.get(id(1)).energy_efficiency_class, 'D');
172
+ assert.equal(items.get(id(15)).energy_efficiency_class, undefined);
173
+ assert.equal(report.withoutEnergyClass, 1);
174
+ assert.equal(items.get(id(1)).custom_label_0, '309 EUR mtl.');
175
+ for (const n of [16, 17]) {
176
+ assert.equal(items.get(id(n)).installment, undefined);
177
+ assert.equal(items.get(id(n)).custom_label_0, undefined);
178
+ }
179
+ assert.equal(report.withRate, 18);
180
+ }],
181
+ ['sold car is left out and reported as removed by the next run', async () => {
182
+ const world = await newWorld();
183
+ await writeSource(world, realSources());
184
+ await run(world);
185
+ const cars = realSources();
186
+ cars[2].car.offer.offerAvailability = 'selector_offerAvailability_sold';
187
+ await writeSource(world, cars);
188
+ const report = await run(world, { at: new Date(NOW.getTime() + HOUR) });
189
+ assert.equal(report.excluded.reserved_or_sold, 1);
190
+ assert.equal((await readItems(world.xml)).has(id(3)), false);
191
+ assert.deepEqual(report.ids, { added: 0, removed: 1, kept: 19, removedSample: [id(3)] });
192
+ }],
193
+ ['reserved car is left out', async () => {
194
+ const world = await newWorld();
195
+ const cars = realSources();
196
+ cars[5].car.offer.offerAvailability = 'selector_offerAvailability_reserved';
197
+ await writeSource(world, cars);
198
+ const report = await run(world);
199
+ assert.equal(report.items, 19);
200
+ assert.equal(report.excluded.reserved_or_sold, 1);
201
+ assert.equal((await readItems(world.xml)).has(id(6)), false);
202
+ }],
203
+ ['car that went offline (not in the snapshot any more) drops out of the next full file', async () => {
204
+ const world = await newWorld();
205
+ await writeSource(world, realSources());
206
+ await run(world);
207
+ await writeSource(world, realSources().filter(source => source.car._id !== id(9)));
208
+ const report = await run(world, { at: new Date(NOW.getTime() + HOUR) });
209
+ assert.equal((await readItems(world.xml)).has(id(9)), false);
210
+ assert.equal(report.ids.removed, 1);
211
+ }],
212
+ ['car without a marketing image is left out, no placeholder', async () => {
213
+ const world = await newWorld();
214
+ world.noImage.add(id(7));
215
+ await writeSource(world, realSources());
216
+ const report = await run(world);
217
+ assert.equal(report.excluded.no_marketing_image, 1);
218
+ assert.equal((await readItems(world.xml)).has(id(7)), false);
219
+ assert.ok(!(await fs.readFile(world.xml, 'utf8')).includes('noimage'));
220
+ }],
221
+ ['price change shows up in the next run under the same id', async () => {
222
+ const world = await newWorld();
223
+ await writeSource(world, realSources());
224
+ await run(world);
225
+ const cars = realSources();
226
+ cars[0].car.price.currentSalesPrice = 25990;
227
+ await writeSource(world, cars);
228
+ const report = await run(world, { at: new Date(NOW.getTime() + HOUR) });
229
+ const item = (await readItems(world.xml)).get(id(1));
230
+ assert.equal(item.price, '25990 EUR');
231
+ assert.equal(item.sale_price, '25990 EUR');
232
+ assert.deepEqual(report.ids, { added: 0, removed: 0, kept: 20, removedSample: [] });
233
+ }],
234
+ ['car without a valid price is left out instead of "0 EUR"', async () => {
235
+ const world = await newWorld();
236
+ const cars = realSources();
237
+ cars[0].car.price.currentSalesPrice = 0;
238
+ delete cars[1].car.price.currentSalesPrice;
239
+ // garbage from upstream, not a valid schema value
240
+ (cars[2].car.price as any).currentSalesPrice = 'abc';
241
+ await writeSource(world, cars);
242
+ const report = await run(world);
243
+ assert.equal(report.excluded.no_valid_price, 3);
244
+ assert.equal(report.items, 17);
245
+ assert.ok(!(await fs.readFile(world.xml, 'utf8')).includes('>0 EUR<'));
246
+ }],
247
+ ['duplicate id is published once', async () => {
248
+ const world = await newWorld();
249
+ const cars = realSources();
250
+ cars.push({ car: clone(REAL_CARS[0]), images: [] });
251
+ await writeSource(world, cars);
252
+ const report = await run(world);
253
+ assert.equal(report.items, 20);
254
+ assert.equal(report.excluded.duplicate_id, 1);
255
+ const xml = await fs.readFile(world.xml, 'utf8');
256
+ assert.equal(xml.split(`<g:id>${id(1)}</g:id>`).length - 1, 1);
257
+ }],
258
+ ['car without an id is left out', async () => {
259
+ const world = await newWorld();
260
+ const cars = realSources();
261
+ cars[0].car._id = '';
262
+ await writeSource(world, cars);
263
+ const report = await run(world);
264
+ assert.equal(report.excluded.no_id, 1);
265
+ assert.equal(report.items, 19);
266
+ }],
267
+ ['source outage: empty snapshot is blocked, last good file and state untouched', async () => {
268
+ const world = await newWorld();
269
+ await writeSource(world, realSources());
270
+ await run(world);
271
+ const before = await fs.readFile(world.xml, 'utf8');
272
+ const lastGood = await fs.readFile(join(world.state, 'last-good.json'), 'utf8');
273
+ await writeSource(world, []);
274
+ await blocked(run(world, { at: new Date(NOW.getTime() + HOUR) }), 'source_empty');
275
+ assert.equal(await fs.readFile(world.xml, 'utf8'), before);
276
+ assert.equal(await fs.readFile(join(world.state, 'last-good.json'), 'utf8'), lastGood);
277
+ const report = await readReport(world);
278
+ assert.equal(report.status, 'blocked');
279
+ assert.equal(report.guard.code, 'source_empty');
280
+ }],
281
+ ['source outage: missing snapshot directory is blocked', async () => {
282
+ const world = await newWorld();
283
+ await blocked(run(world), 'source_empty');
284
+ assert.equal(await exists(world.xml), false);
285
+ }],
286
+ ['partial snapshot (missing chunk) is blocked', async () => {
287
+ const world = await newWorld();
288
+ await writeSource(world, realSources(), { skipChunk: 1 });
289
+ await blocked(run(world), 'source_partial');
290
+ assert.equal(await exists(world.xml), false);
291
+ }],
292
+ ['stale snapshot is blocked', async () => {
293
+ const world = await newWorld();
294
+ await writeSource(world, realSources(), { mtime: new Date(NOW.getTime() - 27 * HOUR) });
295
+ await blocked(run(world), 'source_stale');
296
+ }],
297
+ ['stock drop over 20% is blocked, published again with acceptDrop', async () => {
298
+ const world = await newWorld();
299
+ await writeSource(world, realSources());
300
+ await run(world);
301
+ await writeSource(world, realSources().slice(0, 10));
302
+ const later = new Date(NOW.getTime() + HOUR);
303
+ await blocked(run(world, { at: later }), 'count_drop');
304
+ assert.equal((await readItems(world.xml)).size, 20);
305
+ assert.ok(await exists(`${world.xml}.rejected`));
306
+ const report = await run(world, { at: later, guard: { acceptDrop: true } });
307
+ assert.equal(report.items, 10);
308
+ assert.equal(report.ids.removed, 10);
309
+ }],
310
+ ['legitimate empty stock (cars in the snapshot, none publishable) is blocked as published_too_small', async () => {
311
+ const world = await newWorld();
312
+ const cars = realSources().map(source => {
313
+ source.car.offer.offerAvailability = 'selector_offerAvailability_sold';
314
+ return source;
315
+ });
316
+ await writeSource(world, cars);
317
+ await blocked(run(world), 'published_too_small');
318
+ }],
319
+ ['a second run while one holds the lock is refused', async () => {
320
+ const world = await newWorld();
321
+ await writeSource(world, realSources());
322
+ await fs.mkdir(world.state, { recursive: true });
323
+ await fs.writeFile(join(world.state, 'run.lock'), '{}');
324
+ await blocked(run(world), 'locked');
325
+ }],
326
+ ['blocked run reports a stale last good file after maxStaleHours', async () => {
327
+ const world = await newWorld();
328
+ await writeSource(world, realSources());
329
+ await run(world);
330
+ await writeSource(world, []);
331
+ await blocked(run(world, { at: new Date(NOW.getTime() + 49 * HOUR) }), 'source_empty');
332
+ const report = await readReport(world);
333
+ assert.equal(report.stale, true);
334
+ assert.ok(report.lastGoodAgeHours > 48);
335
+ }],
336
+ ['publish is atomic: previous file kept as .prev, no temp file left, report has the metrics', async () => {
337
+ const world = await newWorld();
338
+ await writeSource(world, realSources());
339
+ await run(world);
340
+ await run(world, { at: new Date(NOW.getTime() + HOUR) });
341
+ assert.ok(await exists(`${world.xml}.prev`));
342
+ assert.equal(await exists(`${world.xml}.tmp`), false);
343
+ const report = await readReport(world);
344
+ assert.equal(report.status, 'published');
345
+ assert.equal(report.source.cars, 20);
346
+ assert.equal(report.source.files, 4);
347
+ assert.equal(report.source.ageHours, 1);
348
+ assert.equal(report.lastGood.publishedItems, 20);
349
+ assert.equal(typeof report.durationMs, 'number');
350
+ }],
351
+ ];
352
+
353
+ (async () => {
354
+ let failed = 0;
355
+ for (const [name, fn] of tests) {
356
+ try {
357
+ await fn();
358
+ console.log(`ok ${name}`);
359
+ } catch (e) {
360
+ failed += 1;
361
+ console.error(`FAIL ${name}\n ${e.message}`);
362
+ }
363
+ }
364
+ if (failed) process.exit(1);
365
+ })();