Skip to content

Commit e2dd178

Browse files
committed
test: add unit tests for core pure helpers and CI
Cover normalizeUrl, provider guessing/resolution, tier partitioning, GitHub GraphQL query builders, the GitHub sponsors fetcher, and the SVG composer. Run tests in CI via the shared sxzz workflow and switch the default test script to non-watch mode.
1 parent 2ca15df commit e2dd178

9 files changed

Lines changed: 430 additions & 42 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Unit Test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions: {}
10+
11+
jobs:
12+
unit-test:
13+
uses: sxzz/workflows/.github/workflows/unit-test.yml@main
14+
15+
# coverage:
16+
# uses: sxzz/workflows/.github/workflows/coverage.yml@main
17+
# needs: unit-test
18+
# permissions:
19+
# id-token: write

.github/workflows/lint.yml

Lines changed: 0 additions & 41 deletions
This file was deleted.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
"scripts": {
4040
"build": "tsdown",
4141
"dev": "tsx src/cli.ts",
42-
"test": "vitest",
42+
"test": "vitest run",
43+
"test:watch": "vitest",
4344
"lint": "eslint .",
4445
"typecheck": "tsc --noEmit",
4546
"prepublishOnly": "pnpm run build",

src/configs/index.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { Sponsorship, Tier } from '../types.ts'
2+
import { describe, expect, it } from 'vitest'
3+
import { partitionTiers } from './index.ts'
4+
5+
function sponsor(login: string, monthlyDollars: number, createdAt: string): Sponsorship {
6+
return {
7+
sponsor: {
8+
type: 'User',
9+
login,
10+
name: login,
11+
avatarUrl: '',
12+
},
13+
monthlyDollars,
14+
createdAt,
15+
}
16+
}
17+
18+
const tiers: Tier[] = [
19+
{ title: 'Backers' }, // the required zero-dollar tier
20+
{ title: 'Sponsors', monthlyDollars: 10 },
21+
{ title: 'Gold', monthlyDollars: 100 },
22+
]
23+
24+
describe('partitionTiers', () => {
25+
it('requires exactly one tier without monthlyDollars', () => {
26+
expect(() => partitionTiers([], [{ title: 'a', monthlyDollars: 10 }]))
27+
.toThrow('There should be exactly one tier with no `monthlyDollars`, but got 0')
28+
29+
expect(() => partitionTiers([], [{ title: 'a' }, { title: 'b' }]))
30+
.toThrow('There should be exactly one tier with no `monthlyDollars`, but got 2')
31+
})
32+
33+
it('buckets sponsors into the highest tier they qualify for', () => {
34+
const sponsors = [
35+
sponsor('gold', 100, '2024-01-01'),
36+
sponsor('mid', 10, '2024-01-02'),
37+
sponsor('small', 5, '2024-01-03'),
38+
]
39+
40+
const result = partitionTiers(sponsors, tiers)
41+
42+
// sorted by monthlyDollars descending
43+
expect(result.map(t => t.monthlyDollars)).toEqual([100, 10, 0])
44+
expect(result[0].sponsors.map(s => s.sponsor.login)).toEqual(['gold'])
45+
expect(result[1].sponsors.map(s => s.sponsor.login)).toEqual(['mid'])
46+
expect(result[2].sponsors.map(s => s.sponsor.login)).toEqual(['small'])
47+
})
48+
49+
it('excludes past sponsors unless includePastSponsors is set', () => {
50+
const sponsors = [
51+
sponsor('active', 10, '2024-01-01'),
52+
sponsor('past', -1, '2024-01-02'),
53+
]
54+
55+
const without = partitionTiers(structuredClone(sponsors), tiers)
56+
expect(without.flatMap(t => t.sponsors.map(s => s.sponsor.login))).toEqual(['active'])
57+
58+
const withPast = partitionTiers(structuredClone(sponsors), tiers, true)
59+
expect(withPast.flatMap(t => t.sponsors.map(s => s.sponsor.login)).sort())
60+
.toEqual(['active', 'past'])
61+
})
62+
63+
it('orders sponsors within a tier by createdAt ascending', () => {
64+
const sponsors = [
65+
sponsor('later', 10, '2024-03-01'),
66+
sponsor('earlier', 10, '2024-01-01'),
67+
]
68+
69+
const result = partitionTiers(sponsors, tiers)
70+
const bucket = result.find(t => t.monthlyDollars === 10)!
71+
expect(bucket.sponsors.map(s => s.sponsor.login)).toEqual(['earlier', 'later'])
72+
})
73+
})

src/processing/svg.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { SponsorkitRenderOptions } from '../types.ts'
2+
import { describe, expect, it } from 'vitest'
3+
import { genSvgImage, SvgComposer } from './svg.ts'
4+
5+
function createComposer(width = 800) {
6+
return new SvgComposer({
7+
width,
8+
svgInlineCSS: '.text { fill: red }',
9+
imageFormat: 'webp',
10+
} as unknown as Required<SponsorkitRenderOptions>)
11+
}
12+
13+
describe('svgComposer', () => {
14+
it('starts empty', () => {
15+
const composer = createComposer()
16+
expect(composer.height).toBe(0)
17+
expect(composer.body).toBe('')
18+
})
19+
20+
it('addText appends centered text and advances the height', () => {
21+
const composer = createComposer(800)
22+
composer.addText('Hello')
23+
expect(composer.height).toBe(20)
24+
expect(composer.body).toContain('x="400"')
25+
expect(composer.body).toContain('>Hello</text>')
26+
expect(composer.body).toContain('class="text"')
27+
})
28+
29+
it('addTitle uses the tier-title class', () => {
30+
const composer = createComposer()
31+
composer.addTitle('Backers')
32+
expect(composer.body).toContain('class="sponsorkit-tier-title"')
33+
expect(composer.body).toContain('>Backers</text>')
34+
})
35+
36+
it('addSpan only advances the height', () => {
37+
const composer = createComposer()
38+
composer.addSpan(42)
39+
expect(composer.height).toBe(42)
40+
expect(composer.body).toBe('')
41+
})
42+
43+
it('addRaw appends without touching the height', () => {
44+
const composer = createComposer()
45+
composer.addRaw('<rect />')
46+
expect(composer.body).toBe('<rect />')
47+
expect(composer.height).toBe(0)
48+
})
49+
50+
it('is chainable', () => {
51+
const composer = createComposer()
52+
expect(composer.addSpan(10).addText('x')).toBe(composer)
53+
})
54+
55+
it('generateSvg wraps the body with dimensions and inline css', () => {
56+
const composer = createComposer(600)
57+
composer.addText('Hi')
58+
const svg = composer.generateSvg()
59+
expect(svg).toContain('viewBox="0 0 600 20"')
60+
expect(svg).toContain('width="600"')
61+
expect(svg).toContain('height="20"')
62+
expect(svg).toContain('<style>.text { fill: red }</style>')
63+
expect(svg).toContain('>Hi</text>')
64+
})
65+
})
66+
67+
describe('genSvgImage', () => {
68+
it('embeds the image as a base64 data uri', () => {
69+
const svg = genSvgImage(1, 2, 40, 0.5, 'QUJD', 'png', 'crop-1')
70+
expect(svg).toContain('href="data:image/png;base64,QUJD"')
71+
expect(svg).toContain('x="1"')
72+
expect(svg).toContain('y="2"')
73+
expect(svg).toContain('width="40"')
74+
expect(svg).toContain('rx="20"') // size * radius
75+
})
76+
77+
it('uses the provided crop id for both the clip path and its reference', () => {
78+
const svg = genSvgImage(0, 0, 40, 0.5, 'SAME', 'webp', 'render-crop-42')
79+
expect(svg).toContain('<clipPath id="render-crop-42">')
80+
expect(svg).toContain('clip-path="url(#render-crop-42)"')
81+
})
82+
})

src/providers/github.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import type { Mock } from 'vitest'
2+
import type { SponsorkitConfig } from '../types.ts'
3+
import { $fetch } from 'ofetch'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import {
6+
fetchGitHubSponsors,
7+
makeQuery,
8+
makeSponsoringQuery,
9+
makeSponsoringTotalAmountQuery,
10+
} from './github.ts'
11+
12+
vi.mock('ofetch', () => ({ $fetch: vi.fn() }))
13+
14+
const fetchMock = $fetch as unknown as Mock
15+
16+
beforeEach(() => {
17+
fetchMock.mockReset()
18+
})
19+
20+
describe('makeQuery', () => {
21+
it('embeds the login and account type', () => {
22+
expect(makeQuery('antfu', 'user')).toContain('user(login: "antfu")')
23+
expect(makeQuery('antfu', 'organization')).toContain('organization(login: "antfu")')
24+
})
25+
26+
it('queries sponsorships as a maintainer', () => {
27+
expect(makeQuery('antfu', 'user')).toContain('sponsorshipsAsMaintainer(')
28+
})
29+
30+
it('interpolates the activeOnly flag', () => {
31+
expect(makeQuery('antfu', 'user', true)).toContain('activeOnly: true')
32+
expect(makeQuery('antfu', 'user', false)).toContain('activeOnly: false')
33+
})
34+
35+
it('adds an after cursor only when provided', () => {
36+
expect(makeQuery('antfu', 'user', true)).not.toContain('after:')
37+
expect(makeQuery('antfu', 'user', true, 'CURSOR')).toContain('after: "CURSOR"')
38+
})
39+
})
40+
41+
describe('makeSponsoringQuery', () => {
42+
it('queries sponsorships as a sponsor', () => {
43+
const query = makeSponsoringQuery('antfu', 'user', false, 'CURSOR')
44+
expect(query).toContain('sponsorshipsAsSponsor(')
45+
expect(query).toContain('activeOnly: false')
46+
expect(query).toContain('after: "CURSOR"')
47+
})
48+
})
49+
50+
describe('makeSponsoringTotalAmountQuery', () => {
51+
it('omits parameters when no options are given', () => {
52+
const query = makeSponsoringTotalAmountQuery('antfu', 'user')
53+
expect(query).toContain('totalSponsorshipAmountAsSponsorInCents\n')
54+
expect(query).not.toContain('totalSponsorshipAmountAsSponsorInCents(')
55+
})
56+
57+
it('serializes since, until and sponsorableLogins', () => {
58+
const query = makeSponsoringTotalAmountQuery('antfu', 'user', {
59+
since: '2024-01-01',
60+
until: '2024-12-31',
61+
sponsorableLogins: ['vuejs', 'vitejs'],
62+
})
63+
expect(query).toContain('since: "2024-01-01"')
64+
expect(query).toContain('until: "2024-12-31"')
65+
expect(query).toContain('sponsorableLogins: ["vuejs", "vitejs"]')
66+
})
67+
})
68+
69+
describe('fetchGitHubSponsors', () => {
70+
it('validates its required arguments', async () => {
71+
await expect(fetchGitHubSponsors('', 'antfu', 'user', {}))
72+
.rejects
73+
.toThrow('GitHub token is required')
74+
await expect(fetchGitHubSponsors('token', '', 'user', {}))
75+
.rejects
76+
.toThrow('GitHub login is required')
77+
await expect(fetchGitHubSponsors('token', 'antfu', 'invalid' as any, {}))
78+
.rejects
79+
.toThrow('GitHub type must be either `user` or `organization`')
80+
})
81+
82+
it('paginates and maps the response into sponsorships', async () => {
83+
const page = (login: string, hasNextPage: boolean, endCursor: string | null) => ({
84+
data: {
85+
user: {
86+
sponsorshipsAsMaintainer: {
87+
totalCount: 2,
88+
pageInfo: { hasNextPage, endCursor },
89+
nodes: [
90+
{
91+
createdAt: '2024-01-01T00:00:00Z',
92+
privacyLevel: 'PUBLIC',
93+
isActive: true,
94+
tier: { name: 'Gold', isOneTime: false, monthlyPriceInCents: 1000, monthlyPriceInDollars: 10 },
95+
sponsorEntity: { __typename: 'User', login, name: login, avatarUrl: 'a', websiteUrl: 'example.com' },
96+
},
97+
],
98+
},
99+
},
100+
},
101+
})
102+
103+
fetchMock
104+
.mockResolvedValueOnce(page('alice', true, 'CURSOR'))
105+
.mockResolvedValueOnce(page('bob', false, null))
106+
107+
const sponsors = await fetchGitHubSponsors('token', 'antfu', 'user', {})
108+
109+
expect(fetchMock).toHaveBeenCalledTimes(2)
110+
// second request carries the cursor from the first page
111+
expect(fetchMock.mock.calls[1][1].body.query).toContain('after: "CURSOR"')
112+
113+
expect(sponsors.map(s => s.sponsor.login)).toEqual(['alice', 'bob'])
114+
expect(sponsors[0]).toMatchObject({
115+
monthlyDollars: 10,
116+
tierName: 'Gold',
117+
isOneTime: false,
118+
sponsor: {
119+
login: 'alice',
120+
type: 'User',
121+
websiteUrl: 'https://example.com',
122+
linkUrl: 'https://github.com/alice',
123+
},
124+
})
125+
})
126+
127+
it('marks inactive non-prorated sponsors as past sponsors', async () => {
128+
fetchMock.mockResolvedValueOnce({
129+
data: {
130+
user: {
131+
sponsorshipsAsMaintainer: {
132+
totalCount: 1,
133+
pageInfo: { hasNextPage: false, endCursor: null },
134+
nodes: [
135+
{
136+
createdAt: '2024-01-01T00:00:00Z',
137+
privacyLevel: 'PUBLIC',
138+
isActive: false,
139+
tier: { name: 'Gold', isOneTime: false, monthlyPriceInCents: 1000, monthlyPriceInDollars: 10 },
140+
sponsorEntity: { __typename: 'User', login: 'past', name: 'past', avatarUrl: 'a' },
141+
},
142+
],
143+
},
144+
},
145+
},
146+
})
147+
148+
const sponsors = await fetchGitHubSponsors('token', 'antfu', 'user', {})
149+
expect(sponsors[0].monthlyDollars).toBe(-1)
150+
})
151+
152+
it('throws when the API returns errors', async () => {
153+
fetchMock.mockResolvedValueOnce({ errors: [{ type: 'INSUFFICIENT_SCOPES' }] })
154+
await expect(fetchGitHubSponsors('token', 'antfu', 'user', {} as SponsorkitConfig))
155+
.rejects
156+
.toThrow('read:user')
157+
})
158+
})

0 commit comments

Comments
 (0)