diff --git a/src/github.test.ts b/src/github.test.ts index ed6dbbb..a3afff4 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -26,12 +26,11 @@ function makeSponsorshipResponse( Promise.resolve({ data: { organization: { - sponsorshipsAsMaintainer: { + sponsorsActivities: { nodes: sponsors.map(({ login, createdAt = "2024-01-01T00:00:00Z", isOneTimePayment = true, amount = 5 }) => ({ - createdAt, - isOneTimePayment, - tier: { monthlyPriceInDollars: amount }, - sponsorEntity: { login }, + timestamp: createdAt, + sponsorsTier: { monthlyPriceInDollars: amount, isOneTime: isOneTimePayment }, + sponsor: { login }, })), pageInfo: { hasNextPage, endCursor }, }, @@ -171,6 +170,87 @@ describe("checkSponsorship", () => { const body = JSON.parse(options.body); expect(body.query).toContain("includePrivate: true"); }); + + it("sponsorsActivities를 NEW_SPONSORSHIP 액션으로 조회한다", async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(makeSponsorshipResponse([{ login: "octocat" }])); + vi.stubGlobal("fetch", mockFetch); + + await checkSponsorship("octocat", "DaleStudy", "my-token"); + + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.query).toContain("sponsorsActivities"); + expect(body.query).toContain("NEW_SPONSORSHIP"); + }); + + it("결제 이벤트의 timestamp를 record의 createdAt으로 사용한다", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + makeSponsorshipResponse([{ login: "octocat", createdAt: "2026-06-27T09:00:00Z", amount: 5 }]), + ), + ); + + const result = await checkSponsorship("octocat", "DaleStudy", "test-token"); + expect(result.records).toEqual([ + { createdAt: "2026-06-27T09:00:00Z", isOneTimePayment: true, amount: 5 }, + ]); + }); + + it("재참여자의 반복 일시후원이 각각 별도 레코드로 집계된다", async () => { + // 같은 후원자가 지난 기수와 새 기수에 각각 일시후원한 경우, + // 두 결제가 각자의 timestamp로 별도 기록되어 호출부의 날짜 필터가 정확히 동작한다. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + makeSponsorshipResponse([ + { login: "u-siop", createdAt: "2026-02-10T00:00:00Z", amount: 5 }, + { login: "u-siop", createdAt: "2026-06-27T09:00:00Z", amount: 5 }, + ]), + ), + ); + + const result = await checkSponsorship("u-siop", "DaleStudy", "test-token"); + expect(result.sponsored).toBe(true); + expect(result.records).toHaveLength(2); + expect(result.records.map((r) => r.createdAt)).toEqual([ + "2026-02-10T00:00:00Z", + "2026-06-27T09:00:00Z", + ]); + }); + + it("null 노드가 섞여 있어도 크래시 없이 건너뛴다", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + data: { + organization: { + sponsorsActivities: { + nodes: [ + null, + { + timestamp: "2026-06-27T00:00:00Z", + sponsorsTier: { monthlyPriceInDollars: 5, isOneTime: true }, + sponsor: { login: "octocat" }, + }, + null, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }), + }), + ); + + const result = await checkSponsorship("octocat", "DaleStudy", "test-token"); + expect(result.sponsored).toBe(true); + expect(result.records).toHaveLength(1); + }); }); describe("getTeamCreatedAt", () => { diff --git a/src/github.ts b/src/github.ts index c6fda11..4213bdd 100644 --- a/src/github.ts +++ b/src/github.ts @@ -41,7 +41,11 @@ export interface SponsorshipInfo { } /** - * GitHub 후원 여부 확인 (과거 후원 포함, GraphQL 페이지네이션) + * GitHub 후원 여부 확인 + * + * 후원 "관계"가 아니라 결제 "이벤트"(NEW_SPONSORSHIP)를 조회한다. 각 결제의 실제 timestamp 를 + * 쓰므로 반복 일시후원·재참여 후원을 결제일 기준으로 집계할 수 있다. + * (팀 생성일 이후만 합산하는 날짜 필터는 호출부에서 수행) */ export async function checkSponsorship( githubUsername: string, @@ -51,13 +55,13 @@ export async function checkSponsorship( const query = ` query($org: String!, $cursor: String) { organization(login: $org) { - sponsorshipsAsMaintainer(first: 100, after: $cursor, activeOnly: false, includePrivate: true) { + sponsorsActivities(first: 100, after: $cursor, period: ALL, includePrivate: true, actions: [NEW_SPONSORSHIP]) { nodes { - createdAt - isOneTimePayment - tier { monthlyPriceInDollars } - sponsorEntity { + timestamp + sponsorsTier { monthlyPriceInDollars isOneTime } + sponsor { ... on User { login } + ... on Organization { login } } } pageInfo { @@ -90,20 +94,20 @@ export async function checkSponsorship( throw new Error(`GraphQL error: ${JSON.stringify(result.errors)}`); } - const sponsorships = result.data?.organization?.sponsorshipsAsMaintainer; - const nodes = sponsorships?.nodes ?? []; + const activities = result.data?.organization?.sponsorsActivities; + const nodes = activities?.nodes ?? []; for (const node of nodes) { - if (node.sponsorEntity?.login?.toLowerCase() !== githubUsername.toLowerCase()) continue; + if (node?.sponsor?.login?.toLowerCase() !== githubUsername.toLowerCase()) continue; records.push({ - createdAt: node.createdAt, - isOneTimePayment: node.isOneTimePayment ?? false, - amount: node.tier?.monthlyPriceInDollars ?? 0, + createdAt: node.timestamp, + isOneTimePayment: node.sponsorsTier?.isOneTime ?? false, + amount: node.sponsorsTier?.monthlyPriceInDollars ?? 0, }); } - if (!sponsorships?.pageInfo?.hasNextPage) break; - cursor = sponsorships.pageInfo.endCursor; + if (!activities?.pageInfo?.hasNextPage) break; + cursor = activities.pageInfo.endCursor; } if (records.length === 0) { diff --git a/src/index.ts b/src/index.ts index eefd09d..0774b15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,7 +106,7 @@ async function processVerify( console.log(`[verify] teamCreatedAt=${teamCreatedAt} eligibleRecords=${eligibleRecords.length} totalAmount=${totalAmount}`); if (totalAmount < 5) { - return `❌ 가장 최근 후원 금액($${totalAmount})이 $5 미만입니다. 여러 번 후원하셨다면 API 한계로 최근 1건만 확인됩니다. 운영자에게 문의해주세요.`; + return `❌ 팀 생성일 이후 후원 금액($${totalAmount})이 $5 미만입니다. 후원 내역이 맞다면 운영자에게 문의해주세요.`; } const existingMembership = await getTeamMembership(env.GITHUB_ORG, teamConfig.teamSlug, githubUsername, token);