129 lines
5.7 KiB
TypeScript
129 lines
5.7 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// Simulated ports prove module boundaries, not live service/database acceptance.
|
|
import { actorRef, projectRef } from "@conusai-kit/business"
|
|
import type { ProjectScope } from "@conusai-kit/core"
|
|
import { describe, expect, it, vi } from "vitest"
|
|
import type { ActivitiesService, TaskRecord } from "../core/ports.js"
|
|
import { addComment, changeTask, createTask, listComments, listTasks, ownedTask, updateTask } from "../core/tasks.js"
|
|
import { en } from "../i18n/en.js"
|
|
import { lt } from "../i18n/lt.js"
|
|
|
|
const bindingId = "01900000-0000-7000-8000-000000000001"
|
|
const projectId = "01900000-0000-7000-8000-000000000002"
|
|
const taskId = "act_01900000-0000-7000-8000-000000000003"
|
|
const userId = "01900000-0000-7000-8000-000000000004"
|
|
const requestId = "01900000-0000-7000-8000-000000000005"
|
|
const scope: ProjectScope = {
|
|
level: "project",
|
|
orgId: bindingId,
|
|
workspaceId: bindingId,
|
|
projectId,
|
|
actor: { userId: userId as never },
|
|
}
|
|
function fixture() {
|
|
const task: TaskRecord = {
|
|
taskId,
|
|
subject: "Synthetic task",
|
|
body: "Fixture details",
|
|
revision: "1",
|
|
status: "open",
|
|
targets: [projectRef(bindingId, projectId)],
|
|
createdAt: "2026-11-03T00:00:00Z",
|
|
}
|
|
const outcomes = new Map<string, { taskId: string; revision: string }>()
|
|
let commits = 0
|
|
const service: ActivitiesService = {
|
|
bindingId,
|
|
get: vi.fn(async () => ({ ...task })),
|
|
list: vi.fn(async () => ({ rows: [{ ...task }] })),
|
|
comments: vi.fn(async () => ({ rows: [] })),
|
|
timeline: vi.fn(async () => ({ rows: [{ ...task }] })),
|
|
create: vi.fn(async () => ({ taskId, revision: "1" })),
|
|
update: vi.fn(async () => ({ taskId, revision: "2" })),
|
|
comment: vi.fn(async () => ({
|
|
commentId: "cmt_01900000-0000-7000-8000-000000000003",
|
|
revision: "2",
|
|
})),
|
|
change: vi.fn(async (input) => {
|
|
const prior = outcomes.get(input.key)
|
|
if (prior) return prior
|
|
if (input.expectedVersion !== task.revision) throw new Error("fixture stale revision")
|
|
task.status = input.state === "done" ? "done" : "open"
|
|
task.revision = (BigInt(task.revision) + 1n).toString()
|
|
const result = { taskId, revision: task.revision }
|
|
outcomes.set(input.key, result)
|
|
commits += 1
|
|
return result
|
|
}),
|
|
}
|
|
return { task, service, commits: () => commits }
|
|
}
|
|
describe("project Activities module boundaries (simulated ports)", () => {
|
|
it("derives project and participant references from resolved scope, with an operation key", async () => {
|
|
const { service } = fixture()
|
|
await createTask(service, scope, { subject: " Task ", requestId })
|
|
expect(service.create).toHaveBeenCalledWith({
|
|
subject: "Task",
|
|
body: "",
|
|
target: projectRef(bindingId, projectId),
|
|
participant: actorRef(bindingId, userId),
|
|
key: `activities:${projectId}:create:${requestId}`,
|
|
})
|
|
await listTasks(service, scope, {})
|
|
expect(service.list).toHaveBeenCalledWith({ target: projectRef(bindingId, projectId), status: "open" })
|
|
})
|
|
it("refuses guessed task ids from another app project before writes or discussion reads", async () => {
|
|
const { task, service } = fixture()
|
|
task.targets = [projectRef(bindingId, "01900000-0000-7000-8000-000000000099")]
|
|
for (const operation of [
|
|
() => ownedTask(service, scope, taskId),
|
|
() => listComments(service, scope, { taskId }),
|
|
() => addComment(service, scope, { taskId, body: "Synthetic", expectedVersion: "1", requestId }),
|
|
() => changeTask(service, scope, { taskId, expectedVersion: "1", requestId }, "done"),
|
|
])
|
|
await expect(operation()).rejects.toMatchObject({ code: "not_found" })
|
|
expect(service.comment).not.toHaveBeenCalled()
|
|
expect(service.change).not.toHaveBeenCalled()
|
|
expect(service.comments).not.toHaveBeenCalled()
|
|
})
|
|
it("refuses future revision prediction before external mutation", async () => {
|
|
const { service } = fixture()
|
|
await expect(changeTask(service, scope, { taskId, expectedVersion: "2", requestId }, "done")).rejects.toMatchObject(
|
|
{ code: "conflict" },
|
|
)
|
|
expect(service.change).not.toHaveBeenCalled()
|
|
})
|
|
it("lets the service deduplicate a lost-response retry without a second domain commit", async () => {
|
|
const f = fixture()
|
|
const input = { taskId, expectedVersion: "1", requestId }
|
|
const first = await changeTask(f.service, scope, input, "done")
|
|
expect(await changeTask(f.service, scope, input, "done")).toEqual(first)
|
|
expect(f.commits()).toBe(1)
|
|
expect(await changeTask(f.service, scope, { ...input, expectedVersion: "2" }, "open")).toEqual({
|
|
taskId,
|
|
revision: "3",
|
|
})
|
|
expect(f.commits()).toBe(2)
|
|
})
|
|
it("rechecks project ownership after discussion reads", async () => {
|
|
const { service } = fixture()
|
|
vi.mocked(service.comments).mockImplementation(async () => {
|
|
vi.mocked(service.get).mockResolvedValue({
|
|
...fixture().task,
|
|
targets: [projectRef(bindingId, "01900000-0000-7000-8000-000000000099")],
|
|
})
|
|
return { rows: [{ commentId: taskId, body: "Must not be returned", author: null, erased: false }] }
|
|
})
|
|
await expect(listComments(service, scope, { taskId })).rejects.toMatchObject({ code: "not_found" })
|
|
})
|
|
it("passes the authorized target to an update so the adapter preserves other fields", async () => {
|
|
const { service } = fixture()
|
|
await updateTask(service, scope, { taskId, subject: "Changed", body: "Details", expectedVersion: "1", requestId })
|
|
expect(service.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({ target: projectRef(bindingId, projectId), expectedVersion: "1", subject: "Changed" }),
|
|
)
|
|
})
|
|
it("has the same translated keys in both languages", () =>
|
|
expect(Object.keys(en).sort()).toEqual(Object.keys(lt).sort()))
|
|
})
|