Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/api-collaboration/__tests__/Collaboration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import { createGraphQLHandler, TEST_CONTENT_TYPE } from "~tests/__helpers/handler.js";

const CONTENT_ID = "articleModel:entry-123";

describe("collaboration threads (graphql)", () => {
const handler = createGraphQLHandler();

it("runs the full thread lifecycle: create, list, get, reply, resolve, reopen, edit, delete", async () => {
// 1. Create a thread with its first message.
const [createResponse] = await handler.createCollabThread({
input: {
contentType: TEST_CONTENT_TYPE,
contentId: CONTENT_ID,
locator: "title",
type: "note",
body: "Should this be the SEO title?",
mentions: ["user-marko"]
}
});

const created = createResponse.data.collaboration.createCollabThread;
expect(created.error).toBeNull();
expect(created.data).toMatchObject({
contentType: TEST_CONTENT_TYPE,
contentId: CONTENT_ID,
locator: "title",
type: "note",
resolved: false,
anchor: { exists: true, authorized: true, label: "Test Field", path: ["Tab 1"] }
});
expect(created.data.messages).toHaveLength(1);
expect(created.data.messages[0]).toMatchObject({
body: "Should this be the SEO title?",
mentions: ["user-marko"]
});
expect(created.data.createdBy.id).toBeTruthy();

const threadId = created.data.id;
const firstMessageId = created.data.messages[0].id;

// 2. List threads for the content target.
const [listResponse] = await handler.listCollabThreads({
where: { contentType: TEST_CONTENT_TYPE, contentId: CONTENT_ID }
});
const list = listResponse.data.collaboration.listCollabThreads;
expect(list.error).toBeNull();
expect(list.data).toHaveLength(1);
expect(list.data[0].id).toBe(threadId);
expect(list.meta.totalCount).toBe(1);

// 3. Get the single thread.
const [getResponse] = await handler.getCollabThread({ id: threadId });
expect(getResponse.data.collaboration.getCollabThread.data.id).toBe(threadId);

// 4. Reply, then confirm two messages.
const [replyResponse] = await handler.replyToCollabThread({
threadId,
body: "Display title. SEO title lives in Tab 2."
});
expect(replyResponse.data.collaboration.replyToCollabThread.error).toBeNull();
const replyId = replyResponse.data.collaboration.replyToCollabThread.data.id;
expect(replyId).toBeTruthy();

const [afterReply] = await handler.getCollabThread({ id: threadId });
expect(afterReply.data.collaboration.getCollabThread.data.messages).toHaveLength(2);

// 5. Resolve — records resolvedBy.
const [resolveResponse] = await handler.resolveCollabThread({ id: threadId });
const resolved = resolveResponse.data.collaboration.resolveCollabThread.data;
expect(resolved.resolved).toBe(true);
expect(resolved.resolvedBy.id).toBeTruthy();
expect(resolved.resolvedOn).toBeTruthy();

// 6. Reopen — clears resolution.
const [reopenResponse] = await handler.reopenCollabThread({ id: threadId });
const reopened = reopenResponse.data.collaboration.reopenCollabThread.data;
expect(reopened.resolved).toBe(false);
expect(reopened.resolvedBy).toBeNull();

// 7. Edit the first message.
const [editResponse] = await handler.updateCollabMessage({
threadId,
messageId: firstMessageId,
body: "Edited: which title convention are we using?"
});
expect(editResponse.data.collaboration.updateCollabMessage.data.body).toBe(
"Edited: which title convention are we using?"
);

// 8. Soft-delete the reply.
const [deleteMessageResponse] = await handler.deleteCollabMessage({
threadId,
messageId: replyId
});
expect(deleteMessageResponse.data.collaboration.deleteCollabMessage.data).toBe(true);

const [afterMessageDelete] = await handler.getCollabThread({ id: threadId });
const deletedMessage =
afterMessageDelete.data.collaboration.getCollabThread.data.messages.find(
(message: { id: string }) => message.id === replyId
);
expect(deletedMessage.deleted).toBe(true);

// 9. Soft-delete the thread; it disappears from reads.
const [deleteThreadResponse] = await handler.deleteCollabThread({ id: threadId });
expect(deleteThreadResponse.data.collaboration.deleteCollabThread.data).toBe(true);

const [afterThreadDelete] = await handler.getCollabThread({ id: threadId });
expect(afterThreadDelete.data.collaboration.getCollabThread.data).toBeNull();
expect(afterThreadDelete.data.collaboration.getCollabThread.error).not.toBeNull();

const [listAfterDelete] = await handler.listCollabThreads({
where: { contentType: TEST_CONTENT_TYPE, contentId: CONTENT_ID }
});
expect(listAfterDelete.data.collaboration.listCollabThreads.data).toHaveLength(0);
});

it("denies creating a thread when no resolver owns the content type", async () => {
const [response] = await handler.createCollabThread({
input: {
contentType: "unknown.type",
contentId: "x:y",
locator: "title",
type: "note",
body: "Nobody can resolve this anchor."
}
});

const result = response.data.collaboration.createCollabThread;
expect(result.data).toBeNull();
expect(result.error).not.toBeNull();
expect(result.error.code).toBe("Collaboration/Thread/NotAuthorized");
});
});
161 changes: 161 additions & 0 deletions packages/api-collaboration/__tests__/__helpers/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { GenericRecord } from "@webiny/api/types.js";

export interface ICollabError {
code: string;
message: string;
data?: GenericRecord;
}

const ERROR = /* GraphQL */ `
error {
code
message
}
`;

const IDENTITY = /* GraphQL */ `
{
id
displayName
type
}
`;

const MESSAGE = /* GraphQL */ `
{
id
body
mentions
createdBy ${IDENTITY}
createdOn
deleted
}
`;

const THREAD = /* GraphQL */ `
{
id
contentType
contentId
locator
type
resolved
resolvedBy ${IDENTITY}
resolvedOn
assigneeId
dueDate
createdBy ${IDENTITY}
createdOn
messages ${MESSAGE}
anchor {
exists
authorized
label
path
}
}
`;

export const CREATE_COLLAB_THREAD_MUTATION = /* GraphQL */ `
mutation CreateCollabThread($input: CreateCollabThreadInput!) {
collaboration {
createCollabThread(input: $input) {
data ${THREAD}
${ERROR}
}
}
}
`;

export const LIST_COLLAB_THREADS_QUERY = /* GraphQL */ `
query ListCollabThreads($where: ListCollabThreadsWhereInput!, $limit: Int, $after: String) {
collaboration {
listCollabThreads(where: $where, limit: $limit, after: $after) {
data ${THREAD}
meta {
totalCount
hasMoreItems
cursor
}
${ERROR}
}
}
}
`;

export const GET_COLLAB_THREAD_QUERY = /* GraphQL */ `
query GetCollabThread($id: ID!) {
collaboration {
getCollabThread(id: $id) {
data ${THREAD}
${ERROR}
}
}
}
`;

export const REPLY_TO_COLLAB_THREAD_MUTATION = /* GraphQL */ `
mutation ReplyToCollabThread($threadId: ID!, $body: String!, $mentions: [String!]) {
collaboration {
replyToCollabThread(threadId: $threadId, body: $body, mentions: $mentions) {
data ${MESSAGE}
${ERROR}
}
}
}
`;

export const RESOLVE_COLLAB_THREAD_MUTATION = /* GraphQL */ `
mutation ResolveCollabThread($id: ID!) {
collaboration {
resolveCollabThread(id: $id) {
data ${THREAD}
${ERROR}
}
}
}
`;

export const REOPEN_COLLAB_THREAD_MUTATION = /* GraphQL */ `
mutation ReopenCollabThread($id: ID!) {
collaboration {
reopenCollabThread(id: $id) {
data ${THREAD}
${ERROR}
}
}
}
`;

export const UPDATE_COLLAB_MESSAGE_MUTATION = /* GraphQL */ `
mutation UpdateCollabMessage($threadId: ID!, $messageId: ID!, $body: String!) {
collaboration {
updateCollabMessage(threadId: $threadId, messageId: $messageId, body: $body) {
data ${MESSAGE}
${ERROR}
}
}
}
`;

export const DELETE_COLLAB_MESSAGE_MUTATION = /* GraphQL */ `
mutation DeleteCollabMessage($threadId: ID!, $messageId: ID!) {
collaboration {
deleteCollabMessage(threadId: $threadId, messageId: $messageId) {
data
${ERROR}
}
}
}
`;

export const DELETE_COLLAB_THREAD_MUTATION = /* GraphQL */ `
mutation DeleteCollabThread($id: ID!) {
collaboration {
deleteCollabThread(id: $id) {
data
${ERROR}
}
}
}
`;
72 changes: 72 additions & 0 deletions packages/api-collaboration/__tests__/__helpers/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useGraphQLHandler, type UseGraphQLHandlerParams } from "@webiny/testing";
import { PluginsContainer } from "@webiny/plugins";
import { ContextPlugin } from "@webiny/api";
import { createCollaboration } from "~/index.js";
import { CollabLocatorResolver } from "~/domain/locator/abstractions.js";
import {
CREATE_COLLAB_THREAD_MUTATION,
DELETE_COLLAB_MESSAGE_MUTATION,
DELETE_COLLAB_THREAD_MUTATION,
GET_COLLAB_THREAD_QUERY,
LIST_COLLAB_THREADS_QUERY,
REOPEN_COLLAB_THREAD_MUTATION,
REPLY_TO_COLLAB_THREAD_MUTATION,
RESOLVE_COLLAB_THREAD_MUTATION,
UPDATE_COLLAB_MESSAGE_MUTATION
} from "./graphql.js";

/**
* A content type used only by tests. It stands in for a real content app (CMS/WB) so the
* collaboration core can be exercised without seeding a full CMS model + entry.
*/
export const TEST_CONTENT_TYPE = "test.entry";

/**
* Stub resolver: any anchor exists and is readable. The `label`/`path` mirror the shape a real
* resolver returns so assertions cover the anchor projection too.
*/
class TestLocatorResolverImpl implements CollabLocatorResolver.Interface {
public readonly contentType = TEST_CONTENT_TYPE;

async resolve(): Promise<CollabLocatorResolver.Resolution> {
return { exists: true, authorized: true, label: "Test Field", path: ["Tab 1"] };
}
}

const TestLocatorResolver = CollabLocatorResolver.createImplementation({
implementation: TestLocatorResolverImpl,
dependencies: []
});

export const createGraphQLHandler = (params: UseGraphQLHandlerParams = {}) => {
const plugins = new PluginsContainer(params.plugins || []);

plugins.register(createCollaboration());

// Register the test stub resolver into the same container.
plugins.register(
new ContextPlugin(async context => {
context.container.register(TestLocatorResolver);
})
);

const handler = useGraphQLHandler({
...params,
permissions: [{ name: "*" }],
debug: params.debug === undefined ? true : params.debug,
plugins: plugins.all()
});

return {
handler,
createCollabThread: handler.createMutation(CREATE_COLLAB_THREAD_MUTATION),
listCollabThreads: handler.createQuery(LIST_COLLAB_THREADS_QUERY),
getCollabThread: handler.createQuery(GET_COLLAB_THREAD_QUERY),
replyToCollabThread: handler.createMutation(REPLY_TO_COLLAB_THREAD_MUTATION),
resolveCollabThread: handler.createMutation(RESOLVE_COLLAB_THREAD_MUTATION),
reopenCollabThread: handler.createMutation(REOPEN_COLLAB_THREAD_MUTATION),
updateCollabMessage: handler.createMutation(UPDATE_COLLAB_MESSAGE_MUTATION),
deleteCollabMessage: handler.createMutation(DELETE_COLLAB_MESSAGE_MUTATION),
deleteCollabThread: handler.createMutation(DELETE_COLLAB_THREAD_MUTATION)
};
};
Loading