mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 04:02:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The timeline page visualizes company activity across a selected date window > - The UI requested only the first paginated issue batch even when the selected zoom covered seven or thirty days > - A busy company could therefore render an incomplete timeline while the controls implied the full window was loaded > - The timeline query needs to exhaust the API pagination for the selected date range and combine each page without duplicating shared timeline records > - This pull request adds a paginated window loader, merges the returned timeline data, and covers the multi-page behavior with a regression test > - The benefit is that the visible timeline matches the selected zoom window instead of silently omitting later issues ## Linked Issues or Issue Description ### Pre-submission checklist - [x] I searched existing open and closed issues and pull requests; no matching report or implementation was found. - [x] I reproduced the behavior against the pre-change `master` implementation. - [x] I confirmed the error originates in Paperclip's core timeline UI, not an adapter, provider, or local configuration. ### What happened? Selecting the default seven-day timeline range loaded only the first API page (up to 500 issues). Companies with more activity therefore displayed incomplete data even though the controls showed the full selected window. ### Expected behavior The timeline should load all issue pages that fall within the selected date window. ### Steps to reproduce 1. Open the company timeline for a date range containing more than 500 issues. 2. Keep the default seven-day range or select another multi-day preset. 3. Observe that only the first page of issue-backed timeline data is shown. ### Paperclip version or commit Pre-change `master`. ### Deployment mode Local dev source build. The behavior is not adapter-specific and is independent of database mode and access context. ### Privacy checklist - [x] No logs, configuration, personally identifiable information, or user data are included. ## What Changed - Added pagination parameters to the timeline API client contract. - Added a timeline window loader that requests every issue page and deduplicates actors, spans, events, and edges while preserving pagination metadata. - Switched the timeline query to use the complete-window loader. - Added a regression test proving a 501-issue window loads both API pages and combines their records. - Preserved delegation events and edges when parent and child issues fall on different API pages, with a server regression test. ## Verification - `pnpm exec vitest run server/src/__tests__/work-timeline-service.test.ts ui/src/pages/Timeline.test.tsx` — 16 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm check:token-gates` — all gates clean. - `git diff --check origin/master...HEAD` — passed. - Remote CI: build, typecheck, both e2e shards, canary, policy, security, every general/serialized test shard, and the aggregate `verify` gate passed on head `24784b28e9`. ## Risks - Low risk: the change is isolated to timeline data loading and has no schema or API endpoint changes. - Large date windows now make sequential requests for all issue pages, increasing request count for very active companies; the 500-item page size bounds each response. - Merged records rely on stable identifiers or composite event/edge keys; the regression test covers cross-page combination and deduplication behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex using GPT-5.4 with reasoning, repository tool use, shell execution, and test execution. The runtime does not expose the exact context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
542 lines
16 KiB
TypeScript
542 lines
16 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
approvals,
|
|
authUsers,
|
|
companies,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueApprovals,
|
|
issueComments,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
import { companyRoutes } from "../routes/companies.js";
|
|
import { normalizeTimelineWindow, workTimelineService } from "../services/work-timeline.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres work timeline tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("work timeline aggregation", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-work-timeline-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(activityLog);
|
|
await db.delete(issueComments);
|
|
await db.delete(issueApprovals);
|
|
await db.delete(approvals);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(issues);
|
|
await db.delete(agents);
|
|
await db.delete(authUsers);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
function createApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = {
|
|
type: "board",
|
|
userId: "local-board",
|
|
companyIds: [],
|
|
memberships: [],
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api/companies", companyRoutes(db, {} as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
async function seedBase() {
|
|
const companyId = randomUUID();
|
|
const userId = "user-1";
|
|
const agentAId = randomUUID();
|
|
const agentBId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Timeline Co",
|
|
issuePrefix: `T${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(authUsers).values({
|
|
id: userId,
|
|
name: "User One",
|
|
email: "user@example.com",
|
|
emailVerified: true,
|
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
|
});
|
|
await db.insert(agents).values([
|
|
{
|
|
id: agentAId,
|
|
companyId,
|
|
name: "Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: agentBId,
|
|
companyId,
|
|
name: "QA",
|
|
role: "qa",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
return { companyId, userId, agentAId, agentBId };
|
|
}
|
|
|
|
it("normalizes timeline windows with a 31 day cap", () => {
|
|
const result = normalizeTimelineWindow({
|
|
from: new Date("2026-01-01T00:00:00Z"),
|
|
to: new Date("2026-03-15T00:00:00Z"),
|
|
}, new Date("2026-03-15T00:00:00Z"));
|
|
|
|
expect(result.capped).toBe(true);
|
|
expect(result.from.toISOString()).toBe("2026-02-12T00:00:00.000Z");
|
|
});
|
|
|
|
it("aggregates runs, human events, approvals, and delegation edges", async () => {
|
|
const { companyId, userId, agentAId, agentBId } = await seedBase();
|
|
const parentIssueId = randomUUID();
|
|
const childIssueId = randomUUID();
|
|
const contextRunId = randomUUID();
|
|
const activityRunId = randomUUID();
|
|
const approvalId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: parentIssueId,
|
|
companyId,
|
|
title: "Parent",
|
|
identifier: "TL-1",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
assigneeAgentId: agentAId,
|
|
createdAt: new Date("2026-03-01T10:00:00Z"),
|
|
updatedAt: new Date("2026-03-01T10:00:00Z"),
|
|
},
|
|
{
|
|
id: childIssueId,
|
|
companyId,
|
|
title: "Child",
|
|
identifier: "TL-2",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
parentId: parentIssueId,
|
|
createdByAgentId: agentAId,
|
|
assigneeAgentId: agentBId,
|
|
createdAt: new Date("2026-03-01T11:00:00Z"),
|
|
updatedAt: new Date("2026-03-01T11:00:00Z"),
|
|
},
|
|
]);
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: contextRunId,
|
|
companyId,
|
|
agentId: agentBId,
|
|
status: "running",
|
|
invocationSource: "issue_assigned",
|
|
startedAt: new Date("2026-03-01T12:00:00Z"),
|
|
finishedAt: null,
|
|
usageJson: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50 },
|
|
contextSnapshot: { issueId: childIssueId },
|
|
},
|
|
{
|
|
id: activityRunId,
|
|
companyId,
|
|
agentId: agentAId,
|
|
status: "completed",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-03-01T12:30:00Z"),
|
|
finishedAt: new Date("2026-03-01T12:45:00Z"),
|
|
contextSnapshot: {},
|
|
},
|
|
]);
|
|
await db.insert(activityLog).values([
|
|
{
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: agentAId,
|
|
action: "issue.updated",
|
|
entityType: "issue",
|
|
entityId: parentIssueId,
|
|
agentId: agentAId,
|
|
runId: activityRunId,
|
|
createdAt: new Date("2026-03-01T12:35:00Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.assigned",
|
|
entityType: "issue",
|
|
entityId: childIssueId,
|
|
details: { assigneeAgentId: agentBId },
|
|
createdAt: new Date("2026-03-01T13:00:00Z"),
|
|
},
|
|
]);
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId: childIssueId,
|
|
authorUserId: userId,
|
|
body: "Looks good",
|
|
createdAt: new Date("2026-03-01T13:30:00Z"),
|
|
});
|
|
await db.insert(approvals).values({
|
|
id: approvalId,
|
|
companyId,
|
|
type: "request_board_approval",
|
|
status: "approved",
|
|
payload: {},
|
|
decidedByUserId: userId,
|
|
decidedAt: new Date("2026-03-01T14:00:00Z"),
|
|
});
|
|
await db.insert(issueApprovals).values({ companyId, issueId: childIssueId, approvalId });
|
|
|
|
const result = await workTimelineService(db).getTimeline({
|
|
companyId,
|
|
from: new Date("2026-03-01T00:00:00Z"),
|
|
to: new Date("2026-03-02T00:00:00Z"),
|
|
});
|
|
|
|
expect(result.actors.map((actor) => actor.name)).toEqual(expect.arrayContaining(["Coder", "QA", "User One"]));
|
|
expect(result.spans).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
runId: contextRunId,
|
|
issueId: childIssueId,
|
|
end: null,
|
|
status: "running",
|
|
usage: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50, totalTokens: 200 },
|
|
}),
|
|
expect.objectContaining({ runId: activityRunId, issueId: parentIssueId, status: "completed" }),
|
|
]));
|
|
expect(result.events.map((event) => event.kind)).toEqual(expect.arrayContaining([
|
|
"created",
|
|
"commented",
|
|
"approved",
|
|
"delegated",
|
|
"assigned",
|
|
]));
|
|
expect(result.edges).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "delegation", issueId: childIssueId }),
|
|
expect.objectContaining({ kind: "assignment", issueId: childIssueId }),
|
|
]));
|
|
});
|
|
|
|
it("preserves delegation edges when parent and child cross a page boundary", async () => {
|
|
const { companyId, userId, agentAId, agentBId } = await seedBase();
|
|
const parentIssueId = randomUUID();
|
|
const childIssueId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: parentIssueId,
|
|
companyId,
|
|
title: "Parent",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
assigneeAgentId: agentAId,
|
|
createdAt: new Date("2026-03-01T10:00:00Z"),
|
|
updatedAt: new Date("2026-03-01T10:00:00Z"),
|
|
},
|
|
{
|
|
id: childIssueId,
|
|
companyId,
|
|
title: "Child",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
parentId: parentIssueId,
|
|
createdByAgentId: agentAId,
|
|
assigneeAgentId: agentBId,
|
|
createdAt: new Date("2026-03-01T11:00:00Z"),
|
|
updatedAt: new Date("2026-03-01T11:00:00Z"),
|
|
},
|
|
]);
|
|
|
|
const result = await workTimelineService(db).getTimeline({
|
|
companyId,
|
|
from: new Date("2026-03-01T00:00:00Z"),
|
|
to: new Date("2026-03-02T00:00:00Z"),
|
|
limit: 1,
|
|
});
|
|
|
|
expect(result.pagination).toEqual({
|
|
limit: 1,
|
|
offset: 0,
|
|
totalIssues: 2,
|
|
hasMore: true,
|
|
});
|
|
expect(result.events).toContainEqual(expect.objectContaining({
|
|
kind: "delegated",
|
|
issueId: childIssueId,
|
|
}));
|
|
expect(result.edges).toContainEqual(expect.objectContaining({
|
|
kind: "delegation",
|
|
issueId: childIssueId,
|
|
fromActorId: `agent:${agentAId}`,
|
|
toActorId: `agent:${agentBId}`,
|
|
}));
|
|
});
|
|
|
|
it("does not join activity rows to runs from another company", async () => {
|
|
const { companyId, agentAId } = await seedBase();
|
|
const otherCompanyId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const foreignRunId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: otherCompanyId,
|
|
name: "Other Timeline Co",
|
|
issuePrefix: `O${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: otherAgentId,
|
|
companyId: otherCompanyId,
|
|
name: "Foreign Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Local issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentAId,
|
|
createdAt: new Date("2026-03-02T10:00:00Z"),
|
|
updatedAt: new Date("2026-03-02T10:00:00Z"),
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: foreignRunId,
|
|
companyId: otherCompanyId,
|
|
agentId: otherAgentId,
|
|
status: "completed",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-03-02T11:00:00Z"),
|
|
finishedAt: new Date("2026-03-02T11:15:00Z"),
|
|
contextSnapshot: {},
|
|
});
|
|
await db.insert(activityLog).values({
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: agentAId,
|
|
action: "issue.updated",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
agentId: agentAId,
|
|
runId: foreignRunId,
|
|
createdAt: new Date("2026-03-02T11:05:00Z"),
|
|
});
|
|
|
|
const result = await workTimelineService(db).getTimeline({
|
|
companyId,
|
|
from: new Date("2026-03-02T00:00:00Z"),
|
|
to: new Date("2026-03-03T00:00:00Z"),
|
|
});
|
|
|
|
expect(result.events.map((event) => event.issueId)).toContain(issueId);
|
|
expect(result.spans.map((span) => span.runId)).not.toContain(foreignRunId);
|
|
});
|
|
|
|
it("applies the user lens as a transitive issue subtree", async () => {
|
|
const { companyId, userId, agentAId, agentBId } = await seedBase();
|
|
const rootIssueId = randomUUID();
|
|
const childIssueId = randomUUID();
|
|
const unrelatedIssueId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: rootIssueId,
|
|
companyId,
|
|
title: "User root",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
assigneeAgentId: agentAId,
|
|
createdAt: new Date("2026-03-03T10:00:00Z"),
|
|
updatedAt: new Date("2026-03-03T10:00:00Z"),
|
|
},
|
|
{
|
|
id: childIssueId,
|
|
companyId,
|
|
title: "Delegated child",
|
|
status: "todo",
|
|
priority: "medium",
|
|
parentId: rootIssueId,
|
|
createdByAgentId: agentAId,
|
|
assigneeAgentId: agentBId,
|
|
createdAt: new Date("2026-03-03T11:00:00Z"),
|
|
updatedAt: new Date("2026-03-03T11:00:00Z"),
|
|
},
|
|
{
|
|
id: unrelatedIssueId,
|
|
companyId,
|
|
title: "Unrelated",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentBId,
|
|
createdAt: new Date("2026-03-03T12:00:00Z"),
|
|
updatedAt: new Date("2026-03-03T12:00:00Z"),
|
|
},
|
|
]);
|
|
|
|
const result = await workTimelineService(db).getTimeline({
|
|
companyId,
|
|
userId,
|
|
from: new Date("2026-03-03T00:00:00Z"),
|
|
to: new Date("2026-03-04T00:00:00Z"),
|
|
});
|
|
|
|
expect(result.events.map((event) => event.issueId)).toEqual(expect.arrayContaining([rootIssueId, childIssueId]));
|
|
expect(result.events.map((event) => event.issueId)).not.toContain(unrelatedIssueId);
|
|
});
|
|
|
|
it("filters unreadable issues before emitting timeline rows", async () => {
|
|
const { companyId, agentAId } = await seedBase();
|
|
const visibleIssueId = randomUUID();
|
|
const hiddenIssueId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: visibleIssueId,
|
|
companyId,
|
|
title: "Visible",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentAId,
|
|
createdAt: new Date("2026-03-04T10:00:00Z"),
|
|
updatedAt: new Date("2026-03-04T10:00:00Z"),
|
|
},
|
|
{
|
|
id: hiddenIssueId,
|
|
companyId,
|
|
title: "Denied",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentAId,
|
|
createdAt: new Date("2026-03-04T11:00:00Z"),
|
|
updatedAt: new Date("2026-03-04T11:00:00Z"),
|
|
},
|
|
]);
|
|
|
|
const result = await workTimelineService(db).getTimeline({
|
|
companyId,
|
|
from: new Date("2026-03-04T00:00:00Z"),
|
|
to: new Date("2026-03-05T00:00:00Z"),
|
|
canReadIssue: async (issue) => issue.id !== hiddenIssueId,
|
|
});
|
|
|
|
expect(result.events.map((event) => event.issueId)).toContain(visibleIssueId);
|
|
expect(result.events.map((event) => event.issueId)).not.toContain(hiddenIssueId);
|
|
expect(result.pagination.totalIssues).toBe(1);
|
|
});
|
|
|
|
it("bounds pre-pagination ACL checks", async () => {
|
|
const { companyId, agentAId } = await seedBase();
|
|
const issueCount = 40;
|
|
await db.insert(issues).values(Array.from({ length: issueCount }, (_, index) => ({
|
|
id: randomUUID(),
|
|
companyId,
|
|
title: `Visible ${index}`,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentAId,
|
|
createdAt: new Date(Date.parse("2026-03-04T10:00:00Z") + index * 1000),
|
|
updatedAt: new Date(Date.parse("2026-03-04T10:00:00Z") + index * 1000),
|
|
})));
|
|
|
|
let activeChecks = 0;
|
|
let maxActiveChecks = 0;
|
|
const result = await workTimelineService(db).getTimeline({
|
|
companyId,
|
|
from: new Date("2026-03-04T00:00:00Z"),
|
|
to: new Date("2026-03-05T00:00:00Z"),
|
|
limit: issueCount,
|
|
canReadIssue: async () => {
|
|
activeChecks += 1;
|
|
maxActiveChecks = Math.max(maxActiveChecks, activeChecks);
|
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
activeChecks -= 1;
|
|
return true;
|
|
},
|
|
});
|
|
|
|
expect(result.pagination.totalIssues).toBe(issueCount);
|
|
expect(maxActiveChecks).toBeLessThanOrEqual(16);
|
|
});
|
|
|
|
it("serves GET /api/companies/:companyId/timeline", async () => {
|
|
const { companyId, agentAId } = await seedBase();
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Route issue",
|
|
identifier: "TL-9",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentAId,
|
|
createdAt: new Date("2026-03-05T10:00:00Z"),
|
|
updatedAt: new Date("2026-03-05T10:00:00Z"),
|
|
});
|
|
|
|
const res = await request(createApp())
|
|
.get(`/api/companies/${companyId}/timeline`)
|
|
.query({ from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body.events).toEqual([
|
|
expect.objectContaining({ kind: "created", issueId }),
|
|
]);
|
|
expect(res.body).toEqual(expect.objectContaining({
|
|
actors: expect.any(Array),
|
|
spans: expect.any(Array),
|
|
edges: expect.any(Array),
|
|
pagination: expect.objectContaining({ totalIssues: 1 }),
|
|
}));
|
|
});
|
|
});
|