import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ withWritableSource: vi.fn(), createTask: vi.fn(), linkEntities: vi.fn(), auditTask: vi.fn(), logActivity: vi.fn(), writeSystemMessage: vi.fn(), publishWakeup: vi.fn(), hasTasksModule: vi.fn(), })); vi.mock("@hivegrid/chat/repository.server", () => ({ withWritableChatMessageTaskSource: (...args: unknown[]) => mocks.withWritableSource(...args), })); vi.mock("@hivegrid/chat/system-message.server", () => ({ writeChatSystemMessageWithTx: (...args: unknown[]) => mocks.writeSystemMessage(...args), })); vi.mock("@hivegrid/chat/realtime-wakeup.server", () => ({ publishChatPersistentRealtimeWakeup: (...args: unknown[]) => mocks.publishWakeup(...args), })); vi.mock("@hivegrid/chat/module-access.server", () => ({ tenantHasTasksModuleWithPermissionsTx: (...args: unknown[]) => mocks.hasTasksModule(...args), })); vi.mock("~/utils/integrations/taskService.server", () => ({ createIdempotentTaskFromSourceWithTx: (...args: unknown[]) => mocks.createTask(...args), })); vi.mock("~/utils/integrations/entityLinkService.server", () => ({ linkEntitiesWithTx: (...args: unknown[]) => mocks.linkEntities(...args), })); vi.mock("~/utils/integrations/taskAudit.server", () => ({ auditTaskEvent: (...args: unknown[]) => mocks.auditTask(...args), logTaskActivity: (...args: unknown[]) => mocks.logActivity(...args), })); import { markChatMessageAsTask } from "./ChatMessageTaskService.server"; const tx = { transaction: "shared" }; const source = { chatId: "chat_1", messageId: "message_1", body: "Heizung prüfen\nDetails", projectId: "project_1" }; const currentPermissions = ["chat.read_own_channels", "chat.write", "tasks.read_own", "tasks.write"]; const chatContext = { tenantId: "tenant_1", userId: "user_1", tenantUserId: "tenant_user_1", permissions: currentPermissions, hasModule: true as const, }; const taskContext = { tenantId: "tenant_1", userId: "user_1", permissions: ["tasks.write"], hasModule: true }; const input = { chatContext, taskContext, chatId: "chat_1", messageId: "message_1", requestId: "request_1", tenantSlug: "demo", fallbackTitle: "Aufgabe aus Chat", }; describe("markChatMessageAsTask", () => { beforeEach(() => { vi.clearAllMocks(); mocks.withWritableSource.mockImplementation(async (_input: unknown, operation: (...args: unknown[]) => unknown) => operation(tx, source, currentPermissions) ); mocks.createTask.mockResolvedValue({ replayed: false, task: { id: "task_1", linkedEntityType: "project", linkedEntityId: "project_1" }, }); mocks.linkEntities.mockResolvedValue(undefined); mocks.auditTask.mockResolvedValue(undefined); mocks.logActivity.mockResolvedValue(undefined); mocks.writeSystemMessage.mockResolvedValue({ replayed: false, event: { event: "chat.message.created" } }); mocks.publishWakeup.mockResolvedValue(undefined); mocks.hasTasksModule.mockResolvedValue(true); }); it("writes task, backlink, strict effects and system link in one chat-authorized transaction", async () => { await expect(markChatMessageAsTask(input)).resolves.toEqual({ taskId: "task_1", replayed: false }); expect(mocks.withWritableSource).toHaveBeenCalledWith( expect.objectContaining({ tenantId: "tenant_1", userId: "user_1", chatId: "chat_1", messageId: "message_1" }), expect.any(Function) ); expect(mocks.createTask).toHaveBeenCalledWith( tx, expect.objectContaining({ tenantId: "tenant_1", title: "Heizung prüfen", description: "Heizung prüfen\nDetails", linkedEntityType: "project", linkedEntityId: "project_1", source: "chat", sourceRef: "chat-message:message_1", ctx: expect.objectContaining({ permissions: currentPermissions }), }) ); expect(mocks.linkEntities).toHaveBeenCalledWith(tx, { tenantId: "tenant_1", userId: "user_1", sourceType: "chat_message", sourceId: "message_1", targetType: "task", targetId: "task_1", linkRole: "created-from", }); expect(mocks.auditTask).toHaveBeenCalledWith(expect.objectContaining({ action: "task.created", taskId: "task_1" }), { client: tx, strict: true }); expect(mocks.logActivity).toHaveBeenCalledWith(expect.objectContaining({ taskId: "task_1", activityType: "task_created", summaryText: "Heizung prüfen" }), { client: tx, strict: true, }); expect(mocks.writeSystemMessage).toHaveBeenCalledWith( tx, expect.objectContaining({ systemEventKey: "task-created:message_1", kind: "task_created", metadata: expect.objectContaining({ taskId: "task_1", taskHref: "/demo/tasks/task_1" }), }) ); expect(mocks.publishWakeup).toHaveBeenCalledOnce(); }); it("repairs the backlink and system reply on replay without duplicating audit or activity", async () => { mocks.createTask.mockResolvedValue({ replayed: true, task: { id: "task_1", linkedEntityType: null, linkedEntityId: null } }); mocks.writeSystemMessage.mockResolvedValue({ replayed: true, event: { event: "chat.message.created" } }); await expect(markChatMessageAsTask(input)).resolves.toEqual({ taskId: "task_1", replayed: true }); expect(mocks.linkEntities).toHaveBeenCalledOnce(); expect(mocks.auditTask).not.toHaveBeenCalled(); expect(mocks.logActivity).not.toHaveBeenCalled(); expect(mocks.writeSystemMessage).toHaveBeenCalledOnce(); expect(mocks.publishWakeup).not.toHaveBeenCalled(); }); it("fails closed when tasks.write was revoked after the route context was loaded", async () => { mocks.withWritableSource.mockImplementation(async (_input: unknown, operation: (...args: unknown[]) => unknown) => operation(tx, source, ["chat.read_own_channels", "chat.write"]) ); await expect(markChatMessageAsTask(input)).rejects.toMatchObject({ status: 404 }); expect(mocks.createTask).not.toHaveBeenCalled(); }); it("fails closed when the Tasks module was disabled after the route context was loaded", async () => { mocks.hasTasksModule.mockResolvedValue(false); await expect(markChatMessageAsTask(input)).rejects.toMatchObject({ status: 404 }); expect(mocks.hasTasksModule).toHaveBeenCalledWith(tx, "tenant_1", currentPermissions); expect(mocks.createTask).not.toHaveBeenCalled(); }); });