@bytebrand/export-cars 1.26.27 → 1.26.29
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/README.md +128 -0
- package/dist/configs/config.d.ts +2 -1
- package/dist/configs/config.js +1 -0
- package/dist/configs/config.js.map +1 -1
- package/dist/configs/google.config.js +16 -0
- package/dist/configs/google.config.js.map +1 -1
- package/dist/configs/index.d.ts +1 -0
- package/dist/configs/index.js +1 -0
- package/dist/configs/index.js.map +1 -1
- package/dist/configs/openai-ads.config.d.ts +22 -0
- package/dist/configs/openai-ads.config.js +56 -0
- package/dist/configs/openai-ads.config.js.map +1 -0
- package/dist/enums/export-type.enum.d.ts +2 -1
- package/dist/enums/export-type.enum.js +1 -0
- package/dist/enums/export-type.enum.js.map +1 -1
- package/dist/enums/image-type.enum.d.ts +2 -1
- package/dist/enums/image-type.enum.js +1 -0
- package/dist/enums/image-type.enum.js.map +1 -1
- package/dist/export.schema.d.ts +6 -2
- package/dist/export.schema.js +4 -0
- package/dist/export.schema.js.map +1 -1
- package/dist/exporters/google.exporter.js +12 -2
- package/dist/exporters/google.exporter.js.map +1 -1
- package/dist/exporters/index.d.ts +1 -0
- package/dist/exporters/index.js +1 -0
- package/dist/exporters/index.js.map +1 -1
- package/dist/exporters/openai-ads.exporter.d.ts +13 -0
- package/dist/exporters/openai-ads.exporter.js +91 -0
- package/dist/exporters/openai-ads.exporter.js.map +1 -0
- package/dist/formats/openai-ads-csv.builder.d.ts +15 -0
- package/dist/formats/openai-ads-csv.builder.js +48 -0
- package/dist/formats/openai-ads-csv.builder.js.map +1 -0
- package/dist/interfaces/openai-ads.interface.d.ts +35 -0
- package/dist/interfaces/openai-ads.interface.js +37 -0
- package/dist/interfaces/openai-ads.interface.js.map +1 -0
- package/dist/interfaces/params.interface.d.ts +20 -0
- package/dist/services/feed-guard.d.ts +44 -0
- package/dist/services/feed-guard.js +156 -0
- package/dist/services/feed-guard.js.map +1 -0
- package/dist/services/image-url-validator.service.d.ts +45 -0
- package/dist/services/image-url-validator.service.js +141 -0
- package/dist/services/image-url-validator.service.js.map +1 -0
- package/dist/services/images.service.d.ts +12 -0
- package/dist/services/images.service.js +46 -1
- package/dist/services/images.service.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/openai-ads-feed-run.d.ts +74 -0
- package/dist/services/openai-ads-feed-run.js +151 -0
- package/dist/services/openai-ads-feed-run.js.map +1 -0
- package/dist/services/openai-ads-ledger.d.ts +19 -0
- package/dist/services/openai-ads-ledger.js +72 -0
- package/dist/services/openai-ads-ledger.js.map +1 -0
- package/dist/services/publication-gate.d.ts +15 -0
- package/dist/services/publication-gate.js +107 -0
- package/dist/services/publication-gate.js.map +1 -0
- package/dist/transformers/google-feed-envkv.d.ts +2 -0
- package/dist/transformers/google-feed-envkv.js +62 -31
- package/dist/transformers/google-feed-envkv.js.map +1 -1
- package/dist/transformers/google-feed-item-row.d.ts +6 -1
- package/dist/transformers/google-feed-item-row.js +3 -1
- package/dist/transformers/google-feed-item-row.js.map +1 -1
- package/dist/transformers/openai-ads.transformer.d.ts +28 -0
- package/dist/transformers/openai-ads.transformer.js +144 -0
- package/dist/transformers/openai-ads.transformer.js.map +1 -0
- package/dist/transformers/openai-image-fields.d.ts +16 -0
- package/dist/transformers/openai-image-fields.js +34 -0
- package/dist/transformers/openai-image-fields.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/openai-ads/AUDIT.md +235 -0
- package/docs/openai-ads/RUNBOOK.md +94 -0
- package/package.json +64 -64
- package/schema/feed-items.json +134 -122
- package/src/configs/config.ts +99 -97
- package/src/configs/google.config.ts +41 -26
- package/src/configs/index.ts +1 -0
- package/src/configs/openai-ads.config.ts +70 -0
- package/src/enums/export-type.enum.ts +2 -0
- package/src/enums/image-type.enum.ts +5 -0
- package/src/export.command.ts +38 -38
- package/src/export.schema.ts +6 -0
- package/src/exporters/google.exporter.ts +67 -56
- package/src/exporters/index.ts +1 -0
- package/src/exporters/openai-ads.exporter.ts +93 -0
- package/src/formats/openai-ads-csv.builder.ts +55 -0
- package/src/interfaces/openai-ads.interface.ts +64 -0
- package/src/interfaces/params.interface.ts +56 -29
- package/src/services/feed-guard.ts +219 -0
- package/src/services/feed-items-bigquery.writer.ts +101 -101
- package/src/services/image-url-validator.service.ts +181 -0
- package/src/services/images.service.ts +72 -0
- package/src/services/index.ts +1 -0
- package/src/services/openai-ads-feed-run.ts +226 -0
- package/src/services/openai-ads-ledger.ts +116 -0
- package/src/services/publication-gate.ts +119 -0
- package/src/transformers/google-feed-envkv.ts +81 -33
- package/src/transformers/google-feed-item-row.ts +78 -71
- package/src/transformers/openai-ads.transformer.ts +211 -0
- package/src/transformers/openai-image-fields.ts +60 -0
- package/test/clean-images.test.ts +86 -0
- package/test/fixtures/images-service.harness.ts +32 -0
- package/test/fixtures/images.golden.json +218 -0
- package/test/fixtures/imbo-images.fixture.ts +77 -0
- package/test/fixtures/openai-ads.fixture.ts +89 -0
- package/test/google-feed-item-row.test.ts +135 -121
- package/test/image-url-validator.test.ts +110 -0
- package/test/images-regression.test.ts +23 -0
- package/test/openai-ads-feed-run.test.ts +278 -0
- package/test/openai-ads-mapping.test.ts +136 -0
- package/test/openai-image-fields.test.ts +99 -0
- package/test/publication-gate.test.ts +86 -0
|
@@ -1,121 +1,135 @@
|
|
|
1
|
-
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
-
import * as assert from 'node:assert/strict';
|
|
3
|
-
import { buildGoogleFeedItemRow } from '../src/transformers/google-feed-item-row';
|
|
4
|
-
import { googleElectricRange, googleElectricRangeSource } from '../src/transformers/google-feed-envkv';
|
|
5
|
-
|
|
6
|
-
const exportedAt = new Date('2026-10-08T23:30:00.000Z');
|
|
7
|
-
|
|
8
|
-
const car = (fuel: string, extra: Record<string, any> = {}): any => ({
|
|
9
|
-
_id: 'cbd-1',
|
|
10
|
-
metaData: { vehicleId: 'veh-1' },
|
|
11
|
-
consumption: { fuel },
|
|
12
|
-
engineData: 'hybridPlugin' in extra ? { hybridPlugin: extra.hybridPlugin } : {},
|
|
13
|
-
battery: extra.battery || {},
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
// Shape of what GoogleTransformer returns after dropping the empty optional attributes.
|
|
17
|
-
const item = (extra: Record<string, any> = {}): any => ({
|
|
18
|
-
id: 'cbd-1',
|
|
19
|
-
brand: 'VW',
|
|
20
|
-
model: 'Golf',
|
|
21
|
-
condition: 'Used',
|
|
22
|
-
engine: 'Gasoline',
|
|
23
|
-
price: '19990.5 EUR',
|
|
24
|
-
availability: 'in_stock',
|
|
25
|
-
...extra,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const cases: Array<[string, () => void]> = [
|
|
29
|
-
['combustion car: sent values, omitted attributes are null', () => {
|
|
30
|
-
const row = buildGoogleFeedItemRow(
|
|
31
|
-
car('selector_fuel_petrol', { hybridPlugin: false }),
|
|
32
|
-
item({ fuel_consumption: '5.1l/100km', co2_emissions: '118g/km' }),
|
|
33
|
-
'12345',
|
|
34
|
-
exportedAt,
|
|
35
|
-
);
|
|
36
|
-
assert.deepEqual(row, {
|
|
37
|
-
partitionDay: '2026-10-08',
|
|
38
|
-
exportedAt: '2026-10-08T23:30:00.000Z',
|
|
39
|
-
feed: 'google',
|
|
40
|
-
runId: '12345',
|
|
41
|
-
itemId: 'cbd-1',
|
|
42
|
-
vehicleId: 'veh-1',
|
|
43
|
-
make: 'VW',
|
|
44
|
-
model: 'Golf',
|
|
45
|
-
condition: 'Used',
|
|
46
|
-
fuel: 'selector_fuel_petrol',
|
|
47
|
-
hybridPlugin: false,
|
|
48
|
-
engine: 'Gasoline',
|
|
49
|
-
electricRange: null,
|
|
50
|
-
fuelConsumption: '5.1l/100km',
|
|
51
|
-
fuelConsumptionDischarged: null,
|
|
52
|
-
energyConsumption: null,
|
|
53
|
-
co2Emissions: '118g/km',
|
|
54
|
-
rangeSourceField: null,
|
|
55
|
-
price: 19990.5,
|
|
56
|
-
availability: 'in_stock',
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
'
|
|
65
|
-
|
|
66
|
-
);
|
|
67
|
-
assert.equal(
|
|
68
|
-
assert.equal(
|
|
69
|
-
|
|
70
|
-
assert.equal(
|
|
71
|
-
assert.equal(
|
|
72
|
-
}],
|
|
73
|
-
['
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
assert.equal(googleElectricRangeSource(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
assert.equal(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
]
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
import * as assert from 'node:assert/strict';
|
|
3
|
+
import { buildGoogleFeedItemRow } from '../src/transformers/google-feed-item-row';
|
|
4
|
+
import { googleElectricRange, googleElectricRangeSource } from '../src/transformers/google-feed-envkv';
|
|
5
|
+
|
|
6
|
+
const exportedAt = new Date('2026-10-08T23:30:00.000Z');
|
|
7
|
+
|
|
8
|
+
const car = (fuel: string, extra: Record<string, any> = {}): any => ({
|
|
9
|
+
_id: 'cbd-1',
|
|
10
|
+
metaData: { vehicleId: 'veh-1' },
|
|
11
|
+
consumption: { fuel },
|
|
12
|
+
engineData: 'hybridPlugin' in extra ? { hybridPlugin: extra.hybridPlugin } : {},
|
|
13
|
+
battery: extra.battery || {},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Shape of what GoogleTransformer returns after dropping the empty optional attributes.
|
|
17
|
+
const item = (extra: Record<string, any> = {}): any => ({
|
|
18
|
+
id: 'cbd-1',
|
|
19
|
+
brand: 'VW',
|
|
20
|
+
model: 'Golf',
|
|
21
|
+
condition: 'Used',
|
|
22
|
+
engine: 'Gasoline',
|
|
23
|
+
price: '19990.5 EUR',
|
|
24
|
+
availability: 'in_stock',
|
|
25
|
+
...extra,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const cases: Array<[string, () => void]> = [
|
|
29
|
+
['combustion car: sent values, omitted attributes are null', () => {
|
|
30
|
+
const row = buildGoogleFeedItemRow(
|
|
31
|
+
car('selector_fuel_petrol', { hybridPlugin: false }),
|
|
32
|
+
item({ fuel_consumption: '5.1l/100km', co2_emissions: '118g/km' }),
|
|
33
|
+
'12345',
|
|
34
|
+
exportedAt,
|
|
35
|
+
);
|
|
36
|
+
assert.deepEqual(row, {
|
|
37
|
+
partitionDay: '2026-10-08',
|
|
38
|
+
exportedAt: '2026-10-08T23:30:00.000Z',
|
|
39
|
+
feed: 'google',
|
|
40
|
+
runId: '12345',
|
|
41
|
+
itemId: 'cbd-1',
|
|
42
|
+
vehicleId: 'veh-1',
|
|
43
|
+
make: 'VW',
|
|
44
|
+
model: 'Golf',
|
|
45
|
+
condition: 'Used',
|
|
46
|
+
fuel: 'selector_fuel_petrol',
|
|
47
|
+
hybridPlugin: false,
|
|
48
|
+
engine: 'Gasoline',
|
|
49
|
+
electricRange: null,
|
|
50
|
+
fuelConsumption: '5.1l/100km',
|
|
51
|
+
fuelConsumptionDischarged: null,
|
|
52
|
+
energyConsumption: null,
|
|
53
|
+
co2Emissions: '118g/km',
|
|
54
|
+
rangeSourceField: null,
|
|
55
|
+
price: 19990.5,
|
|
56
|
+
availability: 'in_stock',
|
|
57
|
+
publicationGate: null,
|
|
58
|
+
estimatedAttributes: null,
|
|
59
|
+
});
|
|
60
|
+
}],
|
|
61
|
+
['publication gate: mode and estimated attributes; none estimated is null', () => {
|
|
62
|
+
const petrol = car('selector_fuel_petrol', { hybridPlugin: false });
|
|
63
|
+
const gated = buildGoogleFeedItemRow(petrol, item(), '12345', exportedAt, {
|
|
64
|
+
mode: 'enforce',
|
|
65
|
+
estimatedAttributes: ['fuel_consumption', 'co2_emissions'],
|
|
66
|
+
});
|
|
67
|
+
assert.equal(gated.publicationGate, 'enforce');
|
|
68
|
+
assert.equal(gated.estimatedAttributes, 'fuel_consumption,co2_emissions');
|
|
69
|
+
const clean = buildGoogleFeedItemRow(petrol, item(), '12345', exportedAt, { mode: 'audit', estimatedAttributes: [] });
|
|
70
|
+
assert.equal(clean.publicationGate, 'audit');
|
|
71
|
+
assert.equal(clean.estimatedAttributes, null);
|
|
72
|
+
}],
|
|
73
|
+
['BEV: range source is the field the range came from', () => {
|
|
74
|
+
const bev = car('selector_fuel_electrical', { battery: { batteryRangeElectric: 400 } });
|
|
75
|
+
const row = buildGoogleFeedItemRow(
|
|
76
|
+
bev,
|
|
77
|
+
item({ engine: 'Electric', electric_range: googleElectricRange(bev), energy_consumption: '16.5kWh/100km', co2_emissions: '0g/km' }),
|
|
78
|
+
'12345',
|
|
79
|
+
exportedAt,
|
|
80
|
+
);
|
|
81
|
+
assert.equal(row.electricRange, '400km');
|
|
82
|
+
assert.equal(row.rangeSourceField, 'battery.batteryRangeElectric');
|
|
83
|
+
assert.equal(row.hybridPlugin, null);
|
|
84
|
+
assert.equal(row.energyConsumption, '16.5kWh/100km');
|
|
85
|
+
assert.equal(row.co2Emissions, '0g/km');
|
|
86
|
+
}],
|
|
87
|
+
['plug-in: EAER first, then WLTP range, then delivered range', () => {
|
|
88
|
+
const phev = (battery: object) => car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: true, battery });
|
|
89
|
+
assert.equal(googleElectricRangeSource(phev({ wltpTotalRange: 62, wltpRange: 59 })), 'battery.wltpTotalRange');
|
|
90
|
+
assert.equal(googleElectricRangeSource(phev({ wltpTotalRange: 0, wltpRange: 59 })), 'battery.wltpRange');
|
|
91
|
+
assert.equal(googleElectricRangeSource(phev({ batteryRangeElectric: 50 })), 'battery.batteryRangeElectric');
|
|
92
|
+
assert.equal(googleElectricRangeSource(phev({ wltpTotalRangeCity: 70 })), null);
|
|
93
|
+
}],
|
|
94
|
+
['range source agrees with googleElectricRange', () => {
|
|
95
|
+
const cars = [
|
|
96
|
+
car('selector_fuel_electrical', { battery: { wltpRange: 451, batteryRangeElectric: 400 } }),
|
|
97
|
+
car('selector_fuel_electrical', { battery: { wltpRange: 0 } }),
|
|
98
|
+
car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: false, battery: { wltpRange: 50 } }),
|
|
99
|
+
car('selector_fuel_petrol', { battery: { wltpRange: 50 } }),
|
|
100
|
+
];
|
|
101
|
+
for (const c of cars) {
|
|
102
|
+
assert.equal(googleElectricRangeSource(c) === null, googleElectricRange(c) === undefined);
|
|
103
|
+
}
|
|
104
|
+
assert.equal(googleElectricRangeSource(cars[0]), 'battery.wltpRange');
|
|
105
|
+
}],
|
|
106
|
+
['no range source when the item carries no electric_range', () => {
|
|
107
|
+
const bev = car('selector_fuel_electrical', { battery: { wltpRange: 451 } });
|
|
108
|
+
assert.equal(buildGoogleFeedItemRow(bev, item(), null, exportedAt).rangeSourceField, null);
|
|
109
|
+
}],
|
|
110
|
+
['missing run id, vehicle id and unparsable price become null', () => {
|
|
111
|
+
const row = buildGoogleFeedItemRow(
|
|
112
|
+
{ consumption: {} } as any,
|
|
113
|
+
item({ price: ' EUR', availability: undefined }),
|
|
114
|
+
undefined,
|
|
115
|
+
exportedAt,
|
|
116
|
+
);
|
|
117
|
+
assert.equal(row.runId, null);
|
|
118
|
+
assert.equal(row.vehicleId, null);
|
|
119
|
+
assert.equal(row.fuel, null);
|
|
120
|
+
assert.equal(row.price, null);
|
|
121
|
+
assert.equal(row.availability, null);
|
|
122
|
+
}],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
let failed = 0;
|
|
126
|
+
for (const [name, fn] of cases) {
|
|
127
|
+
try {
|
|
128
|
+
fn();
|
|
129
|
+
console.log(`ok ${name}`);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
failed++;
|
|
132
|
+
console.log(`FAIL ${name}\n ${(e as Error).message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
// Local stub image host — no real network. requireHttps is switched off only for the stub server.
|
|
3
|
+
import * as assert from 'node:assert/strict';
|
|
4
|
+
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
|
5
|
+
import { AddressInfo } from 'net';
|
|
6
|
+
import { ImageUrlRejectReason as R, ImageUrlValidator } from '../src/services/image-url-validator.service';
|
|
7
|
+
|
|
8
|
+
const hits: Record<string, number> = {};
|
|
9
|
+
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
|
10
|
+
|
|
11
|
+
const routes: Record<string, (req: IncomingMessage, res: ServerResponse) => void> = {
|
|
12
|
+
'/ok.jpg': (_, res) => { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); res.end(JPEG); },
|
|
13
|
+
'/ok.png': (_, res) => { res.writeHead(200, { 'Content-Type': 'image/png' }); res.end(); },
|
|
14
|
+
'/photo.webp': (_, res) => { res.writeHead(200, { 'Content-Type': 'image/webp' }); res.end(); },
|
|
15
|
+
'/page.html': (_, res) => { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('<html>'); },
|
|
16
|
+
// images.auto.de answers unknown images exactly like this: 404 with a placeholder JPEG body
|
|
17
|
+
'/missing.jpg': (_, res) => { res.writeHead(404, { 'Content-Type': 'image/jpeg' }); res.end(JPEG); },
|
|
18
|
+
'/forbidden.jpg': (_, res) => { res.writeHead(403, { 'Content-Type': 'text/html' }); res.end('Just a moment...'); },
|
|
19
|
+
'/error.jpg': (_, res) => { res.writeHead(503); res.end(); },
|
|
20
|
+
'/moved.jpg': (_, res) => { res.writeHead(302, { Location: '/ok.jpg' }); res.end(); },
|
|
21
|
+
'/loop.jpg': (_, res) => { res.writeHead(302, { Location: '/loop.jpg' }); res.end(); },
|
|
22
|
+
'/slow.jpg': (_, res) => { setTimeout(() => { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); res.end(); }, 400); },
|
|
23
|
+
'/no-head.jpg': (req, res) => {
|
|
24
|
+
if (req.method === 'HEAD') { res.writeHead(405); res.end(); return; }
|
|
25
|
+
res.writeHead(200, { 'Content-Type': 'image/jpeg' }); res.end(JPEG);
|
|
26
|
+
},
|
|
27
|
+
'/carimage/noimage/7pNvWELuWhZX/large-cached.jpeg': (_, res) => { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); res.end(JPEG); },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const server = createServer((req, res) => {
|
|
31
|
+
const path = req.url.split('?')[0];
|
|
32
|
+
hits[path] = (hits[path] ?? 0) + 1;
|
|
33
|
+
(routes[path] ?? ((_, r) => { r.writeHead(404); r.end(); }))(req, res);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
(async () => {
|
|
37
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
38
|
+
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
39
|
+
const validator = (options = {}) => new ImageUrlValidator({ requireHttps: false, timeoutMs: 150, ...options });
|
|
40
|
+
const expectReject = async (path: string, reason: R, status?: number) => {
|
|
41
|
+
const check = await validator().validate(`${base}${path}`);
|
|
42
|
+
assert.equal(check.ok, false);
|
|
43
|
+
assert.equal(check.reason, reason);
|
|
44
|
+
if (status) assert.equal(check.status, status);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const cases: Array<[string, () => Promise<void>]> = [
|
|
48
|
+
['200 image/jpeg and image/png are valid', async () => {
|
|
49
|
+
assert.equal((await validator().validate(`${base}/ok.jpg`)).ok, true);
|
|
50
|
+
assert.equal((await validator().validate(`${base}/ok.png`)).ok, true);
|
|
51
|
+
}],
|
|
52
|
+
['404 with an image/jpeg placeholder body is rejected on status', () => expectReject('/missing.jpg', R.httpStatus, 404)],
|
|
53
|
+
['403 (WAF / challenge) is rejected', () => expectReject('/forbidden.jpg', R.httpStatus, 403)],
|
|
54
|
+
['5xx is rejected', () => expectReject('/error.jpg', R.httpStatus, 503)],
|
|
55
|
+
['wrong MIME (HTML) is rejected', () => expectReject('/page.html', R.wrongMime)],
|
|
56
|
+
['WebP is rejected unless the partner allows it', async () => {
|
|
57
|
+
await expectReject('/photo.webp', R.wrongMime);
|
|
58
|
+
assert.equal((await validator({ allowedMimeTypes: ['image/webp'] }).validate(`${base}/photo.webp`)).ok, true);
|
|
59
|
+
}],
|
|
60
|
+
['a redirect within the limit is followed and the final URL kept', async () => {
|
|
61
|
+
const check = await validator().validate(`${base}/moved.jpg`);
|
|
62
|
+
assert.equal(check.ok, true);
|
|
63
|
+
assert.equal(check.finalUrl, `${base}/ok.jpg`);
|
|
64
|
+
}],
|
|
65
|
+
['redirect loop is rejected', () => expectReject('/loop.jpg', R.tooManyRedirects)],
|
|
66
|
+
['timeout is rejected', () => expectReject('/slow.jpg', R.timeout)],
|
|
67
|
+
['HEAD not allowed (405) falls back to GET', async () => {
|
|
68
|
+
assert.equal((await validator().validate(`${base}/no-head.jpg`)).ok, true);
|
|
69
|
+
}],
|
|
70
|
+
['placeholder URL is rejected without any request', async () => {
|
|
71
|
+
await expectReject('/carimage/noimage/7pNvWELuWhZX/large-cached.jpeg', R.placeholder);
|
|
72
|
+
assert.equal(hits['/carimage/noimage/7pNvWELuWhZX/large-cached.jpeg'], undefined);
|
|
73
|
+
}],
|
|
74
|
+
['non-HTTPS, credentials and relative URLs are rejected without a request', async () => {
|
|
75
|
+
const strict = new ImageUrlValidator();
|
|
76
|
+
assert.equal((await strict.validate(`${base}/ok.png`)).reason, R.notHttps);
|
|
77
|
+
assert.equal((await strict.validate('https://user:secret@images.auto.de/carimage/a/b/large-cached.jpeg')).reason, R.credentials);
|
|
78
|
+
assert.equal((await strict.validate('/carimage/a/b/large-cached.jpeg')).reason, R.invalidUrl);
|
|
79
|
+
assert.equal((await strict.validate('ftp://images.auto.de/a.jpg')).reason, R.invalidUrl);
|
|
80
|
+
}],
|
|
81
|
+
['each URL is probed once per run (cached) and counted once', async () => {
|
|
82
|
+
const v = validator();
|
|
83
|
+
const before = hits['/ok.jpg'];
|
|
84
|
+
await v.validateAll([`${base}/ok.jpg`, `${base}/ok.jpg`, `${base}/missing.jpg`]);
|
|
85
|
+
await v.validate(`${base}/ok.jpg`);
|
|
86
|
+
assert.equal(hits['/ok.jpg'] - before, 1);
|
|
87
|
+
assert.deepEqual(v.stats, { ok: 1, [R.httpStatus]: 1 });
|
|
88
|
+
}],
|
|
89
|
+
['probe budget: URLs beyond maxProbes are reported, not fetched', async () => {
|
|
90
|
+
const v = validator({ maxProbes: 1 });
|
|
91
|
+
const [first, second] = await v.validateAll([`${base}/ok.jpg`, `${base}/ok.png`]);
|
|
92
|
+
assert.equal(first.ok, true);
|
|
93
|
+
assert.equal(second.reason, R.budgetExhausted);
|
|
94
|
+
}],
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
let failed = 0;
|
|
98
|
+
for (const [name, fn] of cases) {
|
|
99
|
+
try {
|
|
100
|
+
await fn();
|
|
101
|
+
console.log(`ok ${name}`);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
failed += 1;
|
|
104
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
(server as any).closeAllConnections?.(); // Node ≥18.2; @types/node here is older
|
|
108
|
+
server.close();
|
|
109
|
+
if (failed) process.exit(1);
|
|
110
|
+
})();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
// ADV-336 regression lock: the image URLs every existing partner export (Google, Meta-marketing,
|
|
3
|
+
// default partners) builds must stay byte-identical. images.golden.json was recorded from the
|
|
4
|
+
// ImagesService BEFORE the clean profile was added — never regenerate it to make this test pass.
|
|
5
|
+
import * as assert from 'node:assert/strict';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { snapshotExistingImageOutputs } from './fixtures/images-service.harness';
|
|
8
|
+
|
|
9
|
+
const golden = JSON.parse(readFileSync(`${__dirname}/fixtures/images.golden.json`, 'utf8'));
|
|
10
|
+
|
|
11
|
+
snapshotExistingImageOutputs().then((actual) => {
|
|
12
|
+
let failed = 0;
|
|
13
|
+
for (const name of Object.keys(golden)) {
|
|
14
|
+
try {
|
|
15
|
+
assert.deepEqual(actual[name], golden[name]);
|
|
16
|
+
console.log(`ok existing image outputs unchanged: ${name}`);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
failed += 1;
|
|
19
|
+
console.error(`FAIL existing image outputs changed: ${name}\n ${e.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (failed) process.exit(1);
|
|
23
|
+
});
|