diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6cde22d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Run Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Run article utility tests + run: node src/lib/articleUtils.test.js + + - name: Run feed utility tests + run: node src/lib/feedUtils.test.js diff --git a/.gitignore b/.gitignore index 8841251..11feeab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ aws-config.json .env dump.rdb npm-debug.log -lib +/lib diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..6a46441 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,103 @@ +# Testing Guide for Feed Processing Functions + +This document describes the refactored, testable feed processing utilities and how to run tests. + +## Overview + +The feed processing logic has been extracted into small, testable functions in `src/lib/`: + +- **`articleUtils.js`** - Pure functions for article hashing and scoring (no external dependencies) +- **`feedUtils.js`** - Utility functions for feed processing (headers, Redis keys, validation, etc.) + +## Running Tests + +```bash +node src/lib/feedUtils.test.js +``` + +All tests use the test data in `testdata/test-cases.json` which contains expected inputs and outputs generated from the actual Node.js implementation. + +## Test Coverage + +### Article Functions (`articleUtils.js`) + +1. **`hash(article)`** - MD5 hash of article GUID + - Tests: 3 test cases verifying hash consistency + - Implementation matches `src/articles.js` exactly + +2. **`score(article)`** - Unix timestamp score + - Tests: 3 test cases with different date field names (pubDate, pubdate, date) + - Implementation matches `src/articles.js` exactly + +### Feed Functions (`feedUtils.js`) + +1. **`buildRequestHeaders(storedFeed)`** - Builds HTTP headers for conditional GET + - Tests: 4 test cases (no headers, If-Modified-Since, If-None-Match, both) + +2. **`buildRedisKeys(feedURI)`** - Creates Redis key names + - Tests: 2 test cases with different feed URLs + +3. **`buildArticleKey(hash)`** - Creates article key for Redis sorted set + - Tests: 1 test case verifying format + +4. **`processArticle(article, feedURI, hashFn, scoreFn)`** - Adds computed fields + - Tests: 1 test case verifying hash, score, and feedurl are added + +5. **`shouldStoreArticle(oldScore, newScore)`** - Determines if article needs S3 storage + - Tests: 4 test cases (new article, changed score, unchanged score, type coercion) + +6. **`isValidArticle(article)`** - Validates article has required fields + - Tests: 4 test cases (valid, missing guid, missing description, null) + +7. **`extractFeedMetadata(meta)`** - Extracts title and link from parser meta + - Tests: 1 test case + +8. **`extractArticleIds(articleKeys)`** - Strips "article:" prefix from Redis keys + - Tests: 1 test case + +## Test Data Format + +The `testdata/test-cases.json` file contains test cases organized by function: + +```json +{ + "hash_function_tests": [...], + "score_function_tests": [...], + "request_headers_tests": [...], + ... +} +``` + +Each test case has: +- `description` - Human-readable test description +- `input` - Input value(s) for the function +- `expected` - Expected output value + +## Adding New Tests + +1. Add test data to `testdata/test-cases.json` +2. Add corresponding test code in `src/lib/feedUtils.test.js` +3. Run tests to verify + +## Future Work + +Next steps: +1. Refactor `src/feeds.js` to use these utility functions +2. Add integration tests for Redis and S3 operations +3. Create Go implementation with matching behavior (in `feedfetcher/` directory) +4. Create Go tests that use the same `testdata/test-cases.json` file + +## Why These Functions? + +These functions were extracted because they are: +1. **Pure or nearly pure** - Deterministic output for given input +2. **Core business logic** - Critical for feed processing correctness +3. **Reusable** - Can be used by both Node.js and Go implementations +4. **Independently testable** - No mocking of Redis/S3 needed + +The goal is to ensure both Node.js and Go implementations produce identical results for: +- Article hashing (critical for deduplication) +- Article scoring (critical for sorting) +- Request headers (critical for conditional GET optimization) +- Redis key naming (critical for data storage) +- S3 storage decisions (critical for performance) diff --git a/package.json b/package.json index e63ffcd..0760348 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "eslint-config-airbnb": "^11.1.0", "eslint-plugin-import": "^1.15.0", "eslint-plugin-jsx-a11y": "^2.2.2", - "eslint-plugin-react": "^6.2.0" + "eslint-plugin-react": "^6.2.0", + "js-yaml": "^4.1.0" } } diff --git a/src/articles.js b/src/articles.js index b1b8c60..2347dbf 100644 --- a/src/articles.js +++ b/src/articles.js @@ -1,16 +1,11 @@ -import crypto from 'crypto'; import AWS from 'aws-sdk'; import labels from './labels'; +// Import hash and score functions from testable utilities +import { hash as hashArticle, score as scoreArticle } from './lib/articleUtils.js'; -export function hash(article) { - return crypto.createHash('md5').update(article.guid).digest('hex'); -} - -export function score(article) { - const articleDate = article.pubDate || article.pubdate || article.date; - const articleScore = Date.parse(articleDate) || Date.now(); - return articleScore; -} +// Re-export for backward compatibility +export const hash = hashArticle; +export const score = scoreArticle; function post(req, res) { res.json({ diff --git a/src/feeds.js b/src/feeds.js index 05d87a5..2c3230b 100644 --- a/src/feeds.js +++ b/src/feeds.js @@ -3,6 +3,16 @@ import FeedParser from 'feedparser'; import request from 'request'; import AWS from 'aws-sdk'; import { hash, score } from './articles'; +import { + buildRequestHeaders, + buildRedisKeys, + buildArticleKey, + processArticle, + shouldStoreArticle, + isValidArticle, + extractArticleIds, + generateArticleBody, +} from './lib/feedUtils.js'; const redisURL = process.env.REDIS_URL; const redisClient = redis.createClient(redisURL); @@ -83,7 +93,7 @@ function get(req, res) { const feed = storedFeed; feed.key = feedurl; feeds.push(feed); - const articleIDs = articles.map(key => key.substr(8)); + const articleIDs = extractArticleIds(articles); if (feedurlPosition === feedurls.length - 1) { res.json({ success: true, @@ -111,17 +121,12 @@ const feed = { const params = { Bucket: 'feedreader2018-articles' }; const s3 = new AWS.S3({ params }); const feedURI = decodeURIComponent(req.url.slice(10)); - const feedKey = `feed:${feedURI}`; - const articlesKey = `articles:${feedURI}`; + const { feedKey, articlesKey } = buildRedisKeys(feedURI); redisClient.hgetall(feedKey, (e, storedFeed) => { let fetchedFeed = {}; if ((!e) && storedFeed) fetchedFeed = storedFeed; - const headers = { - 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36', - }; - if (fetchedFeed.lastModified) headers['If-Modified-Since'] = fetchedFeed.lastModified; - if (fetchedFeed.etag) headers['If-None-Match'] = fetchedFeed.etag; + const headers = buildRequestHeaders(fetchedFeed); const requ = request({ uri: feedURI, @@ -187,16 +192,14 @@ const feed = { const stream = this; for (;;) { const article = stream.read(); - if (!article || !article.guid || !article.description) { + if (!isValidArticle(article)) { return; } - article.hash = hash(article); - article.score = score(article); - article.feedurl = feedURI; - const key = article.hash; - const rank = article.score; - const articleKey = `article:${key}`; + const processedArticle = processArticle(article, feedURI, hash, score); + const key = processedArticle.hash; + const rank = processedArticle.score; + const articleKey = buildArticleKey(key); redisClient.zscore(articlesKey, articleKey, (zscoreErr, oldscore) => { if (zscoreErr) { @@ -211,9 +214,9 @@ const feed = { articleAddErr.type = 'Redis Error'; articleAddErr.log = zaddErr.message; stream.emit('error', articleAddErr); - } else if ((oldscore === null) || (rank !== parseInt(oldscore))) { + } else if (shouldStoreArticle(oldscore, rank)) { // Only stringify when we actually need to store it - const body = JSON.stringify(article); + const body = generateArticleBody(processedArticle); s3.putObject({ Key: key, Body: body, @@ -245,7 +248,7 @@ const feed = { }); } else { fetchedFeed.success = true; - fetchedFeed.articles = allArticles.map(key => key.substr(8)); + fetchedFeed.articles = extractArticleIds(allArticles); res.json(fetchedFeed); } }); diff --git a/src/lib/articleUtils.js b/src/lib/articleUtils.js new file mode 100644 index 0000000..771d8bd --- /dev/null +++ b/src/lib/articleUtils.js @@ -0,0 +1,28 @@ +// Pure utility functions for article processing (no external dependencies) +// These can be tested without AWS or Redis + +const crypto = require('crypto'); + +/** + * Generates MD5 hash of article GUID + * Reference: api/src/articles.js hash() function + * @param {Object} article - Article object with guid field + * @returns {string} MD5 hash in hex format + */ +function hash(article) { + return crypto.createHash('md5').update(article.guid).digest('hex'); +} + +/** + * Generates score (timestamp) for article + * Reference: api/src/articles.js score() function + * @param {Object} article - Article object with date fields + * @returns {number} Unix timestamp in milliseconds + */ +function score(article) { + const articleDate = article.pubDate || article.pubdate || article.date; + const articleScore = Date.parse(articleDate) || Date.now(); + return articleScore; +} + +module.exports = { hash, score }; diff --git a/src/lib/articleUtils.test.js b/src/lib/articleUtils.test.js new file mode 100644 index 0000000..79c7759 --- /dev/null +++ b/src/lib/articleUtils.test.js @@ -0,0 +1,63 @@ +// Tests for article utility functions (hash and score) +// Run with: node src/lib/articleUtils.test.js + +const { hash, score } = require('./articleUtils.js'); +const fs = require('fs'); +const yaml = require('js-yaml'); +const assert = require('assert'); + +// Load test cases from YAML +const testCasesYaml = fs.readFileSync('./testdata/test-cases.yaml', 'utf8'); +const testCases = yaml.load(testCasesYaml); + +// Simple test runner +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`✓ ${name}`); + } catch (error) { + failed++; + console.error(`✗ ${name}`); + console.error(` ${error.message}`); + } +} + +// Run all tests +console.log('\n=== Testing Article Utility Functions ===\n'); + +// Test hash function +testCases.hash_function_tests.forEach((testCase) => { + test(testCase.description, () => { + const result = hash(testCase.input); + assert.strictEqual(result, testCase.expected, + `Hash mismatch: got ${result}, expected ${testCase.expected}`); + }); +}); + +// Test score function +testCases.score_function_tests.forEach((testCase) => { + test(testCase.description, () => { + const result = score(testCase.input); + if (testCase.expected_type === 'timestamp') { + // For invalid dates that fallback to Date.now(), just check it's a number + assert.strictEqual(typeof result, 'number', + `Score should be a number: got ${typeof result}`); + assert.ok(result > 0, `Score should be positive: got ${result}`); + } else { + assert.strictEqual(result, testCase.expected, + `Score mismatch: got ${result}, expected ${testCase.expected}`); + } + }); +}); + +// Print summary +console.log(`\n=== Test Summary ===`); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}\n`); + +process.exit(failed > 0 ? 1 : 0); diff --git a/src/lib/feedUtils.js b/src/lib/feedUtils.js new file mode 100644 index 0000000..a898bd5 --- /dev/null +++ b/src/lib/feedUtils.js @@ -0,0 +1,129 @@ +// Utility functions for feed processing +// These functions are extracted from feeds.js to make them testable + +// Redis key prefixes - used for building and parsing keys +const REDIS_FEED_PREFIX = 'feed:'; +const REDIS_ARTICLES_PREFIX = 'articles:'; +const REDIS_ARTICLE_PREFIX = 'article:'; + +/** + * Builds request headers for conditional GET requests + * @param {Object} storedFeed - Feed metadata from Redis + * @param {string} storedFeed.lastModified - Last-Modified header from previous fetch + * @param {string} storedFeed.etag - ETag header from previous fetch + * @returns {Object} Headers object for HTTP request + */ +function buildRequestHeaders(storedFeed = {}) { + const headers = { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36', + }; + + if (storedFeed.lastModified) { + headers['If-Modified-Since'] = storedFeed.lastModified; + } + + if (storedFeed.etag) { + headers['If-None-Match'] = storedFeed.etag; + } + + return headers; +} + +/** + * Builds Redis key names for a feed + * @param {string} feedURI - The feed URL + * @returns {Object} Object containing feedKey and articlesKey + */ +function buildRedisKeys(feedURI) { + return { + feedKey: `${REDIS_FEED_PREFIX}${feedURI}`, + articlesKey: `${REDIS_ARTICLES_PREFIX}${feedURI}`, + }; +} + +/** + * Builds the article key for Redis sorted set + * @param {string} hash - Article hash + * @returns {string} Article key in format "article:{hash}" + */ +function buildArticleKey(hash) { + return `${REDIS_ARTICLE_PREFIX}${hash}`; +} + +/** + * Processes an article by adding computed fields + * @param {Object} article - Raw article from feed parser + * @param {string} feedURI - The feed URL + * @param {Function} hashFn - Hash function from articles.js + * @param {Function} scoreFn - Score function from articles.js + * @returns {Object} Article with hash, score, and feedurl added + */ +function processArticle(article, feedURI, hashFn, scoreFn) { + const processed = Object.assign({}, article); + processed.hash = hashFn(article); + processed.score = scoreFn(article); + processed.feedurl = feedURI; + return processed; +} + +/** + * Determines if an article should be stored in S3 + * @param {number|null} oldScore - Existing score from Redis (null if new) + * @param {number} newScore - New score for the article + * @returns {boolean} True if article should be stored in S3 + */ +function shouldStoreArticle(oldScore, newScore) { + // Store if: + // 1. Article is new (oldScore is null) + // 2. Score has changed (article was updated) + // Note: parseInt(oldScore, 10) - the radix (10) ensures decimal parsing + // and prevents edge cases where strings starting with 0 are parsed as octal + return (oldScore === null) || (newScore !== parseInt(oldScore, 10)); +} + +/** + * Validates that an article has required fields + * @param {Object} article - Article to validate + * @returns {boolean} True if article is valid + */ +function isValidArticle(article) { + // Using !! to convert truthy value to boolean explicitly + // This is equivalent to: !(!article || !article.guid || !article.description) + // but more concise. The && operator short-circuits, so if article is null/undefined, + // it won't try to access article.guid or article.description + return !!(article && article.guid && article.description); +} + +/** + * Extracts article IDs (hashes) from Redis keys by removing the "article:" prefix + * @param {string[]} articleKeys - Array of article keys like "article:hash123" + * @returns {string[]} Array of hashes without the prefix + */ +function extractArticleIds(articleKeys) { + // Remove the "article:" prefix (REDIS_ARTICLE_PREFIX) + const prefixLength = REDIS_ARTICLE_PREFIX.length; + return articleKeys.map(key => key.substring(prefixLength)); +} + +/** + * Generates JSON body for S3 storage + * @param {Object} article - Processed article with all fields + * @returns {string} JSON string representation of article + */ +function generateArticleBody(article) { + return JSON.stringify(article); +} + +module.exports = { + REDIS_FEED_PREFIX, + REDIS_ARTICLES_PREFIX, + REDIS_ARTICLE_PREFIX, + buildRequestHeaders, + buildRedisKeys, + buildArticleKey, + processArticle, + shouldStoreArticle, + isValidArticle, + extractArticleIds, + generateArticleBody, +}; diff --git a/src/lib/feedUtils.test.js b/src/lib/feedUtils.test.js new file mode 100644 index 0000000..741e0ad --- /dev/null +++ b/src/lib/feedUtils.test.js @@ -0,0 +1,142 @@ +// Tests for feed utility functions +// Run with: node src/lib/feedUtils.test.js + +const feedUtils = require('./feedUtils.js'); +const { hash, score } = require('./articleUtils.js'); +const fs = require('fs'); +const yaml = require('js-yaml'); +const assert = require('assert'); + +// Load test cases from YAML +const testCasesYaml = fs.readFileSync('./testdata/test-cases.yaml', 'utf8'); +const testCases = yaml.load(testCasesYaml); + +// Simple test runner +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`✓ ${name}`); + } catch (error) { + failed++; + console.error(`✗ ${name}`); + console.error(` ${error.message}`); + } +} + +// Run all tests +console.log('\n=== Testing Feed Utility Functions ===\n'); + +// Test buildRequestHeaders +testCases.request_headers_tests.forEach((testCase) => { + test(testCase.description, () => { + const result = feedUtils.buildRequestHeaders(testCase.input); + assert.deepStrictEqual(result, testCase.expected, + `Headers mismatch: got ${JSON.stringify(result)}, expected ${JSON.stringify(testCase.expected)}`); + }); +}); + +// Test buildRedisKeys +testCases.redis_keys_tests.forEach((testCase) => { + test(testCase.description, () => { + const result = feedUtils.buildRedisKeys(testCase.input); + assert.deepStrictEqual(result, testCase.expected, + `Redis keys mismatch: got ${JSON.stringify(result)}, expected ${JSON.stringify(testCase.expected)}`); + }); +}); + +// Test buildArticleKey +test('buildArticleKey creates correct format', () => { + const hashValue = '13a0bebeed5b348147d880a1a4917587'; + const result = feedUtils.buildArticleKey(hashValue); + assert.strictEqual(result, `${feedUtils.REDIS_ARTICLE_PREFIX}${hashValue}`); +}); + +// Test Redis key prefix constants +test('Redis prefix constants are defined', () => { + assert.strictEqual(feedUtils.REDIS_FEED_PREFIX, 'feed:'); + assert.strictEqual(feedUtils.REDIS_ARTICLES_PREFIX, 'articles:'); + assert.strictEqual(feedUtils.REDIS_ARTICLE_PREFIX, 'article:'); +}); + +// Test shouldStoreArticle +testCases.article_storage_tests.forEach((testCase) => { + test(testCase.description, () => { + const result = feedUtils.shouldStoreArticle( + testCase.input.oldScore, + testCase.input.newScore + ); + assert.strictEqual(result, testCase.expected, + `Storage decision mismatch: got ${result}, expected ${testCase.expected}`); + }); +}); + +// Test isValidArticle +testCases.article_validation_tests.forEach((testCase) => { + test(testCase.description, () => { + const result = feedUtils.isValidArticle(testCase.input); + assert.strictEqual(result, testCase.expected, + `Validation mismatch: got ${result}, expected ${testCase.expected}`); + }); +}); + +// Test processArticle +test('processArticle adds hash, score, and feedurl', () => { + const article = { + guid: 'https://xkcd.com/3153/', + title: 'Test Article', + pubDate: '2025-10-10T00:00:00Z', + }; + const feedURI = 'https://xkcd.com/atom.xml'; + + const result = feedUtils.processArticle(article, feedURI, hash, score); + + assert.strictEqual(result.hash, '13a0bebeed5b348147d880a1a4917587'); + assert.strictEqual(result.score, 1760054400000); + assert.strictEqual(result.feedurl, feedURI); + assert.strictEqual(result.guid, article.guid); + assert.strictEqual(result.title, article.title); +}); + +// Test extractArticleIds +test('extractArticleIds strips article: prefix', () => { + const keys = [ + 'article:13a0bebeed5b348147d880a1a4917587', + 'article:21664da7ee05988c62d1f516f3442411', + 'article:3fa08ba1591ba3683e87265ee9300946', + ]; + + const result = feedUtils.extractArticleIds(keys); + + assert.deepStrictEqual(result, [ + '13a0bebeed5b348147d880a1a4917587', + '21664da7ee05988c62d1f516f3442411', + '3fa08ba1591ba3683e87265ee9300946', + ]); +}); + +// Test generateArticleBody +test('generateArticleBody produces valid JSON', () => { + const article = { + guid: 'test-guid', + title: 'Test Article', + hash: 'abc123', + score: 1234567890, + }; + + const result = feedUtils.generateArticleBody(article); + const parsed = JSON.parse(result); + + assert.deepStrictEqual(parsed, article); +}); + +// Print summary +console.log(`\n=== Test Summary ===`); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}\n`); + +process.exit(failed > 0 ? 1 : 0); diff --git a/testdata/test-cases.yaml b/testdata/test-cases.yaml new file mode 100644 index 0000000..c0758ce --- /dev/null +++ b/testdata/test-cases.yaml @@ -0,0 +1,130 @@ +# Test cases for feed processing functions +# These values are generated using the actual Node.js implementation + +hash_function_tests: + - description: "Hash function with XKCD article GUID" + input: + guid: "https://xkcd.com/3153/" + expected: "13a0bebeed5b348147d880a1a4917587" + + - description: "Hash function with different GUID" + input: + guid: "https://xkcd.com/3152/" + expected: "21664da7ee05988c62d1f516f3442411" + + - description: "Hash function with empty GUID" + input: + guid: "" + expected: "d41d8cd98f00b204e9800998ecf8427e" + +score_function_tests: + - description: "Score with pubDate" + input: + pubDate: "2024-10-10T00:00:00Z" + expected: 1728518400000 + + - description: "Score with alternative date field 'pubdate'" + input: + pubdate: "2024-10-08T00:00:00Z" + expected: 1728345600000 + + - description: "Score with alternative date field 'date'" + input: + date: "2024-10-06T00:00:00Z" + expected: 1728172800000 + + - description: "Score with invalid date falls back to Date.now()" + input: + pubDate: "invalid-date" + expected_type: "timestamp" # Will be current timestamp + +request_headers_tests: + - description: "Headers with no stored feed data" + input: {} + expected: + user-agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36" + + - description: "Headers with lastModified" + input: + lastModified: "Wed, 09 Oct 2024 12:00:00 GMT" + expected: + user-agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36" + If-Modified-Since: "Wed, 09 Oct 2024 12:00:00 GMT" + + - description: "Headers with etag" + input: + etag: "\"abc123\"" + expected: + user-agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36" + If-None-Match: "\"abc123\"" + + - description: "Headers with both lastModified and etag" + input: + lastModified: "Wed, 09 Oct 2024 12:00:00 GMT" + etag: "\"abc123\"" + expected: + user-agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36" + If-Modified-Since: "Wed, 09 Oct 2024 12:00:00 GMT" + If-None-Match: "\"abc123\"" + +redis_keys_tests: + - description: "Build Redis keys for XKCD feed" + input: "https://xkcd.com/atom.xml" + expected: + feedKey: "feed:https://xkcd.com/atom.xml" + articlesKey: "articles:https://xkcd.com/atom.xml" + + - description: "Build Redis keys for HN feed" + input: "https://news.ycombinator.com/rss" + expected: + feedKey: "feed:https://news.ycombinator.com/rss" + articlesKey: "articles:https://news.ycombinator.com/rss" + +article_storage_tests: + - description: "Should store new article (oldScore is null)" + input: + oldScore: null + newScore: 1728518400000 + expected: true + + - description: "Should store article with changed score" + input: + oldScore: 1728518400000 + newScore: 1728604800000 + expected: true + + - description: "Should NOT store article with same score" + input: + oldScore: 1728518400000 + newScore: 1728518400000 + expected: false + + - description: "Should store article when old score differs (string vs number)" + input: + oldScore: "1728518400000" + newScore: 1728518400001 + expected: true + +article_validation_tests: + - description: "Valid article with all required fields" + input: + guid: "https://example.com/article1" + description: "Article description" + title: "Article Title" + expected: true + + - description: "Invalid article missing guid" + input: + description: "Article description" + title: "Article Title" + expected: false + + - description: "Invalid article missing description" + input: + guid: "https://example.com/article1" + title: "Article Title" + expected: false + + - description: "Invalid article is null" + input: null + expected: false diff --git a/testdata/xkcd.xml b/testdata/xkcd.xml new file mode 100644 index 0000000..0aaa09e --- /dev/null +++ b/testdata/xkcd.xml @@ -0,0 +1,2 @@ + +xkcd.comhttps://xkcd.com/2025-10-10T00:00:00ZHot Water Balloon2025-10-10T00:00:00Zhttps://xkcd.com/3153/<img src="https://imgs.xkcd.com/comics/hot_water_balloon.png" title="Despite a reputation for safety, the temperatures and surprisingly high pressures make them even more dangerous than the air kind, but the NTSB refuses to investigate accidents because they insist there is no 'transportation' involved." alt="Despite a reputation for safety, the temperatures and surprisingly high pressures make them even more dangerous than the air kind, but the NTSB refuses to investigate accidents because they insist there is no 'transportation' involved." />Skateboard2025-10-08T00:00:00Zhttps://xkcd.com/3152/<img src="https://imgs.xkcd.com/comics/skateboard.png" title="I understand it's hard to do more than 300 feet on these 90-second rush jobs, but with a smaller ramp I'm worried the gee forces will be too high for me to do any tricks." alt="I understand it's hard to do more than 300 feet on these 90-second rush jobs, but with a smaller ramp I'm worried the gee forces will be too high for me to do any tricks." />Window Screen2025-10-06T00:00:00Zhttps://xkcd.com/3151/<img src="https://imgs.xkcd.com/comics/window_screen.png" title="The Nobel Prize in Physiology or Medicine or Home Improvement or DIY" alt="The Nobel Prize in Physiology or Medicine or Home Improvement or DIY" />Ping2025-10-03T00:00:00Zhttps://xkcd.com/3150/<img src="https://imgs.xkcd.com/comics/ping.png" title="Progress on getting shipwrecked sailors to adopt ICMPv6 has been slow." alt="Progress on getting shipwrecked sailors to adopt ICMPv6 has been slow." /> \ No newline at end of file