From daeceaf9370e09e9ffe7567e7dca92ca9f714d18 Mon Sep 17 00:00:00 2001 From: "k.yamada" Date: Sun, 21 Jun 2026 05:00:39 +0000 Subject: [PATCH] =?UTF-8?q?test:=20fetchJson=20=E3=81=AE=20content-type=20?= =?UTF-8?q?=E3=82=AC=E3=83=BC=E3=83=89=E3=81=A8=20createReleaseApi=20?= =?UTF-8?q?=E3=82=92=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 要求仕様書 §10.1 / 企画 §1.1-3。既存の fetchText テストに加え: - fetchJson: 正常 JSON / HTML レート制限ページ→retryable Error(repo-token 案内)/ 404→PermanentError / 503→Error - createReleaseApi.getLatestVersion: tag_name をそのまま返す - createReleaseApi.listStableVersions: draft/prerelease 除外 + ページング追従(page=2 取得→部分ページで停止)+ 空ページ停止 fetch を URL/呼び出し回数で分岐スタブし、ページングを実検証。 Closes #3 Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/github.test.ts | 127 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 10 deletions(-) diff --git a/tests/github.test.ts b/tests/github.test.ts index c52f1c6..8b9333c 100644 --- a/tests/github.test.ts +++ b/tests/github.test.ts @@ -1,30 +1,71 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchText } from '../src/github'; +import { createReleaseApi, fetchJson, fetchText } from '../src/github'; import { PermanentError } from '../src/errors'; -function stubFetch(body: string, init: { status?: number; contentType?: string } = {}): void { - const headers: Record = init.contentType ? { 'content-type': init.contentType } : {}; +/** Stub a single fixed response for every fetch() call. */ +function stubFetch( + body: string, + init: { status?: number; contentType?: string } = {}, +): void { + const headers: Record = init.contentType + ? { 'content-type': init.contentType } + : {}; vi.stubGlobal( 'fetch', vi.fn(async () => new Response(body, { status: init.status ?? 200, headers })), ); } -describe('fetchText (checksums fetch)', () => { - afterEach(() => vi.unstubAllGlobals()); +afterEach(() => vi.unstubAllGlobals()); +describe('fetchJson (content-type guard, FR-3)', () => { + it('parses and returns the JSON body on a JSON response', async () => { + stubFetch(JSON.stringify({ tag_name: 'v3.51.1' }), { contentType: 'application/json' }); + await expect(fetchJson('https://api/releases/latest')).resolves.toEqual({ + tag_name: 'v3.51.1', + }); + }); + + it('rejects an HTML rate-limit page as a retryable Error pointing at repo-token', async () => { + // 200 OK but non-JSON body = the "Unicorn" page. Must throw (transient), + // not be parsed, so withRetry() can retry it. + stubFetch('rate limited', { contentType: 'text/html; charset=utf-8' }); + const err = await fetchJson('https://api/releases/latest').catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(PermanentError); + expect((err as Error).message).toMatch(/content-type/); + expect((err as Error).message).toMatch(/repo-token/); + }); + + it('throws PermanentError on 404 (no such resource — never retried)', async () => { + stubFetch('Not Found', { status: 404, contentType: 'application/json' }); + await expect(fetchJson('https://api/releases/latest')).rejects.toBeInstanceOf(PermanentError); + }); + + it('throws a (retryable) Error on other non-OK statuses', async () => { + stubFetch('Server Error', { status: 503, contentType: 'application/json' }); + const err = await fetchJson('https://api/releases/latest').catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(PermanentError); + expect((err as Error).message).toMatch(/503/); + }); +}); + +describe('fetchText (checksums fetch, FR-3)', () => { it('returns the body for a plain-text checksums response', async () => { stubFetch(`${'a'.repeat(64)} task_linux_amd64.tar.gz`, { contentType: 'text/plain' }); - await expect(fetchText('https://example/checksums.txt')).resolves.toContain('task_linux_amd64'); + await expect(fetchText('https://example/checksums.txt')).resolves.toContain( + 'task_linux_amd64', + ); }); it('rejects an HTML error page as a retryable Error (not a silent checksum miss)', async () => { - // The "Unicorn"/rate-limit page: must throw so withRetry() can retry, - // rather than being parsed as an empty checksums file. - stubFetch('rate limited', { contentType: 'text/html; charset=utf-8' }); + stubFetch('rate limited', { + contentType: 'text/html; charset=utf-8', + }); const err = await fetchText('https://example/checksums.txt').catch((e: unknown) => e); expect(err).toBeInstanceOf(Error); - expect(err).not.toBeInstanceOf(PermanentError); // transient → retryable + expect(err).not.toBeInstanceOf(PermanentError); expect((err as Error).message).toMatch(/content-type/); }); @@ -33,3 +74,69 @@ describe('fetchText (checksums fetch)', () => { await expect(fetchText('https://example/checksums.txt')).rejects.toBeInstanceOf(PermanentError); }); }); + +describe('createReleaseApi', () => { + it('getLatestVersion returns the tag_name as-is', async () => { + stubFetch(JSON.stringify({ tag_name: 'v3.51.1' }), { contentType: 'application/json' }); + await expect(createReleaseApi().getLatestVersion()).resolves.toBe('v3.51.1'); + }); + + it('listStableVersions excludes drafts/prereleases and follows pagination', async () => { + // page 1: a full page (100) of releases -> must fetch page 2. + // page 2: a partial page (< 100) -> stop after it. + const page1 = Array.from({ length: 100 }, (_, i) => ({ + tag_name: `v3.${i}.0`, + draft: false, + prerelease: false, + })); + const page2 = [ + { tag_name: 'v3.200.0', draft: false, prerelease: false }, + { tag_name: 'v3.201.0', draft: true, prerelease: false }, // excluded + { tag_name: 'v3.202.0-rc.1', draft: false, prerelease: true }, // excluded + { tag_name: 'not-semver', draft: false, prerelease: false }, // dropped by semver.clean + ]; + + const fetchMock = vi.fn(async (url: string) => { + const page = url.includes('page=2') ? page2 : page1; + return new Response(JSON.stringify(page), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const versions = await createReleaseApi().listStableVersions(); + + expect(fetchMock).toHaveBeenCalledTimes(2); // followed pagination, then stopped + expect(versions).toContain('3.0.0'); // from the full first page (cleaned) + expect(versions).toContain('3.200.0'); // the only stable entry on page 2 + expect(versions).not.toContain('3.201.0'); // draft excluded + expect(versions).not.toContain('3.202.0'); // prerelease excluded + expect(versions).toHaveLength(101); // 100 from page 1 + 1 stable from page 2 + }); + + it('listStableVersions stops on an empty page', async () => { + // A full first page then an empty page: loop must terminate on length === 0. + let call = 0; + const fetchMock = vi.fn(async () => { + call += 1; + const body = + call === 1 + ? Array.from({ length: 100 }, (_, i) => ({ + tag_name: `v3.${i}.0`, + draft: false, + prerelease: false, + })) + : []; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const versions = await createReleaseApi().listStableVersions(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(versions).toHaveLength(100); + }); +});