154 lines
6.3 KiB
TypeScript
154 lines
6.3 KiB
TypeScript
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();
|
|
});
|
|
});
|