import os from "node:os"; import path from "node:path"; import { mkdtemp, readFile } from "node:fs/promises"; import { Command } from "commander"; import { describe, expect, it } from "vitest"; import type { FeedbackTrace } from "@paperclipai/shared"; import { readZipArchive } from "../commands/client/zip.js"; import { buildFeedbackTraceQuery, registerFeedbackCommands, renderFeedbackReport, summarizeFeedbackTraces, writeFeedbackExportBundle, } from "../commands/client/feedback.js"; function makeTrace(overrides: Partial = {}): FeedbackTrace { return { id: "trace-12345678", companyId: "company-123", feedbackVoteId: "vote-12345678", issueId: "issue-123", projectId: "project-123", issueIdentifier: "PAP-123", issueTitle: "Fix the feedback command", authorUserId: "user-123", targetType: "issue_comment", targetId: "comment-123", vote: "down", status: "pending", destination: "paperclip_labs_feedback_v1", exportId: null, consentVersion: "feedback-data-sharing-v1", schemaVersion: "1", bundleVersion: "1", payloadVersion: "1", payloadDigest: null, payloadSnapshot: { vote: { value: "down", reason: "Needed more detail", }, }, targetSummary: { label: "Comment", excerpt: "The first answer was too vague.", authorAgentId: "agent-123", authorUserId: null, createdAt: new Date("2026-03-31T12:00:00.000Z"), documentKey: null, documentTitle: null, revisionNumber: null, }, redactionSummary: null, attemptCount: 0, lastAttemptedAt: null, exportedAt: null, failureReason: null, createdAt: new Date("2026-03-31T12:01:00.000Z"), updatedAt: new Date("2026-03-31T12:02:00.000Z"), ...overrides, }; } describe("registerFeedbackCommands", () => { it("registers the top-level feedback commands", () => { const program = new Command(); expect(() => registerFeedbackCommands(program)).not.toThrow(); const feedback = program.commands.find((command) => command.name() === "feedback"); expect(feedback).toBeDefined(); expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export"]); expect(feedback?.commands[0]?.options.filter((option) => option.long === "--company-id")).toHaveLength(1); }); }); describe("buildFeedbackTraceQuery", () => { it("encodes all supported filters", () => { expect( buildFeedbackTraceQuery({ targetType: "issue_comment", vote: "down", status: "pending", projectId: "project-123", issueId: "issue-123", from: "2026-03-31T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z", sharedOnly: true, }), ).toBe( "?targetType=issue_comment&vote=down&status=pending&projectId=project-123&issueId=issue-123&from=2026-03-31T00%3A00%3A00.000Z&to=2026-03-31T23%3A59%3A59.999Z&sharedOnly=true&includePayload=true", ); }); }); describe("renderFeedbackReport", () => { it("includes summary counts and the optional reason", () => { const traces = [ makeTrace(), makeTrace({ id: "trace-87654321", feedbackVoteId: "vote-87654321", vote: "up", status: "local_only", payloadSnapshot: { vote: { value: "up", reason: null, }, }, }), ]; const report = renderFeedbackReport({ apiBase: "http://127.0.0.1:3100", companyId: "company-123", traces, summary: summarizeFeedbackTraces(traces), includePayloads: false, }); expect(report).toContain("Paperclip Feedback Report"); expect(report).toContain("thumbs up"); expect(report).toContain("thumbs down"); expect(report).toContain("Needed more detail"); }); }); describe("writeFeedbackExportBundle", () => { it("writes votes, traces, a manifest, and a zip archive", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-feedback-export-")); const outputDir = path.join(tempDir, "feedback-export"); const traces = [ makeTrace(), makeTrace({ id: "trace-abcdef12", feedbackVoteId: "vote-abcdef12", issueIdentifier: "PAP-124", issueId: "issue-124", vote: "up", status: "local_only", payloadSnapshot: { vote: { value: "up", reason: null, }, }, }), ]; const exported = await writeFeedbackExportBundle({ apiBase: "http://127.0.0.1:3100", companyId: "company-123", traces, outputDir, }); expect(exported.manifest.summary.total).toBe(2); expect(exported.manifest.summary.withReason).toBe(1); const manifest = JSON.parse(await readFile(path.join(outputDir, "index.json"), "utf8")) as { files: { votes: string[]; traces: string[]; zip: string }; }; expect(manifest.files.votes).toHaveLength(2); expect(manifest.files.traces).toHaveLength(2); const archive = await readFile(exported.zipPath); const zip = await readZipArchive(archive); expect(Object.keys(zip.files)).toEqual( expect.arrayContaining([ "index.json", `votes/${manifest.files.votes[0]}`, `traces/${manifest.files.traces[0]}`, ]), ); }); });