Compare commits

..
Author SHA1 Message Date
reran4ik fd62763313 Add safe CSV artifact preview to job detail UI
coordinator / test (push) Waiting to run
Adds a Preview action next to eligible partial/final CSV artifacts on
the job detail page. Reads at most 64 KiB and 30 rows via a coordinator-
owned blob open, verifying job ownership and the same downloadable rule
as the existing download proxy so an artifact ID from another job is
never disclosed. Non-CSV and malformed/empty content fail safely with a
sanitized message instead of being rendered as text; all cell values go
through html/template escaping.
2026-07-24 15:32:53 +03:00
Emil 6e67daa9eb Merge distributed similarity search 2026-07-24 14:43:57 +03:00
8 changed files with 565 additions and 2 deletions
+1
View File
@@ -82,6 +82,7 @@ func run() error {
DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore),
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
Dashboard: usecase.NewDashboard(uiReadRepo),
PreviewArtifact: usecase.NewPreviewArtifact(uiReadRepo, blobStore),
}
// Background reapers are tracked so shutdown can wait for them. Without this
@@ -29,6 +29,7 @@ type UseCases struct {
DownloadArtifact *usecase.DownloadArtifact
GetTaskInput *usecase.GetTaskInput
Dashboard *usecase.Dashboard
PreviewArtifact *usecase.PreviewArtifact
}
type Server struct {
@@ -82,6 +83,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob)
ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset)
ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload)
ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview)
mux.Handle("/ui", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
mux.Handle("/ui/", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
} else {
@@ -42,6 +42,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
clk := memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC))
tx := memstore.Tx{}
lease := 2 * time.Minute
uiRead := memstore.NewUIReadRepo(jobs, tasks, work, arts)
uc := coordhttp.UseCases{
RegisterWorker: usecase.NewRegisterWorker(work, clk),
@@ -56,7 +57,8 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
UploadArtifact: usecase.NewUploadArtifact(tasks, arts, blobs, tx, clk),
DownloadArtifact: usecase.NewDownloadArtifact(arts, blobs),
GetTaskInput: usecase.NewGetTaskInput(tasks, arts, blobs),
Dashboard: usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, work, arts)),
Dashboard: usecase.NewDashboard(uiRead),
PreviewArtifact: usecase.NewPreviewArtifact(uiRead, blobs),
}
worker, err := uc.RegisterWorker.Execute(context.Background(), usecase.RegisterWorkerInput{
Name: "test-worker", Capabilities: []string{"w", "similarity-search"},
@@ -287,6 +289,134 @@ func TestUIArtifactDownloadRejectsAnotherJobsArtifact(t *testing.T) {
}
}
func TestUIArtifactPreviewRequiresAuth(t *testing.T) {
e := newEnv(t, healthy)
code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
if code != http.StatusCreated {
t.Fatalf("create: %d", code)
}
_, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`)
artifactID := e.putArtifact(t, claim["task_id"].(string), "w1", int(claim["attempt"].(float64)), "a,b\n1,2\n")
req, _ := http.NewRequestWithContext(context.Background(), "GET",
e.ts.URL+"/ui/jobs/"+job["id"].(string)+"/artifacts/"+artifactID+"/preview", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unauthenticated preview = %d, want 401", resp.StatusCode)
}
}
func TestUIArtifactPreviewRendersEscapedCSVRows(t *testing.T) {
e := newEnv(t, healthy)
code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
if code != http.StatusCreated {
t.Fatalf("create: %d", code)
}
_, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`)
csv := "chembl_id,note\nCHEMBL1,<script>alert(1)</script>\n"
artifactID := e.putArtifact(t, claim["task_id"].(string), "w1", int(claim["attempt"].(float64)), csv)
req, _ := http.NewRequestWithContext(context.Background(), "GET",
e.ts.URL+"/ui/jobs/"+job["id"].(string)+"/artifacts/"+artifactID+"/preview", nil)
req.SetBasicAuth("operator", uiToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("preview: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "<script>alert(1)</script>") {
t.Error("preview must escape HTML-like CSV values, found raw <script> tag")
}
if !strings.Contains(string(body), "&lt;script&gt;") {
t.Errorf("expected escaped script tag in preview body: %s", body)
}
if !strings.Contains(string(body), "CHEMBL1") {
t.Error("preview missing expected cell value")
}
}
func TestUIArtifactPreviewRejectsAnotherJobsArtifact(t *testing.T) {
e := newEnv(t, healthy)
code, _ := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
if code != http.StatusCreated {
t.Fatalf("first job: %d", code)
}
_, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`)
artifactID := e.putArtifact(t, claim["task_id"].(string), "w1", int(claim["attempt"].(float64)), "a,b\n1,2\n")
code, second := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
if code != http.StatusCreated {
t.Fatalf("second job: %d", code)
}
req, _ := http.NewRequestWithContext(context.Background(), "GET",
e.ts.URL+"/ui/jobs/"+second["id"].(string)+"/artifacts/"+artifactID+"/preview", nil)
req.SetBasicAuth("operator", uiToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("cross-job preview = %d, want 404", resp.StatusCode)
}
}
func TestUIArtifactPreviewIsFriendlyForNonCSV(t *testing.T) {
e := newEnv(t, healthy)
code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
if code != http.StatusCreated {
t.Fatalf("create: %d", code)
}
_, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`)
taskID := claim["task_id"].(string)
attempt := int(claim["attempt"].(float64))
req, _ := http.NewRequestWithContext(context.Background(), "PUT",
e.ts.URL+"/tasks/"+taskID+"/artifacts/notes.bin", strings.NewReader("\x00\x01binary garbage"))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("X-Worker-ID", e.workerID)
req.Header.Set("X-Task-Attempt", strconv.Itoa(attempt))
putResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer putResp.Body.Close()
if putResp.StatusCode != http.StatusOK {
t.Fatalf("put non-csv artifact: %d", putResp.StatusCode)
}
var m map[string]any
b, _ := io.ReadAll(putResp.Body)
_ = json.Unmarshal(b, &m)
artifactID := m["artifact_id"].(string)
previewReq, _ := http.NewRequestWithContext(context.Background(), "GET",
e.ts.URL+"/ui/jobs/"+job["id"].(string)+"/artifacts/"+artifactID+"/preview", nil)
previewReq.SetBasicAuth("operator", uiToken)
resp, err := http.DefaultClient.Do(previewReq)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("preview status: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "binary garbage") {
t.Error("non-CSV bytes must not be rendered as text")
}
if !strings.Contains(string(body), "not a CSV file") {
t.Errorf("expected a friendly non-CSV explanation, got: %s", body)
}
}
func TestHealthUnavailableWhenDBDown(t *testing.T) {
e := newEnv(t, func(context.Context) error { return context.DeadlineExceeded })
resp := e.get(t, "/health")
@@ -0,0 +1,31 @@
{{define "artifact-preview.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SciMesh artifact preview</title>
<style>
:root{color:#172033;background:#f6f8fc;font:16px/1.5 system-ui,sans-serif}body{margin:0}.page{max-width:1180px;margin:auto;padding:32px 20px 56px}a{color:#174ecf}.back{text-decoration:none}h1{margin:18px 0 4px;font-size:1.6rem;word-break:break-word}.muted{color:#68758b}.notice{margin:16px 0;padding:15px 17px;border:1px solid #f2cb72;border-radius:10px;background:#fff8e6}.table-wrap{overflow-x:auto;border:1px solid #dfe5f0;border-radius:10px;background:#fff;margin-top:16px}table{width:100%;border-collapse:collapse}td,th{padding:10px 12px;border-bottom:1px solid #e8ecf4;text-align:left;vertical-align:top;white-space:pre-wrap;word-break:break-word}th{color:#50617d;font-size:.78rem;text-transform:uppercase;letter-spacing:.06em;background:#f6f8fc}tr:last-child td{border:0}.empty{padding:24px;text-align:center;color:#68758b}
</style>
</head>
<body>
<main class="page">
<a class="back" href="/ui/jobs/{{.JobID}}">← Back to job</a>
<h1>Preview: {{.Filename}}</h1>
<p class="muted">Diagnostic preview only — a partial shard result, not a final molecular-search answer. At most {{.RowLimit}} rows and {{.ByteLimit}} bytes are read from storage.</p>
{{if not .Previewable}}
<div class="notice">{{.Reason}}</div>
{{else}}
{{if .Truncated}}<div class="notice">Truncated: showing at most the first {{.RowLimit}} rows or {{.ByteLimit}} bytes of this artifact. Download it for the full contents.</div>{{end}}
<div class="table-wrap">
<table>
<tr>{{range .Headers}}<th>{{.}}</th>{{end}}</tr>
{{range .Rows}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{else}}<tr><td class="empty" colspan="99">No data rows.</td></tr>{{end}}
</table>
</div>
{{end}}
</main>
</body>
</html>
{{end}}
@@ -17,7 +17,7 @@
<h2>Shard tasks</h2><p class="muted">If a task fails, its code and message appear here. Refresh the page to update the detailed rows.</p>
<div class="table-wrap"><table><tr><th>Shard</th><th>State</th><th>Attempt</th><th>Worker / lease</th><th>Error</th></tr>{{range .Tasks}}<tr><td>#{{.ChunkIndex}}</td><td><span class="status status-{{statusClass .Status}}">{{statusLabel .Status}}</span></td><td>{{.Attempt}} / {{.MaxAttempts}}</td><td>{{if .LeaseOwner}}<code>{{.LeaseOwner}}</code>{{if .LeaseExpiresAt}}<br><small>until {{time .LeaseExpiresAt}}</small>{{end}}{{else}}<span class="muted"></span>{{end}}</td><td class="error">{{if .ErrorCode}}<strong>{{taskErrorLabel .ErrorCode}}</strong><br><small>{{taskErrorHint .ErrorCode}}</small>{{else}}<span class="muted"></span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">No tasks have appeared yet.</td></tr>{{end}}</table></div>
<h2>Coordinator artifacts</h2>
<div class="table-wrap"><table><tr><th>Type</th><th>File</th><th>Size</th><th>Integrity check</th><th></th></tr>{{range .Artifacts}}<tr><td>{{if .Diagnostic}}<strong>Partial result</strong><br><small>diagnostic</small>{{else}}{{.Kind}}{{end}}</td><td>{{.Filename}}</td><td>{{bytes .SizeBytes}}</td><td><code>{{.SHA256}}</code></td><td>{{if .Downloadable}}<a class="download" href="/ui/jobs/{{$.ID}}/artifacts/{{.ID}}">Download CSV</a>{{else}}<span class="muted">Unavailable</span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">No artifacts yet. The worker uploads a CSV after it completes a shard.</td></tr>{{end}}</table></div>
<div class="table-wrap"><table><tr><th>Type</th><th>File</th><th>Size</th><th>Integrity check</th><th></th></tr>{{range .Artifacts}}<tr><td>{{if .Diagnostic}}<strong>Partial result</strong><br><small>diagnostic</small>{{else}}{{.Kind}}{{end}}</td><td>{{.Filename}}</td><td>{{bytes .SizeBytes}}</td><td><code>{{.SHA256}}</code></td><td>{{if .Downloadable}}<a class="download" href="/ui/jobs/{{$.ID}}/artifacts/{{.ID}}">Download CSV</a> <a class="download" href="/ui/jobs/{{$.ID}}/artifacts/{{.ID}}/preview">Preview</a>{{else}}<span class="muted">Unavailable</span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">No artifacts yet. The worker uploads a CSV after it completes a shard.</td></tr>{{end}}</table></div>
</main>
<script>
const id={{printf "%q" .ID}},state={pending:['Waiting for a worker','waiting','Waiting for an available worker with the required capability.'],leased:['Assigned to a worker','active','A worker has claimed the task and should begin processing shortly.'],running:['Running','active','A worker is reading a shard, calculating fingerprints, and uploading its result through the coordinator.'],completed:['Tasks complete','success','Every shard task is complete. Files below are still partial results.'],failed:['Needs attention','danger','One or more shard tasks failed. Open the task list below for details.'],cancelled:['Stopped','waiting','The operator stopped this job. No new shards can be claimed.']};
+23
View File
@@ -279,3 +279,26 @@ func (s *Server) handleUIArtifactDownload(w http.ResponseWriter, r *http.Request
w.Header().Set("X-Checksum-SHA256", art.SHA256)
_, _ = io.Copy(w, body)
}
// handleUIArtifactPreview renders a bounded, job-scoped CSV preview. The use
// case enforces the same ownership and downloadable rule as the download
// proxy above; nothing here trusts the artifact ID beyond that check.
func (s *Server) handleUIArtifactPreview(w http.ResponseWriter, r *http.Request) {
jobID, ok := s.uiJobID(w, r)
if !ok {
return
}
artifactID, err := uuid.Parse(r.PathValue("artifact_id"))
if err != nil {
s.writeError(w, r, domain.ErrInvalidInput)
return
}
ctx, cancel := s.reqCtx(r)
defer cancel()
view, err := s.uc.PreviewArtifact.Execute(ctx, jobID, artifactID)
if err != nil {
s.writeError(w, r, err)
return
}
s.renderUI(w, "artifact-preview.html", view)
}
+150
View File
@@ -0,0 +1,150 @@
package usecase
import (
"context"
"encoding/csv"
"errors"
"io"
"strings"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// previewMaxRows and previewMaxBytes bound how much of an artifact the
// diagnostic preview ever reads or renders: a partial shard CSV can be large,
// and this is a diagnostic aid, not a viewer for the full file.
const (
previewMaxRows = 30
previewMaxBytes = 64 * 1024
)
// ArtifactPreviewView is what the UI renders for a diagnostic CSV preview. It
// never carries a storage path, database error, or worker-local detail.
type ArtifactPreviewView struct {
JobID string
ArtifactID string
Filename string
Previewable bool
Reason string
Headers []string
Rows [][]string
Truncated bool
RowLimit int
ByteLimit int64
}
// PreviewArtifact renders at most the first previewMaxRows rows of a CSV
// artifact, reading at most previewMaxBytes from storage. It reuses the same
// job-scoped, downloadable-artifact rule as the download proxy so an artifact
// ID from another job is never previewable.
type PreviewArtifact struct {
read UIReadRepository
blobs BlobStore
}
func NewPreviewArtifact(read UIReadRepository, blobs BlobStore) *PreviewArtifact {
return &PreviewArtifact{read: read, blobs: blobs}
}
func (p *PreviewArtifact) Execute(ctx context.Context, jobID, artifactID uuid.UUID) (ArtifactPreviewView, error) {
job, err := p.read.GetJob(ctx, jobID)
if err != nil {
return ArtifactPreviewView{}, err
}
tasks, err := p.read.ListTasksByJob(ctx, jobID)
if err != nil {
return ArtifactPreviewView{}, err
}
// Same status derivation the dashboard uses, so a final artifact previews
// exactly when it would also be offered for download.
status := jobCard(*job, tasks).Status
artifacts, err := p.read.ListArtifactsByJob(ctx, jobID)
if err != nil {
return ArtifactPreviewView{}, err
}
var art *domain.Artifact
for i := range artifacts {
if artifacts[i].ID == artifactID {
art = &artifacts[i]
break
}
}
if art == nil {
return ArtifactPreviewView{}, domain.ErrArtifactNotFound
}
downloadable := art.Kind == domain.ArtifactPartialResult ||
(art.Kind == domain.ArtifactFinalResult && status == string(domain.JobCompleted))
if !downloadable {
return ArtifactPreviewView{}, domain.ErrArtifactNotFound
}
view := ArtifactPreviewView{
JobID: jobID.String(),
ArtifactID: art.ID.String(),
Filename: art.Filename,
RowLimit: previewMaxRows,
ByteLimit: previewMaxBytes,
}
if !isCSVArtifact(art) {
view.Reason = "This artifact is not a CSV file, so it cannot be shown as text here. Download it instead."
return view, nil
}
if art.SizeBytes == 0 {
view.Reason = "This artifact is empty."
return view, nil
}
rc, err := p.blobs.Open(ctx, art.StorageKey)
if err != nil {
return ArtifactPreviewView{}, err
}
defer func() { _ = rc.Close() }()
// LimitedReader caps the bytes read from storage regardless of how many
// rows are found within that window — the artifact is never loaded whole.
limited := &io.LimitedReader{R: rc, N: previewMaxBytes}
reader := csv.NewReader(limited)
reader.FieldsPerRecord = -1 // a byte-limited cut mid-row must not look like a schema error
header, err := reader.Read()
if err != nil {
view.Reason = "This artifact could not be read as CSV."
return view, nil
}
view.Headers = append([]string(nil), header...)
rows := make([][]string, 0, previewMaxRows)
for len(rows) < previewMaxRows {
record, err := reader.Read()
if err != nil {
if !errors.Is(err, io.EOF) {
// Malformed content further into the stream: keep what parsed
// cleanly and say the preview stopped early.
view.Truncated = true
}
break
}
rows = append(rows, append([]string(nil), record...))
}
view.Rows = rows
if art.SizeBytes > previewMaxBytes {
view.Truncated = true
} else if len(rows) == previewMaxRows {
if _, err := reader.Read(); err == nil {
view.Truncated = true
}
}
view.Previewable = true
return view, nil
}
func isCSVArtifact(a *domain.Artifact) bool {
if a.ContentType == "text/csv" {
return true
}
return strings.HasSuffix(strings.ToLower(a.Filename), ".csv")
}
@@ -0,0 +1,226 @@
package usecase_test
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
func newPreviewHarness() (*usecase.PreviewArtifact, *memstore.JobRepo, *memstore.TaskRepo, *memstore.ArtifactRepo, *memstore.BlobStore) {
jobs := memstore.NewJobRepo()
tasks := memstore.NewTaskRepo()
work := memstore.NewWorkerRepo()
arts := memstore.NewArtifactRepo()
blobs := memstore.NewBlobStore()
read := memstore.NewUIReadRepo(jobs, tasks, work, arts)
return usecase.NewPreviewArtifact(read, blobs), jobs, tasks, arts, blobs
}
func mustInsertJob(t *testing.T, jobs *memstore.JobRepo, status domain.JobStatus) uuid.UUID {
t.Helper()
job := &domain.Job{ID: uuid.New(), Workload: "similarity-search", Status: status, CreatedAt: time.Now()}
if err := jobs.Insert(context.Background(), job); err != nil {
t.Fatalf("insert job: %v", err)
}
return job.ID
}
func mustCompleteJob(t *testing.T, jobs *memstore.JobRepo, tasks *memstore.TaskRepo) uuid.UUID {
t.Helper()
jobID := mustInsertJob(t, jobs, domain.JobRunning)
task := &domain.Task{
ID: uuid.New(), JobID: jobID, ChunkIndex: 0, Workload: "similarity-search",
Status: domain.TaskCompleted, MaxAttempts: 3, CreatedAt: time.Now(),
}
if err := tasks.InsertBatch(context.Background(), []*domain.Task{task}); err != nil {
t.Fatalf("insert task: %v", err)
}
return jobID
}
func mustInsertArtifact(t *testing.T, arts *memstore.ArtifactRepo, blobs *memstore.BlobStore,
jobID uuid.UUID, kind domain.ArtifactKind, filename, contentType, body string) uuid.UUID {
t.Helper()
id := uuid.New()
sha, size, err := blobs.Put(context.Background(), id.String(), strings.NewReader(body))
if err != nil {
t.Fatalf("put blob: %v", err)
}
art := &domain.Artifact{
ID: id, JobID: jobID, Kind: kind, Filename: filename,
StorageKey: id.String(), ContentType: contentType,
SizeBytes: size, SHA256: sha, CreatedAt: time.Now(),
}
if err := arts.Insert(context.Background(), art); err != nil {
t.Fatalf("insert artifact: %v", err)
}
return id
}
func TestPreviewArtifactRendersCSVRows(t *testing.T) {
preview, jobs, _, arts, blobs := newPreviewHarness()
jobID := mustInsertJob(t, jobs, domain.JobRunning)
artID := mustInsertArtifact(t, arts, blobs, jobID, domain.ArtifactPartialResult, "result.csv", "text/csv",
"chembl_id,score\nCHEMBL1,0.9\nCHEMBL2,0.8\n")
view, err := preview.Execute(context.Background(), jobID, artID)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !view.Previewable {
t.Fatalf("expected previewable, reason=%q", view.Reason)
}
if view.Truncated {
t.Error("small CSV should not be truncated")
}
if len(view.Headers) != 2 || view.Headers[0] != "chembl_id" {
t.Errorf("headers = %v", view.Headers)
}
if len(view.Rows) != 2 || view.Rows[0][0] != "CHEMBL1" {
t.Errorf("rows = %v", view.Rows)
}
}
func TestPreviewArtifactTruncatesAt30Rows(t *testing.T) {
preview, jobs, _, arts, blobs := newPreviewHarness()
jobID := mustInsertJob(t, jobs, domain.JobRunning)
var sb strings.Builder
sb.WriteString("id,value\n")
for i := 0; i < 40; i++ {
sb.WriteString("R" + strconv.Itoa(i) + ",v\n")
}
artID := mustInsertArtifact(t, arts, blobs, jobID, domain.ArtifactPartialResult, "result.csv", "text/csv", sb.String())
view, err := preview.Execute(context.Background(), jobID, artID)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if len(view.Rows) != 30 {
t.Fatalf("rows = %d, want 30", len(view.Rows))
}
if !view.Truncated {
t.Error("expected truncated for more than 30 data rows")
}
}
func TestPreviewArtifactTruncatesAt64KiB(t *testing.T) {
preview, jobs, _, arts, blobs := newPreviewHarness()
jobID := mustInsertJob(t, jobs, domain.JobRunning)
var sb strings.Builder
sb.WriteString("id,value\n")
row := "row," + strings.Repeat("x", 200) + "\n"
for sb.Len() < 70*1024 {
sb.WriteString(row)
}
artID := mustInsertArtifact(t, arts, blobs, jobID, domain.ArtifactPartialResult, "result.csv", "text/csv", sb.String())
view, err := preview.Execute(context.Background(), jobID, artID)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !view.Truncated {
t.Error("expected truncated for an artifact bigger than 64KiB")
}
if len(view.Rows) > 30 {
t.Errorf("rows = %d, want <= 30", len(view.Rows))
}
}
func TestPreviewArtifactRejectsNonCSV(t *testing.T) {
preview, jobs, _, arts, blobs := newPreviewHarness()
jobID := mustInsertJob(t, jobs, domain.JobRunning)
artID := mustInsertArtifact(t, arts, blobs, jobID, domain.ArtifactPartialResult,
"shard-0.tsv", "text/tab-separated-values", "chembl_id\tcanonical_smiles\nCHEMBL1\tCCO\n")
view, err := preview.Execute(context.Background(), jobID, artID)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if view.Previewable {
t.Error("non-CSV artifact must not be previewable as text")
}
if view.Reason == "" {
t.Error("expected a friendly reason")
}
}
func TestPreviewArtifactFailsSafelyOnEmptyArtifact(t *testing.T) {
preview, jobs, _, arts, blobs := newPreviewHarness()
jobID := mustInsertJob(t, jobs, domain.JobRunning)
artID := mustInsertArtifact(t, arts, blobs, jobID, domain.ArtifactPartialResult, "result.csv", "text/csv", "")
view, err := preview.Execute(context.Background(), jobID, artID)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if view.Previewable {
t.Error("empty artifact must not be previewable")
}
if view.Reason == "" {
t.Error("expected a friendly reason")
}
}
func TestPreviewArtifactFailsSafelyOnMalformedCSV(t *testing.T) {
preview, jobs, _, arts, blobs := newPreviewHarness()
jobID := mustInsertJob(t, jobs, domain.JobRunning)
// An unterminated quote makes even the header row unparsable.
artID := mustInsertArtifact(t, arts, blobs, jobID, domain.ArtifactPartialResult, "result.csv", "text/csv", `"unterminated`)
view, err := preview.Execute(context.Background(), jobID, artID)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if view.Previewable {
t.Error("malformed CSV must not be previewable")
}
if view.Reason == "" {
t.Error("expected a friendly reason")
}
}
func TestPreviewArtifactRejectsCrossJobArtifact(t *testing.T) {
preview, jobs, _, arts, blobs := newPreviewHarness()
jobA := mustInsertJob(t, jobs, domain.JobRunning)
jobB := mustInsertJob(t, jobs, domain.JobRunning)
artID := mustInsertArtifact(t, arts, blobs, jobA, domain.ArtifactPartialResult, "result.csv", "text/csv", "a,b\n1,2\n")
if _, err := preview.Execute(context.Background(), jobB, artID); !errors.Is(err, domain.ErrArtifactNotFound) {
t.Fatalf("err = %v, want ErrArtifactNotFound", err)
}
}
func TestPreviewArtifactRejectsUncompletedFinalResult(t *testing.T) {
preview, jobs, _, arts, blobs := newPreviewHarness()
jobID := mustInsertJob(t, jobs, domain.JobRunning)
artID := mustInsertArtifact(t, arts, blobs, jobID, domain.ArtifactFinalResult, "final.csv", "text/csv", "a,b\n1,2\n")
if _, err := preview.Execute(context.Background(), jobID, artID); !errors.Is(err, domain.ErrArtifactNotFound) {
t.Fatalf("err = %v, want ErrArtifactNotFound", err)
}
}
func TestPreviewArtifactAllowsFinalResultOnceJobIsCompleted(t *testing.T) {
preview, jobs, tasks, arts, blobs := newPreviewHarness()
jobID := mustCompleteJob(t, jobs, tasks)
artID := mustInsertArtifact(t, arts, blobs, jobID, domain.ArtifactFinalResult, "final.csv", "text/csv", "a,b\n1,2\n")
view, err := preview.Execute(context.Background(), jobID, artID)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !view.Previewable {
t.Fatalf("expected previewable, reason=%q", view.Reason)
}
}