feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The manager page's engine parts. The task cards under a conversation show each engine task's
|
||||
* Persian stage (and a legacy task's status), and link to the task page. The decision banner lists
|
||||
* only the tasks that wait for an answer, is hidden when there are none, posts the answer, and
|
||||
* shows a refusal from the server in Persian without losing what was typed.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
answerTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: { answerTask: mocks.answerTask } }));
|
||||
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { EngineTask } from "@/lib/api-engine";
|
||||
import { DecisionBanner } from "@/app/(app)/manager/_components/decision-banner";
|
||||
import { ThreadTaskCards } from "@/app/(app)/manager/_components/task-cards";
|
||||
|
||||
const ORG = "org-1";
|
||||
|
||||
function task(overrides: Partial<EngineTask> = {}): EngineTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
title: "صفحهٔ گزارش فروش",
|
||||
status: "running",
|
||||
stage: "pending_test",
|
||||
pipeline: "default",
|
||||
kind: "feature",
|
||||
cycle: 1,
|
||||
max_cycles: 3,
|
||||
thread_id: "thread-1",
|
||||
assigned_employee_id: null,
|
||||
assignee_name: null,
|
||||
created_at: "2026-10-01T00:00:00Z",
|
||||
updated_at: "2026-10-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.answerTask.mockReset();
|
||||
});
|
||||
|
||||
describe("ThreadTaskCards", () => {
|
||||
it("shows each engine task's Persian stage and links the card to the task page", () => {
|
||||
render(<ThreadTaskCards tasks={[task({ stage: "pending_test" })]} />);
|
||||
|
||||
expect(screen.getByText("در انتظار تست")).toBeInTheDocument();
|
||||
expect(screen.getByText("صفحهٔ گزارش فروش").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/task-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a legacy task by its status, since the engine has no stage for it", () => {
|
||||
const legacy = task({ id: "task-2", title: "کار قدیمی", stage: null, status: "done" });
|
||||
render(<ThreadTaskCards tasks={[legacy]} />);
|
||||
|
||||
expect(screen.getByText("کار قدیمی")).toBeInTheDocument();
|
||||
expect(screen.getByText("انجام شد")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing for a conversation without tasks", () => {
|
||||
const { container } = render(<ThreadTaskCards tasks={[]} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DecisionBanner", () => {
|
||||
it("is hidden when no task waits for an answer", () => {
|
||||
const { container } = render(
|
||||
<DecisionBanner orgId={ORG} tasks={[task({ stage: "pending_test" })]} onAnswered={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("lists only the tasks at a pending decision", () => {
|
||||
render(
|
||||
<DecisionBanner
|
||||
orgId={ORG}
|
||||
tasks={[
|
||||
task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" }),
|
||||
task({ id: "task-2", title: "تست واحد", stage: "pending_test" }),
|
||||
]}
|
||||
onAnswered={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("در انتظار تصمیم شما")).toBeInTheDocument();
|
||||
expect(screen.getByText("کدام پایگاه داده؟")).toBeInTheDocument();
|
||||
expect(screen.queryByText("تست واحد")).toBeNull();
|
||||
});
|
||||
|
||||
it("posts the trimmed answer to the task and reports it", async () => {
|
||||
mocks.answerTask.mockResolvedValue({});
|
||||
const onAnswered = vi.fn();
|
||||
render(
|
||||
<DecisionBanner
|
||||
orgId={ORG}
|
||||
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
|
||||
onAnswered={onAnswered}
|
||||
/>,
|
||||
);
|
||||
const send = screen.getByRole("button", { name: "ارسال پاسخ" });
|
||||
expect(send).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: " PostgreSQL " } });
|
||||
expect(send).toBeEnabled();
|
||||
fireEvent.click(send);
|
||||
|
||||
await waitFor(() => expect(onAnswered).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.answerTask).toHaveBeenCalledWith(ORG, "task-1", "PostgreSQL");
|
||||
});
|
||||
|
||||
it("shows the server's refusal in Persian and keeps the typed answer", async () => {
|
||||
mocks.answerTask.mockRejectedValue(
|
||||
new ApiError(409, "conflict", "این کار دیگر منتظر پاسخ نیست."),
|
||||
);
|
||||
const onAnswered = vi.fn();
|
||||
render(
|
||||
<DecisionBanner
|
||||
orgId={ORG}
|
||||
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
|
||||
onAnswered={onAnswered}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: "PostgreSQL" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "ارسال پاسخ" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("این کار دیگر منتظر پاسخ نیست.");
|
||||
expect(onAnswered).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("پاسخ شما")).toHaveValue("PostgreSQL");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user