import { afterEach, describe, expect, it, vi } from 'vitest'; import { HttpUsersStore } from '../../src/core/store/HttpUsersStore'; import { accountActions, nameProblem, newPasswordProblem } from '../../src/editor/users/accountRules'; import { MIN_PASSWORD_LENGTH } from '../../src/editor/auth/loginRules'; function stubFetch(body: BodyInit | null, init: ResponseInit) { const mock = vi.fn(() => Promise.resolve(new Response(body, init))); vi.stubGlobal('fetch', mock); return mock; } afterEach(() => { vi.unstubAllGlobals(); }); describe('HttpUsersStore (#179)', () => { it('lists the accounts with their projects, an unknown role read as user', async () => { const mock = stubFetch( JSON.stringify([ { username: 'admin', role: 'admin', createdAt: '', projects: [] }, { username: 'odd', role: 'root', createdAt: '2026-09-23T10:00:00+00:00', projects: [{ id: 'p1' }] }, ]), { status: 200 }, ); const accounts = await new HttpUsersStore('/api').list(); expect(accounts.map((a) => [a.username, a.role])).toEqual([ ['admin', 'admin'], ['odd', 'user'], ]); expect(accounts[1]?.projects).toHaveLength(1); expect(mock.mock.calls[0]?.[0] as string).toContain('r=users'); }); it('creates, changes and deletes through their own routes and methods', async () => { const mock = stubFetch(JSON.stringify({}), { status: 200 }); const store = new HttpUsersStore('/api'); await store.create('anna', 'a-password', 'admin'); await store.update('anna', { username: 'annabel' }); stubFetch(null, { status: 204 }); await store.remove('annabel'); const [createUrl, create] = mock.mock.calls[0]!; expect(createUrl as string).toMatch(/r=users$/); expect(create?.method).toBe('POST'); expect(JSON.parse(create?.body as string)).toEqual({ username: 'anna', password: 'a-password', role: 'admin', }); const [patchUrl, patch] = mock.mock.calls[1]!; expect(patchUrl as string).toContain('r=users%2Fanna'); expect(patch?.method).toBe('PATCH'); // Only what changes travels: a field left out is one the server leaves alone. expect(JSON.parse(patch?.body as string)).toEqual({ username: 'annabel' }); }); // The dialogs show the error as it stands, so it has to be the server's sentence and nothing else. it('turns a refusal into the server’s own sentence', async () => { stubFetch(JSON.stringify({ error: 'anna still owns 2 projects' }), { status: 409 }); await expect(new HttpUsersStore('/api').remove('anna')).rejects.toThrow(/^anna still owns 2 projects$/); stubFetch(JSON.stringify({ error: 'Administrators only' }), { status: 403 }); await expect(new HttpUsersStore('/api').list()).rejects.toThrow(/^Administrators only$/); }); it('keeps route and status for a failure that is no refusal', async () => { stubFetch(JSON.stringify({ error: 'Internal server error' }), { status: 500 }); await expect(new HttpUsersStore('/api').list()).rejects.toThrow(/Internal server error \(500\)/); }); }); describe('the rules of the user management forms (#179)', () => { it('asks for a name the server takes', () => { expect(nameProblem('')).toBe('Enter a user name'); expect(nameProblem('a')).not.toBeNull(); expect(nameProblem('a b')).not.toBeNull(); expect(nameProblem('-dash-first')).not.toBeNull(); expect(nameProblem('x'.repeat(33))).not.toBeNull(); expect(nameProblem('anna.b_2-x')).toBeNull(); expect(nameProblem('x'.repeat(32))).toBeNull(); }); it('asks for a password long enough, typed twice the same', () => { const long = 'a'.repeat(MIN_PASSWORD_LENGTH); expect(newPasswordProblem('', '')).toBe('Enter a password'); expect(newPasswordProblem(long.slice(1), long.slice(1))).toBe( `The password needs at least ${MIN_PASSWORD_LENGTH} characters`, ); expect(newPasswordProblem(long, `${long}x`)).toBe('The two passwords are different'); expect(newPasswordProblem(long, long)).toBeNull(); }); it('offers nothing but a new password on the administrator’s own row', () => { expect(accountActions('admin', 'Admin')).toEqual({ rename: false, role: false, remove: false }); expect(accountActions('admin', 'anna')).toEqual({ rename: true, role: true, remove: true }); }); });