mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
* refactor(timeline): drop server-side comment + timeline pagination (MUL-1929) The cursor-paginated /timeline and /comments endpoints were sized for a problem the data shape doesn't have: prod p99 is ~30 comments per issue and the all-time max is ~1.1k. Time-based pagination also splits reply threads across page boundaries (orphan replies), which the frontend was papering over with an "orphan rescue" that promoted disconnected replies to top-level — confusing UX with no real benefit. Replace both endpoints with a single full-issue fetch, capped server-side at 2000 rows as a defensive safety net (never hit in practice). Server - /api/issues/:id/timeline now returns a flat ASC TimelineEntry[] (matches the legacy desktop contract — older Multica.app builds keep working because the wrapped TimelineResponse + cursors are gone, and the raw array shape was always what they consumed). - /api/issues/:id/comments drops limit/offset; only ?since is honoured for the CLI agent-polling flow. - Drop ListCommentsBefore/After/Latest, ListActivitiesBefore/After/Latest and the timelineCursor encoding. - Replace with ListCommentsForIssue / ListCommentsSinceForIssue / ListActivitiesForIssue (capped by argument). CLI - multica issue comment list drops --limit / --offset and the X-Total-Count reporting; --since is preserved for incremental polling. Frontend - Replace useInfiniteQuery with useQuery in useIssueTimeline; drop fetchOlder/Newer, jumpToLatest, isAtLatest, newEntriesBelowCount. - Remove timeline-cache helpers (mapAllEntries / filterAllEntries / prependToLatestPage) and the TimelinePage / TimelinePageParam types. - WS event handlers update the single flat-array cache directly. - Drop the orphan-reply rescue in issue-detail — every reply's parent is now guaranteed to be in the same array. - Strip the "show older / show newer / jump to latest" buttons and their i18n strings. Co-authored-by: multica-agent <github@multica.ai> * fix(timeline): address review feedback on pagination removal Three issues caught in PR #2322 review: 1. /timeline broke for stale clients between #2128 and this PR. They send ?limit/?before/?after/?around and parse with the wrapped TimelinePageSchema; the new flat-array response was failing schema validation and falling back to an empty timeline. Restore the wrapped shape on those query params (DESC entries, null cursors, has_more_*=false), keeping the flat ASC array for bare requests. Around-mode now also fills target_index from the merged slice so legacy clients can still scroll-to-anchor without a follow-up. 2. The agent prompts in runtime_config.go and prompt.go still told agents that `multica issue comment list` accepts --limit/--offset and to use `--limit 30` on truncated output. With those flags removed in this PR, new agent runs would hit "unknown flag" or skip context. Update the prompt copy to "returns all comments, capped at 2000; --since for incremental polling". 3. useCreateComment's onSuccess was a bare append to the timeline cache with no id-dedupe, so a fast comment:created WS event firing before onSuccess produced a transient duplicate. Restore the id guard the old prependToLatestPage helper used to provide. Adds two new boundary tests: - TestListTimeline_LegacyWrappedShape_OnPaginationParams - TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex Co-authored-by: multica-agent <github@multica.ai> * test(handler): fix timeline test assertions for handler-package isolation The TestListTimeline_* assertions assumed CreateIssue would seed an "issue_created" activity_log row, but the activity listener that publishes those rows is registered in cmd/server/main.go — handler-package tests don't wire it up. CI saw 5 entries (3 comments + 2 activities) where the test expected ≥6. Drop the auto-activity assumption: assert exactly 5 entries in TestListTimeline_MergesCommentsAndActivities, and tighten TestListTimeline_EmptyIssue to assert a fully-empty timeline. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
147 lines
5.3 KiB
TypeScript
147 lines
5.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import { ApiClient } from "./client";
|
|
import { parseWithFallback } from "./schema";
|
|
|
|
// Helper: stub fetch with a single JSON response. Status defaults to 200.
|
|
function stubFetchJson(body: unknown, status = 200) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
// These tests cover the five failure modes that white-screened the desktop
|
|
// app in past incidents. The contract is: a malformed response degrades to
|
|
// an empty/safe shape, never throws into React.
|
|
describe("ApiClient schema fallback", () => {
|
|
describe("listTimeline", () => {
|
|
it("falls back to an empty array when the body is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it("falls back when the body is not an array", async () => {
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it("accepts a new entry type rather than crashing on enum drift", async () => {
|
|
stubFetchJson([
|
|
{
|
|
type: "future_kind", // not in TS union
|
|
id: "e-1",
|
|
actor_type: "member",
|
|
actor_id: "u-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
},
|
|
]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]?.type).toBe("future_kind");
|
|
});
|
|
|
|
// Forward-compat: when the server adds a new field to an existing
|
|
// shape, `.loose()` lets it pass through unchanged. Without `.loose()`
|
|
// zod 4 strips it, which would silently break a future TS type that
|
|
// adopts the field — see schemas.ts header comment.
|
|
it("preserves unknown fields the schema didn't list", async () => {
|
|
stubFetchJson([
|
|
{
|
|
type: "comment",
|
|
id: "e-1",
|
|
actor_type: "member",
|
|
actor_id: "u-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
// New server-side field not present in TimelineEntrySchema:
|
|
future_field: { nested: "value" },
|
|
},
|
|
]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
const entry = entries[0] as unknown as Record<string, unknown>;
|
|
expect(entry.future_field).toEqual({ nested: "value" });
|
|
});
|
|
});
|
|
|
|
describe("listIssues", () => {
|
|
it("falls back to an empty list when the response is malformed", async () => {
|
|
// `issues` having the wrong type triggers the fallback. An object
|
|
// with only unexpected keys would *succeed* parsing now (every
|
|
// declared field has a default) and just pass the extras through
|
|
// via `.loose()`, so we use a wrong-type payload here instead.
|
|
stubFetchJson({ issues: "not-an-array", total: 0 });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listIssues();
|
|
expect(res).toEqual({ issues: [], total: 0 });
|
|
});
|
|
});
|
|
|
|
describe("listComments", () => {
|
|
it("returns [] when the response is not an array", async () => {
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const comments = await client.listComments("issue-1");
|
|
expect(comments).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("listIssueSubscribers", () => {
|
|
it("returns [] when the response is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const subs = await client.listIssueSubscribers("issue-1");
|
|
expect(subs).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("listChildIssues", () => {
|
|
it("returns { issues: [] } when the issues field is missing", async () => {
|
|
stubFetchJson({});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listChildIssues("issue-1");
|
|
expect(res).toEqual({ issues: [] });
|
|
});
|
|
});
|
|
});
|
|
|
|
// Direct tests for the helper, decoupled from any specific endpoint —
|
|
// guards against an endpoint refactor masking a regression in the helper.
|
|
describe("parseWithFallback", () => {
|
|
const opts = { endpoint: "TEST /unit" };
|
|
|
|
it("returns parsed data on success", () => {
|
|
const schema = z.object({ id: z.string() });
|
|
const out = parseWithFallback({ id: "x" }, schema, { id: "fallback" }, opts);
|
|
expect(out).toEqual({ id: "x" });
|
|
});
|
|
|
|
it("returns the fallback when validation fails", () => {
|
|
const schema = z.object({ id: z.string() });
|
|
const fallback = { id: "fallback" };
|
|
const out = parseWithFallback({ id: 123 }, schema, fallback, opts);
|
|
expect(out).toBe(fallback);
|
|
});
|
|
|
|
it("returns the fallback when data is null", () => {
|
|
const schema = z.object({ id: z.string() });
|
|
const fallback = { id: "fallback" };
|
|
const out = parseWithFallback(null, schema, fallback, opts);
|
|
expect(out).toBe(fallback);
|
|
});
|
|
});
|