@bytebrand/export-cars 1.26.39 → 1.26.41
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.
- package/dist/configs/autouncle.config.js +22 -0
- package/dist/configs/autouncle.config.js.map +1 -1
- package/dist/configs/criteo.config.d.ts +10 -0
- package/dist/configs/criteo.config.js +26 -1
- package/dist/configs/criteo.config.js.map +1 -1
- package/dist/exporters/autouncle.exporter.js +10 -0
- package/dist/exporters/autouncle.exporter.js.map +1 -1
- package/dist/exporters/criteo.exporter.js +27 -23
- package/dist/exporters/criteo.exporter.js.map +1 -1
- package/dist/services/autouncle-feed-run.d.ts +6 -0
- package/dist/services/autouncle-feed-run.js +10 -1
- package/dist/services/autouncle-feed-run.js.map +1 -1
- package/dist/services/criteo-feed-run.d.ts +76 -0
- package/dist/services/criteo-feed-run.js +186 -0
- package/dist/services/criteo-feed-run.js.map +1 -0
- package/dist/transformers/autouncle.transformer.d.ts +3 -0
- package/dist/transformers/autouncle.transformer.js +9 -5
- package/dist/transformers/autouncle.transformer.js.map +1 -1
- package/dist/transformers/google-feed-envkv.d.ts +2 -0
- package/dist/transformers/google-feed-envkv.js +9 -1
- package/dist/transformers/google-feed-envkv.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/autouncle/AUDIT.md +7 -3
- package/docs/autouncle/PARTNER-QUESTIONS.md +3 -1
- package/docs/autouncle/RUNBOOK.md +2 -0
- package/docs/criteo/RUNBOOK.md +86 -0
- package/package.json +2 -2
- package/src/configs/autouncle.config.ts +26 -1
- package/src/configs/criteo.config.ts +37 -0
- package/src/exporters/autouncle.exporter.ts +12 -0
- package/src/exporters/criteo.exporter.ts +40 -28
- package/src/services/autouncle-feed-run.ts +15 -1
- package/src/services/criteo-feed-run.ts +253 -0
- package/src/transformers/autouncle.transformer.ts +17 -4
- package/src/transformers/google-feed-envkv.ts +10 -0
- package/test/autouncle-feed-run.test.ts +25 -2
- package/test/autouncle-mapping.test.ts +41 -2
- package/test/criteo-feed-run.test.ts +365 -0
- package/test/criteo-mapping.test.ts +122 -122
- package/test/fixtures/criteo-20-cases.json +582 -0
|
@@ -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|&#|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&medium=cpc&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'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
|
+
})();
|
|
@@ -1,122 +1,122 @@
|
|
|
1
|
-
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
-
// ADV-319: Criteo item mapping bugs found in the DEV feed of 27.10.2026.
|
|
3
|
-
// - custom_label_0 was "[object Object] EUR mtl." for every car;
|
|
4
|
-
// - a car without a rate got "<g:months>0</g:months><g:amount>0 EUR</g:amount>";
|
|
5
|
-
// - a title with an apostrophe was escaped twice ("cee&#39;d");
|
|
6
|
-
// - an unknown energy class was sent as the literal "default".
|
|
7
|
-
import * as assert from 'node:assert/strict';
|
|
8
|
-
import * as fs from 'node:fs';
|
|
9
|
-
import * as os from 'node:os';
|
|
10
|
-
import * as path from 'node:path';
|
|
11
|
-
import { CriteoTransformer } from '../src/transformers/criteo.transformer';
|
|
12
|
-
import { GoogleXmlBuilder } from '../src/formats/google-xml.builder';
|
|
13
|
-
|
|
14
|
-
const IMAGE = 'https://images.autode-dev.de/generated_images/0/0/0/00000000-0000-4000-8000-000000000001/criteo-Test.jpg';
|
|
15
|
-
|
|
16
|
-
const baseCar = (patch: any = {}): any => ({
|
|
17
|
-
_id: '00000000-0000-4000-8000-000000000001',
|
|
18
|
-
mainData: { make: 'Volkswagen', model: 'Golf', condition: 'selector_condition_used', category: 'selector_category_limousine' },
|
|
19
|
-
price: { currentSalesPrice: 19990 },
|
|
20
|
-
offer: { availabilityMode: 'selector_availabilityMode_always' },
|
|
21
|
-
environmentEmissions: { wltpEnergyEfficiencyClass: 'selector_energyEfficiencyClass_d' },
|
|
22
|
-
rate: { monthlyInstallment: 164.6, paybackPeriod: 60 },
|
|
23
|
-
metaData: { ownerId: 'test-dealer', imagesCount: 5 },
|
|
24
|
-
...patch,
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
const transformer = new CriteoTransformer(
|
|
28
|
-
{} as any,
|
|
29
|
-
{ config: { isCriteo: true, imageType: 'marketing', domain: 'autode-dev.de' } } as any,
|
|
30
|
-
{ getImages: async () => [IMAGE] } as any,
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
const renderItem = async (item: any): Promise<string> => {
|
|
34
|
-
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'criteo-mapping-')), 'feed.xml');
|
|
35
|
-
const builder = new GoogleXmlBuilder(file);
|
|
36
|
-
await builder.buildStart('autode-dev.de');
|
|
37
|
-
await builder.addItem(item);
|
|
38
|
-
await builder.buildEnd();
|
|
39
|
-
return fs.readFileSync(file, 'utf8');
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const tests: Array<[string, () => Promise<void>]> = [
|
|
43
|
-
['custom_label_0 carries the rounded monthly rate, installment keeps months and amount', async () => {
|
|
44
|
-
const item = await transformer.transform({ car: baseCar(), images: [] });
|
|
45
|
-
assert.equal(item?.custom_label_0, '165 EUR mtl.');
|
|
46
|
-
assert.deepEqual(item?.installment, { paybackPeriod: 60, monthlyInstallment: 165 });
|
|
47
|
-
const xml = await renderItem(item);
|
|
48
|
-
assert.match(xml, /<g:months>60<\/g:months>/);
|
|
49
|
-
assert.match(xml, /<g:amount>165 EUR<\/g:amount>/);
|
|
50
|
-
assert.ok(!xml.includes('[object Object]'));
|
|
51
|
-
}],
|
|
52
|
-
['car without a rate has no installment and no custom_label_0', async () => {
|
|
53
|
-
for (const rate of [undefined, null, {}, { monthlyInstallment: 0, paybackPeriod: 0 }, { monthlyInstallment: 199 }, { paybackPeriod: 48 }]) {
|
|
54
|
-
const item = await transformer.transform({ car: baseCar({ rate }), images: [] });
|
|
55
|
-
assert.ok(item, `item for rate ${JSON.stringify(rate)}`);
|
|
56
|
-
assert.ok(!('installment' in item), `installment for rate ${JSON.stringify(rate)}`);
|
|
57
|
-
assert.ok(!('custom_label_0' in item), `custom_label_0 for rate ${JSON.stringify(rate)}`);
|
|
58
|
-
const xml = await renderItem(item);
|
|
59
|
-
assert.ok(!xml.includes('g:installment') && !xml.includes('g:custom_label_0'));
|
|
60
|
-
}
|
|
61
|
-
}],
|
|
62
|
-
['title with an apostrophe is escaped once, by the XML builder', async () => {
|
|
63
|
-
const item = await transformer.transform({
|
|
64
|
-
car: baseCar({ mainData: { make: 'Kia', model: "cee'd", condition: 'selector_condition_used' } }),
|
|
65
|
-
images: [],
|
|
66
|
-
});
|
|
67
|
-
assert.equal(item?.title, "Kia cee'd");
|
|
68
|
-
const xml = await renderItem(item);
|
|
69
|
-
assert.match(xml, /<g:title>Kia cee'd<\/g:title>/);
|
|
70
|
-
assert.ok(!xml.includes('&#39;'));
|
|
71
|
-
}],
|
|
72
|
-
['title keeps the Mercedes / VW brand aliases (ACF-6332, ACF-6534)', async () => {
|
|
73
|
-
const vw = await transformer.transform({ car: baseCar(), images: [] });
|
|
74
|
-
assert.equal(vw?.title, 'VW Golf');
|
|
75
|
-
assert.equal(vw?.brand, 'Volkswagen');
|
|
76
|
-
const mb = await transformer.transform({
|
|
77
|
-
car: baseCar({ mainData: { make: 'Mercedes-Benz', model: 'A 180', condition: 'selector_condition_used' } }),
|
|
78
|
-
images: [],
|
|
79
|
-
});
|
|
80
|
-
assert.equal(mb?.title, 'Mercedes A 180');
|
|
81
|
-
}],
|
|
82
|
-
['known energy class is sent, unknown or missing class leaves the element out', async () => {
|
|
83
|
-
const known = await transformer.transform({ car: baseCar(), images: [] });
|
|
84
|
-
assert.equal(known?.energy_efficiency_class, 'D');
|
|
85
|
-
for (const environmentEmissions of [{ wltpEnergyEfficiencyClass: 'selector_unknown' }, {}, undefined]) {
|
|
86
|
-
const item = await transformer.transform({ car: baseCar({ environmentEmissions }), images: [] });
|
|
87
|
-
assert.ok(item && !('energy_efficiency_class' in item));
|
|
88
|
-
const xml = await renderItem(item);
|
|
89
|
-
assert.ok(!xml.includes('g:energy_efficiency_class') && !xml.includes('>default<'));
|
|
90
|
-
}
|
|
91
|
-
}],
|
|
92
|
-
['unchanged legacy fields: id, link params, price, condition, availability', async () => {
|
|
93
|
-
const item = await transformer.transform({ car: baseCar(), images: [] });
|
|
94
|
-
assert.equal(item?.id, '00000000-0000-4000-8000-000000000001');
|
|
95
|
-
assert.equal(
|
|
96
|
-
item?.link,
|
|
97
|
-
'https://www.autode-dev.de/search/vehicle/00000000-0000-4000-8000-000000000001?source=criteo&medium=cpc&campaign=retargeting',
|
|
98
|
-
);
|
|
99
|
-
assert.equal(item?.price, '19990 EUR');
|
|
100
|
-
assert.equal(item?.sale_price, '19990 EUR');
|
|
101
|
-
assert.equal(item?.condition, 'Gebraucht');
|
|
102
|
-
assert.equal(item?.availability, 'Sofort');
|
|
103
|
-
assert.deepEqual(Object.keys(item as any), [
|
|
104
|
-
'id', 'title', 'description', 'google_product_category', 'product_type', 'link', 'image_link', 'condition',
|
|
105
|
-
'availability', 'brand', 'price', 'sale_price', 'energy_efficiency_class', 'color', 'installment', 'custom_label_0',
|
|
106
|
-
]);
|
|
107
|
-
}],
|
|
108
|
-
];
|
|
109
|
-
|
|
110
|
-
(async () => {
|
|
111
|
-
let failed = 0;
|
|
112
|
-
for (const [name, fn] of tests) {
|
|
113
|
-
try {
|
|
114
|
-
await fn();
|
|
115
|
-
console.log(`ok ${name}`);
|
|
116
|
-
} catch (e) {
|
|
117
|
-
failed += 1;
|
|
118
|
-
console.error(`FAIL ${name}\n ${e.message}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (failed) process.exit(1);
|
|
122
|
-
})();
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
// ADV-319: Criteo item mapping bugs found in the DEV feed of 27.10.2026.
|
|
3
|
+
// - custom_label_0 was "[object Object] EUR mtl." for every car;
|
|
4
|
+
// - a car without a rate got "<g:months>0</g:months><g:amount>0 EUR</g:amount>";
|
|
5
|
+
// - a title with an apostrophe was escaped twice ("cee&#39;d");
|
|
6
|
+
// - an unknown energy class was sent as the literal "default".
|
|
7
|
+
import * as assert from 'node:assert/strict';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { CriteoTransformer } from '../src/transformers/criteo.transformer';
|
|
12
|
+
import { GoogleXmlBuilder } from '../src/formats/google-xml.builder';
|
|
13
|
+
|
|
14
|
+
const IMAGE = 'https://images.autode-dev.de/generated_images/0/0/0/00000000-0000-4000-8000-000000000001/criteo-Test.jpg';
|
|
15
|
+
|
|
16
|
+
const baseCar = (patch: any = {}): any => ({
|
|
17
|
+
_id: '00000000-0000-4000-8000-000000000001',
|
|
18
|
+
mainData: { make: 'Volkswagen', model: 'Golf', condition: 'selector_condition_used', category: 'selector_category_limousine' },
|
|
19
|
+
price: { currentSalesPrice: 19990 },
|
|
20
|
+
offer: { availabilityMode: 'selector_availabilityMode_always' },
|
|
21
|
+
environmentEmissions: { wltpEnergyEfficiencyClass: 'selector_energyEfficiencyClass_d' },
|
|
22
|
+
rate: { monthlyInstallment: 164.6, paybackPeriod: 60 },
|
|
23
|
+
metaData: { ownerId: 'test-dealer', imagesCount: 5 },
|
|
24
|
+
...patch,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const transformer = new CriteoTransformer(
|
|
28
|
+
{} as any,
|
|
29
|
+
{ config: { isCriteo: true, imageType: 'marketing', domain: 'autode-dev.de' } } as any,
|
|
30
|
+
{ getImages: async () => [IMAGE] } as any,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const renderItem = async (item: any): Promise<string> => {
|
|
34
|
+
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'criteo-mapping-')), 'feed.xml');
|
|
35
|
+
const builder = new GoogleXmlBuilder(file);
|
|
36
|
+
await builder.buildStart('autode-dev.de');
|
|
37
|
+
await builder.addItem(item);
|
|
38
|
+
await builder.buildEnd();
|
|
39
|
+
return fs.readFileSync(file, 'utf8');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const tests: Array<[string, () => Promise<void>]> = [
|
|
43
|
+
['custom_label_0 carries the rounded monthly rate, installment keeps months and amount', async () => {
|
|
44
|
+
const item = await transformer.transform({ car: baseCar(), images: [] });
|
|
45
|
+
assert.equal(item?.custom_label_0, '165 EUR mtl.');
|
|
46
|
+
assert.deepEqual(item?.installment, { paybackPeriod: 60, monthlyInstallment: 165 });
|
|
47
|
+
const xml = await renderItem(item);
|
|
48
|
+
assert.match(xml, /<g:months>60<\/g:months>/);
|
|
49
|
+
assert.match(xml, /<g:amount>165 EUR<\/g:amount>/);
|
|
50
|
+
assert.ok(!xml.includes('[object Object]'));
|
|
51
|
+
}],
|
|
52
|
+
['car without a rate has no installment and no custom_label_0', async () => {
|
|
53
|
+
for (const rate of [undefined, null, {}, { monthlyInstallment: 0, paybackPeriod: 0 }, { monthlyInstallment: 199 }, { paybackPeriod: 48 }]) {
|
|
54
|
+
const item = await transformer.transform({ car: baseCar({ rate }), images: [] });
|
|
55
|
+
assert.ok(item, `item for rate ${JSON.stringify(rate)}`);
|
|
56
|
+
assert.ok(!('installment' in item), `installment for rate ${JSON.stringify(rate)}`);
|
|
57
|
+
assert.ok(!('custom_label_0' in item), `custom_label_0 for rate ${JSON.stringify(rate)}`);
|
|
58
|
+
const xml = await renderItem(item);
|
|
59
|
+
assert.ok(!xml.includes('g:installment') && !xml.includes('g:custom_label_0'));
|
|
60
|
+
}
|
|
61
|
+
}],
|
|
62
|
+
['title with an apostrophe is escaped once, by the XML builder', async () => {
|
|
63
|
+
const item = await transformer.transform({
|
|
64
|
+
car: baseCar({ mainData: { make: 'Kia', model: "cee'd", condition: 'selector_condition_used' } }),
|
|
65
|
+
images: [],
|
|
66
|
+
});
|
|
67
|
+
assert.equal(item?.title, "Kia cee'd");
|
|
68
|
+
const xml = await renderItem(item);
|
|
69
|
+
assert.match(xml, /<g:title>Kia cee'd<\/g:title>/);
|
|
70
|
+
assert.ok(!xml.includes('&#39;'));
|
|
71
|
+
}],
|
|
72
|
+
['title keeps the Mercedes / VW brand aliases (ACF-6332, ACF-6534)', async () => {
|
|
73
|
+
const vw = await transformer.transform({ car: baseCar(), images: [] });
|
|
74
|
+
assert.equal(vw?.title, 'VW Golf');
|
|
75
|
+
assert.equal(vw?.brand, 'Volkswagen');
|
|
76
|
+
const mb = await transformer.transform({
|
|
77
|
+
car: baseCar({ mainData: { make: 'Mercedes-Benz', model: 'A 180', condition: 'selector_condition_used' } }),
|
|
78
|
+
images: [],
|
|
79
|
+
});
|
|
80
|
+
assert.equal(mb?.title, 'Mercedes A 180');
|
|
81
|
+
}],
|
|
82
|
+
['known energy class is sent, unknown or missing class leaves the element out', async () => {
|
|
83
|
+
const known = await transformer.transform({ car: baseCar(), images: [] });
|
|
84
|
+
assert.equal(known?.energy_efficiency_class, 'D');
|
|
85
|
+
for (const environmentEmissions of [{ wltpEnergyEfficiencyClass: 'selector_unknown' }, {}, undefined]) {
|
|
86
|
+
const item = await transformer.transform({ car: baseCar({ environmentEmissions }), images: [] });
|
|
87
|
+
assert.ok(item && !('energy_efficiency_class' in item));
|
|
88
|
+
const xml = await renderItem(item);
|
|
89
|
+
assert.ok(!xml.includes('g:energy_efficiency_class') && !xml.includes('>default<'));
|
|
90
|
+
}
|
|
91
|
+
}],
|
|
92
|
+
['unchanged legacy fields: id, link params, price, condition, availability', async () => {
|
|
93
|
+
const item = await transformer.transform({ car: baseCar(), images: [] });
|
|
94
|
+
assert.equal(item?.id, '00000000-0000-4000-8000-000000000001');
|
|
95
|
+
assert.equal(
|
|
96
|
+
item?.link,
|
|
97
|
+
'https://www.autode-dev.de/search/vehicle/00000000-0000-4000-8000-000000000001?source=criteo&medium=cpc&campaign=retargeting',
|
|
98
|
+
);
|
|
99
|
+
assert.equal(item?.price, '19990 EUR');
|
|
100
|
+
assert.equal(item?.sale_price, '19990 EUR');
|
|
101
|
+
assert.equal(item?.condition, 'Gebraucht');
|
|
102
|
+
assert.equal(item?.availability, 'Sofort');
|
|
103
|
+
assert.deepEqual(Object.keys(item as any), [
|
|
104
|
+
'id', 'title', 'description', 'google_product_category', 'product_type', 'link', 'image_link', 'condition',
|
|
105
|
+
'availability', 'brand', 'price', 'sale_price', 'energy_efficiency_class', 'color', 'installment', 'custom_label_0',
|
|
106
|
+
]);
|
|
107
|
+
}],
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
(async () => {
|
|
111
|
+
let failed = 0;
|
|
112
|
+
for (const [name, fn] of tests) {
|
|
113
|
+
try {
|
|
114
|
+
await fn();
|
|
115
|
+
console.log(`ok ${name}`);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
failed += 1;
|
|
118
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (failed) process.exit(1);
|
|
122
|
+
})();
|