Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Unit Test

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions: {}

jobs:
unit-test:
uses: sxzz/workflows/.github/workflows/unit-test.yml@main

# coverage:
# uses: sxzz/workflows/.github/workflows/coverage.yml@main
# needs: unit-test
# permissions:
# id-token: write
41 changes: 0 additions & 41 deletions .github/workflows/lint.yml

This file was deleted.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"scripts": {
"build": "tsdown",
"dev": "tsx src/cli.ts",
"test": "vitest",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"prepublishOnly": "pnpm run build",
Expand Down
73 changes: 73 additions & 0 deletions src/configs/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Sponsorship, Tier } from '../types.ts'
import { describe, expect, it } from 'vitest'
import { partitionTiers } from './index.ts'

function sponsor(login: string, monthlyDollars: number, createdAt: string): Sponsorship {
return {
sponsor: {
type: 'User',
login,
name: login,
avatarUrl: '',
},
monthlyDollars,
createdAt,
}
}

const tiers: Tier[] = [
{ title: 'Backers' }, // the required zero-dollar tier
{ title: 'Sponsors', monthlyDollars: 10 },
{ title: 'Gold', monthlyDollars: 100 },
]

describe('partitionTiers', () => {
it('requires exactly one tier without monthlyDollars', () => {
expect(() => partitionTiers([], [{ title: 'a', monthlyDollars: 10 }]))
.toThrow('There should be exactly one tier with no `monthlyDollars`, but got 0')

expect(() => partitionTiers([], [{ title: 'a' }, { title: 'b' }]))
.toThrow('There should be exactly one tier with no `monthlyDollars`, but got 2')
})

it('buckets sponsors into the highest tier they qualify for', () => {
const sponsors = [
sponsor('gold', 100, '2024-01-01'),
sponsor('mid', 10, '2024-01-02'),
sponsor('small', 5, '2024-01-03'),
]

const result = partitionTiers(sponsors, tiers)

// sorted by monthlyDollars descending
expect(result.map(t => t.monthlyDollars)).toEqual([100, 10, 0])
expect(result[0].sponsors.map(s => s.sponsor.login)).toEqual(['gold'])
expect(result[1].sponsors.map(s => s.sponsor.login)).toEqual(['mid'])
expect(result[2].sponsors.map(s => s.sponsor.login)).toEqual(['small'])
})

it('excludes past sponsors unless includePastSponsors is set', () => {
const sponsors = [
sponsor('active', 10, '2024-01-01'),
sponsor('past', -1, '2024-01-02'),
]

const without = partitionTiers(structuredClone(sponsors), tiers)
expect(without.flatMap(t => t.sponsors.map(s => s.sponsor.login))).toEqual(['active'])

const withPast = partitionTiers(structuredClone(sponsors), tiers, true)
expect(withPast.flatMap(t => t.sponsors.map(s => s.sponsor.login)).sort())
.toEqual(['active', 'past'])
})

it('orders sponsors within a tier by createdAt ascending', () => {
const sponsors = [
sponsor('later', 10, '2024-03-01'),
sponsor('earlier', 10, '2024-01-01'),
]

const result = partitionTiers(sponsors, tiers)
const bucket = result.find(t => t.monthlyDollars === 10)!
expect(bucket.sponsors.map(s => s.sponsor.login)).toEqual(['earlier', 'later'])
})
})
82 changes: 82 additions & 0 deletions src/processing/svg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { SponsorkitRenderOptions } from '../types.ts'
import { describe, expect, it } from 'vitest'
import { genSvgImage, SvgComposer } from './svg.ts'

function createComposer(width = 800) {
return new SvgComposer({
width,
svgInlineCSS: '.text { fill: red }',
imageFormat: 'webp',
} as unknown as Required<SponsorkitRenderOptions>)
}

describe('svgComposer', () => {
it('starts empty', () => {
const composer = createComposer()
expect(composer.height).toBe(0)
expect(composer.body).toBe('')
})

it('addText appends centered text and advances the height', () => {
const composer = createComposer(800)
composer.addText('Hello')
expect(composer.height).toBe(20)
expect(composer.body).toContain('x="400"')
expect(composer.body).toContain('>Hello</text>')
expect(composer.body).toContain('class="text"')
})

it('addTitle uses the tier-title class', () => {
const composer = createComposer()
composer.addTitle('Backers')
expect(composer.body).toContain('class="sponsorkit-tier-title"')
expect(composer.body).toContain('>Backers</text>')
})

it('addSpan only advances the height', () => {
const composer = createComposer()
composer.addSpan(42)
expect(composer.height).toBe(42)
expect(composer.body).toBe('')
})

it('addRaw appends without touching the height', () => {
const composer = createComposer()
composer.addRaw('<rect />')
expect(composer.body).toBe('<rect />')
expect(composer.height).toBe(0)
})

it('is chainable', () => {
const composer = createComposer()
expect(composer.addSpan(10).addText('x')).toBe(composer)
})

it('generateSvg wraps the body with dimensions and inline css', () => {
const composer = createComposer(600)
composer.addText('Hi')
const svg = composer.generateSvg()
expect(svg).toContain('viewBox="0 0 600 20"')
expect(svg).toContain('width="600"')
expect(svg).toContain('height="20"')
expect(svg).toContain('<style>.text { fill: red }</style>')
expect(svg).toContain('>Hi</text>')
})
})

describe('genSvgImage', () => {
it('embeds the image as a base64 data uri', () => {
const svg = genSvgImage(1, 2, 40, 0.5, 'QUJD', 'png', 'crop-1')
expect(svg).toContain('href="data:image/png;base64,QUJD"')
expect(svg).toContain('x="1"')
expect(svg).toContain('y="2"')
expect(svg).toContain('width="40"')
expect(svg).toContain('rx="20"') // size * radius
})

it('uses the provided crop id for both the clip path and its reference', () => {
const svg = genSvgImage(0, 0, 40, 0.5, 'SAME', 'webp', 'render-crop-42')
expect(svg).toContain('<clipPath id="render-crop-42">')
expect(svg).toContain('clip-path="url(#render-crop-42)"')
})
})
158 changes: 158 additions & 0 deletions src/providers/github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { Mock } from 'vitest'
import type { SponsorkitConfig } from '../types.ts'
import { $fetch } from 'ofetch'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchGitHubSponsors,
makeQuery,
makeSponsoringQuery,
makeSponsoringTotalAmountQuery,
} from './github.ts'

vi.mock('ofetch', () => ({ $fetch: vi.fn() }))

const fetchMock = $fetch as unknown as Mock

beforeEach(() => {
fetchMock.mockReset()
})

describe('makeQuery', () => {
it('embeds the login and account type', () => {
expect(makeQuery('antfu', 'user')).toContain('user(login: "antfu")')
expect(makeQuery('antfu', 'organization')).toContain('organization(login: "antfu")')
})

it('queries sponsorships as a maintainer', () => {
expect(makeQuery('antfu', 'user')).toContain('sponsorshipsAsMaintainer(')
})

it('interpolates the activeOnly flag', () => {
expect(makeQuery('antfu', 'user', true)).toContain('activeOnly: true')
expect(makeQuery('antfu', 'user', false)).toContain('activeOnly: false')
})

it('adds an after cursor only when provided', () => {
expect(makeQuery('antfu', 'user', true)).not.toContain('after:')
expect(makeQuery('antfu', 'user', true, 'CURSOR')).toContain('after: "CURSOR"')
})
})

describe('makeSponsoringQuery', () => {
it('queries sponsorships as a sponsor', () => {
const query = makeSponsoringQuery('antfu', 'user', false, 'CURSOR')
expect(query).toContain('sponsorshipsAsSponsor(')
expect(query).toContain('activeOnly: false')
expect(query).toContain('after: "CURSOR"')
})
})

describe('makeSponsoringTotalAmountQuery', () => {
it('omits parameters when no options are given', () => {
const query = makeSponsoringTotalAmountQuery('antfu', 'user')
expect(query).toContain('totalSponsorshipAmountAsSponsorInCents\n')
expect(query).not.toContain('totalSponsorshipAmountAsSponsorInCents(')
})

it('serializes since, until and sponsorableLogins', () => {
const query = makeSponsoringTotalAmountQuery('antfu', 'user', {
since: '2024-01-01',
until: '2024-12-31',
sponsorableLogins: ['vuejs', 'vitejs'],
})
expect(query).toContain('since: "2024-01-01"')
expect(query).toContain('until: "2024-12-31"')
expect(query).toContain('sponsorableLogins: ["vuejs", "vitejs"]')
})
})

describe('fetchGitHubSponsors', () => {
it('validates its required arguments', async () => {
await expect(fetchGitHubSponsors('', 'antfu', 'user', {}))
.rejects
.toThrow('GitHub token is required')
await expect(fetchGitHubSponsors('token', '', 'user', {}))
.rejects
.toThrow('GitHub login is required')
await expect(fetchGitHubSponsors('token', 'antfu', 'invalid' as any, {}))
.rejects
.toThrow('GitHub type must be either `user` or `organization`')
})

it('paginates and maps the response into sponsorships', async () => {
const page = (login: string, hasNextPage: boolean, endCursor: string | null) => ({
data: {
user: {
sponsorshipsAsMaintainer: {
totalCount: 2,
pageInfo: { hasNextPage, endCursor },
nodes: [
{
createdAt: '2024-01-01T00:00:00Z',
privacyLevel: 'PUBLIC',
isActive: true,
tier: { name: 'Gold', isOneTime: false, monthlyPriceInCents: 1000, monthlyPriceInDollars: 10 },
sponsorEntity: { __typename: 'User', login, name: login, avatarUrl: 'a', websiteUrl: 'example.com' },
},
],
},
},
},
})

fetchMock
.mockResolvedValueOnce(page('alice', true, 'CURSOR'))
.mockResolvedValueOnce(page('bob', false, null))

const sponsors = await fetchGitHubSponsors('token', 'antfu', 'user', {})

expect(fetchMock).toHaveBeenCalledTimes(2)
// second request carries the cursor from the first page
expect(fetchMock.mock.calls[1][1].body.query).toContain('after: "CURSOR"')

expect(sponsors.map(s => s.sponsor.login)).toEqual(['alice', 'bob'])
expect(sponsors[0]).toMatchObject({
monthlyDollars: 10,
tierName: 'Gold',
isOneTime: false,
sponsor: {
login: 'alice',
type: 'User',
websiteUrl: 'https://example.com',
linkUrl: 'https://github.com/alice',
},
})
})

it('marks inactive non-prorated sponsors as past sponsors', async () => {
fetchMock.mockResolvedValueOnce({
data: {
user: {
sponsorshipsAsMaintainer: {
totalCount: 1,
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [
{
createdAt: '2024-01-01T00:00:00Z',
privacyLevel: 'PUBLIC',
isActive: false,
tier: { name: 'Gold', isOneTime: false, monthlyPriceInCents: 1000, monthlyPriceInDollars: 10 },
sponsorEntity: { __typename: 'User', login: 'past', name: 'past', avatarUrl: 'a' },
},
],
},
},
},
})

const sponsors = await fetchGitHubSponsors('token', 'antfu', 'user', {})
expect(sponsors[0].monthlyDollars).toBe(-1)
})

it('throws when the API returns errors', async () => {
fetchMock.mockResolvedValueOnce({ errors: [{ type: 'INSUFFICIENT_SCOPES' }] })
await expect(fetchGitHubSponsors('token', 'antfu', 'user', {} as SponsorkitConfig))
.rejects
.toThrow('read:user')
})
})
Loading