Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/services/ChatMessageTaskService.server.test.ts
T

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();
});
});