f1a90e120e
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own picture with no user id, and the host stats never carried avatarAt, so every card fell back to letters. The groups now carry avatarAt and the card passes userId. The user editor's header, the pool board's member chips and the pool member picker drew hand-made initials; they use UserAvatar too.
596 lines
23 KiB
TypeScript
596 lines
23 KiB
TypeScript
/**
|
|
* @file AccountPoolCard.test.tsx
|
|
* @description The Users page's account pool card: the pools board with every
|
|
* tool's accounts (live panes and quota) and members per pool, then a tab per
|
|
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
|
|
* other installed tool; an
|
|
* admin adds an account only with their own password typed (the token goes
|
|
* in once and is not shown back) — a saved login moved in (the server's own
|
|
* included), or a setup-token — and removes one the same way, and an analyst
|
|
* sees no add or remove control. An account's Edit ticks the users who run on
|
|
* it, saved with the admin's own password; the users show on the account.
|
|
* The board moves members and accounts between pools (drag, or a column's
|
|
* menu) — an account sits in one pool, so a move takes it out of the other —
|
|
* splits an account off onto New pool, merges a pool by dragging its only
|
|
* account into another, adds a saved profile to a pool, shows each account's
|
|
* plan, and saves every change with ONE password.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import type { AccountPool } from "../../../lib/api";
|
|
|
|
const accountPool = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
add: vi.fn(),
|
|
remove: vi.fn(),
|
|
profiles: vi.fn(),
|
|
setUsers: vi.fn(),
|
|
createGroup: vi.fn(),
|
|
updateGroup: vi.fn(),
|
|
deleteGroup: vi.fn(),
|
|
}));
|
|
const users = vi.hoisted(() => ({ list: vi.fn(), avatarUrl: () => null }));
|
|
const aiTools = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
ompSharing: vi.fn(),
|
|
setOmpSharing: vi.fn(),
|
|
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
|
}));
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return { ...actual, api: { accountPool, users, aiTools } };
|
|
});
|
|
|
|
import { AccountPoolCard } from "../AccountPoolCard";
|
|
|
|
/** The card reads `?poolAdd=` and links to Settings: it needs a router. */
|
|
const card = (isAdmin: boolean, url = "/admin") => (
|
|
<MemoryRouter initialEntries={[url]}>
|
|
<AccountPoolCard isAdmin={isAdmin} />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const POOL: AccountPool = {
|
|
accounts: [
|
|
{
|
|
name: "team-1",
|
|
kind: "login",
|
|
email: "team1@example.com",
|
|
refreshError: null,
|
|
addedAt: "2026-10-01T00:00:00Z",
|
|
expiresAt: null,
|
|
expired: false,
|
|
livePanes: 2,
|
|
spend5h: 1.5,
|
|
spend7d: 12,
|
|
tokens5h: 250_000,
|
|
tokens7d: 3_400_000,
|
|
quota: {
|
|
fiveHour: {
|
|
utilization: 42,
|
|
resetsAt: new Date(Date.now() + 2 * 3600e3 + 630e3).toISOString(),
|
|
},
|
|
sevenDay: {
|
|
utilization: 15,
|
|
resetsAt: new Date(Date.now() + 3 * 86400e3 + 3630e3).toISOString(),
|
|
},
|
|
checkedAt: "2026-10-01T00:00:00Z",
|
|
error: null,
|
|
},
|
|
quotaPercent: 42,
|
|
full: false,
|
|
},
|
|
{
|
|
name: "team-2",
|
|
kind: "token",
|
|
email: null,
|
|
refreshError: null,
|
|
addedAt: "2026-10-01T00:00:00Z",
|
|
expiresAt: null,
|
|
expired: false,
|
|
livePanes: 0,
|
|
spend5h: 0,
|
|
spend7d: 0,
|
|
tokens5h: 0,
|
|
tokens7d: 0,
|
|
// A 5-hour window that has reset since the reading is full again.
|
|
quota: {
|
|
fiveHour: { utilization: 100, resetsAt: "2020-01-01T00:00:00Z" },
|
|
sevenDay: null,
|
|
checkedAt: "2026-10-01T00:00:00Z",
|
|
error: null,
|
|
},
|
|
quotaPercent: null,
|
|
full: false,
|
|
},
|
|
],
|
|
codex: [
|
|
{
|
|
name: "cx-1",
|
|
kind: "apikey",
|
|
addedAt: "2026-10-02T00:00:00Z",
|
|
livePanes: 0,
|
|
spend5h: 0,
|
|
spend7d: 0,
|
|
tokens5h: 0,
|
|
tokens7d: 0,
|
|
quotaPercent: null,
|
|
full: false,
|
|
},
|
|
],
|
|
blockPercent: 90,
|
|
};
|
|
|
|
const tool = (id: string, label: string, installed = true) => ({ id, label, installed });
|
|
const OMP = {
|
|
available: true,
|
|
enabled: false,
|
|
installed: true,
|
|
broker: { running: false },
|
|
gateway: { running: false },
|
|
};
|
|
|
|
/** The account names a pool column holds (its move menu names others too). */
|
|
const held = (col: HTMLElement) =>
|
|
within(col)
|
|
.queryAllByTestId("pool-group-account")
|
|
.map((c) => within(c).queryByText(/^(team|cx)-\d$/)?.textContent);
|
|
|
|
/** A drag-and-drop between two elements, through one shared DataTransfer. */
|
|
function drag(from: Element, to: Element) {
|
|
const store: Record<string, string> = {};
|
|
const dataTransfer = {
|
|
setData: (k: string, v: string) => (store[k] = v),
|
|
getData: (k: string) => store[k] ?? "",
|
|
get types() {
|
|
return Object.keys(store);
|
|
},
|
|
effectAllowed: "",
|
|
};
|
|
fireEvent.dragStart(from, { dataTransfer });
|
|
fireEvent.dragOver(to, { dataTransfer });
|
|
fireEvent.drop(to, { dataTransfer });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
for (const fn of Object.values(accountPool)) fn.mockReset();
|
|
aiTools.list.mockResolvedValue({
|
|
tools: [
|
|
tool("claude", "Claude Code"),
|
|
tool("codex", "Codex"),
|
|
tool("cursor", "Cursor CLI"),
|
|
tool("omp", "Oh My Pi"),
|
|
tool("antigravity", "Antigravity"),
|
|
tool("goose", "Goose", false),
|
|
],
|
|
default: "claude",
|
|
});
|
|
aiTools.ompSharing.mockResolvedValue(OMP);
|
|
aiTools.setOmpSharing.mockResolvedValue({ ...OMP, enabled: true });
|
|
accountPool.list.mockResolvedValue(POOL);
|
|
users.list.mockResolvedValue({
|
|
users: [
|
|
{ id: 7, username: "mia", role: "developer", disabled: false },
|
|
{ id: 8, username: "leo", role: "analyst", disabled: false },
|
|
],
|
|
});
|
|
accountPool.profiles.mockResolvedValue({
|
|
profiles: [
|
|
{
|
|
name: "work",
|
|
label: "Work",
|
|
email: "w@example.com",
|
|
subscriptionType: "max",
|
|
active: false,
|
|
},
|
|
{ name: "me", label: "Me", email: "me@example.com", subscriptionType: "pro", active: true },
|
|
],
|
|
});
|
|
});
|
|
|
|
describe("AccountPoolCard", () => {
|
|
it("shows every tool's accounts on the board, and a tab per tool with no pool", async () => {
|
|
const user = userEvent.setup();
|
|
render(card(false));
|
|
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
|
// With no pool made yet, every account sits in the shared pool, by tool.
|
|
const shared = screen.getByTestId("pool-shared");
|
|
expect(shared).toHaveTextContent("Claude Code2");
|
|
expect(shared).toHaveTextContent("Codex1");
|
|
expect(shared).toHaveTextContent("cx-1");
|
|
const [account, second] = screen.getAllByTestId("pool-account");
|
|
// Every account of the pool has its card; quota is shown as what is LEFT.
|
|
expect(second).toHaveTextContent("team-2");
|
|
const quota = screen.getAllByTestId("pool-quota")[0]!;
|
|
expect(quota).toHaveTextContent("5 hours");
|
|
expect(quota).toHaveTextContent("58%");
|
|
expect(quota).toHaveTextContent("resets in 2h 10m");
|
|
expect(quota).toHaveTextContent("Week");
|
|
expect(quota).toHaveTextContent("85%");
|
|
expect(quota).toHaveTextContent("resets in 3d 1h");
|
|
const reset = screen.getAllByTestId("pool-quota")[1]!;
|
|
expect(reset).toHaveTextContent("100%");
|
|
expect(reset).toHaveTextContent("—");
|
|
expect(account).toHaveTextContent("250.0K tokens");
|
|
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
|
|
expect(account).toHaveTextContent("2 live");
|
|
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
|
// An analyst reads: no add, remove or new pool.
|
|
expect(screen.queryByTestId("pool-add-account")).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
|
expect(screen.queryByRole("button", { name: "New pool" })).toBeNull();
|
|
// Pool tools have no tab any more; an installed tool without a pool does.
|
|
expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull();
|
|
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
|
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
|
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
|
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
|
});
|
|
|
|
it("adds a Cursor API key from Add account", async () => {
|
|
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
|
|
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor");
|
|
const dialog = await screen.findByRole("dialog");
|
|
expect(screen.queryByLabelText("From")).toBeNull();
|
|
await user.type(screen.getByLabelText("Name"), "cu-1");
|
|
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
await user.click(
|
|
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
|
);
|
|
await waitFor(() =>
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "cursor", name: "cu-1", token: "key_abc" },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
|
});
|
|
|
|
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
|
|
accountPool.list.mockResolvedValue({
|
|
...POOL,
|
|
groups: [
|
|
{
|
|
id: 1,
|
|
name: "Team A",
|
|
users: [{ userId: 8, username: "leo" }],
|
|
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
|
},
|
|
],
|
|
});
|
|
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
|
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
|
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
const teamA = await screen.findByTestId("pool-group");
|
|
expect(teamA).toHaveTextContent("leo");
|
|
expect(teamA).toHaveTextContent("team-1");
|
|
await screen.findByText("mia");
|
|
|
|
await user.click(screen.getByRole("button", { name: "New pool" }));
|
|
const names = screen.getAllByLabelText("Pool name");
|
|
await user.type(names[names.length - 1]!, "Team B");
|
|
const teamB = screen.getAllByTestId("pool-group")[1]!;
|
|
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
|
|
expect(teamB).toHaveTextContent("mia");
|
|
const add = within(teamB).getByLabelText("Move an account into Team B");
|
|
await user.selectOptions(add, "codex|cx-1");
|
|
await user.selectOptions(add, "claude|profile:work");
|
|
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
|
|
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
|
|
expect(accountPool.updateGroup).not.toHaveBeenCalled();
|
|
|
|
await user.click(
|
|
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
|
);
|
|
const dialog = await screen.findByRole("dialog");
|
|
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
|
|
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
|
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
|
await waitFor(() =>
|
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
|
2,
|
|
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "claude", name: "work", profile: "work" },
|
|
"boss-pass"
|
|
);
|
|
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
|
// Team A did not change: nothing is sent for it.
|
|
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
|
|
accountPool.list.mockResolvedValue({
|
|
...POOL,
|
|
groups: [
|
|
{
|
|
id: 1,
|
|
name: "Team A",
|
|
users: [{ userId: 8, username: "leo" }],
|
|
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
|
},
|
|
],
|
|
});
|
|
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
const teamA = await screen.findByTestId("pool-group");
|
|
const shared = screen.getByTestId("pool-shared");
|
|
const item = (col: HTMLElement, name: string) =>
|
|
within(col)
|
|
.getAllByTestId("pool-group-account")
|
|
.find((c) => c.textContent?.includes(name))!;
|
|
drag(item(shared, "team-2"), teamA);
|
|
expect(held(teamA)).toEqual(["team-1", "team-2"]);
|
|
// And back again: an account is in one pool, so it leaves the other.
|
|
drag(item(teamA, "team-1"), shared);
|
|
expect(held(teamA)).toEqual(["team-2"]);
|
|
expect(held(shared)).toContain("team-1");
|
|
expect(teamA).toHaveTextContent("team-2");
|
|
await user.click(
|
|
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
|
|
);
|
|
expect(shared).toHaveTextContent("leo");
|
|
await user.click(
|
|
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
|
);
|
|
const dialog = await screen.findByRole("dialog");
|
|
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
|
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
|
await waitFor(() =>
|
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
|
1,
|
|
{
|
|
name: "Team A",
|
|
userIds: [],
|
|
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
|
},
|
|
"boss-pass"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("shows each account's plan on its card", async () => {
|
|
accountPool.list.mockResolvedValue({
|
|
...POOL,
|
|
accounts: [{ ...POOL.accounts[0]!, plan: "Max 5x" }, POOL.accounts[1]!],
|
|
});
|
|
render(card(false));
|
|
const [first, second] = await screen.findAllByTestId("pool-account");
|
|
expect(within(first!).getByTestId("pool-account-plan")).toHaveTextContent("Max 5x");
|
|
expect(within(second!).queryByTestId("pool-account-plan")).toBeNull();
|
|
});
|
|
|
|
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
|
|
accountPool.list.mockResolvedValue({
|
|
...POOL,
|
|
groups: [
|
|
{
|
|
id: 1,
|
|
name: "Team A",
|
|
users: [{ userId: 8, username: "leo" }],
|
|
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Team B",
|
|
users: [{ userId: 7, username: "mia" }],
|
|
accounts: { claude: [], codex: ["cx-1"], cursor: [] },
|
|
},
|
|
],
|
|
});
|
|
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
|
|
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
|
accountPool.deleteGroup.mockResolvedValue({ ok: true });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
|
|
await screen.findByText("mia");
|
|
const item = (col: HTMLElement, name: string) =>
|
|
within(col)
|
|
.getAllByTestId("pool-group-account")
|
|
.find((c) => c.textContent?.includes(name))!;
|
|
// Split: team-2 leaves the shared pool for a new pool of its own.
|
|
drag(item(screen.getByTestId("pool-shared"), "team-2"), screen.getByTestId("pool-new"));
|
|
const pools = screen.getAllByTestId("pool-group");
|
|
expect(pools).toHaveLength(3);
|
|
expect(held(pools[2]!)).toEqual(["team-2"]);
|
|
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
|
|
// Merge: Team B's only account into Team A brings mia along; Team B goes.
|
|
drag(item(teamB!, "cx-1"), teamA!);
|
|
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
|
|
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
|
|
expect(teamA).toHaveTextContent("mia");
|
|
|
|
await user.click(
|
|
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
|
);
|
|
const dialog = await screen.findByRole("dialog");
|
|
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
|
|
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
|
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
|
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
|
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
|
1,
|
|
{
|
|
name: "Team A",
|
|
userIds: [8, 7],
|
|
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
|
|
},
|
|
"boss-pass"
|
|
);
|
|
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
|
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
|
3,
|
|
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
|
|
"boss-pass"
|
|
);
|
|
// The members moved before the emptied pool was deleted.
|
|
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
|
fn.mock.invocationCallOrder[0]!;
|
|
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
|
|
});
|
|
|
|
it("turns Oh My Pi login sharing on from its tab", async () => {
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.click(screen.getByRole("tab", { name: /Oh My Pi/ }));
|
|
expect(screen.getByTestId("omp-pool-status")).toHaveTextContent(/Sharing is off/);
|
|
await user.click(screen.getByRole("switch"));
|
|
await waitFor(() => expect(aiTools.setOmpSharing).toHaveBeenCalledWith(true));
|
|
expect(screen.getByRole("tab", { name: /Oh My Pi/ })).toHaveTextContent("shared");
|
|
});
|
|
|
|
it("moves a saved login in, the server's own included", async () => {
|
|
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
|
const dialog = await screen.findByRole("dialog");
|
|
const select = await screen.findByLabelText("Profile");
|
|
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
|
expect(screen.getByRole("option", { name: /Me/ })).toBeEnabled();
|
|
await user.selectOptions(select, "work");
|
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Add account"
|
|
)!;
|
|
expect(confirm).toBeDisabled();
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
await user.click(confirm);
|
|
await waitFor(() =>
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "claude", name: "work", profile: "work" },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("adds a setup-token only with the admin's own password", async () => {
|
|
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
|
const dialog = await screen.findByRole("dialog");
|
|
await user.selectOptions(screen.getByLabelText("From"), "token");
|
|
await user.type(screen.getByLabelText("Name"), "team-2");
|
|
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
|
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Add account"
|
|
)!;
|
|
expect(confirm).toBeDisabled();
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
expect(confirm).toBeEnabled();
|
|
await user.click(confirm);
|
|
await waitFor(() =>
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "claude", name: "team-2", token: "sk-ant-oat01-xyz" },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("removes an account with the admin's own password", async () => {
|
|
accountPool.remove.mockResolvedValue({ ok: true });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
|
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
|
await user.click(screen.getByRole("button", { name: "Remove" }));
|
|
await waitFor(() =>
|
|
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
|
);
|
|
});
|
|
|
|
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
|
|
accountPool.list.mockResolvedValue({
|
|
...POOL,
|
|
pins: {
|
|
claude: [
|
|
{ userId: 8, username: "leo", account: "team-1" },
|
|
{ userId: 7, username: "mia", account: "team-0" },
|
|
],
|
|
codex: [],
|
|
},
|
|
});
|
|
accountPool.setUsers.mockResolvedValue({
|
|
added: [{ userId: 7, username: "mia", switched: "switched" }],
|
|
removed: [{ userId: 8, username: "leo" }],
|
|
});
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
|
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
|
const dialog = await screen.findByRole("dialog");
|
|
const leo = await screen.findByRole("checkbox", { name: /leo/ });
|
|
const mia = screen.getByRole("checkbox", { name: /mia/ });
|
|
expect(leo).toHaveAttribute("aria-checked", "true");
|
|
expect(mia).toHaveAttribute("aria-checked", "false");
|
|
expect(screen.getByText("now on team-0")).toBeInTheDocument();
|
|
await user.click(mia);
|
|
await user.click(leo);
|
|
const save = Array.from(dialog.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Save"
|
|
)!;
|
|
expect(save).toBeDisabled();
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
await user.click(save);
|
|
await waitFor(() =>
|
|
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
|
);
|
|
});
|
|
|
|
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
|
|
accountPool.add.mockResolvedValue({ account: { name: "cx" } });
|
|
accountPool.profiles.mockImplementation(async (tool: string) =>
|
|
tool === "codex"
|
|
? {
|
|
profiles: [
|
|
{ name: "cx", label: "Codex", email: "c@x", subscriptionType: "plus", active: false },
|
|
],
|
|
}
|
|
: { profiles: [] }
|
|
);
|
|
const user = userEvent.setup();
|
|
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
|
const dialog = await screen.findByRole("dialog");
|
|
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
|
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Add account"
|
|
)!;
|
|
await user.click(confirm);
|
|
await waitFor(() =>
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "codex", name: "cx", profile: "cx" },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("links back to the profiles in Settings", async () => {
|
|
render(card(false));
|
|
const link = await screen.findByRole("link", { name: "Profiles in Settings" });
|
|
expect(link.getAttribute("href")).toBe("/settings/agents#tool-profiles");
|
|
});
|
|
});
|