diff --git a/internal/cloudcp/portal/frontend/src/account_controller.test.ts b/internal/cloudcp/portal/frontend/src/account_controller.test.ts
new file mode 100644
index 000000000..56a3c4e44
--- /dev/null
+++ b/internal/cloudcp/portal/frontend/src/account_controller.test.ts
@@ -0,0 +1,118 @@
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { installAccountController } from './account_controller';
+
+function jsonResponse(payload: unknown, ok = true, status = ok ? 200 : 500) {
+ return {
+ ok,
+ status,
+ json: vi.fn().mockResolvedValue(payload),
+ text: vi.fn().mockResolvedValue(typeof payload === 'string' ? payload : JSON.stringify(payload)),
+ };
+}
+
+async function flushAsync() {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await new Promise(function(resolve) {
+ setTimeout(resolve, 0);
+ });
+}
+
+const deps = {
+ getAccountAPIBasePath: vi.fn(),
+ getPortalAPIBasePath: vi.fn(),
+ getPortalPath: vi.fn(),
+ refreshBootstrap: vi.fn(),
+ showToast: vi.fn(),
+};
+
+describe('account controller', function() {
+ beforeAll(function() {
+ installAccountController(deps);
+ });
+
+ beforeEach(function() {
+ document.body.innerHTML = '';
+ vi.restoreAllMocks();
+ deps.getAccountAPIBasePath = vi.fn().mockReturnValue('/api/accounts');
+ deps.getPortalAPIBasePath = vi.fn().mockReturnValue('/api/portal');
+ deps.getPortalPath = vi.fn().mockReturnValue('/portal');
+ deps.refreshBootstrap = vi.fn().mockResolvedValue(true);
+ deps.showToast = vi.fn();
+ });
+
+ it('creates a workspace through the managed account action flow', async function() {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ tenant_id: 't_123' })));
+
+ document.body.innerHTML =
+ '' +
+ '
';
+
+ document.getElementById('toggle')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(true);
+
+ document.getElementById('create')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await flushAsync();
+
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/accounts/acct_1/tenants',
+ expect.objectContaining({
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ display_name: 'Acme Corp' }),
+ })
+ );
+ expect(deps.refreshBootstrap).toHaveBeenCalled();
+ expect(deps.showToast).toHaveBeenCalledWith('Workspace created!');
+ expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).style.display).toBe('none');
+ });
+
+ it('loads and updates team membership from delegated team actions', async function() {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn()
+ .mockResolvedValueOnce(jsonResponse([{ email: 'owner@example.com', role: 'owner', user_id: 'u1' }]))
+ .mockResolvedValueOnce(jsonResponse({}))
+ .mockResolvedValueOnce(jsonResponse([{ email: 'owner@example.com', role: 'admin', user_id: 'u1' }]))
+ );
+
+ document.body.innerHTML =
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+
+ document.getElementById('team-toggle')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await flushAsync();
+
+ var roleSelect = document.querySelector('[data-action="change-role"]') as HTMLSelectElement | null;
+ expect(roleSelect).not.toBeNull();
+ expect(roleSelect?.value).toBe('owner');
+
+ roleSelect!.value = 'admin';
+ roleSelect!.dispatchEvent(new Event('change', { bubbles: true }));
+ await flushAsync();
+
+ expect(fetch).toHaveBeenNthCalledWith(1, '/api/accounts/acct_1/members');
+ expect(fetch).toHaveBeenNthCalledWith(
+ 2,
+ '/api/accounts/acct_1/members/u1',
+ expect.objectContaining({
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ role: 'admin' }),
+ })
+ );
+ expect(deps.refreshBootstrap).toHaveBeenCalled();
+ expect(deps.showToast).toHaveBeenCalledWith('Role updated.');
+ });
+});