Files
record-asr/internal/server/upload_test.go
T
root e5a2dc0a8d add chunked upload to bypass 100MB proxy body limit
Resumable /api/uploads flow (init -> append -> complete -> abort) so a
large recording uploads as many small slices (<= ASR_CHUNK_SIZE_MB, default
50MB) instead of one request a size-capping proxy (Cloudflare's 100MB) would
reject. The web console auto-routes files larger than one slice through the
chunked path with cross-slice progress and transient-error retry; smaller
files keep the single-shot upload.

- store: in-memory upload sessions + .part reassembly (offset writes,
  out-of-order/idempotent), reuse upload id as task id, orphan sweep on
  start, idle-session GC
- fix limitedReader rejecting bodies exactly at the cap (broke full chunks)
- fix drag-drop double-firing when a drop lands on the dropzone (its drop
  handler now stopPropagation so the window listener doesn't re-upload)
- tests: store + HTTP flow, plus a 120MB reassembly test (ASR_TEST_LARGE)
2026-09-10 15:12:15 +08:00

201 lines
6.3 KiB
Go

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