package server import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "testing" "git.misaka.ren/m1saka/record-asr/internal/config" "git.misaka.ren/m1saka/record-asr/internal/store" ) // posReader emits a deterministic, position-dependent byte stream so that no // two slices of the generated file are identical — a reorder would then // change the reassembled hash and be caught. type posReader struct{ pos int64 } func (r *posReader) Read(p []byte) (int, error) { for i := range p { x := r.pos + int64(i) p[i] = byte((x*127 ^ (x >> 7)) & 0xff) } r.pos += int64(len(p)) return len(p), nil } // sha256sum returns the hex SHA-256 of a file, streaming so 120MB never sits // in memory at once. func sha256sum(t *testing.T, path string) string { t.Helper() f, err := os.Open(path) if err != nil { t.Fatalf("open %s: %v", path, err) } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { t.Fatalf("hash %s: %v", path, err) } return hex.EncodeToString(h.Sum(nil)) } // TestChunkedUploadLargeFile uploads a 120MB file — larger than the 100MB CDN // body cap the feature exists to bypass — as 25MB slices and checks that the // reassembled file is byte-identical to the original. Guarded so the default // test suite stays fast; run with: // // ASR_TEST_LARGE=1 go test -run Large ./internal/server/... func TestChunkedUploadLargeFile(t *testing.T) { if os.Getenv("ASR_TEST_LARGE") == "" { t.Skip("set ASR_TEST_LARGE=1 to run the large-file chunked upload test (~120MB on disk)") } const total = 120 << 20 // 120 MiB — over the 100MB cap, no per-request limit here const chunk = 25 << 20 // 25 MiB slices → 5 chunks (last is 20 MiB, partial) dir := t.TempDir() st, err := store.Open(dir) if err != nil { t.Fatalf("store.Open: %v", err) } cfg := config.Config{DataDir: dir, ChunkSize: chunk, MaxUploadBytes: 512 << 20, MaxRetries: 3} srv := New(cfg, st, nil, nil) // upload path never touches the transcribe/model stack ts := httptest.NewServer(srv.Router()) t.Cleanup(ts.Close) // 1. Generate the 120MB original on disk, hashing as we go. origPath := filepath.Join(dir, "original.wav") of, err := os.Create(origPath) if err != nil { t.Fatalf("create orig: %v", err) } if _, err := io.Copy(of, io.LimitReader(&posReader{}, total)); err != nil { t.Fatalf("write orig: %v", err) } of.Close() wantHash := sha256sum(t, origPath) // 2. Open a chunked-upload session. body, _ := json.Marshal(map[string]any{"filename": "large.wav", "size": total}) resp, err := http.Post(ts.URL+"/api/uploads", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("init: %v", err) } var init struct { UploadID string `json:"upload_id"` ChunkSize int64 `json:"chunk_size"` TotalChunks int `json:"total_chunks"` } json.NewDecoder(resp.Body).Decode(&init) resp.Body.Close() if init.UploadID == "" || init.ChunkSize != chunk || init.TotalChunks != 5 { t.Fatalf("init = %+v", init) } // 3. Stream each slice straight from the original file (no full copy in RAM). src, err := os.Open(origPath) if err != nil { t.Fatalf("open src: %v", err) } defer src.Close() for i := 0; i < init.TotalChunks; i++ { offset := int64(i) * chunk size := int64(chunk) if offset+size > total { size = total - offset } req, _ := http.NewRequest("POST", ts.URL+"/api/uploads/"+init.UploadID+"?index="+strconv.Itoa(i), io.NewSectionReader(src, offset, size)) req.Header.Set("Content-Type", "application/octet-stream") cresp, cerr := http.DefaultClient.Do(req) if cerr != nil { t.Fatalf("chunk %d: %v", i, cerr) } if cresp.StatusCode != http.StatusOK { t.Fatalf("chunk %d status = %d", i, cresp.StatusCode) } cresp.Body.Close() } // 4. Complete → 202 + pending task. req, _ := http.NewRequest("POST", ts.URL+"/api/uploads/"+init.UploadID+"/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 { t.Fatalf("task status = %q, want %q", task.Status, store.StatusPending) } // 5. The reassembled file must be byte-identical (same SHA-256) and exact size. gotHash := sha256sum(t, st.AudioPath(&task)) if gotHash != wantHash { t.Fatalf("reassembled file hash mismatch:\n want %s\n got %s", wantHash, gotHash) } fi, err := os.Stat(st.AudioPath(&task)) if err != nil || fi.Size() != total { t.Fatalf("final size = %d (%v), want %d", fi.Size(), err, total) } }