mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
Before: the feed could not tell which conversations had moved since the
reader last looked, so every card read the same.
After: opening an issue (page or peek) records a per-user read cursor after
1.5 s, posting a comment records it at once, and feed cards whose newest
counted comment is later than the cursor carry a small dot on the left: a
plain dot when the reader spoke last, a ringed one when someone else did
and the reply is theirs to make.
How: table issue_read_cursor (no primary key in the create, a CONCURRENTLY
built unique index on (user_id, issue_id) attached as the primary key in a
later migration, no foreign keys), queries UpsertIssueReadCursor (monotonic
GREATEST on the database clock), ListIssueReadCursors and the three
cleanup deletes wired into issue deletion, member removal and workspace
teardown. PUT /api/issues/{id}/read returns 204; thread-summaries gains
last_read_at for the caller. Core adds api.markIssueRead, useMarkIssueRead
and patchThreadSummariesLastRead; the comment-created patch keeps the
cursor. Views add getFeedReadState and the dot on FeedMessageCard, the
delayed mark in IssueDetail and the immediate mark on send in
useIssueTimeline.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
251 lines
9.2 KiB
Go
251 lines
9.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
func markIssueRead(t *testing.T, userID, issueRef string) *testutil.Response {
|
|
t.Helper()
|
|
req := withURLParam(newRequestAs(userID, http.MethodPut, "/api/issues/"+issueRef+"/read", nil), "id", issueRef)
|
|
return testutil.Call(t, testHandler.MarkIssueRead, req)
|
|
}
|
|
|
|
func threadSummariesAs(t *testing.T, userID string, issueIDs ...string) map[string]IssueThreadSummaryResponse {
|
|
t.Helper()
|
|
ids := ""
|
|
for i, id := range issueIDs {
|
|
if i > 0 {
|
|
ids += ","
|
|
}
|
|
ids += id
|
|
}
|
|
var out threadSummariesBody
|
|
req := newRequestAs(userID, http.MethodGet, "/api/issues/thread-summaries?ids="+ids, nil)
|
|
testutil.Call(t, testHandler.ListIssueThreadSummaries, req).Want(http.StatusOK).JSON(&out)
|
|
byIssue := make(map[string]IssueThreadSummaryResponse, len(out.Summaries))
|
|
for _, s := range out.Summaries {
|
|
byIssue[s.IssueID] = s
|
|
}
|
|
return byIssue
|
|
}
|
|
|
|
func tsz(ts time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: ts, Valid: true} }
|
|
|
|
func readCursorAt(t *testing.T, userID, workspaceID, issueID string) (time.Time, bool) {
|
|
t.Helper()
|
|
rows, err := testHandler.Queries.ListIssueReadCursors(context.Background(), db.ListIssueReadCursorsParams{
|
|
UserID: parseUUID(userID),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
IssueIds: []pgtype.UUID{parseUUID(issueID)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list read cursors: %v", err)
|
|
}
|
|
if len(rows) == 0 {
|
|
return time.Time{}, false
|
|
}
|
|
return rows[0].LastReadAt.Time, true
|
|
}
|
|
|
|
func cleanupReadCursors(t *testing.T, issueID string) {
|
|
t.Helper()
|
|
dbfx.Cleanup(t, `DELETE FROM issue_read_cursor WHERE issue_id = $1`, issueID)
|
|
}
|
|
|
|
func TestMarkIssueRead_ReturnsNoContentAndSummaryCarriesLastReadAt(t *testing.T) {
|
|
issueID := dbfx.Issue(t, "read cursor summary")
|
|
cleanupReadCursors(t, issueID)
|
|
dbfx.Comment(t, issueID, "hello")
|
|
|
|
before := threadSummariesAs(t, testUserID, issueID)[issueID]
|
|
if before.LastReadAt != nil {
|
|
t.Fatalf("last_read_at before reading = %q, want null", *before.LastReadAt)
|
|
}
|
|
|
|
start := time.Now().Add(-time.Minute)
|
|
markIssueRead(t, testUserID, issueID).Want(http.StatusNoContent)
|
|
|
|
after := threadSummariesAs(t, testUserID, issueID)[issueID]
|
|
if after.LastReadAt == nil {
|
|
t.Fatal("last_read_at after reading = null, want a timestamp")
|
|
}
|
|
got, err := time.Parse(time.RFC3339Nano, *after.LastReadAt)
|
|
if err != nil {
|
|
t.Fatalf("last_read_at %q is not RFC3339Nano: %v", *after.LastReadAt, err)
|
|
}
|
|
if got.Before(start) || got.After(time.Now().Add(time.Minute)) {
|
|
t.Errorf("last_read_at = %s, want about now", got)
|
|
}
|
|
if after.ReplyCount != before.ReplyCount || after.OpenThreadCount != before.OpenThreadCount {
|
|
t.Errorf("summary counts changed by reading: before %+v, after %+v", before, after)
|
|
}
|
|
}
|
|
|
|
func TestMarkIssueRead_ResolvesIdentifier(t *testing.T) {
|
|
setWorkspaceIssuePrefixForTest(t, "RCT")
|
|
issueID := dbfx.Issue(t, "read cursor identifier")
|
|
cleanupReadCursors(t, issueID)
|
|
|
|
var number int32
|
|
dbfx.QueryRow(t, `SELECT number FROM issue WHERE id = $1`, issueID).Scan(&number)
|
|
|
|
markIssueRead(t, testUserID, fmt.Sprintf("RCT-%d", number)).Want(http.StatusNoContent)
|
|
if _, ok := readCursorAt(t, testUserID, testWorkspaceID, issueID); !ok {
|
|
t.Fatal("cursor not written under the resolved issue id")
|
|
}
|
|
}
|
|
|
|
func TestMarkIssueRead_OtherWorkspaceIssueIsNotFound(t *testing.T) {
|
|
otherWorkspaceID := dbfx.Workspace(t, "Read cursor other", "read-cursor-"+uuid.NewString())
|
|
foreign := dbfx.Issue(t, "foreign issue", testutil.Cols{"workspace_id": otherWorkspaceID})
|
|
cleanupReadCursors(t, foreign)
|
|
|
|
markIssueRead(t, testUserID, foreign).Want(http.StatusNotFound)
|
|
|
|
if n := dbfx.Count(t, `SELECT count(*) FROM issue_read_cursor WHERE issue_id = $1`, foreign); n != 0 {
|
|
t.Errorf("cursor rows for foreign issue = %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
func TestMarkIssueRead_UnknownIssueIsNotFound(t *testing.T) {
|
|
markIssueRead(t, testUserID, uuid.NewString()).Want(http.StatusNotFound)
|
|
markIssueRead(t, testUserID, "not-an-issue").Want(http.StatusNotFound)
|
|
}
|
|
|
|
func TestMarkIssueRead_CursorsAreIndependentPerUser(t *testing.T) {
|
|
issueID := dbfx.Issue(t, "read cursor per user")
|
|
cleanupReadCursors(t, issueID)
|
|
dbfx.Comment(t, issueID, "hello")
|
|
|
|
otherUserID := dbfx.User(t, "Cursor Reader", "cursor-reader-"+uuid.NewString()+"@example.test")
|
|
dbfx.Member(t, testWorkspaceID, otherUserID, "member")
|
|
|
|
markIssueRead(t, otherUserID, issueID).Want(http.StatusNoContent)
|
|
|
|
if got := threadSummariesAs(t, testUserID, issueID)[issueID]; got.LastReadAt != nil {
|
|
t.Errorf("caller sees last_read_at %q after only another user read, want null", *got.LastReadAt)
|
|
}
|
|
if got := threadSummariesAs(t, otherUserID, issueID)[issueID]; got.LastReadAt == nil {
|
|
t.Error("reader's own last_read_at = null, want a timestamp")
|
|
}
|
|
}
|
|
|
|
func TestUpsertIssueReadCursor_IsMonotonic(t *testing.T) {
|
|
issueID := dbfx.Issue(t, "read cursor monotonic")
|
|
cleanupReadCursors(t, issueID)
|
|
ctx := context.Background()
|
|
|
|
base := time.Now().UTC().Truncate(time.Microsecond).Add(-time.Hour)
|
|
upsert := func(ts time.Time) {
|
|
t.Helper()
|
|
if err := testHandler.Queries.UpsertIssueReadCursor(ctx, db.UpsertIssueReadCursorParams{
|
|
UserID: parseUUID(testUserID),
|
|
IssueID: parseUUID(issueID),
|
|
WorkspaceID: parseUUID(testWorkspaceID),
|
|
LastReadAt: tsz(ts),
|
|
}); err != nil {
|
|
t.Fatalf("upsert read cursor: %v", err)
|
|
}
|
|
}
|
|
want := func(label string, ts time.Time) {
|
|
t.Helper()
|
|
got, ok := readCursorAt(t, testUserID, testWorkspaceID, issueID)
|
|
if !ok || !got.Equal(ts) {
|
|
t.Errorf("%s: cursor = %s (present %v), want %s", label, got, ok, ts)
|
|
}
|
|
}
|
|
|
|
upsert(base.Add(10 * time.Minute))
|
|
want("first write", base.Add(10*time.Minute))
|
|
|
|
upsert(base.Add(5 * time.Minute))
|
|
want("older timestamp must not move the cursor back", base.Add(10*time.Minute))
|
|
|
|
upsert(base.Add(20 * time.Minute))
|
|
want("newer timestamp advances the cursor", base.Add(20*time.Minute))
|
|
|
|
if n := dbfx.Count(t, `SELECT count(*) FROM issue_read_cursor WHERE issue_id = $1 AND user_id = $2`, issueID, testUserID); n != 1 {
|
|
t.Errorf("cursor rows = %d, want 1 (upsert, not insert)", n)
|
|
}
|
|
}
|
|
|
|
func TestDeleteIssue_RemovesReadCursors(t *testing.T) {
|
|
issueID := dbfx.Issue(t, "read cursor deleted with issue")
|
|
cleanupReadCursors(t, issueID)
|
|
otherUserID := dbfx.User(t, "Cursor Other", "cursor-other-"+uuid.NewString()+"@example.test")
|
|
dbfx.Member(t, testWorkspaceID, otherUserID, "member")
|
|
|
|
markIssueRead(t, testUserID, issueID).Want(http.StatusNoContent)
|
|
markIssueRead(t, otherUserID, issueID).Want(http.StatusNoContent)
|
|
if n := dbfx.Count(t, `SELECT count(*) FROM issue_read_cursor WHERE issue_id = $1`, issueID); n != 2 {
|
|
t.Fatalf("cursor rows before delete = %d, want 2", n)
|
|
}
|
|
|
|
req := withURLParam(newRequest(http.MethodDelete, "/api/issues/"+issueID, nil), "id", issueID)
|
|
testutil.Call(t, testHandler.DeleteIssue, req).Want(http.StatusNoContent)
|
|
|
|
if n := dbfx.Count(t, `SELECT count(*) FROM issue_read_cursor WHERE issue_id = $1`, issueID); n != 0 {
|
|
t.Errorf("cursor rows after issue delete = %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
func TestIssueReadCursorBulkDeletes(t *testing.T) {
|
|
ctx := context.Background()
|
|
otherWorkspaceID := dbfx.Workspace(t, "Read cursor bulk", "read-cursor-bulk-"+uuid.NewString())
|
|
otherUserID := dbfx.User(t, "Cursor Bulk", "cursor-bulk-"+uuid.NewString()+"@example.test")
|
|
inHere := dbfx.Issue(t, "bulk here")
|
|
inThere := dbfx.Issue(t, "bulk there", testutil.Cols{"workspace_id": otherWorkspaceID})
|
|
cleanupReadCursors(t, inHere)
|
|
cleanupReadCursors(t, inThere)
|
|
|
|
now := time.Now().UTC()
|
|
put := func(userID, workspaceID, issueID string) {
|
|
t.Helper()
|
|
if err := testHandler.Queries.UpsertIssueReadCursor(ctx, db.UpsertIssueReadCursorParams{
|
|
UserID: parseUUID(userID), IssueID: parseUUID(issueID), WorkspaceID: parseUUID(workspaceID), LastReadAt: tsz(now),
|
|
}); err != nil {
|
|
t.Fatalf("upsert read cursor: %v", err)
|
|
}
|
|
}
|
|
put(testUserID, testWorkspaceID, inHere)
|
|
put(otherUserID, testWorkspaceID, inHere)
|
|
put(testUserID, otherWorkspaceID, inThere)
|
|
|
|
// Member removal: only that user's cursors in that workspace go.
|
|
if err := testHandler.Queries.DeleteIssueReadCursorsByUserWorkspace(ctx, db.DeleteIssueReadCursorsByUserWorkspaceParams{
|
|
UserID: parseUUID(otherUserID), WorkspaceID: parseUUID(testWorkspaceID),
|
|
}); err != nil {
|
|
t.Fatalf("delete by user workspace: %v", err)
|
|
}
|
|
if _, ok := readCursorAt(t, otherUserID, testWorkspaceID, inHere); ok {
|
|
t.Error("removed member's cursor survived")
|
|
}
|
|
if _, ok := readCursorAt(t, testUserID, testWorkspaceID, inHere); !ok {
|
|
t.Error("another member's cursor was deleted with the removed member's")
|
|
}
|
|
if _, ok := readCursorAt(t, testUserID, otherWorkspaceID, inThere); !ok {
|
|
t.Error("cursor in another workspace was deleted by member removal here")
|
|
}
|
|
|
|
// Workspace teardown: every cursor in that workspace, none elsewhere.
|
|
if err := testHandler.Queries.DeleteIssueReadCursorsForWorkspace(ctx, parseUUID(otherWorkspaceID)); err != nil {
|
|
t.Fatalf("delete for workspace: %v", err)
|
|
}
|
|
if _, ok := readCursorAt(t, testUserID, otherWorkspaceID, inThere); ok {
|
|
t.Error("workspace delete left a cursor behind")
|
|
}
|
|
if _, ok := readCursorAt(t, testUserID, testWorkspaceID, inHere); !ok {
|
|
t.Error("workspace delete removed a cursor from another workspace")
|
|
}
|
|
}
|