@bytebrand/export-cars 1.26.47 → 1.26.48

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.
Files changed (52) hide show
  1. package/dist/configs/ooyyo.config.d.ts +14 -0
  2. package/dist/configs/ooyyo.config.js +40 -6
  3. package/dist/configs/ooyyo.config.js.map +1 -1
  4. package/dist/exporters/ooyyo.exporter.d.ts +5 -4
  5. package/dist/exporters/ooyyo.exporter.js +45 -25
  6. package/dist/exporters/ooyyo.exporter.js.map +1 -1
  7. package/dist/formats/ooyyo-xml.builder.d.ts +7 -2
  8. package/dist/formats/ooyyo-xml.builder.js +59 -36
  9. package/dist/formats/ooyyo-xml.builder.js.map +1 -1
  10. package/dist/interfaces/export-item.interface.d.ts +30 -2
  11. package/dist/interfaces/params.interface.d.ts +3 -0
  12. package/dist/services/ooyyo-feed-run.d.ts +81 -0
  13. package/dist/services/ooyyo-feed-run.js +183 -0
  14. package/dist/services/ooyyo-feed-run.js.map +1 -0
  15. package/dist/services/ooyyo-feed-verify.d.ts +13 -0
  16. package/dist/services/ooyyo-feed-verify.js +104 -0
  17. package/dist/services/ooyyo-feed-verify.js.map +1 -0
  18. package/dist/transformers/ooyyo.transformer.d.ts +51 -11
  19. package/dist/transformers/ooyyo.transformer.js +268 -95
  20. package/dist/transformers/ooyyo.transformer.js.map +1 -1
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/docs/marktde/AUDIT.md +84 -84
  23. package/docs/marktde/PARTNER-QUESTIONS.md +49 -49
  24. package/docs/marktde/RUNBOOK.md +58 -58
  25. package/docs/marktde/TRACKING.md +75 -69
  26. package/docs/meinestadt/PARTNER-QUESTIONS.md +17 -0
  27. package/docs/meinestadt/TRACKING.md +107 -0
  28. package/docs/ooyyo/AUDIT.md +81 -0
  29. package/docs/ooyyo/PARTNER-QUESTIONS.md +32 -0
  30. package/docs/ooyyo/RUNBOOK.md +67 -0
  31. package/docs/ooyyo/TRACKING.md +54 -0
  32. package/package.json +66 -65
  33. package/src/configs/marktde.config.ts +70 -70
  34. package/src/configs/ooyyo.config.ts +65 -15
  35. package/src/exporters/marktde.exporter.ts +78 -78
  36. package/src/exporters/ooyyo.exporter.ts +75 -48
  37. package/src/formats/ooyyo-xml.builder.ts +87 -48
  38. package/src/interfaces/export-item.interface.ts +43 -2
  39. package/src/interfaces/params.interface.ts +5 -0
  40. package/src/services/marktde-feed-run.ts +233 -233
  41. package/src/services/ooyyo-feed-run.ts +265 -0
  42. package/src/services/ooyyo-feed-verify.ts +131 -0
  43. package/src/transformers/marktde.transformer.ts +255 -255
  44. package/src/transformers/ooyyo.transformer.ts +378 -109
  45. package/test/fixtures/ooyyo/OoyyoFormat.xml +125 -0
  46. package/test/fixtures/ooyyo/OoyyoFormat.xsd +116 -0
  47. package/test/fixtures/ooyyo.fixture.ts +53 -0
  48. package/test/mapping-csv.test.ts +65 -65
  49. package/test/marktde-feed-run.test.ts +263 -263
  50. package/test/marktde-mapping.test.ts +280 -280
  51. package/test/ooyyo-feed-run.test.ts +293 -0
  52. package/test/ooyyo-mapping.test.ts +276 -0
@@ -0,0 +1,293 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ // Integration (ADV-331): real files in a temp dir (base export snapshot, output, state), real XML
3
+ // builder, read-back verifier, gzip, guard and ADV-336 clean-image selection; only the HTTP image
4
+ // check and the postcode table are faked. Covers the partner's full-replacement risk: a partly valid,
5
+ // empty, stale or collapsed snapshot is never published, the last good file stays.
6
+ import * as assert from 'node:assert/strict';
7
+ import { promises as fs } from 'fs';
8
+ import { tmpdir } from 'os';
9
+ import { join } from 'path';
10
+ import { gunzipSync } from 'zlib';
11
+ import { XMLParser } from 'fast-xml-parser';
12
+
13
+ import { FeedGuard, FeedGuardError, FeedGuardOptions } from '../src/services/feed-guard';
14
+ import { OoyyoFeedRun, OoyyoSourceCar } from '../src/services/ooyyo-feed-run';
15
+ import { verifyOoyyoXml } from '../src/services/ooyyo-feed-verify';
16
+ import { OoyyoXmlBuilder } from '../src/formats/ooyyo-xml.builder';
17
+ import { OoyyoExclusion, toOoyyoImages } from '../src/transformers/ooyyo.transformer';
18
+ import { selectOpenAiImageFields } from '../src/transformers/openai-image-fields';
19
+ import { buildImagesService } from './fixtures/images-service.harness';
20
+ import { NOW, region, source, translate, twentyCars } from './fixtures/ooyyo.fixture';
21
+
22
+ const HOUR = 60 * 60 * 1000;
23
+ const imagesService = buildImagesService();
24
+
25
+ interface World {
26
+ root: string;
27
+ source: string;
28
+ xml: string;
29
+ gz: string;
30
+ state: string;
31
+ brokenImages: Set<string>;
32
+ allImagesBroken: boolean;
33
+ }
34
+
35
+ const newWorld = async (): Promise<World> => {
36
+ const root = await fs.mkdtemp(join(tmpdir(), 'ooyyo-'));
37
+ return {
38
+ root,
39
+ source: join(root, 'cars-export'),
40
+ xml: join(root, 'ooyyo-dryrun', 'ooyyo.xml'),
41
+ gz: join(root, 'ooyyo-dryrun', 'ooyyo.xml.gz'),
42
+ state: join(root, 'ooyyo-state', 'dryrun'),
43
+ brokenImages: new Set(),
44
+ allImagesBroken: false,
45
+ };
46
+ };
47
+
48
+ const writeSource = async (world: World, cars: OoyyoSourceCar[], chunkSize = 5, mtime = NOW) => {
49
+ await fs.rm(world.source, { recursive: true, force: true });
50
+ await fs.mkdir(world.source, { recursive: true });
51
+ for (let i = 0; i * chunkSize < cars.length; i++) {
52
+ const file = join(world.source, `cars.${i}.json`);
53
+ await fs.writeFile(file, JSON.stringify(cars.slice(i * chunkSize, (i + 1) * chunkSize)));
54
+ await fs.utimes(file, mtime, mtime);
55
+ }
56
+ };
57
+
58
+ const guardOptions = (world: World, extra: Partial<FeedGuardOptions> = {}): FeedGuardOptions => ({
59
+ stateDir: world.state,
60
+ minSourceItems: 1,
61
+ minPublishedItems: 1,
62
+ maxDropPercent: 20,
63
+ maxSourceAgeHours: 26,
64
+ maxStaleHours: 48,
65
+ acceptDrop: false,
66
+ ...extra,
67
+ });
68
+
69
+ const run = async (world: World, { at = NOW, guard = {} }: { at?: Date; guard?: Partial<FeedGuardOptions> } = {}) => {
70
+ const forEachChunk = async (fn: (cars: OoyyoSourceCar[]) => Promise<void>) => {
71
+ const names = (await fs.readdir(world.source)).sort((a, b) => Number(a.split('.')[1]) - Number(b.split('.')[1]));
72
+ for (const name of names) {
73
+ await fn(JSON.parse(await fs.readFile(join(world.source, name), 'utf8')));
74
+ }
75
+ };
76
+ const feed = new OoyyoFeedRun(
77
+ {
78
+ forEachChunk,
79
+ filterCar: ({ car: c }) => (c?.metaData?.imagesCount ?? 0) > 0,
80
+ resolveImages: async ({ car: c, images: list }) => {
81
+ const clean = imagesService.getCleanImages(c._id, list);
82
+ const checks = clean.map(i => ({ url: i.url, ok: !world.allImagesBroken && !world.brokenImages.has(i.url) }));
83
+ return toOoyyoImages(selectOpenAiImageFields(clean, checks));
84
+ },
85
+ translate,
86
+ region,
87
+ imageStats: () => ({ ok: 1 }),
88
+ guard: new FeedGuard(guardOptions(world, guard)),
89
+ },
90
+ {
91
+ runId: 'test',
92
+ dryRun: true,
93
+ domain: 'auto.de',
94
+ sourceDirectory: world.source,
95
+ sourceBaseFileName: 'cars',
96
+ xmlFile: world.xml,
97
+ gzFile: world.gz,
98
+ stateDir: world.state,
99
+ now: () => at,
100
+ },
101
+ );
102
+ return feed.run();
103
+ };
104
+
105
+ const blocked = async (promise: Promise<unknown>, code: string) => {
106
+ await assert.rejects(promise, (error: any) => error instanceof FeedGuardError && error.code === code);
107
+ };
108
+
109
+ const parse = (xml: string) => new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@', parseTagValue: false, isArray: n => n === 'ad' || n === 'picture' })
110
+ .parse(xml).ooyyo;
111
+ const readAds = async (world: World) => parse(await fs.readFile(world.xml, 'utf8')).ad as any[];
112
+ const idsOf = (ads: any[]) => ads.map(ad => String(ad.url).split('/vehicle/')[1].split('?')[0]);
113
+ const exists = (file: string) => fs.stat(file).then(() => true, () => false);
114
+ const report = async (world: World) => JSON.parse(await fs.readFile(join(world.state, 'last-run-report.json'), 'utf8'));
115
+
116
+ const tests: Array<[string, () => Promise<void>]> = [
117
+ ['20 representative cars: all published, XML verified, gzip identical, partner rules hold on every ad', async () => {
118
+ const world = await newWorld();
119
+ await writeSource(world, twentyCars());
120
+ const r = await run(world);
121
+ assert.equal(r.status, 'published');
122
+ assert.equal(r.items, 20);
123
+ assert.deepEqual(r.excluded, {});
124
+ assert.equal(r.verify.issues, 0);
125
+ const xml = await fs.readFile(world.xml, 'utf8');
126
+ assert.ok(xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>\n<ooyyo xsi:noNamespaceSchemaLocation="OoyyoFormat.xsd"'));
127
+ assert.equal(gunzipSync(await fs.readFile(world.gz)).toString('utf8'), xml);
128
+ assert.deepEqual(verifyOoyyoXml(xml, 20).issues, []);
129
+ const ads = await readAds(world);
130
+ assert.equal(ads.length, 20);
131
+ for (const ad of ads) {
132
+ assert.equal(ad.price['@unit'], 'EUR');
133
+ assert.match(ad.price['#text'], /^[1-9]\d*$/);
134
+ assert.match(ad.year, /^20\d\d$/);
135
+ assert.ok(ad.pictures.picture.length >= 1);
136
+ assert.ok(ad.url.length <= 1500);
137
+ assert.ok(!JSON.stringify(ad).includes('noimage'));
138
+ }
139
+ assert.equal(ads[1].make, 'Škoda');
140
+ assert.equal(ads[1].trim, 'Combi 2.0 TDI Größe L');
141
+ assert.equal(ads[0].location.postcode, '04103');
142
+ assert.equal(ads[0].location.region, 'Sachsen');
143
+ assert.equal(ads[6].euronorm, undefined, 'unknown euronorm left out');
144
+ assert.equal(ads[7].content, undefined, 'empty description left out');
145
+ assert.equal(ads[8].vin, undefined, 'malformed VIN left out');
146
+ // 4103 -> 04103 on every car except the 4 Berlin / München variants
147
+ assert.equal(r.postcodePadded, 16);
148
+ assert.equal(r.lastGood.publishedItems, 20);
149
+ assert.ok(r.bytes.gz < r.bytes.xml);
150
+ }],
151
+
152
+ ['cars OOYYO would reject are excluded with a reason, the rest is published', async () => {
153
+ const world = await newWorld();
154
+ const noHero = source('nohero');
155
+ noHero.images = noHero.images.filter(i => i.imageCodePredicted !== 'ext_f_right');
156
+ const noPics = source('nopics');
157
+ noPics.car.metaData.imagesCount = 0;
158
+ await writeSource(world, [
159
+ ...twentyCars(),
160
+ source('car00'), // duplicate id
161
+ source('sold', { 'offer.offerAvailability': 'selector_offerAvailability_sold' }),
162
+ source('resv', { 'offer.offerAvailability': 'selector_offerAvailability_reserved' }),
163
+ source('noprice', { 'price.currentSalesPrice': 0 }),
164
+ source('sonst', { 'mainData.model': 'SONSTIGE' }),
165
+ source('noyear', { 'mainData.firstRegistration': null, 'mainData.constructionYear': null }),
166
+ source('x'.repeat(1500)), // URL > 1,500 characters
167
+ noHero,
168
+ noPics,
169
+ ]);
170
+ const r = await run(world);
171
+ assert.equal(r.items, 20);
172
+ assert.deepEqual(r.excluded, {
173
+ [OoyyoExclusion.duplicateId]: 1,
174
+ [OoyyoExclusion.notAvailable]: 2,
175
+ [OoyyoExclusion.noPrice]: 1,
176
+ [OoyyoExclusion.noModel]: 1,
177
+ [OoyyoExclusion.noYear]: 1,
178
+ [OoyyoExclusion.urlTooLong]: 1,
179
+ [OoyyoExclusion.noImage]: 1,
180
+ [OoyyoExclusion.filtered]: 1,
181
+ });
182
+ assert.equal(idsOf(await readAds(world)).length, 20);
183
+ }],
184
+
185
+ ['1 bad ad out of 20 in the written file (builder regression): NOT published, live file keeps all 20', async () => {
186
+ const world = await newWorld();
187
+ await writeSource(world, twentyCars());
188
+ await run(world);
189
+ const goodXml = await fs.readFile(world.xml, 'utf8');
190
+ const goodGz = await fs.readFile(world.gz);
191
+ const original = OoyyoXmlBuilder.toXml;
192
+ let n = 0;
193
+ OoyyoXmlBuilder.toXml = (item) => {
194
+ const xml = original(item);
195
+ n += 1;
196
+ return n === 7 ? xml.replace('unit="EUR"', 'unit="euro"') : xml;
197
+ };
198
+ try {
199
+ await blocked(run(world, { at: new Date(NOW.getTime() + HOUR) }), 'verify_failed');
200
+ } finally {
201
+ OoyyoXmlBuilder.toXml = original;
202
+ }
203
+ assert.equal(await fs.readFile(world.xml, 'utf8'), goodXml, 'published XML untouched');
204
+ assert.ok((await fs.readFile(world.gz)).equals(goodGz), 'published gzip untouched');
205
+ assert.ok(await exists(`${world.xml}.rejected`));
206
+ const rep = await report(world);
207
+ assert.equal(rep.status, 'blocked');
208
+ assert.equal(rep.verify.issues, 2); // unit + partner price rule, both on ad 6
209
+ assert.ok(rep.verify.firstIssues.every((i: any) => i.ad === 6));
210
+ assert.equal(rep.lastGood.publishedItems, 20);
211
+ assert.ok(!(await exists(world.gz.replace(/\.gz$/, '.tmp.gz'))));
212
+ }],
213
+
214
+ ['zero readable listings (image service down): blocked, last good kept', async () => {
215
+ const world = await newWorld();
216
+ await writeSource(world, twentyCars());
217
+ await run(world);
218
+ world.allImagesBroken = true;
219
+ await blocked(run(world, { at: new Date(NOW.getTime() + HOUR) }), 'published_too_small');
220
+ assert.equal((await readAds(world)).length, 20);
221
+ assert.deepEqual((await report(world)).excluded, { [OoyyoExclusion.noImage]: 20 });
222
+ }],
223
+
224
+ ['source outage / partial / stale snapshot: blocked before any output is touched', async () => {
225
+ const world = await newWorld();
226
+ await writeSource(world, twentyCars());
227
+ await run(world);
228
+ const good = await fs.readFile(world.xml, 'utf8');
229
+ // missing chunk (transport / base export failure mid-way)
230
+ await fs.rm(join(world.source, 'cars.1.json'));
231
+ await blocked(run(world), 'source_partial');
232
+ // empty snapshot
233
+ await fs.rm(world.source, { recursive: true, force: true });
234
+ await blocked(run(world), 'source_empty');
235
+ // stale snapshot (base export did not run for > 26 h)
236
+ await writeSource(world, twentyCars(), 5, new Date(NOW.getTime() - 27 * HOUR));
237
+ await blocked(run(world), 'source_stale');
238
+ assert.equal(await fs.readFile(world.xml, 'utf8'), good);
239
+ }],
240
+
241
+ ['sold / deleted cars leave with the next run (normal sync); a collapse needs acceptDrop', async () => {
242
+ const world = await newWorld();
243
+ const cars = twentyCars();
244
+ await writeSource(world, cars);
245
+ await run(world);
246
+ // 2 of 20 sold / deleted: -10 %, published
247
+ await writeSource(world, cars.slice(2));
248
+ const r = await run(world, { at: new Date(NOW.getTime() + HOUR) });
249
+ assert.equal(r.items, 18);
250
+ assert.ok(!idsOf(await readAds(world)).includes('car00'));
251
+ // half of the stock gone at once: blocked
252
+ await writeSource(world, cars.slice(2, 11));
253
+ await blocked(run(world, { at: new Date(NOW.getTime() + 2 * HOUR) }), 'count_drop');
254
+ assert.equal((await readAds(world)).length, 18);
255
+ // a real stock reduction is confirmed once with acceptDrop
256
+ const accepted = await run(world, { at: new Date(NOW.getTime() + 3 * HOUR), guard: { acceptDrop: true } });
257
+ assert.equal(accepted.items, 9);
258
+ }],
259
+
260
+ ['stale last good is reported; a concurrent run is locked out', async () => {
261
+ const world = await newWorld();
262
+ await writeSource(world, twentyCars());
263
+ await run(world);
264
+ world.allImagesBroken = true;
265
+ const later = new Date(NOW.getTime() + 49 * HOUR);
266
+ await writeSource(world, twentyCars(), 5, later);
267
+ await blocked(run(world, { at: later }), 'published_too_small');
268
+ const rep = await report(world);
269
+ assert.equal(rep.stale, true);
270
+ assert.ok(rep.lastGoodAgeHours > 48);
271
+ world.allImagesBroken = false;
272
+ await fs.writeFile(join(world.state, 'run.lock'), JSON.stringify({ runId: 'other' }));
273
+ await blocked(run(world, { at: later }), 'locked');
274
+ }],
275
+ ];
276
+
277
+ (async () => {
278
+ let failed = 0;
279
+ for (const [name, fn] of tests) {
280
+ try {
281
+ await fn();
282
+ console.log(`ok - ${name}`);
283
+ } catch (error) {
284
+ failed += 1;
285
+ console.log(`not ok - ${name}\n${error?.stack ?? error}`);
286
+ }
287
+ }
288
+ if (failed) {
289
+ console.log(`${failed} of ${tests.length} failed`);
290
+ process.exit(1);
291
+ }
292
+ console.log(`ooyyo-feed-run: ${tests.length} passed`);
293
+ })();
@@ -0,0 +1,276 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ // OOYYO mapping, partner acceptance rules and XML structure (ADV-331), against the official
3
+ // OoyyoFormat.xsd / OoyyoFormat.xml in test/fixtures/ooyyo.
4
+ import * as assert from 'node:assert/strict';
5
+ import { readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { XMLParser } from 'fast-xml-parser';
8
+
9
+ import { OoyyoXmlBuilder, cdata } from '../src/formats/ooyyo-xml.builder';
10
+ import {
11
+ OOYYO_LINK_PARAMS, OoyyoExclusion, getOoyyoContent, getOoyyoDateTime, getOoyyoEuronorm, getOoyyoPostcode, getOoyyoPrice,
12
+ getOoyyoYear, mapOoyyoItem, validateOoyyoItem,
13
+ } from '../src/transformers/ooyyo.transformer';
14
+ import {
15
+ OOYYO_AD_ELEMENTS, OOYYO_LOCATION_ELEMENTS, OOYYO_UNIT_ELEMENTS, verifyOoyyoXml,
16
+ } from '../src/services/ooyyo-feed-verify';
17
+ import { NOW, car, translate } from './fixtures/ooyyo.fixture';
18
+
19
+ const FIXTURES = join(__dirname, 'fixtures', 'ooyyo');
20
+ const IMAGES = ['https://images.auto.de/carimage/x/hero/large', 'https://images.auto.de/carimage/x/side/large'];
21
+
22
+ const map = (overrides: Record<string, any> = {}, ctx: Record<string, any> = {}) =>
23
+ mapOoyyoItem(car('abc-1', overrides), { domain: 'auto.de', images: IMAGES, translate, region: 'Sachsen', now: NOW, ...ctx });
24
+
25
+ const okItem = (overrides: Record<string, any> = {}) => {
26
+ const result = map(overrides);
27
+ assert.equal(result.ok, true, JSON.stringify(result));
28
+ return (result as any).item;
29
+ };
30
+
31
+ const reason = (overrides: Record<string, any> = {}, ctx: Record<string, any> = {}) => {
32
+ const result = map(overrides, ctx);
33
+ assert.equal(result.ok, false);
34
+ return (result as any).reason;
35
+ };
36
+
37
+ // Element names / required unit attributes straight from the XSD file.
38
+ const xsd = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@' })
39
+ .parse(readFileSync(join(FIXTURES, 'OoyyoFormat.xsd'), 'utf8'))['xsd:schema'];
40
+ const xsdElements: any[] = xsd['xsd:element'];
41
+ const refsOf = (name: string): string[] => {
42
+ const el = xsdElements.find(e => e['@name'] === name);
43
+ return [].concat(el['xsd:complexType']['xsd:choice']['xsd:element']).map((e: any) => e['@ref']);
44
+ };
45
+ const unitRequired = xsdElements
46
+ .filter(e => {
47
+ const attr = e?.['xsd:complexType']?.['xsd:simpleContent']?.['xsd:extension']?.['xsd:attribute'];
48
+ return attr && attr['@name'] === 'unit' && attr['@use'] === 'required';
49
+ })
50
+ .map(e => e['@name']);
51
+
52
+ const tests: Array<[string, () => void]> = [
53
+ ['element / unit lists of the verifier are exactly those of OoyyoFormat.xsd', () => {
54
+ assert.deepEqual(refsOf('ooyyo'), ['ad']);
55
+ assert.deepEqual(refsOf('ad'), OOYYO_AD_ELEMENTS);
56
+ assert.deepEqual(refsOf('location'), OOYYO_LOCATION_ELEMENTS);
57
+ assert.deepEqual(refsOf('pictures'), ['picture']);
58
+ assert.deepEqual(unitRequired.sort(), [...OOYYO_UNIT_ELEMENTS].sort());
59
+ }],
60
+
61
+ ['full item: German copy, EUR integer price, units, string postcode with leading zero, Berlin date/time', () => {
62
+ const item = okItem();
63
+ assert.equal(item.url, `https://www.auto.de/search/vehicle/abc-1?${OOYYO_LINK_PARAMS}`);
64
+ assert.equal(item.title, 'Opel Astra 1.2 Turbo Edition');
65
+ assert.equal(item.content, 'Ausstattung\n* ABS\n* Klimaanlage\nFahrzeug in gutem Zustand – Prüfung bestanden.');
66
+ assert.equal(item.price, '15148');
67
+ assert.equal(item.priceUnit, 'EUR');
68
+ assert.equal(item.year, '2025');
69
+ assert.equal(item.mileage, '13000');
70
+ assert.equal(item.engine_size, '1199');
71
+ assert.equal(item.power, '85');
72
+ assert.equal(item.color, 'Rot');
73
+ assert.equal(item.body_type, 'Kombi');
74
+ assert.equal(item.fuel_type, 'Benzin');
75
+ assert.equal(item.transmission, 'Manuell');
76
+ assert.equal(item.euronorm, '6');
77
+ assert.deepEqual(item.location, { city: 'Leipzig', postcode: '04103', region: 'Sachsen' });
78
+ assert.equal(item.vin, 'WVWZZZ1KZAW000001');
79
+ assert.equal(item.is_new, '0');
80
+ assert.equal(item.date, '07/07/2026');
81
+ assert.equal(item.time, '09:13');
82
+ assert.deepEqual(item.pictures, IMAGES);
83
+ }],
84
+
85
+ ['clickout URL: partner in source + identical utm_*, no click id, no duplicate params', () => {
86
+ const url = new URL(okItem().url);
87
+ assert.equal(url.protocol, 'https:');
88
+ assert.equal(url.pathname, '/search/vehicle/abc-1');
89
+ const keys = [...url.searchParams.keys()];
90
+ assert.equal(new Set(keys).size, keys.length);
91
+ assert.deepEqual(Object.fromEntries(url.searchParams), {
92
+ source: 'ooyyo', medium: 'listing', campaign: 'listing', utm_source: 'ooyyo', utm_medium: 'listing', utm_campaign: 'listing',
93
+ });
94
+ assert.ok(!/click|gclid|fbclid|ooyyo_id/i.test(keys.join(',')));
95
+ }],
96
+
97
+ ['price: currentSalesPrice only, whole euros; leasing rate / dealer cost never used', () => {
98
+ assert.equal(getOoyyoPrice(26900), '26900');
99
+ assert.equal(getOoyyoPrice('26900'), '26900');
100
+ assert.equal(getOoyyoPrice(26900.4), '26900');
101
+ for (const bad of [null, undefined, '', 0, -1, 'abc', true]) {
102
+ assert.equal(getOoyyoPrice(bad), null);
103
+ }
104
+ assert.equal(okItem({ 'price.currentSalesPrice': 9990, 'rate.monthlyRate': 99 }).price, '9990');
105
+ assert.equal(reason({ 'price.currentSalesPrice': null, 'price.purchasePrice': 11000 }), OoyyoExclusion.noPrice);
106
+ }],
107
+
108
+ ['year: first registration, else construction year; never invented (legacy: now - 2 months)', () => {
109
+ assert.equal(getOoyyoYear(car('a'), NOW), '2025');
110
+ assert.equal(getOoyyoYear(car('a', { 'mainData.firstRegistration': null, 'mainData.constructionYear': 2026 }), NOW), '2026');
111
+ assert.equal(getOoyyoYear(car('a', { 'mainData.firstRegistration': 0, 'mainData.constructionYear': null }), NOW), '');
112
+ // ms instead of s => year far in the future, not used
113
+ assert.equal(getOoyyoYear(car('a', { 'mainData.firstRegistration': 1750809600000, 'mainData.constructionYear': null }), NOW), '');
114
+ assert.equal(reason({ 'mainData.condition': 'selector_condition_new', 'mainData.firstRegistration': null, 'mainData.constructionYear': null }),
115
+ OoyyoExclusion.noYear);
116
+ const newCar = okItem({ 'mainData.condition': 'selector_condition_new', 'mainData.firstRegistration': null, 'mainData.mileage': 0, 'mainData.constructionYear': 2026 });
117
+ assert.equal(newCar.year, '2026');
118
+ assert.equal(newCar.is_new, '1');
119
+ assert.equal(newCar.mileage, '0');
120
+ }],
121
+
122
+ ['make / model OOYYO cannot recognise are excluded (SONSTIGE fallback, empty)', () => {
123
+ assert.equal(reason({ 'mainData.model': 'SONSTIGE' }), OoyyoExclusion.noModel);
124
+ assert.equal(reason({ 'mainData.model': '' }), OoyyoExclusion.noModel);
125
+ assert.equal(reason({ 'mainData.make': '' }), OoyyoExclusion.noMake);
126
+ assert.equal(reason({ 'mainData.make': 'selector_unknown' }), OoyyoExclusion.noMake);
127
+ }],
128
+
129
+ ['photos: at least one HTTPS photo, URLs <= 1,500 chars; vehicle URL <= 1,500 chars', () => {
130
+ assert.equal(reason({}, { images: [] }), OoyyoExclusion.noImage);
131
+ assert.equal(reason({}, { images: ['http://images.auto.de/a.jpg'] }), OoyyoExclusion.noImage);
132
+ const p1500 = `https://images.auto.de/${'a'.repeat(1500 - 'https://images.auto.de/'.length)}`;
133
+ assert.equal(p1500.length, 1500);
134
+ assert.equal(map({}, { images: [p1500] }).ok, true);
135
+ assert.equal(reason({}, { images: [`${p1500}b`] }), OoyyoExclusion.urlTooLong);
136
+ const fixed = `https://www.auto.de/search/vehicle/?${OOYYO_LINK_PARAMS}`.length;
137
+ assert.equal(map({ _id: 'x'.repeat(1500 - fixed) }).ok, true);
138
+ assert.equal(reason({ _id: 'x'.repeat(1501 - fixed) }), OoyyoExclusion.urlTooLong);
139
+ }],
140
+
141
+ ['reserved / sold are not listed', () => {
142
+ assert.equal(reason({ 'offer.offerAvailability': 'selector_offerAvailability_sold' }), OoyyoExclusion.notAvailable);
143
+ assert.equal(reason({ 'offer.offerAvailability': 'selector_offerAvailability_reserved' }), OoyyoExclusion.notAvailable);
144
+ }],
145
+
146
+ ['postcode as string: leading zero restored for DE, invalid dropped, foreign kept', () => {
147
+ assert.equal(getOoyyoPostcode(4103, 'DE'), '04103');
148
+ assert.equal(getOoyyoPostcode('04103', 'DE'), '04103');
149
+ assert.equal(getOoyyoPostcode(10115, 'de'), '10115');
150
+ assert.equal(getOoyyoPostcode(1, 'DE'), '');
151
+ assert.equal(getOoyyoPostcode(999, null), '');
152
+ assert.equal(getOoyyoPostcode(null, 'DE'), '');
153
+ assert.equal(getOoyyoPostcode('1010', 'AT'), '1010');
154
+ }],
155
+
156
+ ['optional fields: unknown / 0 / invalid are left out, never "default" / "undefined" / fake VIN', () => {
157
+ assert.equal(getOoyyoEuronorm('selector_unknown'), '');
158
+ assert.equal(getOoyyoEuronorm('selector_emissionClass_euro6d_temp'), '6d-TEMP');
159
+ const item = okItem({
160
+ 'mainData.doors': 0, 'driveSuspension.gears': null, 'engineData.cubicCapacity': null, 'metaData.vin': 'FAKEVIN',
161
+ 'mainData.category': 'selector_unknown', 'mainData.condition': null, 'offer.offerDescription': null, 'metaData.creationDate': null,
162
+ });
163
+ for (const field of ['doors', 'gears', 'engine_size', 'vin', 'body_type', 'is_new', 'content', 'date', 'time']) {
164
+ assert.equal(item[field], '', field);
165
+ }
166
+ const xml = OoyyoXmlBuilder.toXml(item);
167
+ for (const tag of ['doors', 'gears', 'engine_size', 'vin', 'body_type', 'is_new', 'content', 'date', 'time']) {
168
+ assert.ok(!xml.includes(`<${tag}`), `${tag} left out`);
169
+ }
170
+ assert.ok(!/undefined|null|default/.test(xml));
171
+ }],
172
+
173
+ ['date / time: DD/MM/YYYY and HH:MM (24 h) in Europe/Berlin, summer and winter', () => {
174
+ assert.deepEqual(getOoyyoDateTime(1783408438), { date: '07/07/2026', time: '09:13' });
175
+ assert.deepEqual(getOoyyoDateTime(Date.parse('2026-01-15T22:30:00Z') / 1000), { date: '15/01/2026', time: '23:30' });
176
+ assert.deepEqual(getOoyyoDateTime(Date.parse('2026-03-31T22:05:00Z') / 1000), { date: '01/04/2026', time: '00:05' });
177
+ assert.deepEqual(getOoyyoDateTime(null), { date: '', time: '' });
178
+ }],
179
+
180
+ ['content: mobile.de markup to plain text, umlauts intact, nothing added', () => {
181
+ assert.equal(getOoyyoContent('**Top**\\\\Größe: L\\\\\\\\\\\\\\\\Ende'), 'Top\nGröße: L\n\nEnde');
182
+ assert.equal(getOoyyoContent(' '), '');
183
+ assert.equal(getOoyyoContent(undefined), '');
184
+ }],
185
+
186
+ ['XML: CDATA-safe ("]]>" split, invalid control chars removed), UTF-8 umlauts, & unescaped in CDATA', () => {
187
+ assert.equal(cdata('a]]>b'), '<![CDATA[a]]]]><![CDATA[>b]]>');
188
+ assert.equal(cdata('a\u0001b\u000Bc\td'), '<![CDATA[abc\td]]>');
189
+ const item = okItem({ 'mainData.make': 'Škoda', 'mainData.model': 'Octavia', 'offer.offerDescription': 'Größe ]]> Ölwechsel\u0007 & Co' });
190
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<ooyyo>\n${OoyyoXmlBuilder.toXml(item)}</ooyyo>\n`;
191
+ const parsed = new XMLParser({ ignoreAttributes: false, parseTagValue: false }).parse(xml);
192
+ assert.equal(parsed.ooyyo.ad.content, 'Größe ]]> Ölwechsel & Co');
193
+ assert.equal(parsed.ooyyo.ad.make, 'Škoda');
194
+ assert.equal(parsed.ooyyo.ad.url, item.url);
195
+ assert.deepEqual(verifyOoyyoXml(xml, 1).issues, []);
196
+ }],
197
+
198
+ ['XML structure: root as in the partner sample, price unit EUR, units on every unit element', () => {
199
+ const item = okItem();
200
+ const ad = OoyyoXmlBuilder.toXml(item);
201
+ assert.ok(ad.includes('<price unit="EUR"><![CDATA[15148]]></price>'));
202
+ assert.ok(ad.includes('<engine_size unit="cc">'));
203
+ assert.ok(ad.includes('<mileage unit="km">'));
204
+ assert.ok(ad.includes('<power unit="kw">'));
205
+ assert.ok(!ad.includes('unit="euro"'));
206
+ const sample = readFileSync(join(FIXTURES, 'OoyyoFormat.xml'), 'utf8');
207
+ assert.ok(sample.includes('<ooyyo xsi:noNamespaceSchemaLocation="OoyyoFormat.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'));
208
+ // element order as in the sample
209
+ const order = [...ad.matchAll(/^ {4}<([a-z_]+)/gm)].map(m => m[1]);
210
+ const sampleOrder = [...sample.split('</ad>')[0].matchAll(/^ {8}<([a-z_]+)/gm)].map(m => m[1]);
211
+ assert.deepEqual(order, sampleOrder.filter(t => order.includes(t)));
212
+ }],
213
+
214
+ ['verifier rejects the legacy / partner-sample defects (unit euro, empty <power/>, noimage-only, no year)', () => {
215
+ const legacy = `<?xml version="1.0" encoding="UTF-8"?>
216
+ <ooyyo xsi:noNamespaceSchemaLocation="OoyyoFormat.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
217
+ <ad>
218
+ <url><![CDATA[https://www.autode-dev.de/search/vehicle/42c3?source=classifieds&medium=listing&campaign=ooyyo]]></url>
219
+ <content/>
220
+ <price unit="euro"><![CDATA[13348]]></price>
221
+ <make><![CDATA[MG]]></make>
222
+ <model><![CDATA[MG3]]></model>
223
+ <engine_size/>
224
+ <year><![CDATA[2025]]></year>
225
+ <euronorm><![CDATA[default]]></euronorm>
226
+ <pictures>
227
+ <picture><![CDATA[https://images.auto.de/carimage/noimage/7pNvWELuWhZX/large-cached.jpeg]]></picture>
228
+ </pictures>
229
+ </ad>
230
+ <ad>
231
+ <url><![CDATA[https://www.autode-dev.de/search/vehicle/42c4]]></url>
232
+ <price unit="EUR"><![CDATA[9000]]></price>
233
+ <make><![CDATA[MG]]></make>
234
+ <model><![CDATA[ZS]]></model>
235
+ <pictures><picture><![CDATA[https://images.auto.de/a.jpg]]></picture></pictures>
236
+ </ad>
237
+ </ooyyo>`;
238
+ const { ads, issues } = verifyOoyyoXml(legacy, 2);
239
+ assert.equal(ads, 2);
240
+ const problems = issues.map(i => `${i.ad}:${i.problem}`);
241
+ assert.ok(problems.some(p => p.startsWith('0:<price> unit="euro"')), problems.join('\n'));
242
+ assert.ok(problems.some(p => p.startsWith('0:<engine_size> unit=""')), problems.join('\n'));
243
+ assert.ok(problems.some(p => p === `0:rejected by partner rules: ${OoyyoExclusion.noPrice}`), problems.join('\n'));
244
+ assert.ok(problems.some(p => p === `1:rejected by partner rules: ${OoyyoExclusion.noYear}`), problems.join('\n'));
245
+ // empty unit-less / broken / foreign root files
246
+ assert.equal(verifyOoyyoXml('<ooyyo><ad></ooyyo>', 0).issues[0].ad, -1);
247
+ assert.match(verifyOoyyoXml('<feed></feed>', 0).issues[0].problem, /root element/);
248
+ assert.match(verifyOoyyoXml('<ooyyo></ooyyo>', 3).issues[0].problem, /holds 0 ads, the run wrote 3/);
249
+ }],
250
+
251
+ ['validateOoyyoItem: the partner minimum (photo, year, price EUR, make, model, url)', () => {
252
+ const item = okItem();
253
+ assert.equal(validateOoyyoItem(item), null);
254
+ assert.equal(validateOoyyoItem({ ...item, priceUnit: 'euro' as any }), OoyyoExclusion.noPrice);
255
+ assert.equal(validateOoyyoItem({ ...item, price: '0' }), OoyyoExclusion.noPrice);
256
+ assert.equal(validateOoyyoItem({ ...item, year: '' }), OoyyoExclusion.noYear);
257
+ assert.equal(validateOoyyoItem({ ...item, pictures: [] }), OoyyoExclusion.noImage);
258
+ assert.equal(validateOoyyoItem({ ...item, url: 'http://www.auto.de/x' }), OoyyoExclusion.noId);
259
+ }],
260
+ ];
261
+
262
+ let failed = 0;
263
+ for (const [name, fn] of tests) {
264
+ try {
265
+ fn();
266
+ console.log(`ok - ${name}`);
267
+ } catch (error) {
268
+ failed += 1;
269
+ console.log(`not ok - ${name}\n${error?.stack ?? error}`);
270
+ }
271
+ }
272
+ if (failed) {
273
+ console.log(`${failed} of ${tests.length} failed`);
274
+ process.exit(1);
275
+ }
276
+ console.log(`ooyyo-mapping: ${tests.length} passed`);