import type { StubServer } from '@test/fixtures/stub-server' import { testHttpClient } from '@test/fixtures/http-client' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, describe, expect, it } from 'vitest' import { isHttpClientError } from '@/errors/base' import { MembersClient } from './members.js' import { WorkspacesClient } from './workspaces.js' function makeClient(host: string): MembersClient { return new MembersClient(testHttpClient(host, 'dfoa_test')) } describe('MembersClient.list', () => { let stub: StubServer afterEach(async () => { await stub?.stop() }) it('GETs /workspaces//members and returns parsed envelope', async () => { stub = await startStubServer(cap => jsonResponder( 200, { page: 1, limit: 20, total: 1, has_more: false, data: [ { id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' }, ], }, cap, )) const result = await makeClient(stub.url).list('ws-1') expect(stub.captured.method).toBe('GET') expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members') expect(result.data[0]?.email).toBe('mia@e.com') }) it('URL-encodes workspace id', async () => { stub = await startStubServer(cap => jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap)) await makeClient(stub.url).list('ws with space') expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws%20with%20space/members') }) it('forwards page/limit as query params', async () => { stub = await startStubServer(cap => jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap)) await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 }) expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members?page=2&limit=50') }) it('propagates server 403 as classified BaseError', async () => { stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap)) await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy( err => isHttpClientError(err) && err.httpStatus === 403, ) }) it('propagates 404 as classified BaseError', async () => { stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap)) await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy( err => isHttpClientError(err) && err.httpStatus === 404, ) }) }) describe('MembersClient.invite', () => { let stub: StubServer afterEach(async () => { await stub?.stop() }) it('POSTs JSON body and returns parsed invite response', async () => { stub = await startStubServer(cap => jsonResponder( 201, { result: 'success', email: 'new@e.com', role: 'normal', member_id: 'acct-9', invite_url: 'https://console.example.com/activate?email=new&token=tok', tenant_id: 'ws-1', }, cap, )) const result = await makeClient(stub.url).invite('ws-1', { email: 'new@e.com', role: 'normal', }) expect(stub.captured.method).toBe('POST') expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members') expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({ email: 'new@e.com', role: 'normal', }) expect(result.member_id).toBe('acct-9') expect(result.invite_url).toContain('token=tok') }) it('propagates 400 (already in tenant) as classified BaseError', async () => { stub = await startStubServer(cap => jsonResponder(400, { error: 'already in tenant' }, cap)) await expect( makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }), ).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400) }) }) describe('MembersClient.remove', () => { let stub: StubServer afterEach(async () => { await stub?.stop() }) it('DELETEs member by id and returns success', async () => { stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap)) const result = await makeClient(stub.url).remove('ws-1', 'm-1') expect(stub.captured.method).toBe('DELETE') expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1') expect(result.result).toBe('success') }) it('propagates 400 (cannot operate self / cannot remove owner)', async () => { stub = await startStubServer(cap => jsonResponder(400, { error: 'cannot operate self' }, cap)) await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy( err => isHttpClientError(err) && err.httpStatus === 400, ) }) }) describe('MembersClient.updateRole', () => { let stub: StubServer afterEach(async () => { await stub?.stop() }) it('PUTs role payload to /role subresource', async () => { stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap)) const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }) expect(stub.captured.method).toBe('PUT') expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1/role') expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({ role: 'admin' }) expect(result.result).toBe('success') }) it('propagates 400 (admin cannot demote owner)', async () => { stub = await startStubServer(cap => jsonResponder(400, { error: 'no permission' }, cap)) await expect( makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }), ).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400) }) }) describe('WorkspacesClient.switch (integration with stub)', () => { let stub: StubServer afterEach(async () => { await stub?.stop() }) it('POSTs /workspaces//switch and returns workspace detail', async () => { stub = await startStubServer(cap => jsonResponder( 200, { id: 'ws-1', name: 'Workspace 1', role: 'owner', status: 'normal', current: true, created_at: '2026-05-18T00:00:00Z', }, cap, )) const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test')) const result = await client.switch('ws-1') expect(stub.captured.method).toBe('POST') expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/switch') expect(result.current).toBe(true) }) it('propagates 404 (non-member)', async () => { stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap)) const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test')) await expect(client.switch('ws-x')).rejects.toSatisfy( err => isHttpClientError(err) && err.httpStatus === 404, ) }) })