// node --test scripts/fetch-materials.test.mjs import assert from 'node:assert/strict' import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { parseFeed, run, weatherTitle } from './fetch-materials.mjs' const RSS = ` <![CDATA[第一則 & 標題]]>https://n.example/1<p>摘要</p>內文x 沒有連結 第二則https://n.example/2d2 ` const ATOM = `原子s` const mkFetch = routes => async url => { for (const [k, v] of Object.entries(routes)) { if (url.includes(k)) { if (v instanceof Error) throw v return { ok: true, status: 200, json: async () => v, text: async () => v } } } return { ok: false, status: 404, json: async () => ({}), text: async () => '' } } const WEATHER = { current: { temperature_2m: 26.4, weather_code: 61 } } const NOW = Date.UTC(2026, 9, 14, 8, 0) async function withRoot(user, fn) { const root = await mkdtemp(join(tmpdir(), 'companion-')) try { if (user) await writeFile(join(root, 'user.json'), JSON.stringify(user)) return await fn(root) } finally { await rm(root, { recursive: true, force: true }) } } test('RSS 與 Atom 只留標題、摘要、連結、時間,沒有連結的略過', () => { const items = parseFeed(RSS, NOW) assert.deepEqual(items.map(i => i.title), ['第一則 & 標題', '第二則']) assert.equal(items[0].summary, '摘要 內文') assert.deepEqual(Object.keys(items[0]).sort(), ['at', 'link', 'summary', 'title']) assert.equal(parseFeed(ATOM, NOW)[0].link, 'https://a.example/1') }) test('天氣代碼對應標題', () => { assert.equal(weatherTitle(0), '晴') assert.equal(weatherTitle(63), '雨') assert.equal(weatherTitle(95), '雷雨') }) test('沒有 location 就不啟用,也不寫快取、不連網', async () => { await withRoot({ feeds: ['https://n.example/rss'] }, async root => { let called = 0 const r = await run({ root, now: NOW, fetch: async () => void called++ }) assert.equal(r.enabled, false) assert.equal(called, 0) await assert.rejects(readFile(join(root, 'materials.json'))) }) }) test('有 location:寫入天氣與新聞快取', async () => { await withRoot({ location: '25.03,121.56', feeds: ['https://n.example/rss'] }, async root => { const fetch = mkFetch({ 'api.open-meteo.com': WEATHER, 'n.example/rss': RSS }) const r = await run({ root, now: NOW, fetch }) assert.deepEqual([r.enabled, r.weather, r.news], [true, true, 2]) const cache = JSON.parse(await readFile(join(root, 'materials.json'), 'utf8')) assert.equal(cache.weather.title, '雨') assert.equal(cache.weather.at, NOW) assert.equal(cache.news.length, 2) }) }) test('地名會先做地理編碼', async () => { await withRoot({ location: '台北' }, async root => { const fetch = mkFetch({ 'geocoding-api': { results: [{ latitude: 25.04, longitude: 121.5, name: '台北' }] }, 'api.open-meteo.com': WEATHER }) const r = await run({ root, now: NOW, fetch }) assert.equal(r.weather, true) }) }) test('抓取失敗時保留原快取,不覆蓋成空的', async () => { await withRoot({ location: '25,121', feeds: ['https://n.example/rss'] }, async root => { const old = { weather: { title: '晴', summary: 's', link: 'https://x', at: 1 }, news: [{ title: '舊聞', summary: '', link: 'https://o', at: 1 }] } await writeFile(join(root, 'materials.json'), JSON.stringify(old)) const fetch = mkFetch({ 'api.open-meteo.com': new Error('offline'), 'n.example/rss': new Error('offline') }) const r = await run({ root, now: NOW, fetch }) assert.equal(r.errors.length, 2) assert.deepEqual(JSON.parse(await readFile(join(root, 'materials.json'), 'utf8')), old) }) }) test('RSS 預設空清單時只更新天氣', async () => { await withRoot({ location: '25,121' }, async root => { const r = await run({ root, now: NOW, fetch: mkFetch({ 'api.open-meteo.com': WEATHER }) }) assert.equal(r.news, 0) }) }) test('腳本可單獨執行(未啟用時正常結束)', async () => { await withRoot(null, async root => { const { stdout } = await promisify(execFile)(process.execPath, [fileURLToPath(new URL('./fetch-materials.mjs', import.meta.url))], { env: { ...process.env, COMPANION_ROOT: root } }) assert.match(stdout, /未啟用/) }) })