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 = { 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}` }, ]) }) })