@bytebrand/export-cars 1.26.31 → 1.26.32

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 (56) hide show
  1. package/dist/configs/autouncle.config.d.ts +14 -0
  2. package/dist/configs/autouncle.config.js +36 -3
  3. package/dist/configs/autouncle.config.js.map +1 -1
  4. package/dist/configs/meinstadt.config.d.ts +15 -0
  5. package/dist/configs/meinstadt.config.js +42 -3
  6. package/dist/configs/meinstadt.config.js.map +1 -1
  7. package/dist/constants/autouncle.contants.d.ts +1 -3
  8. package/dist/constants/autouncle.contants.js +2 -4
  9. package/dist/constants/autouncle.contants.js.map +1 -1
  10. package/dist/exporters/autouncle.exporter.d.ts +3 -2
  11. package/dist/exporters/autouncle.exporter.js +45 -27
  12. package/dist/exporters/autouncle.exporter.js.map +1 -1
  13. package/dist/exporters/meinstadt.exporter.d.ts +3 -2
  14. package/dist/exporters/meinstadt.exporter.js +40 -23
  15. package/dist/exporters/meinstadt.exporter.js.map +1 -1
  16. package/dist/formats/autouncle-json.builder.js +1 -4
  17. package/dist/formats/autouncle-json.builder.js.map +1 -1
  18. package/dist/interfaces/export-item.interface.d.ts +8 -12
  19. package/dist/interfaces/params.interface.d.ts +6 -0
  20. package/dist/services/autouncle-feed-run.d.ts +66 -0
  21. package/dist/services/autouncle-feed-run.js +135 -0
  22. package/dist/services/autouncle-feed-run.js.map +1 -0
  23. package/dist/services/meinstadt-feed-run.d.ts +81 -0
  24. package/dist/services/meinstadt-feed-run.js +166 -0
  25. package/dist/services/meinstadt-feed-run.js.map +1 -0
  26. package/dist/transformers/autouncle.transformer.d.ts +38 -22
  27. package/dist/transformers/autouncle.transformer.js +156 -144
  28. package/dist/transformers/autouncle.transformer.js.map +1 -1
  29. package/dist/transformers/meinstadt.transformer.d.ts +17 -13
  30. package/dist/transformers/meinstadt.transformer.js +88 -61
  31. package/dist/transformers/meinstadt.transformer.js.map +1 -1
  32. package/dist/tsconfig.build.tsbuildinfo +1 -1
  33. package/docs/autouncle/AUDIT.md +97 -0
  34. package/docs/autouncle/PARTNER-QUESTIONS.md +53 -0
  35. package/docs/autouncle/RUNBOOK.md +64 -0
  36. package/docs/meinestadt/AUDIT.md +146 -0
  37. package/docs/meinestadt/PARTNER-QUESTIONS.md +54 -0
  38. package/docs/meinestadt/RUNBOOK.md +64 -0
  39. package/package.json +3 -2
  40. package/src/configs/autouncle.config.ts +51 -3
  41. package/src/configs/meinstadt.config.ts +57 -2
  42. package/src/constants/autouncle.contants.ts +2 -3
  43. package/src/exporters/autouncle.exporter.ts +59 -37
  44. package/src/exporters/meinstadt.exporter.ts +53 -32
  45. package/src/formats/autouncle-json.builder.ts +2 -4
  46. package/src/interfaces/export-item.interface.ts +15 -12
  47. package/src/interfaces/params.interface.ts +10 -0
  48. package/src/services/autouncle-feed-run.ts +197 -0
  49. package/src/services/meinstadt-feed-run.ts +240 -0
  50. package/src/transformers/autouncle.transformer.ts +217 -175
  51. package/src/transformers/meinstadt.transformer.ts +114 -68
  52. package/test/autouncle-feed-run.test.ts +256 -0
  53. package/test/autouncle-mapping.test.ts +225 -0
  54. package/test/fixtures/meinstadt.fixture.ts +87 -0
  55. package/test/meinstadt-feed-run.test.ts +288 -0
  56. package/test/meinstadt-mapping.test.ts +215 -0
@@ -0,0 +1,256 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ // Integration: real files in a temp dir (base export snapshot, output, state), real JSON writer,
3
+ // guard and ADV-336 clean-image selection; only HTTP (image check) and UPA (dealer) are faked.
4
+ import * as assert from 'node:assert/strict';
5
+ import { promises as fs } from 'fs';
6
+ import { tmpdir } from 'os';
7
+ import { join } from 'path';
8
+
9
+ import { FeedGuard, FeedGuardError, FeedGuardOptions } from '../src/services/feed-guard';
10
+ import { AutouncleFeedRun, AutouncleSourceCar } from '../src/services/autouncle-feed-run';
11
+ import { toAutouncleImages } from '../src/transformers/autouncle.transformer';
12
+ import { selectOpenAiImageFields } from '../src/transformers/openai-image-fields';
13
+ import { buildImagesService } from './fixtures/images-service.harness';
14
+ import { NOW, car, images } from './fixtures/meinstadt.fixture';
15
+
16
+ const HOUR = 60 * 60 * 1000;
17
+ const imagesService = buildImagesService();
18
+ const ADDRESS = { city: 'Sandersdorf-Brehna', addr1: 'Max-Planck-Str. 19', region: 'Brehna', postal_code: '06796' };
19
+
20
+ interface World {
21
+ root: string;
22
+ source: string;
23
+ json: string;
24
+ state: string;
25
+ brokenImages: Set<string>;
26
+ unknownDealers: Set<string>;
27
+ dealerLookups: number;
28
+ }
29
+
30
+ const newWorld = async (): Promise<World> => {
31
+ const root = await fs.mkdtemp(join(tmpdir(), 'autouncle-'));
32
+ return {
33
+ root,
34
+ source: join(root, 'cars-export'),
35
+ json: join(root, 'autouncle-dryrun', 'api_export_cars_autouncle.json'),
36
+ state: join(root, 'autouncle-state', 'dryrun'),
37
+ brokenImages: new Set(),
38
+ unknownDealers: new Set(),
39
+ dealerLookups: 0,
40
+ };
41
+ };
42
+
43
+ const source = (id: string, overrides: Record<string, any> = {}): AutouncleSourceCar => ({ car: car(id, overrides), images: images(id) });
44
+
45
+ const writeSource = async (world: World, cars: AutouncleSourceCar[], chunkSize = 2, mtime = NOW) => {
46
+ await fs.rm(world.source, { recursive: true, force: true });
47
+ await fs.mkdir(world.source, { recursive: true });
48
+ for (let i = 0; i * chunkSize < cars.length; i++) {
49
+ const file = join(world.source, `cars.${i}.json`);
50
+ await fs.writeFile(file, JSON.stringify(cars.slice(i * chunkSize, (i + 1) * chunkSize)));
51
+ await fs.utimes(file, mtime, mtime);
52
+ }
53
+ };
54
+
55
+ const guardOptions = (world: World, extra: Partial<FeedGuardOptions> = {}): FeedGuardOptions => ({
56
+ stateDir: world.state,
57
+ minSourceItems: 1,
58
+ minPublishedItems: 1,
59
+ maxDropPercent: 20,
60
+ maxSourceAgeHours: 26,
61
+ maxStaleHours: 48,
62
+ acceptDrop: false,
63
+ ...extra,
64
+ });
65
+
66
+ const run = async (world: World, { at = NOW, guard = {} }: { at?: Date; guard?: Partial<FeedGuardOptions> } = {}) => {
67
+ const forEachChunk = async (fn: (cars: AutouncleSourceCar[]) => Promise<void>) => {
68
+ const names = (await fs.readdir(world.source)).sort((a, b) => Number(a.split('.')[1]) - Number(b.split('.')[1]));
69
+ for (const name of names) {
70
+ await fn(JSON.parse(await fs.readFile(join(world.source, name), 'utf8')));
71
+ }
72
+ };
73
+ const feed = new AutouncleFeedRun(
74
+ {
75
+ forEachChunk,
76
+ filterCar: ({ car: c }) => (c?.metaData?.imagesCount ?? 0) > 0,
77
+ dealerAddress: async (ownerId) => {
78
+ world.dealerLookups += 1;
79
+ return world.unknownDealers.has(ownerId) ? null : ADDRESS;
80
+ },
81
+ resolveImages: async ({ car: c, images: list }) => {
82
+ const clean = imagesService.getCleanImages(c._id, list);
83
+ return toAutouncleImages(selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: !world.brokenImages.has(i.url) }))));
84
+ },
85
+ imageStats: () => ({ ok: 1 }),
86
+ translate: key => key,
87
+ guard: new FeedGuard(guardOptions(world, guard)),
88
+ },
89
+ {
90
+ runId: 'test',
91
+ dryRun: true,
92
+ domain: 'auto.de',
93
+ sourceDirectory: world.source,
94
+ sourceBaseFileName: 'cars',
95
+ jsonFile: world.json,
96
+ stateDir: world.state,
97
+ now: () => at,
98
+ },
99
+ );
100
+ return feed.run();
101
+ };
102
+
103
+ const readAds = async (world: World) => JSON.parse(await fs.readFile(world.json, 'utf8')).ads;
104
+ const ids = (ads: any[]) => ads.map((a: any) => a.back_link.split('/vehicle/')[1].split('?')[0]);
105
+ const exists = (file: string) => fs.stat(file).then(() => true, () => false);
106
+
107
+ const tests: Array<[string, () => Promise<void>]> = [
108
+ ['first run: {"ads":[...]} without the duplicated first car, exclusions counted, report written', async () => {
109
+ const world = await newWorld();
110
+ const noImages = source('nopic');
111
+ noImages.car.metaData.imagesCount = 0;
112
+ const noHero = source('nohero');
113
+ noHero.images = noHero.images.filter(i => i.imageCodePredicted !== 'ext_f_right');
114
+ world.unknownDealers.add('dealer-x');
115
+ await writeSource(world, [
116
+ source('aaa1', { 'metaData.ownerId': 'dealer-1' }),
117
+ source('aaa2', { 'metaData.ownerId': 'dealer-1' }),
118
+ source('aaa3', { 'metaData.ownerId': 'dealer-2', 'mainData.firstRegistration': null }),
119
+ source('aaa1'),
120
+ noImages,
121
+ noHero,
122
+ source('sold', { 'offer.offerAvailability': 'selector_offerAvailability_sold' }),
123
+ source('resv', { 'offer.offerAvailability': 'selector_offerAvailability_reserved' }),
124
+ source('noprice', { 'price.currentSalesPrice': null }),
125
+ source('nodealer', { 'metaData.ownerId': 'dealer-x' }),
126
+ ]);
127
+ const report = await run(world);
128
+ const ads = await readAds(world);
129
+ assert.deepEqual(ids(ads), ['aaa1', 'aaa2', 'aaa3']);
130
+ // two cars of one dealer share seller_id
131
+ assert.deepEqual(ads.map((a: any) => a.seller_id), ['dealer-1', 'dealer-1', 'dealer-2']);
132
+ assert.deepEqual(ads.map((a: any) => a.year), ['2025', '2025', '']);
133
+ for (const ad of ads) {
134
+ assert.equal(ad.images.length, 4);
135
+ assert.match(ad.images[0].url, /hero1\/large-cached\.jpeg$/);
136
+ assert.ok(ad.images.every(({ url }: { url: string }) => !url.includes('/noimage/')));
137
+ assert.equal(ad.price, 'EUR 15148');
138
+ }
139
+ assert.equal(report.status, 'published');
140
+ assert.equal(report.items, 3);
141
+ assert.equal(report.dealers, 2);
142
+ assert.deepEqual(report.excluded, {
143
+ duplicate_id: 1,
144
+ filtered: 1,
145
+ no_valid_image: 1,
146
+ reserved_or_sold: 2,
147
+ no_valid_price: 1,
148
+ no_dealer: 1,
149
+ });
150
+ assert.deepEqual(report.imagesPerItem, { min: 4, max: 4, total: 12 });
151
+ const saved = JSON.parse(await fs.readFile(join(world.state, 'last-run-report.json'), 'utf8'));
152
+ assert.equal(saved.status, 'published');
153
+ assert.equal(await exists(`${world.json}.tmp`), false);
154
+ assert.equal(await exists(join(world.state, 'run.lock')), false);
155
+ }],
156
+
157
+ ['JSON file: UTF-8 umlauts, no BOM, single object with ads array', async () => {
158
+ const world = await newWorld();
159
+ await writeSource(world, [source('u1', { 'mainData.subModel': 'Größe Ü' }), source('u2')]);
160
+ await run(world);
161
+ const bytes = await fs.readFile(world.json);
162
+ assert.notEqual(bytes[0], 0xef);
163
+ assert.ok(bytes.includes(Buffer.from('Größe Ü', 'utf8')));
164
+ assert.ok(bytes.toString('utf8').startsWith('{"ads":[{'));
165
+ assert.equal((await readAds(world)).length, 2);
166
+ }],
167
+
168
+ ['broken image URLs are left out; broken hero -> car not sent', async () => {
169
+ const world = await newWorld();
170
+ world.brokenImages.add('https://images.auto.de/carimage/b1/b1back1/large-cached.jpeg');
171
+ world.brokenImages.add('https://images.auto.de/carimage/b2/b2hero1/large-cached.jpeg');
172
+ await writeSource(world, [source('b1'), source('b2')]);
173
+ const report = await run(world);
174
+ const ads = await readAds(world);
175
+ assert.deepEqual(ids(ads), ['b1']);
176
+ assert.deepEqual(ads[0].images.map((i: any) => i.url), [
177
+ 'https://images.auto.de/carimage/b1/b1hero1/large-cached.jpeg',
178
+ 'https://images.auto.de/carimage/b1/b1side1/large-cached.jpeg',
179
+ 'https://images.auto.de/carimage/b1/b1int01/large-cached.jpeg',
180
+ ]);
181
+ assert.equal(report.excluded.no_valid_image, 1);
182
+ }],
183
+
184
+ ['source outage: empty snapshot is blocked, last good JSON untouched', async () => {
185
+ const world = await newWorld();
186
+ await writeSource(world, [source('g1'), source('g2')]);
187
+ await run(world);
188
+ const good = await fs.readFile(world.json, 'utf8');
189
+ await writeSource(world, []);
190
+ await assert.rejects(run(world, { at: new Date(NOW.getTime() + HOUR) }), (e: any) => e instanceof FeedGuardError && e.code === 'source_empty');
191
+ assert.equal(await fs.readFile(world.json, 'utf8'), good);
192
+ const saved = JSON.parse(await fs.readFile(join(world.state, 'last-run-report.json'), 'utf8'));
193
+ assert.equal(saved.status, 'blocked');
194
+ assert.equal(saved.guard.code, 'source_empty');
195
+ assert.equal(await exists(join(world.state, 'run.lock')), false);
196
+ }],
197
+
198
+ ['partial snapshot (missing chunk) and stale snapshot are blocked', async () => {
199
+ const world = await newWorld();
200
+ await writeSource(world, [source('p1'), source('p2'), source('p3'), source('p4'), source('p5')]);
201
+ await fs.rm(join(world.source, 'cars.1.json'));
202
+ await assert.rejects(run(world), (e: any) => e instanceof FeedGuardError && e.code === 'source_partial');
203
+ assert.equal(await exists(world.json), false);
204
+
205
+ await writeSource(world, [source('s1')], 2, new Date(NOW.getTime() - 30 * HOUR));
206
+ await assert.rejects(run(world), (e: any) => e instanceof FeedGuardError && e.code === 'source_stale');
207
+ assert.equal(await exists(world.json), false);
208
+ }],
209
+
210
+ ['count drop > 20% is blocked (rejected file kept), acceptDrop publishes once', async () => {
211
+ const world = await newWorld();
212
+ const ten = Array.from({ length: 10 }, (_, i) => source(`c${i}`));
213
+ await writeSource(world, ten);
214
+ await run(world);
215
+ const good = await fs.readFile(world.json, 'utf8');
216
+ await writeSource(world, ten.slice(0, 4));
217
+ const later = new Date(NOW.getTime() + HOUR);
218
+ await assert.rejects(run(world, { at: later }), (e: any) => e instanceof FeedGuardError && e.code === 'count_drop');
219
+ assert.equal(await fs.readFile(world.json, 'utf8'), good);
220
+ assert.equal(await exists(`${world.json}.rejected`), true);
221
+ const accepted = await run(world, { at: later, guard: { acceptDrop: true } });
222
+ assert.equal(accepted.status, 'published');
223
+ assert.equal((await readAds(world)).length, 4);
224
+ assert.equal(await fs.readFile(`${world.json}.prev`, 'utf8'), good);
225
+ }],
226
+
227
+ ['nothing publishable -> blocked, never an empty {"ads":[]} file', async () => {
228
+ const world = await newWorld();
229
+ await writeSource(world, [source('n1', { 'price.currentSalesPrice': 0 }), source('n2', { 'offer.offerAvailability': 'selector_offerAvailability_sold' })]);
230
+ await assert.rejects(run(world), (e: any) => e instanceof FeedGuardError && e.code === 'published_too_small');
231
+ assert.equal(await exists(world.json), false);
232
+ }],
233
+
234
+ ['second run with the same snapshot: byte-identical file (stable ids, order, links)', async () => {
235
+ const world = await newWorld();
236
+ await writeSource(world, [source('i1'), source('i2'), source('i3')]);
237
+ await run(world);
238
+ const first = await fs.readFile(world.json, 'utf8');
239
+ await run(world, { at: new Date(NOW.getTime() + HOUR) });
240
+ assert.equal(await fs.readFile(world.json, 'utf8'), first);
241
+ }],
242
+ ];
243
+
244
+ (async () => {
245
+ let failed = 0;
246
+ for (const [name, fn] of tests) {
247
+ try {
248
+ await fn();
249
+ console.log(`ok ${name}`);
250
+ } catch (e) {
251
+ failed += 1;
252
+ console.error(`FAIL ${name}\n ${e.message}`);
253
+ }
254
+ }
255
+ if (failed) process.exit(1);
256
+ })();
@@ -0,0 +1,225 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ // AutoUncle `ads[]` mapping (ADV-322) and image list (ADV-323) — pure functions, no network.
3
+ import * as assert from 'node:assert/strict';
4
+
5
+ import { AutouncleConfig } from '../src/configs/autouncle.config';
6
+ import { AutouncleJsonBuilder } from '../src/formats/autouncle-json.builder';
7
+ import { Environment, ExportType, ImageType } from '../src/enums';
8
+ import {
9
+ AutouncleExclusion,
10
+ AutouncleTransformer,
11
+ getAutounclePrice,
12
+ getAutouncleStatus,
13
+ getAutouncleYear,
14
+ mapAutouncleItem,
15
+ toAutouncleImages,
16
+ } from '../src/transformers/autouncle.transformer';
17
+ import { selectOpenAiImageFields } from '../src/transformers/openai-image-fields';
18
+ import { buildImagesService } from './fixtures/images-service.harness';
19
+ import { car, eightImages, images } from './fixtures/meinstadt.fixture';
20
+ import { promises as fs } from 'fs';
21
+ import { tmpdir } from 'os';
22
+ import { join } from 'path';
23
+
24
+ const imagesService = buildImagesService();
25
+ const ADDRESS = { city: 'Sandersdorf-Brehna', addr1: 'Max-Planck-Str. 19', region: 'Sachsen-Anhalt', postal_code: '06796' };
26
+ const ctx = (extra: Record<string, any> = {}) => ({
27
+ domain: 'auto.de',
28
+ address: ADDRESS,
29
+ images: ['https://images.auto.de/carimage/x/hero/large-cached.jpeg'],
30
+ translate: (key: string) => ({ selector_exteriorColor_red: 'Rot', other: 'Andere' }[key] ?? key),
31
+ ...extra,
32
+ });
33
+ const item = (c: any, extra: Record<string, any> = {}) => {
34
+ const result = mapAutouncleItem(c, ctx(extra));
35
+ assert.equal(result.ok, true, `expected ok, got ${JSON.stringify(result)}`);
36
+ return (result as any).item;
37
+ };
38
+ const reason = (c: any, extra: Record<string, any> = {}) => {
39
+ const result = mapAutouncleItem(c, ctx(extra));
40
+ assert.equal(result.ok, false);
41
+ return (result as any).reason;
42
+ };
43
+ const NOW = new Date('2026-10-13T08:00:00Z');
44
+
45
+ // Historic key set and order (Confluence "2.7.2 Export Plugins" + the exporter since init commit),
46
+ // misspellings included, until AutoUncle confirms a current schema.
47
+ const HISTORIC_KEYS = [
48
+ 'back_link', 'make', 'model_name', 'model_variant', 'fuel', 'body', 'doors', 'price', 'year', 'status',
49
+ 'mileage', 'leasing', 'color', 'engine_size', 'power', 'gear_type', 'has_4wd', 'seller_type', 'seller_region',
50
+ 'seller_city', 'seller_street_address', 'seller_postcode', 'seller_name', 'seller_id', 'description', 'images',
51
+ 'fuel_economy', 'co2_emission', 'co2_effiency_class', 'euro_emission_class', 'enviromental_sticker',
52
+ ];
53
+
54
+ const tests: Array<[string, () => void | Promise<void>]> = [
55
+ ['keys: historic set, order and spellings unchanged', () => {
56
+ assert.deepEqual(Object.keys(item(car('k1'))), HISTORIC_KEYS);
57
+ }],
58
+
59
+ ['identity: seller_id = dealer (ownerId) for every car of one dealer; back_link = stable car _id', () => {
60
+ const a = item(car('car-a', { 'metaData.ownerId': 'dealer-7' }));
61
+ const b = item(car('car-b', { 'metaData.ownerId': 'dealer-7' }));
62
+ assert.equal(a.seller_id, 'dealer-7');
63
+ assert.equal(b.seller_id, 'dealer-7');
64
+ assert.notEqual(a.back_link, b.back_link);
65
+ assert.equal(a.back_link, 'https://www.auto.de/search/vehicle/car-a?source=classifieds&medium=cpc&campaign=autouncle');
66
+ // same car again -> byte-identical back_link (no relisting under a new URL)
67
+ assert.equal(item(car('car-a', { 'metaData.ownerId': 'dealer-7' })).back_link, a.back_link);
68
+ // seller_id is never a vehicle id
69
+ assert.notEqual(a.seller_id, car('car-a').metaData.vehicleId);
70
+ }],
71
+
72
+ ['year: null / 0 / missing / garbage -> "" (never 1970); status follows the same rule', () => {
73
+ for (const value of [null, 0, undefined, '', 'abc', -5]) {
74
+ assert.equal(getAutouncleYear(value, NOW), '', `year for ${JSON.stringify(value)}`);
75
+ assert.equal(getAutouncleStatus(value), 'Neuwagen');
76
+ }
77
+ const noField = car('y0');
78
+ delete noField.mainData.firstRegistration;
79
+ assert.equal(item(noField).year, '');
80
+ const nullYear = item(car('y1', { 'mainData.firstRegistration': null }));
81
+ assert.equal(nullYear.year, '');
82
+ assert.notEqual(nullYear.year, '1970');
83
+ assert.equal(nullYear.status, 'Neuwagen');
84
+ // 2025-06-25 in Unix seconds
85
+ const used = item(car('y2', { 'mainData.firstRegistration': 1750809600 }));
86
+ assert.equal(used.year, '2025');
87
+ assert.equal(used.status, 'Gebrauchtwagen');
88
+ // milliseconds by mistake -> year 57453 -> unknown, not a fantasy year
89
+ assert.equal(getAutouncleYear(1750809600000, NOW), '');
90
+ }],
91
+
92
+ ['price: EUR purchase price (currentSalesPrice), never the rate or the dealer cost; invalid -> car not sent', () => {
93
+ const c = car('p1', { 'price.currentSalesPrice': 15148, 'price.purchasePrice': 11000, 'rate.monthlyRate': 199 });
94
+ assert.equal(item(c).price, 'EUR 15148');
95
+ // a price change shows up 1:1
96
+ assert.equal(item(car('p1', { 'price.currentSalesPrice': 14990 })).price, 'EUR 14990');
97
+ for (const value of [null, undefined, '', 0, -1, 'abc', true]) {
98
+ assert.equal(getAutounclePrice(value), null, `price ${JSON.stringify(value)}`);
99
+ assert.equal(reason(car('p2', { 'price.currentSalesPrice': value })), AutouncleExclusion.noPrice);
100
+ }
101
+ }],
102
+
103
+ ['availability: reserved / sold cars are not sendable stock', () => {
104
+ assert.equal(reason(car('r1', { 'offer.offerAvailability': 'selector_offerAvailability_reserved' })), AutouncleExclusion.notAvailable);
105
+ assert.equal(reason(car('r2', { 'offer.offerAvailability': 'selector_offerAvailability_sold' })), AutouncleExclusion.notAvailable);
106
+ assert.equal(mapAutouncleItem(car('r3', { 'offer.offerAvailability': 'selector_offerAvailability_available' }), ctx()).ok, true);
107
+ }],
108
+
109
+ ['no id / no dealer -> not sent', () => {
110
+ assert.equal(reason(car('', {})), AutouncleExclusion.noId);
111
+ assert.equal(reason(car('d1', { 'metaData.ownerId': '' })), AutouncleExclusion.noDealer);
112
+ }],
113
+
114
+ ['selectors: unknown / null category, fuel, gearbox -> Other/other, the key is never dropped', () => {
115
+ const odd = item(car('o1', { 'mainData.category': 'selector_category_pickup', 'consumption.fuel': null, 'driveSuspension.gearbox': undefined }));
116
+ assert.equal(odd.body, 'Other');
117
+ assert.equal(odd.fuel, 'Other');
118
+ assert.equal(odd.gear_type, 'other');
119
+ assert.deepEqual(Object.keys(JSON.parse(JSON.stringify(odd))), HISTORIC_KEYS);
120
+ const known = item(car('o2', { 'mainData.category': 'selector_category_suv', 'consumption.fuel': 'selector_fuel_hybridElectricalPetrol', 'driveSuspension.gearbox': 'selector_gearbox_semiAutomatic' }));
121
+ assert.deepEqual([known.body, known.fuel, known.gear_type], ['Suv', 'Hybrid', 'automatic']);
122
+ }],
123
+
124
+ ['units: no "ccm" without a value; km and kW as before', () => {
125
+ assert.equal(item(car('u1')).engine_size, '');
126
+ assert.equal(item(car('u2', { 'engineData.cubicCapacity': 1497 })).engine_size, '1497ccm');
127
+ assert.equal(item(car('u3')).mileage, '13000km');
128
+ assert.equal(item(car('u4', { 'mainData.mileage': 0 })).mileage, '1km');
129
+ assert.equal(item(car('u5')).power, '85');
130
+ }],
131
+
132
+ ['seller address: the dealer\'s own address, region from the postal code table', () => {
133
+ const a = item(car('s1'));
134
+ assert.equal(a.seller_city, 'Sandersdorf-Brehna');
135
+ assert.equal(a.seller_postcode, '06796');
136
+ assert.equal(a.seller_street_address, 'Max-Planck-Str. 19');
137
+ assert.equal(a.seller_region, 'Sachsen-Anhalt');
138
+ assert.equal(a.seller_name, 'auto.de');
139
+ assert.equal(a.seller_type, 'Dealer');
140
+ }],
141
+
142
+ ['getAddress: place unknown to the postal code table keeps street/city/zip (were blanked), region Deutschland', async () => {
143
+ const transformer = new AutouncleTransformer();
144
+ const unknown = await transformer.getAddress({ mainAddress: { city: 'Nirgendwo', zip: '00000', street: 'Teststr. 1', country: { name: 'Deutschland' } } });
145
+ assert.deepEqual(unknown, { city: 'Nirgendwo', addr1: 'Teststr. 1', region: 'Deutschland', postal_code: '00000' });
146
+ const known = await transformer.getAddress({ businessAddress: { city: 'Sandersdorf-Brehna', zip: '06796', street: 'Max-Planck-Str. 19', country: { name: 'Deutschland' } } });
147
+ assert.equal(known.city, 'Sandersdorf-Brehna');
148
+ assert.equal(known.postal_code, '06796');
149
+ assert.ok(known.region && known.region !== 'Deutschland', `region from table, got ${known.region}`);
150
+ assert.deepEqual(await transformer.getAddress(null), { city: '', addr1: '', region: 'Deutschland', postal_code: '' });
151
+ }],
152
+
153
+ ['images: [{url}] objects, clean profile order, explicit JPEG, never a placeholder', () => {
154
+ const clean = imagesService.getCleanImages('car-i', images('i'));
155
+ const urls = toAutouncleImages(selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: true }))));
156
+ const out = item(car('car-i'), { images: urls }).images;
157
+ assert.deepEqual(out, [
158
+ { url: 'https://images.auto.de/carimage/car-i/ihero1/large-cached.jpeg' },
159
+ { url: 'https://images.auto.de/carimage/car-i/iside1/large-cached.jpeg' },
160
+ { url: 'https://images.auto.de/carimage/car-i/iback1/large-cached.jpeg' },
161
+ { url: 'https://images.auto.de/carimage/car-i/iint01/large-cached.jpeg' },
162
+ ]);
163
+ assert.ok(out.every(({ url }: { url: string }) => !url.includes('/noimage/')));
164
+ }],
165
+
166
+ ['images: no valid 3/4 hero -> [] -> car not sent; duplicates once; maxImages cuts only the list', () => {
167
+ const clean = imagesService.getCleanImages('car-h', images('h'));
168
+ const heroBroken = selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: !i.heroCandidate })));
169
+ assert.deepEqual(toAutouncleImages(heroBroken), []);
170
+ assert.equal(reason(car('car-h'), { images: [] }), AutouncleExclusion.noImage);
171
+ // no hero shot at all
172
+ assert.deepEqual(imagesService.getCleanImages('car-n', images('n').filter(i => i.imageCodePredicted !== 'ext_f_right')), []);
173
+ // the same Imbo image twice -> once
174
+ const dup = [...eightImages('d'), ...eightImages('d')];
175
+ const dupClean = imagesService.getCleanImages('car-d', dup);
176
+ assert.equal(dupClean.length, 8);
177
+ const allOk = selectOpenAiImageFields(dupClean, dupClean.map(i => ({ url: i.url, ok: true })));
178
+ assert.equal(toAutouncleImages(allOk).length, 8);
179
+ assert.equal(toAutouncleImages(allOk, 3).length, 3);
180
+ assert.equal(allOk.fields.additional_image_urls.length, 7);
181
+ }],
182
+
183
+ ['builder: first item written once, {"ads":[...]} parses', async () => {
184
+ const file = join(await fs.mkdtemp(join(tmpdir(), 'autouncle-b-')), 'out.json');
185
+ const builder = new AutouncleJsonBuilder(file);
186
+ await builder.buildStart();
187
+ await builder.addItem(item(car('b1')), true);
188
+ await builder.addItem(item(car('b2')), false);
189
+ await builder.buildEnd();
190
+ const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
191
+ assert.deepEqual(parsed.ads.map((a: any) => a.back_link.split('/vehicle/')[1].split('?')[0]), ['b1', 'b2']);
192
+ }],
193
+
194
+ ['config: dry-run by default (path no Cronicle step reads), live only with autouncleDryRun=false; clean images', () => {
195
+ const base = { environment: Environment.DEVELOP, exportType: ExportType.AUTOUNCLE };
196
+ const dry = new AutouncleConfig({ ...base } as any);
197
+ assert.equal(dry.dryRun, true);
198
+ assert.equal(dry.outputFiles[0], 'resources/autouncle-dryrun/api_export_cars_autouncle.json');
199
+ assert.equal(dry.stateDir, 'resources/autouncle-state/dryrun');
200
+ assert.equal(dry.imageType, ImageType.clean);
201
+ assert.equal(dry.maxImages, 0);
202
+ assert.equal(dry.feedGuard.minPublishedItems, 1);
203
+ assert.equal(new AutouncleConfig({ ...base, autouncleDryRun: 'no' } as any).dryRun, true);
204
+ const live = new AutouncleConfig({ ...base, autouncleDryRun: 'false', autouncleMaxImages: '12', feedGuardMinPublishedItems: '50' } as any);
205
+ assert.equal(live.dryRun, false);
206
+ assert.equal(live.outputFiles[0], 'resources/autouncle-export/api_export_cars_autouncle.json');
207
+ assert.equal(live.stateDir, 'resources/autouncle-state/export');
208
+ assert.equal(live.maxImages, 12);
209
+ assert.equal(live.feedGuard.minPublishedItems, 50);
210
+ }],
211
+ ];
212
+
213
+ (async () => {
214
+ let failed = 0;
215
+ for (const [name, fn] of tests) {
216
+ try {
217
+ await fn();
218
+ console.log(`ok ${name}`);
219
+ } catch (e) {
220
+ failed += 1;
221
+ console.error(`FAIL ${name}\n ${e.message}`);
222
+ }
223
+ }
224
+ if (failed) process.exit(1);
225
+ })();
@@ -0,0 +1,87 @@
1
+ import { ImboImageResponseInterface } from '@bytebrand/chunkable';
2
+
3
+ export const NOW = new Date('2026-10-13T08:00:00Z');
4
+
5
+ // Minimal CBD car as the base export delivers it (only the fields the meinestadt mapping reads).
6
+ export const car = (id: string, overrides: Record<string, any> = {}): any => {
7
+ const base: any = {
8
+ _id: id,
9
+ metaData: {
10
+ vehicleId: `V${id.slice(0, 5).toUpperCase()}`,
11
+ creationDate: 1783408438,
12
+ ownerId: 'dealer-1',
13
+ imagesCount: 4,
14
+ },
15
+ mainData: {
16
+ make: 'Opel',
17
+ model: 'Astra',
18
+ subModel: '1.2 Turbo Edition',
19
+ accidentDamaged: false,
20
+ mileage: 13000,
21
+ firstRegistration: 1750809600,
22
+ doors: 5,
23
+ seats: 5,
24
+ numberOfPreviousOwners: 1,
25
+ category: 'selector_category_combi',
26
+ condition: 'selector_condition_used',
27
+ },
28
+ price: { currentSalesPrice: 15148, purchasePrice: 11000 },
29
+ rate: { monthlyRate: 199 },
30
+ engineData: { powerPS: 116, powerKW: 85 },
31
+ environmentEmissions: { co2: 137, emissionClass: 'selector_emissionClass_euro6', emissionSticker: 'selector_emissionSticker_green' },
32
+ consumption: { consumptionCombined: 6.1, fuel: 'selector_fuel_petrol' },
33
+ exterior: { exteriorColor: 'selector_exteriorColor_red' },
34
+ driveSuspension: { gearbox: 'selector_gearbox_manualShift' },
35
+ };
36
+ for (const [path, value] of Object.entries(overrides)) {
37
+ const keys = path.split('.');
38
+ let target = base;
39
+ for (const key of keys.slice(0, -1)) {
40
+ target[key] = target[key] ?? {};
41
+ target = target[key];
42
+ }
43
+ target[keys[keys.length - 1]] = value;
44
+ }
45
+ return base;
46
+ };
47
+
48
+ const img = (imageIdentifier: string, imageCodePredicted: string): ImboImageResponseInterface => ({
49
+ imageIdentifier,
50
+ imageCodePredicted,
51
+ });
52
+
53
+ // Per car: 3/4 hero + side + back + interior (all clean-eligible) + a feature close-up (dropped).
54
+ export const images = (prefix: string): ImboImageResponseInterface[] => [
55
+ img(`${prefix}int01`, 'int_f_ipanel1'),
56
+ img(`${prefix}rims1`, 'ext_feat_rims'),
57
+ img(`${prefix}back1`, 'ext_b_main'),
58
+ img(`${prefix}hero1`, 'ext_f_right'),
59
+ img(`${prefix}side1`, 'ext_l_main'),
60
+ ];
61
+
62
+ // Eight raw clean-eligible photos of one car.
63
+ export const eightImages = (prefix: string): ImboImageResponseInterface[] => [
64
+ img(`${prefix}hero1`, 'ext_f_right'),
65
+ img(`${prefix}hero2`, 'ext_f_left'),
66
+ img(`${prefix}cowl1`, 'ext_f_cowl'),
67
+ img(`${prefix}side1`, 'ext_l_main'),
68
+ img(`${prefix}back1`, 'ext_b_main'),
69
+ img(`${prefix}back2`, 'ext_b_left'),
70
+ img(`${prefix}int01`, 'int_f_ipanel1'),
71
+ img(`${prefix}int02`, 'int_f_driverseats'),
72
+ ];
73
+
74
+ // German labels as @bytebrand/i18n-dictionaries `de` returns them (checked 13.10.2026).
75
+ const LABELS: Record<string, string> = {
76
+ selector_exteriorColor_red: 'Rot',
77
+ selector_category_combi: 'Kombi',
78
+ selector_emissionClass_euro6: 'Euro 6',
79
+ selector_emissionSticker_green: 'Feinstaubplakette grün (4)',
80
+ selector_fuel_petrol: 'Benzin',
81
+ selector_gearbox_manualShift: 'Manuell',
82
+ selector_condition_used: 'Gebraucht',
83
+ selector_condition_new: 'Neu',
84
+ selector_unknown: 'Unbekannt',
85
+ };
86
+
87
+ export const translate = (key: string): string | undefined => LABELS[key];