/** * 期間カバレッジテスト * * 各スクレーパーが SCRAPE_WINDOW_DAYS(65日)をカバーするために * 必要な月のURLを全てフェッチしているかを検証する。 * * fetch をモックし、呼ばれた URL の月一覧を確認する。 */ import { test, describe, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; // ---- テスト用ユーティリティ ---- function monthsInWindow(from: Date, windowDays: number): string[] { const end = new Date(from); end.setDate(end.getDate() + windowDays); const months: string[] = []; const d = new Date(from.getFullYear(), from.getMonth(), 1); while (d <= end) { months.push( `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` ); d.setMonth(d.getMonth() + 1); } return months; } const WINDOW_DAYS = 65; /** fetch をモックし、呼ばれた URL 一覧を返す */ function mockFetch(htmlByUrl: Record = {}): { fetchedUrls: string[]; restore: () => void; } { const fetchedUrls: string[] = []; const orig = global.fetch; // @ts-ignore global.fetch = async (url: string | URL) => { const s = url.toString(); fetchedUrls.push(s); const html = htmlByUrl[s] ?? ""; return { ok: true, status: 200, text: async () => html, json: async () => ({ events: [] }), } as unknown as Response; }; return { fetchedUrls, restore: () => { global.fetch = orig; } }; } /** YYYY-MM 文字列が URL に含まれるか確認(複数フォーマット対応) */ function urlCoversMonth(urls: string[], yyyymm: string): boolean { const [y, m] = yyyymm.split("-"); const mInt = parseInt(m, 10).toString(); // "06" → "6" return urls.some( (u) => u.includes(`/${y}/${m}`) || u.includes(`/${y}-${m}`) || u.includes(`_${y}-${m}`) || // duo: index_2026-05.html u.includes(`${y}${m}`) || u.includes(`y=${y}&m=${mInt}`) || u.includes(`y=${y}&m=${m}`) || u.includes(encodeURIComponent(`${y}/${m}`)) || u.includes(`date=${y}%2F${m}`) ); } // ---- ウィンドウ計算ロジック テスト ---- describe("monthsInWindow", () => { test("65日窓は最大3ヶ月にまたがる(月初付近)", () => { // 5/1 から65日 = 7/5 → May, Jun, Jul const months = monthsInWindow(new Date("2026-05-01"), WINDOW_DAYS); assert.deepEqual(months, ["2026-05", "2026-06", "2026-07"]); }); test("65日窓は最大3ヶ月にまたがる(月末付近)", () => { // 5/31 から65日 = 8/4 → May, Jun, Jul, Aug const months = monthsInWindow(new Date("2026-05-31"), WINDOW_DAYS); assert.deepEqual(months, ["2026-05", "2026-06", "2026-07", "2026-08"]); }); test("年またぎ対応", () => { // 11/15 から65日 = 2027/1/19 → Nov, Dec, Jan const months = monthsInWindow(new Date("2026-11-15"), WINDOW_DAYS); assert.deepEqual(months, ["2026-11", "2026-12", "2027-01"]); }); }); // ---- スクレーパー別 URL カバレッジテスト ---- describe("fever-shindaita: 3ヶ月分フェッチ", () => { let restore: () => void; let fetchedUrls: string[]; beforeEach(() => { ({ fetchedUrls, restore } = mockFetch()); }); afterEach(() => restore()); test("今月・来月・再来月のURLをフェッチする", async () => { const { scraper } = await import("../app/scrapers/fever-shindaita.js"); await scraper.scrape(); const now = new Date(); const expected = monthsInWindow(now, WINDOW_DAYS); for (const m of expected) { const [y, mo] = m.split("-"); assert.ok( fetchedUrls.some((u) => u.includes(`/${y}/${mo}/`)), `fever-shindaita: ${m} のURLがフェッチされていない\nfetched: ${fetchedUrls.join(", ")}` ); } }); }); describe("liquid-room: 3ヶ月分フェッチ", () => { let restore: () => void; let fetchedUrls: string[]; beforeEach(() => { ({ fetchedUrls, restore } = mockFetch()); }); afterEach(() => restore()); test("今月・来月・再来月のURLをフェッチする", async () => { const { scraper } = await import("../app/scrapers/liquid-room.js"); await scraper.scrape(); const now = new Date(); const expected = monthsInWindow(now, WINDOW_DAYS); for (const m of expected) { assert.ok( urlCoversMonth(fetchedUrls, m), `liquid-room: ${m} のURLがフェッチされていない\nfetched: ${fetchedUrls.join(", ")}` ); } }); }); describe("club-quattro: 3ヶ月分フェッチ", () => { let restore: () => void; let fetchedUrls: string[]; beforeEach(() => { ({ fetchedUrls, restore } = mockFetch()); }); afterEach(() => restore()); test("今月・来月・再来月のURLをフェッチする", async () => { const { scraper } = await import("../app/scrapers/club-quattro.js"); await scraper.scrape(); const now = new Date(); const expected = monthsInWindow(now, WINDOW_DAYS); for (const m of expected) { assert.ok( urlCoversMonth(fetchedUrls, m), `club-quattro: ${m} のURLがフェッチされていない\nfetched: ${fetchedUrls.join(", ")}` ); } }); }); describe("meets-otsuka: 3ヶ月分フェッチ", () => { let restore: () => void; let fetchedUrls: string[]; beforeEach(() => { ({ fetchedUrls, restore } = mockFetch()); }); afterEach(() => restore()); test("今月・来月・再来月のURLをフェッチする", async () => { const { scraper } = await import("../app/scrapers/meets-otsuka.js"); await scraper.scrape(); const now = new Date(); const expected = monthsInWindow(now, WINDOW_DAYS); for (const m of expected) { assert.ok( urlCoversMonth(fetchedUrls, m), `meets-otsuka: ${m} のURLがフェッチされていない\nfetched: ${fetchedUrls.join(", ")}` ); } }); }); describe("nishieifuku-jam: 3ヶ月分フェッチ", () => { let restore: () => void; let fetchedUrls: string[]; beforeEach(() => { ({ fetchedUrls, restore } = mockFetch()); }); afterEach(() => restore()); test("今月・来月・再来月のURLをフェッチする", async () => { const { scraper } = await import("../app/scrapers/nishieifuku-jam.js"); await scraper.scrape(); const now = new Date(); const expected = monthsInWindow(now, WINDOW_DAYS); for (const m of expected) { assert.ok( urlCoversMonth(fetchedUrls, m), `nishieifuku-jam: ${m} のURLがフェッチされていない\nfetched: ${fetchedUrls.join(", ")}` ); } }); }); describe("shibuya-o: 全サブ会場 × 3ヶ月分フェッチ", () => { let restore: () => void; let fetchedUrls: string[]; beforeEach(() => { ({ fetchedUrls, restore } = mockFetch()); }); afterEach(() => restore()); test("east/west/crest/nest × 今月・来月・再来月をフェッチする", async () => { const { scraper } = await import("../app/scrapers/shibuya-o.js"); await scraper.scrape(); const now = new Date(); const expected = monthsInWindow(now, WINDOW_DAYS); for (const subVenue of ["east", "west", "crest", "nest"]) { for (const m of expected) { assert.ok( urlCoversMonth( fetchedUrls.filter((u) => u.includes(`/${subVenue}/`)), m ), `shibuya-o/${subVenue}: ${m} のURLがフェッチされていない` ); } } }); }); describe("duo-music-exchange: 3ヶ月分フェッチ", () => { let restore: () => void; let fetchedUrls: string[]; beforeEach(() => { ({ fetchedUrls, restore } = mockFetch()); }); afterEach(() => restore()); test("今月・来月・再来月のURLをフェッチする", async () => { const { scraper } = await import("../app/scrapers/duo-music-exchange.js"); await scraper.scrape(); const now = new Date(); const expected = monthsInWindow(now, WINDOW_DAYS); for (const m of expected) { assert.ok( urlCoversMonth(fetchedUrls, m), `duo-music-exchange: ${m} のURLがフェッチされていない\nfetched: ${fetchedUrls.join(", ")}` ); } }); });