web: component tests for the Share dialog

@vue/test-utils and jsdom as dev dependencies, jsdom chosen per file with the
vitest environment comment so the existing unit tests stay on node. The dialog's
repo.shares is faked; the tests cover offering everyone, sharing with a person
and a group, changing and removing a share (and the board's shared flag that
follows), a refused share keeping the choice, the load retry, and the
single-person instance. #5313.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:28:22 -04:00
co-authored by Claude Opus 5.5
parent 3829d52e4d
commit 4149229988
3 changed files with 869 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Directory, NoteShare } from "../adapters/repo";
import { useNotesStore, type Note } from "../stores/notes";
import ShareDialog from "./ShareDialog.vue";
// The dialog talks to the server only through repo.shares, so that is all this fakes.
// Every call answers the note's shares as the server would.
const shares = vi.hoisted(() => ({
directory: vi.fn(),
list: vi.fn(),
share: vi.fn(),
unshare: vi.fn(),
}));
vi.mock("../adapters", () => ({ repo: { shares } }));
const ana = { id: "u1", display_name: "Ana", email: "ana@example.com" };
const ben = { id: "u2", display_name: "Ben", email: "ben@example.com" };
const family = { id: "g1", name: "Family", member_count: 3 };
const DIRECTORY: Directory = { members: [ana, ben], groups: [family] };
const toAna = (permission: "view" | "edit" = "view"): NoteShare => ({
id: "s1",
member: ana,
group: null,
permission,
created_at: null,
});
const toFamily: NoteShare = { id: "s2", member: null, group: family, permission: "edit", created_at: null };
async function open(existing: NoteShare[] = [], directory: Directory = DIRECTORY) {
shares.directory.mockResolvedValue(directory);
shares.list.mockResolvedValue(existing);
const pinia = createPinia();
setActivePinia(pinia);
const notes = useNotesStore();
notes.items.push({ id: "n1", shared: existing.length > 0 } as Note);
const wrapper = mount(ShareDialog, { props: { noteId: "n1" }, global: { plugins: [pinia] } });
await flushPromises();
return { wrapper, notes };
}
const picker = (w: VueWrapper<any>) => w.get('select[aria-label="Person or group"]');
const choices = (w: VueWrapper<any>) =>
picker(w)
.findAll("option")
.map((o) => o.attributes("value"))
.filter(Boolean);
beforeEach(() => vi.resetAllMocks());
describe("the Share dialog", () => {
it("offers everyone, and says the note is only yours when it isn't shared", async () => {
const { wrapper } = await open();
expect(shares.list).toHaveBeenCalledWith("n1");
expect(choices(wrapper)).toEqual(["u:u1", "u:u2", "g:g1"]);
expect(wrapper.text()).toContain("Only you.");
});
it("shares with a person at the chosen permission, and marks the note shared", async () => {
const { wrapper, notes } = await open();
shares.share.mockResolvedValue([toAna("edit")]);
await picker(wrapper).setValue("u:u1");
await wrapper.get('select[aria-label="Permission"]').setValue("edit");
await wrapper.get("form").trigger("submit");
await flushPromises();
expect(shares.share).toHaveBeenCalledWith("n1", { user_id: "u1" }, "edit");
expect(wrapper.text()).toContain("Ana");
expect(wrapper.text()).not.toContain("Only you.");
// Someone it's already shared with isn't offered twice.
expect(choices(wrapper)).toEqual(["u:u2", "g:g1"]);
expect(notes.items[0].shared).toBe(true);
});
it("shares with a group by its id, not as a person", async () => {
const { wrapper } = await open();
shares.share.mockResolvedValue([toFamily]);
await picker(wrapper).setValue("g:g1");
await wrapper.get("form").trigger("submit");
await flushPromises();
expect(shares.share).toHaveBeenCalledWith("n1", { group_id: "g1" }, "view");
expect(wrapper.text()).toContain("Group · 3 people");
});
it("changes a share's permission in place", async () => {
const { wrapper } = await open([toAna()]);
shares.share.mockResolvedValue([toAna("edit")]);
await wrapper.get('select[aria-label="Permission for Ana"]').setValue("edit");
await flushPromises();
expect(shares.share).toHaveBeenCalledWith("n1", { user_id: "u1" }, "edit");
});
it("stops sharing, and the note is yours alone again", async () => {
const { wrapper, notes } = await open([toAna()]);
shares.unshare.mockResolvedValue([]);
await wrapper.get('button[aria-label="Stop sharing with Ana"]').trigger("click");
await flushPromises();
expect(shares.unshare).toHaveBeenCalledWith("n1", "s1");
expect(wrapper.text()).toContain("Only you.");
expect(notes.items[0].shared).toBe(false);
});
it("says why a share failed and keeps the choice to try again", async () => {
const { wrapper } = await open();
shares.share.mockRejectedValue({ error: "Ana can't be reached.", status: 400 });
await picker(wrapper).setValue("u:u1");
await wrapper.get("form").trigger("submit");
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toBe("Ana can't be reached.");
expect((picker(wrapper).element as HTMLSelectElement).value).toBe("u:u1");
});
it("offers a retry when it couldn't load who it's shared with", async () => {
shares.directory.mockRejectedValueOnce(new Error(""));
const { wrapper } = await open();
expect(wrapper.text()).toContain("Couldn't load who this is shared with.");
await wrapper.get("button:not([aria-label])").trigger("click");
await flushPromises();
expect(choices(wrapper)).toEqual(["u:u1", "u:u2", "g:g1"]);
});
it("says there is no one to share with on a single-person instance", async () => {
const { wrapper } = await open([], { members: [], groups: [] });
expect(wrapper.text()).toContain("No one else has an account here yet.");
expect(wrapper.find("form").exists()).toBe(false);
});
});