1
2
3import assert from 'node:assert/strict';
4import { after, describe, it } from 'node:test';
5import { brotliCompressSync, gzipSync } from 'node:zlib';
6
7import { iterateCatalog, type CatalogStats } from '../src/catalog.js';
8import { detectStore } from '../src/detect.js';
9import { DomainClient } from '../src/http.js';
10import { normalizeInput } from '../src/normalize.js';
11import type { NormalizedCandidate } from '../src/normalize.js';
12import type { ShopifyProduct } from '../src/types.js';
13import { html, json, makeProduct, shopifyStoreHandler, startServer, type FakeStore } from './helpers/server.js';
14
15const openStores: FakeStore[] = [];
16const openClients: DomainClient[] = [];
17
18after(async () => {
19 await Promise.all(openClients.map((client) => client.destroy()));
20 await Promise.all(openStores.map((store) => store.close()));
21});
22
23function candidateFor(store: FakeStore): NormalizedCandidate {
24 const result = normalizeInput(store.origin);
25 assert.equal(result.ok, true);
26 return (result as { ok: true; candidate: NormalizedCandidate }).candidate;
27}
28
29function clientFor(store: FakeStore): DomainClient {
30 const client = new DomainClient({ host: store.host, delayMs: 0, timeoutMs: 5_000, proxyUrl: null });
31 openClients.push(client);
32 return client;
33}
34
35async function serve(handler: Parameters<typeof startServer>[0]): Promise<FakeStore> {
36 const store = await startServer(handler);
37 openStores.push(store);
38 return store;
39}
40
41describe('detectStore', () => {
42 it('detects a healthy store via /products.json and reuses the first page', async () => {
43 const store = await serve(shopifyStoreHandler([[makeProduct(1), makeProduct(2)]]));
44 const result = await detectStore(clientFor(store), candidateFor(store));
45
46 assert.equal(result.status, 'ok');
47 assert.equal(result.detectionMethod, 'products.json');
48 assert.equal(result.catalogPath, '/products.json');
49 assert.equal(result.firstPage?.length, 2);
50 assert.equal(result.currency, 'USD');
51 });
52
53 it('reads gzip- and brotli-compressed responses', async () => {
54 for (const encoding of ['gzip', 'br'] as const) {
55 const store = await serve((_req, res, url) => {
56 const send = (body: unknown) => {
57 const raw = Buffer.from(JSON.stringify(body));
58 const compressed = encoding === 'gzip' ? gzipSync(raw) : brotliCompressSync(raw);
59 res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': encoding });
60 res.end(compressed);
61 };
62 if (url.pathname === '/products.json') return send({ products: [makeProduct(1)] });
63 if (url.pathname === '/cart.js') return send({ token: 't', currency: 'CAD' });
64 return html(res, 200, '<html>x</html>');
65 });
66
67 const result = await detectStore(clientFor(store), candidateFor(store));
68 assert.equal(result.status, 'ok', `${encoding} response should parse`);
69 assert.equal(result.firstPage?.length, 1);
70 assert.equal(result.currency, 'CAD');
71 }
72 });
73
74 it('falls back to /collections/all/products.json when the root feed is closed', async () => {
75 const store = await serve((_req, res, url) => {
76 if (url.pathname === '/products.json') return html(res, 404, 'nope');
77 if (url.pathname === '/collections/all/products.json') return json(res, 200, { products: [makeProduct(1)] });
78 if (url.pathname === '/cart.js') return json(res, 200, { token: 't', currency: 'EUR' });
79 return html(res, 200, '<html>store</html>');
80 });
81
82 const result = await detectStore(clientFor(store), candidateFor(store));
83 assert.equal(result.status, 'ok');
84 assert.equal(result.detectionMethod, 'collections/all/products.json');
85 assert.equal(result.currency, 'EUR');
86 });
87
88 it('falls back to the requested origin when the redirect target has no catalog', async () => {
89
90 const wwwStore = await serve((_req, res, url) => {
91 if (url.pathname.endsWith('products.json')) return html(res, 404, 'not found');
92 return html(res, 200, '<html><script src="https://cdn.shopify.com/x.js"></script></html>');
93 });
94 const apexStore = await serve((_req, res, url) => {
95 if (url.pathname === '/') {
96 res.writeHead(301, { location: `${wwwStore.origin}/` });
97 return res.end();
98 }
99 if (url.pathname === '/products.json') return json(res, 200, { products: [makeProduct(1)] });
100 if (url.pathname === '/cart.js') return json(res, 200, { token: 't', currency: 'USD' });
101 return html(res, 404, 'no');
102 });
103
104 const result = await detectStore(clientFor(apexStore), candidateFor(apexStore));
105 assert.equal(result.status, 'ok');
106 assert.equal(result.origin, apexStore.origin);
107 assert.equal(result.host, apexStore.host);
108 assert.equal(result.firstPage?.length, 1);
109 });
110
111 it('keeps the requested host as the identity when a geo-redirect target also serves a catalog', async () => {
112
113
114 const geoStore = await serve(shopifyStoreHandler([[makeProduct(1), makeProduct(2)]]));
115 const apexStore = await serve((_req, res, url) => {
116 if (url.pathname === '/') {
117 res.writeHead(301, { location: `${geoStore.origin}/` });
118 return res.end();
119 }
120 if (url.pathname === '/products.json') return json(res, 200, { products: [makeProduct(1)] });
121 if (url.pathname === '/cart.js') return json(res, 200, { token: 't', currency: 'USD' });
122 return html(res, 404, 'no');
123 });
124
125 const result = await detectStore(clientFor(apexStore), candidateFor(apexStore));
126 assert.equal(result.status, 'ok');
127 assert.equal(result.host, apexStore.host, 'state key should stay on the requested host');
128 assert.equal(result.resolvedHost, geoStore.host, 'redirect target is still reported for deduplication');
129 });
130
131 it('follows a redirect on the catalog endpoint itself', async () => {
132 const store = await serve((_req, res, url) => {
133 if (url.pathname === '/products.json') {
134 res.writeHead(302, { location: '/en-us/products.json?limit=250&page=1' });
135 return res.end();
136 }
137 if (url.pathname === '/en-us/products.json') return json(res, 200, { products: [makeProduct(1)] });
138 if (url.pathname === '/cart.js') return json(res, 200, { token: 't', currency: 'USD' });
139 return html(res, 200, '<html>x</html>');
140 });
141
142 const result = await detectStore(clientFor(store), candidateFor(store));
143 assert.equal(result.status, 'ok');
144 assert.equal(result.firstPage?.length, 1);
145 });
146
147 it('reports catalog_restricted — not not_shopify — when only /meta.json answers', async () => {
148 const store = await serve((_req, res, url) => {
149 if (url.pathname === '/meta.json') return json(res, 200, { id: 999, name: 'Locked Shop' });
150 if (url.pathname.endsWith('products.json')) return html(res, 404, 'not found');
151 return html(res, 200, '<html>plain page</html>');
152 });
153
154 const result = await detectStore(clientFor(store), candidateFor(store));
155 assert.equal(result.status, 'catalog_restricted');
156 assert.equal(result.detectionMethod, 'meta.json');
157 assert.equal(result.shopId, 999);
158 assert.match(result.message, /store setting, not an Actor error/);
159 });
160
161 it('detects a store from /cart.js alone and keeps its currency', async () => {
162 const store = await serve((_req, res, url) => {
163 if (url.pathname === '/cart.js') return json(res, 200, { token: 'abc', currency: 'GBP' });
164 if (url.pathname.endsWith('products.json')) return html(res, 403, 'denied');
165 if (url.pathname === '/meta.json') return html(res, 404, 'no');
166 return html(res, 200, '<html>plain</html>');
167 });
168
169 const result = await detectStore(clientFor(store), candidateFor(store));
170 assert.equal(result.status, 'catalog_restricted');
171 assert.equal(result.detectionMethod, 'cart.js');
172 assert.equal(result.currency, 'GBP');
173 });
174
175 it('detects a store from response headers', async () => {
176 const store = await serve((_req, res, url) => {
177 if (url.pathname === '/') return html(res, 200, '<html>plain</html>', { 'x-shopid': '4242' });
178 return html(res, 404, 'no');
179 });
180
181 const result = await detectStore(clientFor(store), candidateFor(store));
182 assert.equal(result.status, 'catalog_restricted');
183 assert.equal(result.detectionMethod, 'response_headers');
184 });
185
186 it('detects a store from Shopify cookies', async () => {
187 const store = await serve((_req, res, url) => {
188 if (url.pathname === '/') {
189 return html(res, 200, '<html>plain</html>', { 'set-cookie': ['foo=bar; Path=/', '_shopify_y=xyz; Path=/'] });
190 }
191 return html(res, 404, 'no');
192 });
193
194 const result = await detectStore(clientFor(store), candidateFor(store));
195 assert.equal(result.status, 'catalog_restricted');
196 assert.equal(result.detectionMethod, 'cookies');
197 });
198
199 it('detects a store from HTML markers', async () => {
200 const store = await serve((_req, res, url) => {
201 if (url.pathname === '/') return html(res, 200, '<html><script>var x = Shopify.shop;</script></html>');
202 return html(res, 404, 'no');
203 });
204
205 const result = await detectStore(clientFor(store), candidateFor(store));
206 assert.equal(result.status, 'catalog_restricted');
207 assert.equal(result.detectionMethod, 'html_markers');
208 });
209
210 it('reports password_protected when the storefront redirects to /password', async () => {
211 const store = await serve((_req, res, url) => {
212 if (url.pathname === '/') {
213 res.writeHead(302, { location: '/password' });
214 return res.end();
215 }
216 if (url.pathname === '/password') {
217 return html(res, 200, '<html><script src="https://cdn.shopify.com/x.js"></script></html>');
218 }
219 return html(res, 404, 'no');
220 });
221
222 const result = await detectStore(clientFor(store), candidateFor(store));
223 assert.equal(result.status, 'password_protected');
224 });
225
226 it('reports not_shopify for an ordinary website', async () => {
227 const store = await serve((_req, res, url) => {
228 if (url.pathname === '/') return html(res, 200, '<html><body>Just a website</body></html>');
229 return html(res, 404, 'no');
230 });
231
232 const result = await detectStore(clientFor(store), candidateFor(store));
233 assert.equal(result.status, 'not_shopify');
234 assert.equal(result.detectionMethod, null);
235 });
236
237 it('reports unreachable for a host that refuses connections', async () => {
238
239 const store = await startServer((_req, res) => html(res, 200, 'x'));
240 await store.close();
241
242 const client = new DomainClient({ host: store.host, delayMs: 0, timeoutMs: 2_000, proxyUrl: null });
243 openClients.push(client);
244 const result = await detectStore(client, candidateFor(store));
245
246 assert.equal(result.status, 'unreachable');
247 assert.ok(result.networkError);
248 });
249
250 it('follows redirects to another host and adopts the final origin', async () => {
251 const target = await serve(shopifyStoreHandler([[makeProduct(1)]]));
252 const source = await serve((_req, res) => {
253 res.writeHead(301, { location: `${target.origin}/` });
254 res.end();
255 });
256
257 const result = await detectStore(clientFor(source), candidateFor(source));
258 assert.equal(result.host, target.host);
259 assert.equal(result.origin, target.origin);
260 assert.equal(result.status, 'ok');
261 });
262});
263
264describe('iterateCatalog', () => {
265 function freshStats(): CatalogStats {
266 return { productsSeen: 0, pagesFetched: 0, truncated: false, warnings: [] };
267 }
268
269 async function collect(iter: AsyncGenerator<ShopifyProduct[]>): Promise<ShopifyProduct[]> {
270 const all: ShopifyProduct[] = [];
271 for await (const page of iter) all.push(...page);
272 return all;
273 }
274
275 it('paginates until the products array comes back empty', async () => {
276 const page1 = Array.from({ length: 250 }, (_, i) => makeProduct(i + 1));
277 const page2 = Array.from({ length: 250 }, (_, i) => makeProduct(i + 251));
278 const page3 = [makeProduct(999)];
279 const store = await serve(shopifyStoreHandler([page1, page2, page3]));
280 const stats = freshStats();
281
282 const products = await collect(iterateCatalog(clientFor(store), store.origin, '/products.json', page1, 5_000, stats));
283
284 assert.equal(products.length, 501);
285 assert.equal(stats.productsSeen, 501);
286 assert.equal(stats.truncated, false);
287 assert.equal(stats.pagesFetched, 3);
288 });
289
290 it('stops at maxProductsPerStore and flags truncation', async () => {
291 const page1 = Array.from({ length: 250 }, (_, i) => makeProduct(i + 1));
292 const page2 = Array.from({ length: 250 }, (_, i) => makeProduct(i + 251));
293 const store = await serve(shopifyStoreHandler([page1, page2, []]));
294 const stats = freshStats();
295
296 const products = await collect(iterateCatalog(clientFor(store), store.origin, '/products.json', page1, 300, stats));
297
298 assert.equal(products.length, 300);
299 assert.equal(stats.truncated, true);
300 });
301
302 it('handles an empty catalog without error', async () => {
303 const store = await serve(shopifyStoreHandler([[]]));
304 const stats = freshStats();
305 const products = await collect(iterateCatalog(clientFor(store), store.origin, '/products.json', [], 5_000, stats));
306 assert.equal(products.length, 0);
307 assert.equal(stats.truncated, false);
308 });
309
310 it('skips products that vanished mid-pagination instead of crashing', async () => {
311 const page1 = [makeProduct(1), null as unknown as ShopifyProduct, makeProduct(2)];
312 const store = await serve(shopifyStoreHandler([page1]));
313 const stats = freshStats();
314 const products = await collect(iterateCatalog(clientFor(store), store.origin, '/products.json', page1, 5_000, stats));
315 assert.equal(products.length, 2);
316 assert.ok(stats.warnings.some((w) => w.includes('malformed')));
317 });
318
319 it('stops when a store ignores ?page= and keeps serving page 1', async () => {
320 const page1 = Array.from({ length: 250 }, (_, i) => makeProduct(i + 1));
321 const store = await serve((_req, res, url) => {
322 if (url.pathname === '/products.json') return json(res, 200, { products: page1 });
323 return html(res, 200, 'x');
324 });
325 const stats = freshStats();
326
327 const products = await collect(iterateCatalog(clientFor(store), store.origin, '/products.json', page1, 5_000, stats));
328
329 assert.equal(products.length, 250);
330 assert.ok(stats.warnings.some((w) => w.includes('pagination appears unsupported')));
331 });
332});