package server import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "os" "strconv" "testing" "git.misaka.ren/m1saka/record-asr/internal/config" "git.misaka.ren/m1saka/record-asr/internal/store" ) // newUploadTestServer wires a Server backed by a real store + a minimal config // (auth disabled, tiny 100B chunks) with nil pipeline/manager — the upload // handlers never touch the transcribe/model stack, so this is safe. func newUploadTestServer(t *testing.T) (*store.Store, *httptest.Server) { t.Helper() dir := t.TempDir() st, err := store.Open(dir) if err != nil { t.Fatalf("store.Open: %v", err) } cfg := config.Config{ DataDir: dir, ChunkSize: 100, MaxUploadBytes: 1 << 20, MaxRetries: 3, } srv := New(cfg, st, nil, nil) ts := httptest.NewServer(srv.Router()) t.Cleanup(ts.Close) return st, ts } // initSession opens a chunked-upload session and returns the upload id. func initSession(t *testing.T, ts *httptest.Server, name string, size int64) string { t.Helper() body, _ := json.Marshal(map[string]any{"filename": name, "size": size}) resp, err := http.Post(ts.URL+"/api/uploads", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("init: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("init status = %d", resp.StatusCode) } var r struct { UploadID string `json:"upload_id"` ChunkSize int64 `json:"chunk_size"` TotalChunks int `json:"total_chunks"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { t.Fatalf("init decode: %v", err) } if r.UploadID == "" || r.ChunkSize != 100 { t.Fatalf("init resp = %+v", r) } return r.UploadID } func sendChunk(t *testing.T, ts, id string, idx int, data []byte) int { t.Helper() req, _ := http.NewRequest("POST", ts+"/api/uploads/"+id+"?index="+strconv.Itoa(idx), bytes.NewReader(data)) req.Header.Set("Content-Type", "application/octet-stream") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("chunk %d: %v", idx, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("chunk %d status = %d", idx, resp.StatusCode) } var r struct { Received int `json:"received"` Total int `json:"total"` } json.NewDecoder(resp.Body).Decode(&r) return r.Received } // TestChunkedUploadHTTP exercises the full endpoint flow end-to-end. func TestChunkedUploadHTTP(t *testing.T) { st, ts := newUploadTestServer(t) // /healthz advertises the chunk size the client uses as its threshold. hresp, _ := http.Get(ts.URL + "/healthz") var hz struct { ChunkSize int64 `json:"chunk_size"` MaxUploadBytes int64 `json:"max_upload_bytes"` } json.NewDecoder(hresp.Body).Decode(&hz) hresp.Body.Close() if hz.ChunkSize != 100 || hz.MaxUploadBytes != 1<<20 { t.Fatalf("healthz = %+v", hz) } // 250B file -> 3 chunks (100, 100, 50). Send out of order. uid := initSession(t, ts, "clip.wav", 250) c0 := bytes.Repeat([]byte{0xAA}, 100) c1 := bytes.Repeat([]byte{0xBB}, 100) c2 := bytes.Repeat([]byte{0xCC}, 50) want := append(append([]byte{}, c0...), c1...) want = append(want, c2...) if got := sendChunk(t, ts.URL, uid, 1, c1); got != 1 { t.Fatalf("after chunk 1 received=%d want 1", got) } if got := sendChunk(t, ts.URL, uid, 0, c0); got != 2 { t.Fatalf("after chunk 0 received=%d want 2", got) } if got := sendChunk(t, ts.URL, uid, 2, c2); got != 3 { t.Fatalf("after chunk 2 received=%d want 3", got) } // Complete -> 202 + task (same shape as /api/tasks). req, _ := http.NewRequest("POST", ts.URL+"/api/uploads/"+uid+"/complete", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("complete: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { t.Fatalf("complete status = %d", resp.StatusCode) } var task store.Task json.NewDecoder(resp.Body).Decode(&task) if task.Status != store.StatusPending || task.OriginalName != "clip.wav" { t.Fatalf("task = %+v", task) } // The reassembled file matches the original bytes exactly. got, err := os.ReadFile(st.AudioPath(&task)) if err != nil { t.Fatalf("read final file: %v", err) } if !bytes.Equal(got, want) { t.Fatalf("reassembled bytes mismatch:\n got %x\n want %x", got, want) } } // TestChunkedUploadHTTPErrors checks the error paths: missing index, unknown // session, oversized chunk, and idempotent abort. func TestChunkedUploadHTTPErrors(t *testing.T) { _, ts := newUploadTestServer(t) // Missing ?index -> 400. resp, _ := http.Post(ts.URL+"/api/uploads/someid", "application/octet-stream", bytes.NewReader([]byte("x"))) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("missing-index status = %d, want 400", resp.StatusCode) } // Complete on an unknown session -> 404. req, _ := http.NewRequest("POST", ts.URL+"/api/uploads/doesnotexist/complete", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("unknown complete: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown complete status = %d, want 404", resp.StatusCode) } // Oversized chunk (101B > 100B cap) -> 400. uid := initSession(t, ts, "big.wav", 250) req, _ = http.NewRequest("POST", ts.URL+"/api/uploads/"+uid+"?index=0", bytes.NewReader(bytes.Repeat([]byte{0x9}, 101))) req.Header.Set("Content-Type", "application/octet-stream") resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatalf("oversized chunk: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("oversized chunk status = %d, want 400", resp.StatusCode) } // Abort a fresh session -> 204, and the id is gone afterwards. uid2 := initSession(t, ts, "abort.wav", 100) req, _ = http.NewRequest("DELETE", ts.URL+"/api/uploads/"+uid2, nil) resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatalf("abort: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("abort status = %d, want 204", resp.StatusCode) } // Completing an aborted session -> 404. req, _ = http.NewRequest("POST", ts.URL+"/api/uploads/"+uid2+"/complete", nil) resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatalf("post-abort complete: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("post-abort complete status = %d, want 404", resp.StatusCode) } }