feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
311
apps/mobile-rn/__tests__/teams.test.ts
Normal file
311
apps/mobile-rn/__tests__/teams.test.ts
Normal 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}` },
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue