Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/expenses/ExpenseDialog.drag.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function mountScope() {
act(() => root.render(<ExpenseScopePanel
scope={{ scheduleId: 1, label: "1일차" }} roomId="room" expenses={[]}
isPending={false} isError={false} canManage busy={false}
onAdd={() => {}} onEdit={() => {}} onRetry={() => {}} onClose={close}
onAdd={() => {}} onEdit={() => {}} onDelete={async () => {}} onRetry={() => {}} onClose={close}
/>));
}
it("dismisses the day/place list sheet by dragging its header", () => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/expenses/ExpenseNameFallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ it.each([26, null])("uses category only in ledger titles regardless of linked pl
expect(JSON.stringify(renderer.toJSON())).toContain("별도 메모");
});
it("keeps scoped expense titles and edit labels category-only, with memo and place separate", async () => {
await act(async () => { renderer = create(<QueryClientProvider client={client}><ExpenseScopePanel scope={{ scheduleId: 2, label: "2일차" }} roomId="room" expenses={[expense]} isPending={false} isError={false} canManage busy={false} onAdd={vi.fn()} onEdit={vi.fn()} onRetry={vi.fn()} onClose={vi.fn()} /></QueryClientProvider>); });
await act(async () => { renderer = create(<QueryClientProvider client={client}><ExpenseScopePanel scope={{ scheduleId: 2, label: "2일차" }} roomId="room" expenses={[expense]} isPending={false} isError={false} canManage busy={false} onAdd={vi.fn()} onEdit={vi.fn()} onDelete={async () => {}} onRetry={vi.fn()} onClose={vi.fn()} /></QueryClientProvider>); });
const edit = renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 수정"))!;
expect(edit.props["aria-label"]).toBe("식비 1,000 KRW 비용 수정");
expect(edit.findAllByType("span")[1].children).toEqual(["식비"]);
expect(renderer.root.findByType("li").findAllByType("span").some(span => span.children.length === 1 && span.children[0] === "식비")).toBe(true);
expect(JSON.stringify(renderer.toJSON())).toContain("별도 메모");
expect(JSON.stringify(renderer.toJSON())).toContain("경복궁");
});
Expand Down
77 changes: 77 additions & 0 deletions src/components/expenses/ExpenseProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,83 @@ it("never reports a foreground load during successful tab-return revalidation",
expect(context.syncStatus).toBe("ready");
});


it("confirms a scoped expense deletion, supports cancel, and updates the card total", async () => {
const linked = { ...record, scheduleId: 2, scheduleItemId: 3 };
await mountPlaceExpenseButton([linked]);
mocks.remove.mockReset();
mocks.remove.mockResolvedValue(undefined);
mocks.list.mockResolvedValue([]);
await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} }));
const deleteButton = () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"))!;
expect(deleteButton()).toBeDefined();
await act(async () => deleteButton().props.onClick());
expect(mocks.remove).not.toHaveBeenCalled();
await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 취소" }).props.onClick());
expect(mocks.remove).not.toHaveBeenCalled();
await act(async () => deleteButton().props.onClick());
await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.onClick());
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(mocks.remove).toHaveBeenCalledExactlyOnceWith("r", 10, 2);
expect(renderer.root.findByType(ExpenseScopePanel).findAllByType("li")).toHaveLength(0);
expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(0);
expect(renderer.root.findByProps({ "aria-label": "장소 비용 추가" })).toBeDefined();
});


it("keeps a scoped expense after a failed delete and prevents duplicate submissions", async () => {
await mountPlaceExpenseButton([{ ...record, scheduleId: 2, scheduleItemId: 3 }]);
mocks.remove.mockReset();
let reject!: (reason: Error) => void;
mocks.remove.mockImplementationOnce(() => new Promise((_, fail) => { reject = fail; }));
await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} }));
const button = renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"));
expect(button).toBeDefined();
await act(async () => button!.props.onClick());
const confirm = renderer.root.findByProps({ "aria-label": "비용 삭제 확인" });
await act(async () => { confirm.props.onClick(); confirm.props.onClick(); });
expect(mocks.remove).toHaveBeenCalledTimes(1);
expect(renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.disabled).toBe(true);
await act(async () => reject(new Error("네트워크 오류")));
expect(renderer.root.findByProps({ role: "alert" }).children.join("")).toContain("네트워크 오류");
expect(renderer.root.findByType(ExpenseScopePanel).findAllByType("li")).toHaveLength(1);
});


it("requires reviewing the refreshed scoped expense before deleting after a conflict", async () => {
const { ExpenseApiError } = await import("@/lib/api/rooms/expenses");
const linked = { ...record, scheduleId: 2, scheduleItemId: 3 };
await mountPlaceExpenseButton([linked]);
mocks.remove.mockReset();
mocks.remove.mockRejectedValueOnce(new ExpenseApiError(409, "EXPENSE_CONFLICT", "changed"));
mocks.list.mockResolvedValue([{ ...linked, version: 3, totalAmount: "200" }]);
await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} }));
const deleteButton = () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"))!;
await act(async () => deleteButton().props.onClick());
await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.onClick());
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(mocks.remove).toHaveBeenCalledTimes(1);
expect(renderer.root.findAllByProps({ "aria-label": "비용 삭제 확인" })).toHaveLength(0);
expect(deleteButton().props["aria-label"]).toContain("200 KRW");
mocks.remove.mockResolvedValue(undefined);
mocks.list.mockResolvedValue([]);
await act(async () => deleteButton().props.onClick());
await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.onClick());
expect(mocks.remove).toHaveBeenLastCalledWith("r", 10, 3);
});

it("hides scoped deletion controls when management permission is lost", async () => {
await mountPlaceExpenseButton([{ ...record, scheduleId: 2, scheduleItemId: 3 }]);
await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} }));
await act(async () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"))!.props.onClick());
await act(async () => {
client.setQueryData(expenseKeys.members("r"), { members: [] });
await new Promise(resolve => setTimeout(resolve, 20));
});
expect(renderer.root.findAllByProps({ "aria-label": "비용 삭제 확인" })).toHaveLength(0);
expect(renderer.root.findAllByType("button").filter(b => b.props["aria-label"]?.endsWith("비용 삭제"))).toHaveLength(0);
});

it("emits no expense or budget analytics while pending, after rejected writes, or blocked duplicate writes", async () => {
await mountMutations();
let reject!: (error: Error) => void;
Expand Down
3 changes: 2 additions & 1 deletion src/components/expenses/ExpenseProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,8 @@ function ExpenseProviderLifetime({
? { scheduleId: scope.scheduleId }
: { scheduleId: scope.scheduleId, scheduleItemId: scope.scheduleItemId })}
onEdit={(expense) => setEntry({ expense })}
onRetry={() => void state.list.refetch()}
onDelete={state.remove}
onRetry={() => state.list.refetch({ throwOnError: true })}
onClose={() => setScope(null)}
/>
)}
Expand Down
84 changes: 84 additions & 0 deletions src/components/expenses/ExpenseScopePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Expense } from "@/lib/api/rooms/expenses";
import { ExpenseScopePanel } from "./ExpenseScopePanel";

const expense: Expense = {
id: 10, version: 2, expenseGroup: "TRIP_DAY", scheduleId: 2, scheduleItemId: null,
currency: "USD", totalAmount: "50", category: "OTHER", memo: null, name: "교통",
payerUserIds: [1], participantUserIds: [1], createdAt: "", updatedAt: "",
};
let root: Root;
let host: HTMLDivElement;
const close = vi.fn();
const remove = vi.fn<() => Promise<void>>();
function Harness({ canManage = true }: { canManage?: boolean }) {
const [expenses, setExpenses] = useState([expense]);
return <ExpenseScopePanel scope={{ scheduleId: 2, label: "1일차" }} roomId="r" expenses={expenses}
isPending={false} isError={false} canManage={canManage} busy={false} onAdd={() => {}} onEdit={() => {}}
onDelete={async () => { await remove(); setExpenses([]); }} onRetry={() => {}} onClose={close} />;
}
const button = (label: string) => host.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
const confirmation = () => host.querySelector<HTMLDialogElement>('dialog[role="alertdialog"]');
async function openDelete() {
const trigger = host.querySelector<HTMLButtonElement>('[aria-label$="USD 비용 삭제"]')!;
trigger.focus();
await act(async () => trigger.click());
return trigger;
}
beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
HTMLDialogElement.prototype.showModal = function () { this.open = true; };
HTMLDialogElement.prototype.close = function () { this.open = false; };
close.mockReset(); remove.mockReset(); remove.mockResolvedValue(undefined);
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root.render(<Harness />));
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); });

it("opens a separate modal and restores the delete trigger after cancel", async () => {
const trigger = await openDelete();
expect(confirmation()?.open).toBe(true);
expect(document.activeElement).toBe(button("비용 삭제 취소"));
await act(async () => button("비용 삭제 취소").click());
expect(confirmation()).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(remove).not.toHaveBeenCalled();
});

it("Escape cancels only the confirmation and preserves the expense list", async () => {
const trigger = await openDelete();
expect(confirmation()).not.toBeNull();
await act(async () => confirmation()!.dispatchEvent(new Event("cancel", { bubbles: true, cancelable: true })));
expect(close).not.toHaveBeenCalled();
expect(confirmation()).toBeNull();
expect(document.activeElement).toBe(trigger);
});

it("blocks dismissal during deletion and restores focus to the list heading after success", async () => {
let finish!: () => void;
remove.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await openDelete();
expect(confirmation()).not.toBeNull();
await act(async () => button("비용 삭제 확인").click());
await act(async () => confirmation()!.dispatchEvent(new Event("cancel", { bubbles: true, cancelable: true })));
expect(confirmation()?.open).toBe(true);
expect(button("비용 삭제 취소").disabled).toBe(true);
expect(close).not.toHaveBeenCalled();
await act(async () => finish());
expect(confirmation()).toBeNull();
expect(document.activeElement).toBe(host.querySelector("h2"));
expect(host.querySelectorAll("li")).toHaveLength(0);
});


it("allows closing the list after management permission is lost during confirmation", async () => {
await openDelete();
await act(async () => root.render(<Harness canManage={false} />));
expect(confirmation()).toBeNull();
await act(async () => host.querySelector("dialog")!.dispatchEvent(new Event("cancel", { cancelable: true })));
expect(close).toHaveBeenCalledOnce();
expect(remove).not.toHaveBeenCalled();
});
Loading