tftsr-devops_investigation/tests/unit/attachmentStore.test.ts
Shaun Arman 1b36ebfb3d
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
feat: attachment DB storage and cross-incident recall
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>
2026-05-31 17:55:47 -05:00

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);
});
});