@@ -0,0 +1,128 @@
|
||||
// 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()))
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { businessAppError, toBusinessError } from "@conusai-kit/business"
|
||||
import { businessRefusalNotices } from "@conusai-kit/business/refusals"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { en } from "../i18n/en.js"
|
||||
import { lt } from "../i18n/lt.js"
|
||||
import { describeFailure, type Labels } from "../ui/labels.js"
|
||||
|
||||
// What the loader hands the forms (ui/pages.tsx): the module's messages and the kit's refusal sentences.
|
||||
const labelsOf = (messages: typeof en, locale: "en" | "lt"): Labels => ({
|
||||
...messages,
|
||||
refusals: businessRefusalNotices(locale, {
|
||||
conflict: messages.conflict,
|
||||
revision_conflict: messages.conflict,
|
||||
invalid: messages.invalid,
|
||||
}),
|
||||
})
|
||||
|
||||
describe("Activities error text", () => {
|
||||
it.each([
|
||||
[en, "en"],
|
||||
[lt, "lt"],
|
||||
] as const)("translates known refusals without showing remote detail", (messages, locale) => {
|
||||
const labels = labelsOf(messages, locale)
|
||||
for (const [code, expected] of [
|
||||
["business_conflict", messages.conflict],
|
||||
["business_revision_conflict", messages.conflict],
|
||||
["business_invalid", messages.invalid],
|
||||
["service_partition_revoked", labels.refusals.service_partition_revoked],
|
||||
["service_degraded", labels.refusals.service_degraded],
|
||||
["service_migrating", labels.refusals.service_migrating],
|
||||
]) {
|
||||
const message = describeFailure({ problem: { code, detail: "upstream private fixture value" } }, labels)
|
||||
expect(message).toBe(expected)
|
||||
expect(message).not.toContain("private fixture value")
|
||||
}
|
||||
expect(describeFailure({ problem: { code: "untrusted", detail: "upstream private fixture value" } }, labels)).toBe(
|
||||
messages.unavailable,
|
||||
)
|
||||
})
|
||||
|
||||
it("tells a Lithuanian reader the kit's sentence for a refusal of the Activities service", () => {
|
||||
const refusal = businessAppError(
|
||||
toBusinessError(
|
||||
{ type: "urn:conusai:activities:erased-participant", detail: "upstream private fixture value" },
|
||||
new Response(null, { status: 409 }),
|
||||
),
|
||||
)
|
||||
expect(describeFailure(refusal, labelsOf(lt, "lt"))).toBe(
|
||||
"Dalyvio duomenys ištrinti, todėl šios veiklos pakeisti negalima.",
|
||||
)
|
||||
expect(describeFailure({ problem: { code: "service_migrating" } }, labelsOf(lt, "lt"))).toBe(
|
||||
"Paslauga atnaujinama. Bandykite po kelių minučių.",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
import { businessModulesOn } from "../../index.js"
|
||||
|
||||
const id = "01900000-0000-7000-8000-000000000001"
|
||||
const slugs = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
const task = (t: IsolationTenant) => ({ ...slugs(t), taskId: `act_${id}` })
|
||||
const mutate = (t: IsolationTenant) => ({ ...task(t), expectedVersion: "1", requestId: id })
|
||||
export const activitiesIsolationFixtures: Record<string, IsolationFixture> = {
|
||||
"activities.task.list": { input: slugs },
|
||||
"activities.task.get": { input: task },
|
||||
"activities.timeline.list": { input: slugs },
|
||||
"activities.task.create": { input: (t) => ({ ...slugs(t), subject: "Synthetic", requestId: id }) },
|
||||
"activities.task.update": { input: (t) => ({ ...mutate(t), subject: "Synthetic", body: "Fixture" }) },
|
||||
"activities.task.complete": { input: mutate },
|
||||
"activities.task.reopen": { input: mutate },
|
||||
"activities.comment.list": { input: task },
|
||||
"activities.comment.add": { input: (t) => ({ ...mutate(t), body: "Synthetic" }) },
|
||||
}
|
||||
export const isolation: {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
} = { seed: async () => ({}), fixtures: businessModulesOn.includes("activities") ? activitiesIsolationFixtures : {} }
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated SDK + synthetic HTTP proves wire contracts, never live service acceptance.
|
||||
import { actorRef, projectRef, requestBusinessBinding } from "@conusai-kit/business"
|
||||
import { businessServiceHooks } from "@conusai-kit/business/server"
|
||||
import { Container, type ServiceAdmissionContext } from "@conusai-kit/core"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { sdkActivitiesService } from "../adapters/service.js"
|
||||
import { activityId, commentReceipt, receipt } from "../contract/index.js"
|
||||
import { activitiesModule } from "../module.js"
|
||||
|
||||
const bindingId = "01900000-0000-7000-8000-000000000001"
|
||||
const projectId = "01900000-0000-7000-8000-000000000002"
|
||||
const actorId = "01900000-0000-7000-8000-000000000003"
|
||||
const taskId = "act_01900000-0000-7000-8000-000000000004"
|
||||
const commentId = "cmt_01900000-0000-7000-8000-000000000005"
|
||||
const revision = "9007199254740993"
|
||||
const target = projectRef(bindingId, projectId)
|
||||
const member = actorRef(bindingId, actorId)
|
||||
const task = {
|
||||
id: taskId,
|
||||
kind: "task",
|
||||
status: "open",
|
||||
revision: "RAW_REVISION",
|
||||
subject: "Synthetic task",
|
||||
body: "Details",
|
||||
targets: [target],
|
||||
participants: [member],
|
||||
assignee: member,
|
||||
due_at: "2026-12-01T00:00:00Z",
|
||||
ext: { fixture: "preserve" },
|
||||
pinned: true,
|
||||
created_at: "2026-11-03T00:00:00Z",
|
||||
updated_at: "2026-11-03T00:00:00Z",
|
||||
erased: false,
|
||||
deleted: false,
|
||||
}
|
||||
function json(value: unknown) {
|
||||
return new Response(JSON.stringify(value).replaceAll('"RAW_REVISION"', revision), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
async function fixture(reply: (request: Request) => Promise<Response> | Response) {
|
||||
const requests: Request[] = []
|
||||
vi.stubGlobal("fetch", async (input: Request | string | URL) => {
|
||||
const request = new Request(input)
|
||||
requests.push(request.clone())
|
||||
return reply(request)
|
||||
})
|
||||
const container = new Container()
|
||||
const hooks = businessServiceHooks({
|
||||
env: { apiUrl: "https://platform.example.test/api", token: "synthetic-token", orgSlug: "fixture" },
|
||||
platformOrganizationId: 7,
|
||||
platformProjectId: 9,
|
||||
platformEnvironmentId: 11,
|
||||
profile: "postgres",
|
||||
controlPlane: {
|
||||
lookup: async (appTenantId) => ({
|
||||
partition_major: 1,
|
||||
binding_id: bindingId,
|
||||
organization_id: 7,
|
||||
project_id: 9,
|
||||
environment_id: 11,
|
||||
environment_uuid: projectId,
|
||||
app_tenant_id: appTenantId,
|
||||
}),
|
||||
observe: async () => {
|
||||
throw new Error("This fixture does not certify readiness")
|
||||
},
|
||||
},
|
||||
})
|
||||
const context: ServiceAdmissionContext = {
|
||||
container,
|
||||
module: "activities",
|
||||
procedure: "activities.task.list",
|
||||
kind: "read",
|
||||
scope: { level: "org", orgId: bindingId, actor: { userId: actorId } },
|
||||
}
|
||||
await hooks.prepareServices?.(container, context, activitiesModule.serviceRequirements ?? [])
|
||||
return { service: sdkActivitiesService(requestBusinessBinding(container)), requests }
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
describe("Activities generated wire adapter (synthetic HTTP)", () => {
|
||||
it("accepts real resource prefixes and preserves int64 revision digits", async () => {
|
||||
const { service, requests } = await fixture(() => json(task))
|
||||
const result = await service.get(taskId)
|
||||
expect(result.revision).toBe(revision)
|
||||
expect(activityId.parse(result.taskId)).toBe(taskId)
|
||||
expect(() => activityId.parse(taskId.slice(4))).toThrow()
|
||||
expect(requests[0]?.headers.get("Conusai-App-Binding")).toBe(bindingId)
|
||||
expect(requests[0]?.headers.get("Conusai-App-Actor")).toBe(actorId)
|
||||
expect(requests[0]?.redirect).toBe("error")
|
||||
})
|
||||
it("forwards a service cursor unchanged in both directions", async () => {
|
||||
const cursor = "eyJvcmRlciI6WzEsInN5bnRoZXRpYyJdfQ"
|
||||
const { service, requests } = await fixture(() => json({ data: [task], next_after: cursor }))
|
||||
expect((await service.list({ target, after: cursor })).after).toBe(cursor)
|
||||
expect(new URL(requests[0]?.url ?? "").searchParams.get("after")).toBe(cursor)
|
||||
expect(new URL(requests[0]?.url ?? "").searchParams.get("target")).toBe(target)
|
||||
})
|
||||
it("preserves untouched fields on edit and sends revision digits exactly", async () => {
|
||||
let body = ""
|
||||
const { service } = await fixture(async (request) => {
|
||||
if (request.method === "GET") return json(task)
|
||||
body = await request.text()
|
||||
return json({ id: taskId, revision: "RAW_REVISION" })
|
||||
})
|
||||
const current = await service.get(taskId)
|
||||
const result = await service.update({
|
||||
task: current,
|
||||
target,
|
||||
subject: "Changed",
|
||||
body: "Edited",
|
||||
expectedVersion: revision,
|
||||
key: "synthetic-update",
|
||||
})
|
||||
expect(receipt.parse(result).revision).toBe(revision)
|
||||
expect(body).toContain(`"revision":${revision}`)
|
||||
expect(JSON.parse(body).fields).toMatchObject({
|
||||
assignee: member,
|
||||
due_at: task.due_at,
|
||||
ext: task.ext,
|
||||
participants: [member],
|
||||
targets: [target],
|
||||
pinned: true,
|
||||
subject: "Changed",
|
||||
body: "Edited",
|
||||
})
|
||||
})
|
||||
it("returns the actual comment receipt instead of inventing a comment body", async () => {
|
||||
const { service } = await fixture(() => json({ id: commentId, revision: "RAW_REVISION" }))
|
||||
const result = await service.comment({
|
||||
taskId,
|
||||
body: "Synthetic comment",
|
||||
expectedVersion: revision,
|
||||
key: "synthetic-comment",
|
||||
})
|
||||
expect(commentReceipt.parse(result)).toEqual({ commentId, revision })
|
||||
expect(result).not.toHaveProperty("body")
|
||||
})
|
||||
it("refuses a changed project target before the adapter writes", async () => {
|
||||
const other = projectRef(bindingId, "01900000-0000-7000-8000-000000000099")
|
||||
const { service, requests } = await fixture(() => json({ ...task, targets: [other] }))
|
||||
await expect(
|
||||
service.update({
|
||||
task: {
|
||||
taskId,
|
||||
subject: "Old",
|
||||
body: "",
|
||||
revision,
|
||||
status: "open",
|
||||
targets: [target],
|
||||
createdAt: task.created_at,
|
||||
},
|
||||
target,
|
||||
subject: "Changed",
|
||||
body: "Edited",
|
||||
expectedVersion: revision,
|
||||
key: "synthetic-foreign",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "not_found" })
|
||||
expect(requests.every((request) => request.method === "GET")).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user