Skip to content

Commit b59806c

Browse files
committed
feat(HNT-2086): validate inbound crawl messages and the publisher list
1 parent 5da2693 commit b59806c

4 files changed

Lines changed: 534 additions & 0 deletions

File tree

packages/crawl-common/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,10 @@ export type {
2121
LiveArticle,
2222
PublisherList,
2323
} from './types/index.js';
24+
export {
25+
MessageValidationError,
26+
validateCrawlArticleMessage,
27+
validateCrawlArticleDiscoveryMessage,
28+
validateLiveArticle,
29+
validatePublisherList,
30+
} from './validation/index.js';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export {
2+
MessageValidationError,
3+
validateCrawlArticleMessage,
4+
validateCrawlArticleDiscoveryMessage,
5+
validateLiveArticle,
6+
validatePublisherList,
7+
} from './messages.js';
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
MessageValidationError,
4+
validateCrawlArticleMessage,
5+
validateCrawlArticleDiscoveryMessage,
6+
validatePublisherList,
7+
} from './messages.js';
8+
9+
/** Return a shallow copy of obj without the given key. */
10+
function omit<T extends object>(obj: T, key: keyof T): Partial<T> {
11+
const copy = { ...obj };
12+
delete copy[key];
13+
return copy;
14+
}
15+
16+
const VALID_ARTICLE = {
17+
url: 'https://example.com/news/article-1',
18+
source_url: 'https://example.com/news',
19+
crawl_id: 'crawl-001',
20+
enqueued_at: '2025-06-01T12:00:00Z',
21+
};
22+
23+
const VALID_CORPUS_ITEM = {
24+
external_id: 'ext-123',
25+
title: 'Original Headline',
26+
excerpt: 'Original excerpt text',
27+
authors: [{ name: 'Jane Doe' }],
28+
status: 'CORPUS',
29+
language: 'EN',
30+
publisher: 'Example News',
31+
image_url: 'https://s3.amazonaws.com/image.jpg',
32+
topic: 'TECHNOLOGY',
33+
is_time_sensitive: false,
34+
};
35+
36+
const VALID_DISCOVERY = {
37+
url: 'https://example.com/news',
38+
interval_minutes: 20,
39+
contexts: [{ surface_id: 'NEW_TAB_EN_US', topic: 'technology' }],
40+
};
41+
42+
describe('validateCrawlArticleMessage', () => {
43+
it('accepts a discovered-article message without corpus_item', () => {
44+
expect(validateCrawlArticleMessage(VALID_ARTICLE)).toEqual(VALID_ARTICLE);
45+
});
46+
47+
it('accepts a live-article message with a full corpus_item', () => {
48+
const message = { ...VALID_ARTICLE, corpus_item: VALID_CORPUS_ITEM };
49+
expect(validateCrawlArticleMessage(message)).toEqual(message);
50+
});
51+
52+
it('keeps a blank excerpt, which curators may leave empty', () => {
53+
const message = {
54+
...VALID_ARTICLE,
55+
corpus_item: { ...VALID_CORPUS_ITEM, excerpt: '' },
56+
};
57+
expect(validateCrawlArticleMessage(message).corpus_item?.excerpt).toBe('');
58+
});
59+
60+
it('keeps a blank publisher, which the Corpus DB may store empty', () => {
61+
const message = {
62+
...VALID_ARTICLE,
63+
corpus_item: { ...VALID_CORPUS_ITEM, publisher: '' },
64+
};
65+
expect(validateCrawlArticleMessage(message).corpus_item?.publisher).toBe(
66+
'',
67+
);
68+
});
69+
70+
it.each([null, undefined, 42, 'string', []])(
71+
'rejects a non-object payload (%s)',
72+
(raw) => {
73+
expect(() => validateCrawlArticleMessage(raw)).toThrow(
74+
MessageValidationError,
75+
);
76+
},
77+
);
78+
79+
it.each(['url', 'source_url', 'crawl_id', 'enqueued_at'] as const)(
80+
'rejects a missing %s',
81+
(field) => {
82+
expect(() =>
83+
validateCrawlArticleMessage(omit(VALID_ARTICLE, field)),
84+
).toThrow(new RegExp(field));
85+
},
86+
);
87+
88+
it('rejects an empty url', () => {
89+
expect(() =>
90+
validateCrawlArticleMessage({ ...VALID_ARTICLE, url: ' ' }),
91+
).toThrow(/url must be a non-empty string/);
92+
});
93+
94+
it('rejects a non-string crawl_id', () => {
95+
expect(() =>
96+
validateCrawlArticleMessage({ ...VALID_ARTICLE, crawl_id: 7 }),
97+
).toThrow(/crawl_id/);
98+
});
99+
100+
it('rejects a corpus_item missing a required field', () => {
101+
const corpus = omit(VALID_CORPUS_ITEM, 'topic');
102+
expect(() =>
103+
validateCrawlArticleMessage({ ...VALID_ARTICLE, corpus_item: corpus }),
104+
).toThrow(/corpus_item.topic/);
105+
});
106+
107+
it('rejects a corpus_item with a non-boolean is_time_sensitive', () => {
108+
expect(() =>
109+
validateCrawlArticleMessage({
110+
...VALID_ARTICLE,
111+
corpus_item: { ...VALID_CORPUS_ITEM, is_time_sensitive: 'no' },
112+
}),
113+
).toThrow(/is_time_sensitive must be a boolean/);
114+
});
115+
116+
it('rejects a corpus_item author without a name', () => {
117+
expect(() =>
118+
validateCrawlArticleMessage({
119+
...VALID_ARTICLE,
120+
corpus_item: { ...VALID_CORPUS_ITEM, authors: [{}] },
121+
}),
122+
).toThrow(/authors\[0\].name/);
123+
});
124+
125+
it('accepts and preserves a positive refresh_interval_minutes', () => {
126+
const message = { ...VALID_ARTICLE, refresh_interval_minutes: 20 };
127+
expect(validateCrawlArticleMessage(message)).toEqual(message);
128+
});
129+
130+
it('omits refresh_interval_minutes when absent (older messages)', () => {
131+
const result = validateCrawlArticleMessage(VALID_ARTICLE);
132+
expect(result.refresh_interval_minutes).toBeUndefined();
133+
});
134+
135+
it('rejects a non-numeric refresh_interval_minutes', () => {
136+
expect(() =>
137+
validateCrawlArticleMessage({
138+
...VALID_ARTICLE,
139+
refresh_interval_minutes: '20',
140+
}),
141+
).toThrow(/refresh_interval_minutes/);
142+
});
143+
144+
it.each([0, -5])(
145+
'rejects a non-positive refresh_interval_minutes (%s)',
146+
(value) => {
147+
expect(() =>
148+
validateCrawlArticleMessage({
149+
...VALID_ARTICLE,
150+
refresh_interval_minutes: value,
151+
}),
152+
).toThrow(/refresh_interval_minutes must be a positive number/);
153+
},
154+
);
155+
});
156+
157+
describe('validateCrawlArticleDiscoveryMessage', () => {
158+
it('accepts a well-formed discovery message', () => {
159+
expect(validateCrawlArticleDiscoveryMessage(VALID_DISCOVERY)).toEqual(
160+
VALID_DISCOVERY,
161+
);
162+
});
163+
164+
it('rejects a missing interval_minutes', () => {
165+
expect(() =>
166+
validateCrawlArticleDiscoveryMessage(
167+
omit(VALID_DISCOVERY, 'interval_minutes'),
168+
),
169+
).toThrow(/interval_minutes must be a finite number/);
170+
});
171+
172+
it('rejects a non-numeric interval_minutes', () => {
173+
expect(() =>
174+
validateCrawlArticleDiscoveryMessage({
175+
...VALID_DISCOVERY,
176+
interval_minutes: '20',
177+
}),
178+
).toThrow(/interval_minutes/);
179+
});
180+
181+
it.each([0, -5])('rejects a non-positive interval_minutes (%s)', (value) => {
182+
expect(() =>
183+
validateCrawlArticleDiscoveryMessage({
184+
...VALID_DISCOVERY,
185+
interval_minutes: value,
186+
}),
187+
).toThrow(/interval_minutes must be a positive number/);
188+
});
189+
190+
it('rejects an empty contexts array', () => {
191+
expect(() =>
192+
validateCrawlArticleDiscoveryMessage({
193+
...VALID_DISCOVERY,
194+
contexts: [],
195+
}),
196+
).toThrow(/contexts must not be empty/);
197+
});
198+
199+
it('rejects a context missing surface_id', () => {
200+
expect(() =>
201+
validateCrawlArticleDiscoveryMessage({
202+
...VALID_DISCOVERY,
203+
contexts: [{ topic: 'technology' }],
204+
}),
205+
).toThrow(/contexts\[0\].surface_id/);
206+
});
207+
});
208+
209+
describe('validatePublisherList', () => {
210+
const VALID_LIST = {
211+
pages: [VALID_DISCOVERY],
212+
live_articles: [{ url: VALID_ARTICLE.url, corpus_item: VALID_CORPUS_ITEM }],
213+
};
214+
215+
it('accepts a well-formed publisher list', () => {
216+
expect(validatePublisherList(VALID_LIST)).toEqual(VALID_LIST);
217+
});
218+
219+
it('accepts empty page and live-article lists', () => {
220+
expect(validatePublisherList({ pages: [], live_articles: [] })).toEqual({
221+
pages: [],
222+
live_articles: [],
223+
});
224+
});
225+
226+
it('rejects a non-array pages field', () => {
227+
expect(() =>
228+
validatePublisherList({ pages: {}, live_articles: [] }),
229+
).toThrow(/pages must be an array/);
230+
});
231+
232+
it('rejects an invalid page entry', () => {
233+
expect(() =>
234+
validatePublisherList({
235+
pages: [omit(VALID_DISCOVERY, 'url')],
236+
live_articles: [],
237+
}),
238+
).toThrow(/url must be a non-empty string/);
239+
});
240+
241+
it('rejects duplicate page URLs', () => {
242+
expect(() =>
243+
validatePublisherList({
244+
pages: [VALID_DISCOVERY, VALID_DISCOVERY],
245+
live_articles: [],
246+
}),
247+
).toThrow(/pages has a duplicate url/);
248+
});
249+
250+
it('rejects duplicate live-article URLs', () => {
251+
const entry = { url: VALID_ARTICLE.url, corpus_item: VALID_CORPUS_ITEM };
252+
expect(() =>
253+
validatePublisherList({ pages: [], live_articles: [entry, entry] }),
254+
).toThrow(/live_articles has a duplicate url/);
255+
});
256+
257+
it('rejects a live article with a malformed corpus_item', () => {
258+
expect(() =>
259+
validatePublisherList({
260+
pages: [],
261+
live_articles: [
262+
{
263+
url: VALID_ARTICLE.url,
264+
corpus_item: omit(VALID_CORPUS_ITEM, 'topic'),
265+
},
266+
],
267+
}),
268+
).toThrow(/corpus_item.topic/);
269+
});
270+
});

0 commit comments

Comments
 (0)