feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,311 @@
jest.mock('../src/lib/supabase', () => ({
supabase: {
rpc: jest.fn(),
functions: { invoke: jest.fn() },
channel: jest.fn(),
removeChannel: jest.fn(async () => 'ok'),
from: jest.fn(),
},
}))
const mockedSupabase = jest.requireMock('../src/lib/supabase').supabase as {
rpc: jest.Mock
functions: { invoke: jest.Mock }
channel: jest.Mock
}
const mockRpc = mockedSupabase.rpc
const mockFunctionsInvoke = mockedSupabase.functions.invoke
const mockRealtimeOn = jest.fn()
const mockRealtimeSubscribe = jest.fn()
const mockRealtimeChannel: Record<string, unknown> = {
on: mockRealtimeOn,
subscribe: mockRealtimeSubscribe,
}
mockRealtimeOn.mockReturnValue(mockRealtimeChannel)
mockRealtimeSubscribe.mockReturnValue(mockRealtimeChannel)
mockedSupabase.channel.mockReturnValue(mockRealtimeChannel)
import {
acceptTeamInvite,
cancelTeamInvite,
createTeam,
createTeamInvite,
extractInviteToken,
normalizeInviteEmail,
normalizeInviteToken,
normalizeInviteUrl,
normalizeTeamInvite,
normalizeTeamMember,
normalizeTeamName,
subscribeToTeam,
TeamServiceError,
updateTeamMemberRole,
} from '../src/features/teams/team-service'
const USER_ID = '11111111-1111-4111-8111-111111111111'
const MEMBER_ID = '22222222-2222-4222-8222-222222222222'
const TEAM_ID = '33333333-3333-4333-8333-333333333333'
const INVITE_ID = '44444444-4444-4444-8444-444444444444'
const TOKEN = 'Abcdefghijklmnopqrstuvwxyz_12345'
describe('team input and response contracts', () => {
test('normalizes bounded names and email addresses', () => {
expect(normalizeTeamName(' Product Voice ')).toBe('Product Voice')
expect(normalizeInviteEmail(' MEMBER@Example.COM ')).toBe('member@example.com')
expect(() => normalizeTeamName(' ')).toThrow(TeamServiceError)
expect(() => normalizeTeamName('a'.repeat(81))).toThrow(TeamServiceError)
expect(() => normalizeInviteEmail('not-an-email')).toThrow(TeamServiceError)
})
test('extracts only URL-safe 24-byte invitation tokens', () => {
expect(normalizeInviteToken(TOKEN)).toBe(TOKEN)
expect(extractInviteToken(`https://d3ro.chanpaca.net/accept-invite?token=${TOKEN}`)).toBe(TOKEN)
expect(extractInviteToken(`d3ro-voice://accept-invite?token=${TOKEN}&source=email`)).toBe(TOKEN)
expect(() => extractInviteToken('https://d3ro.chanpaca.net/accept-invite?token=short'))
.toThrow('Invite token is invalid')
expect(() => extractInviteToken('https://d3ro.chanpaca.net/accept-invite'))
.toThrow('does not contain a token')
})
test('accepts only the production invitation origin and exact bearer URL shape', () => {
expect(normalizeInviteUrl(`https://d3ro.chanpaca.net/accept-invite/?token=${TOKEN}`))
.toBe(`https://d3ro.chanpaca.net/accept-invite/?token=${TOKEN}`)
expect(() => normalizeInviteUrl(`https://evil.example/accept-invite?token=${TOKEN}`))
.toThrow('not trusted')
expect(() => normalizeInviteUrl(`https://d3ro.chanpaca.net/accept-invite/?token=${TOKEN}&next=https://evil.example`))
.toThrow('not trusted')
expect(normalizeInviteUrl(`https://d3ro.chanpaca.net/accept-invite?token=${TOKEN}`))
.toBe(`https://d3ro.chanpaca.net/accept-invite?token=${TOKEN}`)
expect(() => normalizeInviteUrl(`https://d3ro.chanpaca.net/other?token=${TOKEN}`))
.toThrow('not trusted')
expect(() => normalizeInviteUrl(`https://d3ro.chanpaca.net/accept-invite-evil?token=${TOKEN}`))
.toThrow('not trusted')
})
test('accepts sanitized member directory rows', () => {
expect(normalizeTeamMember({
user_id: MEMBER_ID,
role: 'member',
joined_at: '2026-08-21T00:00:00.000Z',
display_name: 'Member',
avatar_url: null,
is_current: false,
})).toEqual({
userId: MEMBER_ID,
role: 'member',
joinedAt: '2026-08-21T00:00:00.000Z',
displayName: 'Member',
avatarUrl: null,
})
})
test('rejects any bearer token leaked by the invitation listing RPC', () => {
expect(() => normalizeTeamInvite({
id: INVITE_ID,
email: 'member@example.com',
role: 'member',
expires_at: '2026-08-28T00:00:00.000Z',
created_at: '2026-08-21T00:00:00.000Z',
token: TOKEN,
})).toThrow('leaked a bearer token')
expect(() => normalizeTeamInvite({
id: INVITE_ID,
email: 'member@example.com',
role: 'member',
expires_at: '2026-08-28T00:00:00.000Z',
created_at: '2026-08-21T00:00:00.000Z',
invite_token: TOKEN,
})).toThrow('leaked a bearer token')
})
})
describe('team mutation safety', () => {
beforeEach(() => {
mockRpc.mockReset()
mockFunctionsInvoke.mockReset()
})
test('creates owner membership only through the atomic create_team RPC', async () => {
mockRpc.mockResolvedValue({
data: {
id: TEAM_ID,
name: 'Product',
owner_id: USER_ID,
role: 'owner',
member_count: 1,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T00:00:00.000Z',
},
error: null,
})
await expect(createTeam(USER_ID, ' Product ')).resolves.toMatchObject({
id: TEAM_ID,
role: 'owner',
memberCount: 1,
avatar_url: null,
})
expect(mockRpc).toHaveBeenCalledWith('create_team', { team_name: 'Product' })
})
test('changes roles through the invariant-preserving RPC and verifies the new directory row', async () => {
mockRpc
.mockResolvedValueOnce({ data: { user_id: MEMBER_ID, role: 'admin' }, error: null })
.mockResolvedValueOnce({
data: [{
user_id: MEMBER_ID,
role: 'admin',
joined_at: '2026-08-21T00:00:00.000Z',
display_name: 'Member',
avatar_url: null,
}],
error: null,
})
await expect(updateTeamMemberRole(TEAM_ID, MEMBER_ID, 'admin'))
.resolves.toMatchObject({ userId: MEMBER_ID, role: 'admin' })
expect(mockRpc).toHaveBeenNthCalledWith(1, 'update_team_member_role', {
target_team_id: TEAM_ID,
member_user_id: MEMBER_ID,
new_role: 'admin',
})
expect(mockRpc).toHaveBeenNthCalledWith(2, 'list_team_members', {
target_team_id: TEAM_ID,
})
})
test('does not allow a client to request owner promotion', async () => {
await expect(updateTeamMemberRole(TEAM_ID, MEMBER_ID, 'owner' as 'admin'))
.rejects.toMatchObject({ code: 'validation' })
expect(mockRpc).not.toHaveBeenCalled()
})
test('validates invitation function success and idempotent acceptance', async () => {
mockFunctionsInvoke
.mockResolvedValueOnce({
data: {
id: INVITE_ID,
url: `https://d3ro.chanpaca.net/accept-invite/?token=${TOKEN}`,
expires_at: '2026-08-28T00:00:00.000Z',
email_sent: false,
email_error: null,
},
error: null,
})
.mockResolvedValueOnce({
data: {
team_id: TEAM_ID,
role: 'member',
already_member: true,
},
error: null,
})
await expect(createTeamInvite(TEAM_ID, 'MEMBER@example.com', 'member'))
.resolves.toMatchObject({ id: INVITE_ID, emailSent: false })
expect(mockFunctionsInvoke).toHaveBeenNthCalledWith(1, 'team-invite', {
body: { team_id: TEAM_ID, email: 'member@example.com', role: 'member' },
})
await expect(acceptTeamInvite(TOKEN)).resolves.toEqual({
teamId: TEAM_ID,
role: 'member',
alreadyMember: true,
})
})
test.each([
['invite_not_found', 404, 'invalid-token'],
['invite_expired', 410, 'expired'],
['invite_email_mismatch', 403, 'email-mismatch'],
['invite_already_accepted', 409, 'duplicate'],
['invite_acceptance_conflict', 409, 'conflict'],
])('maps team-accept Edge error %s without losing its domain meaning', async (
edgeCode,
status,
expectedCode,
) => {
mockFunctionsInvoke.mockResolvedValue({
data: null,
error: {
context: new Response(JSON.stringify({ error: edgeCode, message: edgeCode }), {
status,
headers: { 'content-type': 'application/json' },
}),
},
})
await expect(acceptTeamInvite(TOKEN)).rejects.toMatchObject({ code: expectedCode })
})
test.each([
['already_team_member', 409, 'duplicate'],
['team_admin_required', 403, 'forbidden'],
['invite_rate_limited', 429, 'rate-limited'],
])('maps team-invite Edge error %s without reporting generic server failure', async (
edgeCode,
status,
expectedCode,
) => {
mockFunctionsInvoke.mockResolvedValue({
data: null,
error: {
context: new Response(JSON.stringify({ error: edgeCode, message: edgeCode }), {
status,
headers: { 'content-type': 'application/json' },
}),
},
})
await expect(createTeamInvite(TEAM_ID, 'member@example.com', 'member'))
.rejects.toMatchObject({ code: expectedCode })
})
test('maps a missing member RPC row to not-found instead of a retryable server error', async () => {
mockRpc.mockResolvedValue({
data: null,
error: { code: 'P0002', message: 'team_member_not_found' },
})
await expect(updateTeamMemberRole(TEAM_ID, MEMBER_ID, 'admin'))
.rejects.toMatchObject({ code: 'not-found' })
expect(mockRpc).toHaveBeenCalledTimes(1)
})
test('keeps invite-not-found distinct for cancellation and bearer-token acceptance', async () => {
mockRpc.mockResolvedValue({
data: null,
error: { code: 'P0002', message: 'invite_not_found' },
})
await expect(cancelTeamInvite(TEAM_ID, INVITE_ID))
.rejects.toMatchObject({ code: 'not-found' })
mockFunctionsInvoke.mockResolvedValue({
data: null,
error: {
context: new Response(JSON.stringify({
error: 'invite_not_found',
message: 'invite_not_found',
}), { status: 404, headers: { 'content-type': 'application/json' } }),
},
})
await expect(acceptTeamInvite(TOKEN)).rejects.toMatchObject({ code: 'invalid-token' })
})
})
describe('team realtime contract', () => {
test('filters every mutable team table by the selected team', () => {
mockRealtimeOn.mockClear()
subscribeToTeam(TEAM_ID, jest.fn(), jest.fn())
const filters = mockRealtimeOn.mock.calls.map((call) => call[1] as {
table: string
filter: string
})
expect(filters).toEqual([
{ event: '*', schema: 'public', table: 'teams', filter: `id=eq.${TEAM_ID}` },
{ event: '*', schema: 'public', table: 'team_members', filter: `team_id=eq.${TEAM_ID}` },
{ event: '*', schema: 'public', table: 'team_invites', filter: `team_id=eq.${TEAM_ID}` },
{ event: '*', schema: 'public', table: 'meetings', filter: `team_id=eq.${TEAM_ID}` },
])
})
})