// @vitest-environment jsdom import { type ComponentProps, flushSync, mount, unmount } from 'svelte' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Space } from '$lib/space.js' import NewMember from './NewMember.svelte' // Granting membership, and — in a private space — the ticket that grant is the precondition for. // // The claims worth holding are about ORDER and about what survives a half-failure, because both are // invisible in the rendering and both land on somebody else's screen when they are wrong: a ticket // minted for a DID with no grant names a device no member polls for, so every peer refuses it and // the invitee waits forever (ADR §24). So: the grant is written first, the mint is never reached if // it fails, and a mint that fails on its own leaves the panel saying the grant landed rather than // reporting the whole thing as broken. const written: string[][] = [] const toasts: string[] = [] let writeFails = '' vi.mock('$lib/auth.svelte.js', () => ({ account: { status: 'signed-in', did: 'did:plc:admin' }, })) vi.mock('$lib/ui.svelte.js', () => ({ toast: (message: string) => toasts.push(message), filtering: () => false, matches: () => true, })) vi.mock('$lib/write.js', () => ({ write: async (args: string[]) => { written.push(args) if (writeFails) throw new Error(writeFails) return { records: [] } }, })) const SPACE = 'at://did:plc:founder/com.disnetdev.radial.space/skunk' const space = (isPrivate: boolean): Space => ({ uri: SPACE, index: { private: isPrivate, space: { did: 'did:plc:founder' } }, directory: { get: () => ({ did: 'did:plc:admin', name: 'admin.test', role: 'admin', active: true }), }, fixture: false, }) as unknown as Space let target: HTMLElement | undefined let component: Record | undefined beforeEach(() => { written.length = 0 toasts.length = 0 writeFails = '' }) afterEach(() => { if (component) unmount(component as never) target?.remove() component = undefined target = undefined }) type Props = ComponentProps function render(props: Partial & Pick): HTMLElement { target = document.createElement('div') document.body.appendChild(target) component = mount(NewMember, { target, props: { close: () => undefined, ...props } }) as Record< string, unknown > flushSync() return target } /** Fill the handle and submit, the way the form is used. */ function grant(root: HTMLElement, actor: string): void { const input = root.querySelector('#member-actor') as HTMLInputElement input.value = actor input.dispatchEvent(new Event('input', { bubbles: true })) flushSync() ;(root.querySelector('form') as HTMLFormElement).requestSubmit() } const said = (root: HTMLElement): string => (root.textContent ?? '').replace(/\s+/g, ' ') describe('adding a member', () => { it('grants and closes in a public space, minting nothing', async () => { const mint = vi.fn() let closed = false const root = render({ space: space(false), close: () => (closed = true), mint }) expect(said(root)).toContain('Add a member') grant(root, 'someone.example.com') await vi.waitFor(() => { flushSync() expect(closed).toBe(true) }) expect(written).toEqual([ ['member', 'add', '--space', SPACE, '--actor', 'someone.example.com', '--kind', 'human', '--role', 'member'], ]) expect(mint).not.toHaveBeenCalled() expect(toasts[0]).toContain('Member added') }) it('grants BEFORE it mints, and shows the ticket without closing', async () => { // What the mint SAW when it ran, which is the only way to observe the order from here: a mint // that found no write behind it would be minting a ticket for somebody with no grant. const sawWrites: number[] = [] const mint = vi.fn(async () => { sawWrites.push(written.length) return { ticket: 'radial-ticket-1.abc', fingerprint: 'a1b2c3d4e5f6' } }) let closed = false const root = render({ space: space(true), close: () => (closed = true), mint }) expect(said(root)).toContain('Invite a member') grant(root, 'someone.example.com') await vi.waitFor(() => { flushSync() expect(root.querySelector('[data-ticket]')).not.toBeNull() }) expect(sawWrites).toEqual([1]) expect(written[0]?.slice(0, 2)).toEqual(['member', 'add']) // The panel is the receipt, so it stays up: a ticket auto-closed behind a toast is a secret // shown to nobody. expect(closed).toBe(false) expect((root.querySelector('[data-ticket]') as HTMLTextAreaElement).value).toBe( 'radial-ticket-1.abc', ) expect(said(root)).toContain('someone.example.com is granted') expect(toasts).toEqual([]) }) it('mints nothing when the grant fails', async () => { const mint = vi.fn() writeFails = 'the PDS refused that' const root = render({ space: space(true), mint }) grant(root, 'someone.example.com') await vi.waitFor(() => { flushSync() expect(said(root)).toContain('the PDS refused that') }) expect(mint).not.toHaveBeenCalled() expect(root.querySelector('[data-ticket]')).toBeNull() // Still the form, so the same submission can be retried rather than started over. expect(root.querySelector('#member-actor')).not.toBeNull() }) it('says the grant landed when only the mint fails', async () => { const mint = vi.fn(async () => { throw new Error('no active device is published for the founder') }) const root = render({ space: space(true), mint }) grant(root, 'someone.example.com') await vi.waitFor(() => { flushSync() expect(said(root)).toContain('The grant landed and the ticket did not') }) expect(written).toHaveLength(1) expect(said(root)).toContain('no active device is published for the founder') // Nothing to undo, and somewhere to go: the member is on the list, and their row mints one. expect(said(root)).toContain('Nothing needs undoing') expect(said(root)).toContain('Ticket button') }) })