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)
154 lines
4.7 KiB
Go
154 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|