Files
PaperClipAI/server/src/__tests__/issues-user-context.test.ts
T
45e91f097c fix(inbox): requalify existing unread fix (#18670)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The personal inbox must keep new comments unread until the person
reads or answers them.
> - A comment updates task recency, even when another person writes it.
> - The assignee unread query treats that task timestamp as a read
receipt.
> - [rudycelekli](https://github.com/rudycelekli) already repaired this
in #15085, including a reviewed deleted-comment safeguard.
> - That PR now has merge conflicts and checks from an older base. This
PR ports and requalifies the existing unread fix on current master.
> - The port adds real human-comment and API regressions. The original
contribution remains open and unchanged.

## Linked Issues or Issue Description

Fixes: #15079
Refs: #15085

This is an attributed maintainer port of #15085 at
`fe6a486c9ca21cd3a6ea0cef9764c372c53f4572`. The commit includes the
verified original contributor credit. This is not an independent claim
to the original repair.

## What Changed

- Port the stable creation-time fallback for a human assignee in SQL and
derived user context. Explicit reads and own replies still take
precedence.
- Port the reviewed safeguard that excludes deleted external comments.
Keep the historical touch from a deleted own reply.
- Exercise real comment writes, task recency updates, existing read
receipts, read/unread toggles, later assignment, company isolation, and
comment deletion through native PostgreSQL and ordinary API projections.

## Verification

- Native PostgreSQL regressions fail before the repair: real comment
writes leave the assignee falsely read, and later assignment hides
existing comments.
- A separate deletion regression fails before the reviewed safeguard: a
deleted external comment remains the latest external activity.
- The frozen head `0dadf85611cbfdc7a762cb82b03ed64519be5434` passes all
41 focused inbox, source-visibility, request, archive, and user-context
tests on native PostgreSQL. No tests were skipped.
- `pnpm -r typecheck` and `pnpm build` pass on that same frozen head
with Node 24 and pnpm 9.15.4. The current-head CI is green, including
all general and serialized test shards, build, typecheck, Runner checks,
and eight E2E shards. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/41525545915).
- The local full-test attempt was stopped before the one master rebase.
It is incomplete and is not a local full-suite pass. The full
current-head CI completed successfully.
- Fresh Greptile review is 5/5 on
`0dadf85611cbfdc7a762cb82b03ed64519be5434`, with no actionable findings
or open review threads.

## Risks

- Assigned tasks with older external comments can appear unread. A later
explicit read or own reply still clears them.
- This changes no schema, API fields, company membership rules, or query
joins.
- Legacy compact-response cache policy is unchanged. Immediate mutation
checks use the ordinary, rows, and index projections.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository inspection,
code editing, and command execution. This session does not expose a more
specific backend model identifier or 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 (no
documentation change is needed to restore the existing unread contract)
- [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: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-02 15:26:28 -06:00

128 lines
4.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { deriveIssueUserContext } from "../services/issues.ts";
function makeIssue(overrides?: Partial<{
createdByAgentId: string | null;
originKind: string | null;
createdByUserId: string | null;
assigneeUserId: string | null;
createdAt: Date;
updatedAt: Date;
}>) {
return {
createdByUserId: null,
assigneeUserId: null,
createdAt: new Date("2026-03-06T10:00:00.000Z"),
updatedAt: new Date("2026-03-06T11:00:00.000Z"),
...overrides,
};
}
describe("deriveIssueUserContext", () => {
it("marks issue unread when external comments are newer than my latest comment", () => {
const context = deriveIssueUserContext(
makeIssue({ createdByUserId: "user-1" }),
"user-1",
{
myLastCommentAt: new Date("2026-03-06T12:00:00.000Z"),
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T12:00:00.000Z");
expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T13:00:00.000Z");
expect(context.isUnreadForMe).toBe(true);
});
it("marks issue read when my latest comment is newest", () => {
const context = deriveIssueUserContext(
makeIssue({ createdByUserId: "user-1" }),
"user-1",
{
myLastCommentAt: new Date("2026-03-06T14:00:00.000Z"),
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
},
);
expect(context.isUnreadForMe).toBe(false);
});
it("uses issue creation time as fallback touch point for creator", () => {
const context = deriveIssueUserContext(
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
"user-1",
{
myLastCommentAt: null,
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T10:00:00.000Z"),
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T09:00:00.000Z");
expect(context.isUnreadForMe).toBe(true);
});
it("excludes automatic creator attribution from the personal touch time", () => {
for (const originKind of ["chat_channel", "routine_execution"]) {
const context = deriveIssueUserContext(
makeIssue({ originKind, createdByUserId: "user-1" }),
"user-1",
{ myLastCommentAt: null, myLastReadAt: null, lastExternalCommentAt: null },
);
expect(context.myLastTouchAt).toBeNull();
expect(context.isUnreadForMe).toBe(false);
}
});
it("uses stable creation time for assignees instead of treating task recency as a read", () => {
const context = deriveIssueUserContext(
makeIssue({ assigneeUserId: "user-1", updatedAt: new Date("2026-03-06T15:00:00.000Z") }),
"user-1",
{
myLastCommentAt: null,
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T14:59:00.000Z"),
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T10:00:00.000Z");
expect(context.isUnreadForMe).toBe(true);
});
it("uses latest read timestamp to clear unread without requiring a comment", () => {
const context = deriveIssueUserContext(
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
"user-1",
{
myLastCommentAt: null,
myLastReadAt: new Date("2026-03-06T11:30:00.000Z"),
lastExternalCommentAt: new Date("2026-03-06T11:00:00.000Z"),
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T11:30:00.000Z");
expect(context.isUnreadForMe).toBe(false);
});
it("handles SQL timestamp strings without throwing", () => {
const context = deriveIssueUserContext(
makeIssue({
createdByUserId: "user-1",
createdAt: new Date("2026-03-06T09:00:00.000Z"),
}),
"user-1",
{
myLastCommentAt: "2026-03-06T10:00:00.000Z",
myLastReadAt: null,
lastExternalCommentAt: "2026-03-06T11:00:00.000Z",
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T10:00:00.000Z");
expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T11:00:00.000Z");
expect(context.isUnreadForMe).toBe(true);
});
});