import { render, screen, fireEvent, act } from '@testing-library/react'
import {
MembersTable,
RoleSelector,
InviteMemberDialog,
PendingInvitationCallout,
DomainClaimCard,
LeaveTeamDialog,
OrgSwitcher,
} from '../index'
import type { MemberRow, InvitationLite, DomainClaimLite, TeamLite, CompanyLite } from '../types'
// ---------- MembersTable ---------------------------------------------------
describe('MembersTable', () => {
const baseMembers: MemberRow[] = [
{
id: 'm1',
userId: 'u1',
teamId: 't1',
role: 'owner',
joinedAt: '2025-01-01',
user: { id: 'u1', email: 'owner@x.com', firstName: 'Olivia', lastName: 'Owner' },
},
{
id: 'm2',
userId: 'u2',
teamId: 't1',
role: 'member',
joinedAt: '2025-01-02',
user: { id: 'u2', email: 'mem@x.com', firstName: 'Mike', lastName: 'Member' },
},
]
it('renders a row per member with name + email + role', () => {
render()
expect(screen.getByText('Olivia Owner')).toBeInTheDocument()
expect(screen.getByText('owner@x.com')).toBeInTheDocument()
expect(screen.getAllByText('Owner').length).toBeGreaterThan(0)
expect(screen.getByText('Mike Member')).toBeInTheDocument()
})
it('hides the actions column when canManage is false', () => {
render()
expect(screen.queryByText('Remove')).not.toBeInTheDocument()
})
it('shows RoleSelector + Remove when canManage is true', () => {
const onChangeRole = jest.fn()
const onRemove = jest.fn()
render(
)
const removes = screen.getAllByText('Remove')
expect(removes).toHaveLength(2)
fireEvent.click(removes[1])
expect(onRemove).toHaveBeenCalledWith('u2')
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]
fireEvent.change(selects[1], { target: { value: 'admin' } })
expect(onChangeRole).toHaveBeenCalledWith('u2', 'admin')
})
it('respects classNames overrides', () => {
const { container } = render(
)
expect(container.firstChild).toHaveClass('my-root-class')
})
it('does not print the email twice when it is also the display name', () => {
// Foundry maps control-plane members with an email and no name, so
// userDisplayName() falls back to the email — the row used to stack the
// same string on itself. startsim-1f3n.4.
render(
)
expect(screen.getAllByText('qosha@debugg.ai')).toHaveLength(1)
})
it('marks the signed-in user so they can tell which row is them', () => {
// startsim-1f3n.7 — the owner appeared as an anonymous row next to a live
// Remove button with nothing saying "this one is you".
render()
expect(screen.getByText('You')).toBeInTheDocument()
const selfRow = screen.getByText('Mike Member').closest('tr')!
expect(selfRow).toContainElement(screen.getByText('You'))
})
it('renders no You badge when currentUserId matches nobody', () => {
render()
expect(screen.queryByText('You')).not.toBeInTheDocument()
})
it('keeps the Role and actions columns to their content width', () => {
// startsim-1f3n.6 — one shared cell class meant Member ate ~55% of the
// table and Role/Remove floated in a gap.
render()
const roleHeader = screen.getByRole('columnheader', { name: 'Role' })
expect(roleHeader.className).toContain('w-px')
expect(roleHeader.className).toContain('whitespace-nowrap')
})
it('shows the empty state when no members', () => {
render()
expect(screen.getByText('No members yet.')).toBeInTheDocument()
})
})
// ---------- RoleSelector ---------------------------------------------------
describe('RoleSelector', () => {
it('renders the supplied roles and fires onChange', () => {
const onChange = jest.fn()
render(
)
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('member')
// Only the three supplied roles should be rendered.
const options = Array.from(select.querySelectorAll('option')).map((o) => o.value)
expect(options).toEqual(['admin', 'member', 'viewer'])
fireEvent.change(select, { target: { value: 'admin' } })
expect(onChange).toHaveBeenCalledWith('admin')
})
it('honors disabled', () => {
render( {}} disabled />)
expect(screen.getByRole('combobox')).toBeDisabled()
})
})
// ---------- InviteMemberDialog --------------------------------------------
describe('InviteMemberDialog', () => {
it('does not render the panel until the trigger is clicked', () => {
render()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /invite member/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('submits email + role + teamId via onSubmit and shows a success line', async () => {
const onSubmit = jest.fn().mockResolvedValue({ id: 'i1' } as InvitationLite)
const onInvited = jest.fn()
render(
)
fireEvent.click(screen.getByRole('button', { name: /invite member/i }))
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'newhire@x.com' },
})
fireEvent.change(screen.getByLabelText('Invite role'), {
target: { value: 'admin' },
})
await act(async () => {
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!)
})
expect(onSubmit).toHaveBeenCalledWith({
email: 'newhire@x.com',
teamId: 't1',
role: 'admin',
})
expect(onInvited).toHaveBeenCalled()
expect(screen.getByText(/Invitation sent to newhire@x.com/)).toBeInTheDocument()
})
it('surfaces submit errors inline', async () => {
const onSubmit = jest.fn().mockRejectedValue(new Error('domain not allowed'))
render()
fireEvent.click(screen.getByRole('button', { name: /invite member/i }))
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'a@x.com' } })
await act(async () => {
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!)
})
expect(screen.getByRole('alert')).toHaveTextContent('domain not allowed')
})
it('honors classNames slots', () => {
render(
)
expect(screen.getByRole('button', { name: /invite member/i })).toHaveClass(
'my-trigger'
)
})
})
// ---------- PendingInvitationCallout --------------------------------------
describe('PendingInvitationCallout', () => {
const invitation: InvitationLite = {
id: 'i1',
email: 'a@x.com',
teamId: 't1',
role: 'admin',
expiresAt: '2099-01-01',
isExpired: false,
isAccepted: false,
}
it('renders the message + team name + role', () => {
render(
)
expect(screen.getByText(/You.*invited/i)).toBeInTheDocument()
expect(screen.getByText('Acme Eng')).toBeInTheDocument()
expect(screen.getByText('admin')).toBeInTheDocument()
})
it('falls back to "team #" when no team name is supplied', () => {
render()
expect(screen.getByText('team #t1')).toBeInTheDocument()
})
it('calls onAccept + onAccepted', async () => {
const onAccept = jest.fn().mockResolvedValue(undefined)
const onAccepted = jest.fn()
render(
)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /accept/i }))
})
expect(onAccept).toHaveBeenCalledWith(invitation)
expect(onAccepted).toHaveBeenCalledWith(invitation)
})
it('only renders Decline when an onDecline is provided', () => {
const { rerender } = render(
)
expect(screen.queryByRole('button', { name: /decline/i })).not.toBeInTheDocument()
rerender(
)
expect(screen.getByRole('button', { name: /decline/i })).toBeInTheDocument()
})
})
// ---------- DomainClaimCard ------------------------------------------------
describe('DomainClaimCard', () => {
const baseClaim: DomainClaimLite = {
id: 'd1',
companyId: 'c1',
domain: 'acme.com',
verified: false,
verificationToken: 'startsim-verify=xyz',
createdAt: '2025-01-01',
}
it('renders the DNS TXT block when a verification token is present', () => {
render()
expect(screen.getByText('acme.com')).toBeInTheDocument()
expect(screen.getByText('startsim-verify=xyz')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /verify dns/i })).toBeInTheDocument()
})
it('hides verification UI once verified=true', () => {
render()
expect(screen.getByText('Verified')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /verify dns/i })).not.toBeInTheDocument()
})
it('initiate-email then submit-code calls the verify handler', async () => {
const onInitiateEmail = jest.fn().mockResolvedValue(undefined)
const onVerifyEmail = jest.fn().mockResolvedValue(undefined)
render(
)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /email attestation/i }))
})
expect(onInitiateEmail).toHaveBeenCalledWith(baseClaim)
const code = screen.getByLabelText('Attestation code')
fireEvent.change(code, { target: { value: '123456' } })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /submit code/i }))
})
expect(onVerifyEmail).toHaveBeenCalledWith(baseClaim, '123456')
})
it('renders Revoke when onRevoke is provided', () => {
render()
expect(screen.getByRole('button', { name: /revoke/i })).toBeInTheDocument()
})
})
// ---------- LeaveTeamDialog ------------------------------------------------
describe('LeaveTeamDialog', () => {
const team: TeamLite = { id: 't1', slug: 'eng', name: 'Engineering', companyId: 'c1' }
it('opens on trigger click, fires onSubmit + onLeft on confirm', async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined)
const onLeft = jest.fn()
render()
fireEvent.click(screen.getByRole('button', { name: /leave team/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
await act(async () => {
// Click the confirm button (red); it's the second 'Leave team' on screen.
fireEvent.click(screen.getAllByRole('button', { name: /leave team/i })[1])
})
expect(onSubmit).toHaveBeenCalledWith(team)
expect(onLeft).toHaveBeenCalledWith(team)
})
it('surfaces backend "last owner" errors inline', async () => {
const onSubmit = jest.fn().mockRejectedValue(new Error('Cannot remove the last owner'))
render()
fireEvent.click(screen.getByRole('button', { name: /leave team/i }))
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: /leave team/i })[1])
})
expect(screen.getByRole('alert')).toHaveTextContent(
/cannot remove the last owner/i
)
})
})
// ---------- OrgSwitcher ----------------------------------------------------
describe('OrgSwitcher', () => {
const companies: CompanyLite[] = [
{ id: 'c1', slug: 'acme', name: 'Acme' },
{ id: 'c2', slug: 'beta', name: 'Beta' },
]
it('renders an option per company + fires onSwitch on change', () => {
const onSwitch = jest.fn()
render(
)
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('c1')
fireEvent.change(select, { target: { value: 'c2' } })
expect(onSwitch).toHaveBeenCalledWith('c2')
})
it('renders nothing when there are no companies', () => {
const { container } = render(
{}} />
)
expect(container.firstChild).toBeNull()
})
})