d3ro-voice/apps/mobile-rn/__tests__/teams.test.ts
Yun Chan 94d8bb8ebe feat(mobile): keep team, template, and report flows in sync with the server
Team, meeting, memo, template, command, and dictionary screens drifted from
the server contract, and report submission could hang instead of confirming
to the user. The screens now use the server responses directly.

The retired Expo shell is removed; the React Native app is the mobile client.
Gradle-generated vector-icon drawables are ignored rather than committed.
2026-09-16 23:25:51 +09:00

365 lines
12 KiB
TypeScript

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,
createTeamActivity,
createTeamInvite,
extractInviteToken,
normalizeInviteEmail,
normalizeInviteToken,
normalizeInviteUrl,
normalizeTeamActivity,
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 activity feed contract', () => {
test('normalizes a valid activity and rejects malformed rows', () => {
const activity = normalizeTeamActivity({
id: INVITE_ID,
team_id: TEAM_ID,
actor_id: USER_ID,
kind: 'note',
body: 'hello',
created_at: '2026-01-01T00:00:00.000Z',
})
expect(activity).toEqual({
id: INVITE_ID,
teamId: TEAM_ID,
actorId: USER_ID,
kind: 'note',
body: 'hello',
createdAt: '2026-01-01T00:00:00.000Z',
})
expect(() => normalizeTeamActivity({ id: 'not-a-uuid', team_id: TEAM_ID, kind: 'note' }))
.toThrow(TeamServiceError)
})
test('posts a trimmed note through the RPC and rejects empty notes', async () => {
mockRpc.mockResolvedValue({
data: {
id: INVITE_ID,
team_id: TEAM_ID,
actor_id: USER_ID,
kind: 'note',
body: 'hello',
created_at: '2026-01-01T00:00:00.000Z',
},
error: null,
})
const created = await createTeamActivity(TEAM_ID, 'note', ' hello ')
expect(mockRpc).toHaveBeenCalledWith('create_team_activity', {
p_team_id: TEAM_ID,
p_kind: 'note',
p_body: 'hello',
p_metadata: {},
})
expect(created.body).toBe('hello')
mockRpc.mockClear()
await expect(createTeamActivity(TEAM_ID, 'note', ' ')).rejects.toMatchObject({
code: 'validation',
})
expect(mockRpc).not.toHaveBeenCalled()
})
})
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}` },
{ event: '*', schema: 'public', table: 'team_activities', filter: `team_id=eq.${TEAM_ID}` },
])
})
})