import type { Did } from "@atcute/lexicons/syntax";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getOrCreateDelegatedSession } from "#lib/api/actAs.ts";
import { cached, invalidate } from "#lib/api/cache.ts";
import type { BobbinContext } from "#lib/api/client.ts";
import { listDelegatedAccounts } from "#lib/api/delegation.ts";
import {
leaveOrganization,
listUserOrganizations,
viewerControlsOrganization
} from "#lib/api/organizations.ts";
import { getProfiles } from "#lib/api/records.ts";
import { removeController } from "#lib/api/tranquil.ts";
import { forgetOrgDid } from "#lib/auth/accounts.ts";
vi.mock("#lib/api/actAs.ts", () => ({ getOrCreateDelegatedSession: vi.fn() }));
vi.mock("#lib/api/delegation.ts", () => ({ listDelegatedAccounts: vi.fn() }));
vi.mock("#lib/api/tranquil.ts", () => ({ removeController: vi.fn() }));
vi.mock("#lib/auth/accounts.ts", () => ({ forgetOrgDid: vi.fn() }));
vi.mock("#lib/api/records.ts", async (importOriginal) => ({
...(await importOriginal<typeof import("#lib/api/records.ts")>()),
getProfiles: vi.fn()
}));
vi.mock("#lib/api/cache.ts", () => ({
cached: vi.fn((_key: string, _ttl: number, fn: () => Promise<unknown>) => fn()),
invalidate: vi.fn()
}));
const OWNER_SCOPES =
"atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage transition:generic";
const EDITOR_SCOPES = "atproto repo:*?action=create blob:*/* rpc:*";
const controllerDid = "did:plc:controller" as Did;
const controller = { sub: controllerDid } as OAuthUserAgent;
const ctx = {} as BobbinContext;
const tranquilDid = "did:web:tranquil.example";
const tangledDid = "did:plc:tangled" as Did;
const aliceDid = "did:plc:alice" as Did;
const profileUri = (did: Did) => `at://${did}/org.tangled.actor.profile/self`;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(listDelegatedAccounts).mockResolvedValue([
{
did: tangledDid,
handle: "tangled.org",
grantedAt: "2025-01-14T09:00:00.000Z",
grantedScopes: OWNER_SCOPES
},
{
did: aliceDid,
handle: "alice.tngl.sh",
grantedAt: "2025-03-02T09:00:00.000Z",
grantedScopes: EDITOR_SCOPES
}
]);
vi.mocked(getProfiles).mockResolvedValue({
items: [
{
uri: profileUri(tangledDid),
value: { $type: "org.tangled.actor.profile", isOrganization: true }
},
{
uri: profileUri(aliceDid),
value: { $type: "org.tangled.actor.profile", isOrganization: true }
}
]
} as never);
});
describe("listUserOrganizations", () => {
it("lists the controlled accounts with the viewer's role", async () => {
const organizations = await listUserOrganizations(controller, ctx, tranquilDid);
expect(listDelegatedAccounts).toHaveBeenCalledWith(controller, tranquilDid);
expect(getProfiles).toHaveBeenCalledWith(
ctx,
[profileUri(tangledDid), profileUri(aliceDid)],
undefined
);
expect(organizations).toEqual([
{
did: tangledDid,
handle: "tangled.org",
role: "owner",
joinedAt: "2025-01-14T09:00:00.000Z"
},
{
did: aliceDid,
handle: "alice.tngl.sh",
role: "member",
joinedAt: "2025-03-02T09:00:00.000Z"
}
]);
});
it("skips delegated accounts that are not organizations", async () => {
vi.mocked(getProfiles).mockResolvedValue({
items: [
{
uri: profileUri(aliceDid),
value: {
$type: "org.tangled.actor.profile",
isOrganization: false
}
}
]
} as never);
const organizations = await listUserOrganizations(controller, ctx, tranquilDid);
expect(organizations.map((organization) => organization.did)).toEqual([tangledDid]);
});
it("still lists the organizations when the appview is unreachable", async () => {
vi.mocked(getProfiles).mockRejectedValue(new Error("bobbin is down"));
const organizations = await listUserOrganizations(controller, ctx, tranquilDid);
expect(organizations).toHaveLength(2);
expect(organizations[0]).toMatchObject({ handle: "tangled.org", role: "owner" });
});
it("does not reach for profiles when nothing is controlled", async () => {
vi.mocked(listDelegatedAccounts).mockResolvedValue([]);
await expect(listUserOrganizations(controller, ctx, tranquilDid)).resolves.toEqual([]);
expect(getProfiles).not.toHaveBeenCalled();
});
});
describe("viewerControlsOrganization", () => {
it("is true only for organizations tranquil lists for the viewer", async () => {
await expect(viewerControlsOrganization(controller, tranquilDid, tangledDid)).resolves.toBe(
true
);
await expect(
viewerControlsOrganization(controller, tranquilDid, "did:plc:stranger")
).resolves.toBe(false);
expect(listDelegatedAccounts).toHaveBeenCalledWith(controller, tranquilDid);
});
it("shares one cached listing with the organizations list", async () => {
await listUserOrganizations(controller, ctx, tranquilDid);
await viewerControlsOrganization(controller, tranquilDid, tangledDid);
const keys = vi.mocked(cached).mock.calls.map(([key]) => key);
expect(keys).toHaveLength(2);
expect(new Set(keys).size).toBe(1);
expect(keys[0]).toContain(controllerDid);
expect(keys[0]).toContain(tranquilDid);
});
it("surfaces a failed listing instead of denying quietly", async () => {
vi.mocked(listDelegatedAccounts).mockRejectedValue(new Error("tranquil is down"));
await expect(
viewerControlsOrganization(controller, tranquilDid, tangledDid)
).rejects.toThrow("tranquil is down");
});
});
describe("leaveOrganization", () => {
it("revokes the viewer's grant and forgets the organization", async () => {
const signOut = vi.fn().mockResolvedValue(undefined);
vi.mocked(getOrCreateDelegatedSession).mockResolvedValue({
signOut
} as unknown as OAuthUserAgent);
vi.mocked(removeController).mockResolvedValue({ success: true });
await leaveOrganization(controller, tangledDid, tranquilDid);
expect(getOrCreateDelegatedSession).toHaveBeenCalledWith(controller, tangledDid);
expect(removeController).toHaveBeenCalledWith(expect.anything(), controllerDid);
expect(signOut).toHaveBeenCalled();
expect(forgetOrgDid).toHaveBeenCalledWith(tangledDid);
await listUserOrganizations(controller, ctx, tranquilDid);
expect(invalidate).toHaveBeenCalledWith(vi.mocked(cached).mock.calls[0][0]);
});
it("keeps the organization when the grant cannot be revoked", async () => {
vi.mocked(getOrCreateDelegatedSession).mockResolvedValue({
signOut: vi.fn()
} as unknown as OAuthUserAgent);
vi.mocked(removeController).mockRejectedValue(new Error("Forbidden"));
await expect(leaveOrganization(controller, tangledDid, tranquilDid)).rejects.toThrow(
"Forbidden"
);
expect(forgetOrgDid).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
});
});