All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m35s
Test / frontend-tests (pull_request) Successful in 1m41s
Test / frontend-typecheck (pull_request) Successful in 1m43s
Test / rust-clippy (pull_request) Successful in 3m10s
Test / rust-tests (pull_request) Successful in 4m39s
PR Review Automation / review (pull_request) Successful in 4m58s
Store compressed log content and raw image bytes in SQLite so attachments are self-contained regardless of source file availability on disk. DB (migrations 020-022): - log_files.content_compressed BLOB — gzip-compressed extracted text - image_attachments.image_data BLOB — raw image bytes - Views v_log_files_with_issue and v_image_attachments_with_issue for cross-incident queries with joined issue title Rust backend: - compress_text / decompress_text helpers (flate2 rust_backend / miniz_oxide) with 100 MB decompression-bomb guard - upload_log_file*, upload_log_file_by_content store content_compressed - upload_image_attachment*, upload_paste_image store image_data - New commands: get_log_file_content, list_all_log_files (analysis.rs) - New commands: get_image_attachment_data, list_all_image_attachments (image.rs) - All commands fall back to file_path for pre-migration records Frontend: - LogFileSummary, ImageAttachmentSummary types in tauriCommands.ts - attachmentStore (Zustand) — loadAttachments, searchAttachments - History page: Issues tab (existing) + Attachments tab (new) with log/image tables, search bar, View modals, lazy thumbnails Tests: 227 Rust (+16 new), 103 frontend (+9 new), tsc clean, clippy clean Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { useAttachmentStore } from "@/stores/attachmentStore";
|
|
import type { LogFileSummary, ImageAttachmentSummary } from "@/lib/tauriCommands";
|
|
|
|
const mockInvoke = vi.mocked(invoke);
|
|
|
|
const makeLogFile = (overrides: Partial<LogFileSummary> = {}): LogFileSummary => ({
|
|
id: "lf-001",
|
|
issue_id: "issue-001",
|
|
issue_title: "Disk Full Alert",
|
|
file_name: "syslog.log",
|
|
file_path: "/var/log/syslog",
|
|
file_size: 2048,
|
|
mime_type: "text/plain",
|
|
content_hash: "abc123",
|
|
uploaded_at: new Date().toISOString(),
|
|
redacted: false,
|
|
...overrides,
|
|
});
|
|
|
|
const makeImage = (overrides: Partial<ImageAttachmentSummary> = {}): ImageAttachmentSummary => ({
|
|
id: "img-001",
|
|
issue_id: "issue-001",
|
|
issue_title: "Disk Full Alert",
|
|
file_name: "screenshot.png",
|
|
file_path: "/tmp/screenshot.png",
|
|
file_size: 51200,
|
|
mime_type: "image/png",
|
|
upload_hash: "def456",
|
|
uploaded_at: new Date().toISOString(),
|
|
pii_warning_acknowledged: true,
|
|
is_paste: false,
|
|
...overrides,
|
|
});
|
|
|
|
const resetStore = () => {
|
|
useAttachmentStore.setState({
|
|
logFiles: [],
|
|
images: [],
|
|
isLoading: false,
|
|
error: null,
|
|
searchQuery: "",
|
|
});
|
|
};
|
|
|
|
describe("Attachment Store", () => {
|
|
beforeEach(() => {
|
|
resetStore();
|
|
mockInvoke.mockReset();
|
|
});
|
|
|
|
// ─── loadAttachments ──────────────────────────────────────────────────────
|
|
|
|
it("loadAttachments populates logFiles and images", async () => {
|
|
const logFiles = [makeLogFile()];
|
|
const images = [makeImage()];
|
|
mockInvoke
|
|
.mockResolvedValueOnce(logFiles) // list_all_log_files
|
|
.mockResolvedValueOnce(images); // list_all_image_attachments
|
|
|
|
await useAttachmentStore.getState().loadAttachments();
|
|
|
|
expect(useAttachmentStore.getState().logFiles).toHaveLength(1);
|
|
expect(useAttachmentStore.getState().images).toHaveLength(1);
|
|
expect(useAttachmentStore.getState().isLoading).toBe(false);
|
|
expect(useAttachmentStore.getState().error).toBeNull();
|
|
});
|
|
|
|
it("loadAttachments sets isLoading=true while in flight", async () => {
|
|
let resolveLog!: (v: unknown) => void;
|
|
mockInvoke.mockReturnValueOnce(new Promise((r) => (resolveLog = r)));
|
|
mockInvoke.mockResolvedValueOnce([]);
|
|
|
|
const p = useAttachmentStore.getState().loadAttachments();
|
|
expect(useAttachmentStore.getState().isLoading).toBe(true);
|
|
|
|
resolveLog([]);
|
|
await p;
|
|
expect(useAttachmentStore.getState().isLoading).toBe(false);
|
|
});
|
|
|
|
it("loadAttachments sets error on failure and clears isLoading", async () => {
|
|
mockInvoke.mockRejectedValueOnce(new Error("DB error"));
|
|
|
|
await useAttachmentStore.getState().loadAttachments();
|
|
|
|
expect(useAttachmentStore.getState().error).toContain("DB error");
|
|
expect(useAttachmentStore.getState().isLoading).toBe(false);
|
|
});
|
|
|
|
it("loadAttachments passes issueId filter when provided", async () => {
|
|
mockInvoke.mockResolvedValueOnce([makeLogFile()]).mockResolvedValueOnce([]);
|
|
|
|
await useAttachmentStore.getState().loadAttachments({ issueId: "issue-001" });
|
|
|
|
// Both calls should have been made with issueId
|
|
const logCall = mockInvoke.mock.calls.find((c) => c[0] === "list_all_log_files");
|
|
expect(logCall).toBeDefined();
|
|
expect(logCall![1]).toMatchObject({ issueId: "issue-001" });
|
|
});
|
|
|
|
// ─── searchAttachments ────────────────────────────────────────────────────
|
|
|
|
it("searchAttachments passes search query and updates store", async () => {
|
|
const logFiles = [makeLogFile({ file_name: "app.log" })];
|
|
mockInvoke
|
|
.mockResolvedValueOnce(logFiles)
|
|
.mockResolvedValueOnce([]);
|
|
|
|
await useAttachmentStore.getState().searchAttachments("app");
|
|
|
|
expect(useAttachmentStore.getState().searchQuery).toBe("app");
|
|
expect(useAttachmentStore.getState().logFiles).toHaveLength(1);
|
|
|
|
const logCall = mockInvoke.mock.calls.find((c) => c[0] === "list_all_log_files");
|
|
expect(logCall![1]).toMatchObject({ search: "app" });
|
|
});
|
|
|
|
it("searchAttachments with empty string clears filter", async () => {
|
|
mockInvoke.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
|
|
|
await useAttachmentStore.getState().searchAttachments("");
|
|
|
|
const logCall = mockInvoke.mock.calls.find((c) => c[0] === "list_all_log_files");
|
|
expect(logCall![1]).toMatchObject({ search: null });
|
|
});
|
|
|
|
// ─── setSearchQuery ───────────────────────────────────────────────────────
|
|
|
|
it("setSearchQuery updates searchQuery without triggering a fetch", () => {
|
|
useAttachmentStore.getState().setSearchQuery("kernel panic");
|
|
expect(useAttachmentStore.getState().searchQuery).toBe("kernel panic");
|
|
expect(mockInvoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ─── data shape ───────────────────────────────────────────────────────────
|
|
|
|
it("log file summary includes issue_title", async () => {
|
|
mockInvoke
|
|
.mockResolvedValueOnce([makeLogFile({ issue_title: "Memory Leak Incident" })])
|
|
.mockResolvedValueOnce([]);
|
|
|
|
await useAttachmentStore.getState().loadAttachments();
|
|
|
|
expect(useAttachmentStore.getState().logFiles[0].issue_title).toBe("Memory Leak Incident");
|
|
});
|
|
|
|
it("image summary includes issue_title and is_paste flag", async () => {
|
|
mockInvoke
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([makeImage({ issue_title: "CPU Spike", is_paste: true })]);
|
|
|
|
await useAttachmentStore.getState().loadAttachments();
|
|
|
|
const img = useAttachmentStore.getState().images[0];
|
|
expect(img.issue_title).toBe("CPU Spike");
|
|
expect(img.is_paste).toBe(true);
|
|
});
|
|
});
|