Files
Test User 13fd446f40
ci / release-artifacts (push) Has been skipped
ci / test (push) Has been cancelled
fix: harden record completion path
2026-06-10 18:24:30 +08:00

1157 lines
43 KiB
Go

package web
import (
"bytes"
"context"
"encoding/json"
"errors"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"speedtest/internal/probe"
recordstore "speedtest/internal/store"
)
func TestRecordsEndpointReturnsRecords(t *testing.T) {
store := &memoryStore{
records: []recordstore.Record{
{
ID: "run-2",
Client: recordstore.ClientInfo{IP: "203.0.113.20", ISP: "Example Mobile", Region: "Beijing"},
Summary: probe.Summary{ID: "run-2", Target: "example.org:443", Score: 88},
Samples: []probe.Sample{{Kind: "tcp", Success: true, LatencyMS: 40}},
},
{
ID: "run-1",
Client: recordstore.ClientInfo{IP: "198.51.100.10", ISP: "Example Telecom", Region: "Shanghai"},
Summary: probe.Summary{ID: "run-1", Target: "example.com:80", Score: 95},
Samples: []probe.Sample{{Kind: "tcp", Success: true, LatencyMS: 30}},
},
},
}
server := New(Options{Store: store, Runner: noopRunner})
req := httptest.NewRequest(http.MethodGet, "/api/records?limit=2", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
}
var payload recordsResponse
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Records) != 2 {
t.Fatalf("len(records) = %d, want 2", len(payload.Records))
}
if payload.Records[0].ID != "run-2" {
t.Fatalf("first record id = %q, want run-2", payload.Records[0].ID)
}
if payload.Records[0].Client.IP != "203.0.113.*" || payload.Records[0].Client.ISP != "Example Mobile" {
t.Fatalf("first record client = %#v, want anonymized client data", payload.Records[0].Client)
}
if len(payload.Records[0].Samples) != 1 {
t.Fatalf("first record samples = %d, want 1", len(payload.Records[0].Samples))
}
}
func TestRecordsEndpointAnonymizesClientIPAndFillsMissingISP(t *testing.T) {
store := &memoryStore{
records: []recordstore.Record{
{
ID: "run-1",
Client: recordstore.ClientInfo{IP: "203.0.113.20", Region: "Beijing"},
Summary: probe.Summary{ID: "run-1", Target: "example.com:80", Score: 95},
},
},
}
server := New(Options{Store: store, Runner: noopRunner})
req := httptest.NewRequest(http.MethodGet, "/api/records?limit=1", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
}
var payload recordsResponse
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
client := payload.Records[0].Client
if client.IP != "203.0.113.*" {
t.Fatalf("client IP = %q, want anonymized 203.0.113.*", client.IP)
}
if client.ISP != "未知运营商" {
t.Fatalf("client ISP = %q, want 未知运营商", client.ISP)
}
}
func TestConfigEndpointReturnsBandwidthLimit(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, BandwidthLimitMbps: 80})
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
}
var payload configResponse
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.BandwidthLimitMbps != 80 {
t.Fatalf("BandwidthLimitMbps = %.2f, want 80", payload.BandwidthLimitMbps)
}
if payload.BandwidthLimitLabel != "80 Mbps" {
t.Fatalf("BandwidthLimitLabel = %q, want 80 Mbps", payload.BandwidthLimitLabel)
}
}
func TestHealthEventsStreamsHeartbeat(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, BandwidthLimitMbps: 30})
req := httptest.NewRequest(http.MethodGet, "/api/health/events?limit=1", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
}
if contentType := res.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/event-stream") {
t.Fatalf("Content-Type = %q, want text/event-stream", contentType)
}
body := res.Body.String()
if !strings.Contains(body, "event: heartbeat") || !strings.Contains(body, `"bandwidthLimitLabel":"30 Mbps"`) {
t.Fatalf("heartbeat body = %q, want heartbeat with bandwidth label", body)
}
}
func TestDownloadEndpointStreamsRequestedBytesAndAdvertisesLimit(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, BandwidthLimitMbps: 80})
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
createRes := httptest.NewRecorder()
server.Handler().ServeHTTP(createRes, createReq)
if createRes.Code != http.StatusAccepted {
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
}
var created createTestResponse
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
req := httptest.NewRequest(http.MethodGet, created.DownloadURL+"&bytes=1024", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
}
if res.Body.Len() != 1024 {
t.Fatalf("download length = %d, want 1024", res.Body.Len())
}
if got := res.Header().Get("X-NetStable-Bandwidth-Limit-Mbps"); got != "80" {
t.Fatalf("limit header = %q, want 80", got)
}
}
func TestDownloadEndpointRequiresActiveTestID(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
req := httptest.NewRequest(http.MethodGet, "/api/download?bytes=1024", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body=%s", res.Code, res.Body.String())
}
}
func TestUploadEndpointAcceptsActiveSession(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, BandwidthLimitMbps: 80})
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
createRes := httptest.NewRecorder()
server.Handler().ServeHTTP(createRes, createReq)
if createRes.Code != http.StatusAccepted {
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
}
var created createTestResponse
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
req := httptest.NewRequest(http.MethodPost, created.UploadURL, bytes.NewBuffer(make([]byte, 2048)))
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
}
var payload transferResponse
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode upload response: %v", err)
}
if payload.Bytes != 2048 {
t.Fatalf("uploaded bytes = %d, want 2048", payload.Bytes)
}
}
func TestUploadEndpointRequiresActiveTestID(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
req := httptest.NewRequest(http.MethodPost, "/api/upload?bytes=1024", bytes.NewBuffer(make([]byte, 1024)))
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body=%s", res.Code, res.Body.String())
}
}
func TestDownloadEndpointRejectsInvalidByteCount(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
req := httptest.NewRequest(http.MethodGet, "/api/download?bytes=0", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", res.Code, res.Body.String())
}
}
func TestCreateTestRejectsInvalidTarget(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
req := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com:abc"}`))
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", res.Code, res.Body.String())
}
if !strings.Contains(res.Body.String(), "invalid target") {
t.Fatalf("body = %q, want invalid target error", res.Body.String())
}
}
func TestClientIPUsesRemoteAddrUnlessProxyHeadersAreTrusted(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "198.51.100.10:54321"
req.Header.Set("X-Forwarded-For", "203.0.113.20")
req.Header.Set("X-Real-IP", "203.0.113.30")
if got := clientIPFromRequest(req, false); got != "198.51.100.10" {
t.Fatalf("clientIPFromRequest untrusted = %q, want RemoteAddr IP", got)
}
if got := clientIPFromRequest(req, true); got != "203.0.113.30" {
t.Fatalf("clientIPFromRequest trusted = %q, want X-Real-IP", got)
}
}
func TestCreateTestReturnsSessionIDAndEventsURL(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, ClientResolver: staticResolver})
req := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1,"intervalMillis":200,"timeoutMillis":300}`))
req.RemoteAddr = "198.51.100.10:54321"
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body=%s", res.Code, res.Body.String())
}
var payload createTestResponse
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.ID == "" {
t.Fatal("payload.ID is empty")
}
if payload.Status != "ready" || payload.QueuePosition != 0 || payload.QueueURL != "/api/tests/"+payload.ID+"/queue" {
t.Fatalf("queue fields = %q/%d/%q, want ready/0/queue URL", payload.Status, payload.QueuePosition, payload.QueueURL)
}
if payload.EventsURL != "/api/tests/"+payload.ID+"/events" {
t.Fatalf("EventsURL = %q, want /api/tests/%s/events", payload.EventsURL, payload.ID)
}
if payload.DownloadURL != "/api/download?testId="+payload.ID {
t.Fatalf("DownloadURL = %q, want /api/download?testId=%s", payload.DownloadURL, payload.ID)
}
if payload.UploadURL != "/api/upload?testId="+payload.ID {
t.Fatalf("UploadURL = %q, want /api/upload?testId=%s", payload.UploadURL, payload.ID)
}
if payload.CompleteURL != "/api/tests/"+payload.ID+"/complete" {
t.Fatalf("CompleteURL = %q, want /api/tests/%s/complete", payload.CompleteURL, payload.ID)
}
if payload.CancelURL != "/api/tests/"+payload.ID+"/cancel" {
t.Fatalf("CancelURL = %q, want /api/tests/%s/cancel", payload.CancelURL, payload.ID)
}
}
func TestCreateTestQueuesWhenAnotherSessionIsActive(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
firstReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
firstRes := httptest.NewRecorder()
server.Handler().ServeHTTP(firstRes, firstReq)
if firstRes.Code != http.StatusAccepted {
t.Fatalf("first status = %d, want 202; body=%s", firstRes.Code, firstRes.Body.String())
}
secondReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
secondRes := httptest.NewRecorder()
server.Handler().ServeHTTP(secondRes, secondReq)
if secondRes.Code != http.StatusAccepted {
t.Fatalf("second status = %d, want 202 queued; body=%s", secondRes.Code, secondRes.Body.String())
}
var queued createTestResponse
if err := json.Unmarshal(secondRes.Body.Bytes(), &queued); err != nil {
t.Fatalf("decode queued response: %v", err)
}
if queued.Status != "queued" || queued.QueuePosition != 1 || queued.QueueURL != "/api/tests/"+queued.ID+"/queue" {
t.Fatalf("queued response = %#v, want queued at position 1", queued)
}
}
func TestQueuedTestPromotesAfterActiveSessionCompletes(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
firstReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
firstRes := httptest.NewRecorder()
server.Handler().ServeHTTP(firstRes, firstReq)
if firstRes.Code != http.StatusAccepted {
t.Fatalf("first status = %d, want 202; body=%s", firstRes.Code, firstRes.Body.String())
}
var first createTestResponse
if err := json.Unmarshal(firstRes.Body.Bytes(), &first); err != nil {
t.Fatalf("decode first response: %v", err)
}
secondReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
secondRes := httptest.NewRecorder()
server.Handler().ServeHTTP(secondRes, secondReq)
if secondRes.Code != http.StatusAccepted {
t.Fatalf("second status = %d, want 202; body=%s", secondRes.Code, secondRes.Body.String())
}
var second createTestResponse
if err := json.Unmarshal(secondRes.Body.Bytes(), &second); err != nil {
t.Fatalf("decode second response: %v", err)
}
if second.Status != "queued" || second.QueuePosition != 1 {
t.Fatalf("second = %#v, want queued position 1", second)
}
queueReq := httptest.NewRequest(http.MethodGet, second.QueueURL, nil)
queueRes := httptest.NewRecorder()
server.Handler().ServeHTTP(queueRes, queueReq)
if queueRes.Code != http.StatusOK {
t.Fatalf("queue status = %d, want 200; body=%s", queueRes.Code, queueRes.Body.String())
}
var queued queueStatusResponse
if err := json.Unmarshal(queueRes.Body.Bytes(), &queued); err != nil {
t.Fatalf("decode queue status: %v", err)
}
if queued.Status != "queued" || queued.QueuePosition != 1 {
t.Fatalf("queue status = %#v, want queued position 1", queued)
}
completeReq := httptest.NewRequest(http.MethodPost, first.CompleteURL, jsonBody(`{"samples":[]}`))
completeRes := httptest.NewRecorder()
server.Handler().ServeHTTP(completeRes, completeReq)
if completeRes.Code != http.StatusOK {
t.Fatalf("complete status = %d, want 200; body=%s", completeRes.Code, completeRes.Body.String())
}
readyReq := httptest.NewRequest(http.MethodGet, second.QueueURL, nil)
readyRes := httptest.NewRecorder()
server.Handler().ServeHTTP(readyRes, readyReq)
if readyRes.Code != http.StatusOK {
t.Fatalf("ready status = %d, want 200; body=%s", readyRes.Code, readyRes.Body.String())
}
var ready queueStatusResponse
if err := json.Unmarshal(readyRes.Body.Bytes(), &ready); err != nil {
t.Fatalf("decode ready status: %v", err)
}
if ready.Status != "ready" || ready.QueuePosition != 0 {
t.Fatalf("ready status = %#v, want ready position 0", ready)
}
downloadReq := httptest.NewRequest(http.MethodGet, second.DownloadURL+"&bytes=16", nil)
downloadRes := httptest.NewRecorder()
server.Handler().ServeHTTP(downloadRes, downloadReq)
if downloadRes.Code != http.StatusOK || downloadRes.Body.Len() != 16 {
t.Fatalf("download status/body = %d/%d, want 200/16; body=%s", downloadRes.Code, downloadRes.Body.Len(), downloadRes.Body.String())
}
}
func TestCancelActiveTestPromotesQueuedSession(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
firstReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":30}`))
firstRes := httptest.NewRecorder()
server.Handler().ServeHTTP(firstRes, firstReq)
if firstRes.Code != http.StatusAccepted {
t.Fatalf("first status = %d, want 202; body=%s", firstRes.Code, firstRes.Body.String())
}
var first createTestResponse
if err := json.Unmarshal(firstRes.Body.Bytes(), &first); err != nil {
t.Fatalf("decode first response: %v", err)
}
secondReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
secondRes := httptest.NewRecorder()
server.Handler().ServeHTTP(secondRes, secondReq)
if secondRes.Code != http.StatusAccepted {
t.Fatalf("second status = %d, want 202; body=%s", secondRes.Code, secondRes.Body.String())
}
var second createTestResponse
if err := json.Unmarshal(secondRes.Body.Bytes(), &second); err != nil {
t.Fatalf("decode second response: %v", err)
}
if second.Status != "queued" {
t.Fatalf("second status = %q, want queued", second.Status)
}
cancelReq := httptest.NewRequest(http.MethodPost, first.CancelURL, nil)
cancelRes := httptest.NewRecorder()
server.Handler().ServeHTTP(cancelRes, cancelReq)
if cancelRes.Code != http.StatusOK {
t.Fatalf("cancel status = %d, want 200; body=%s", cancelRes.Code, cancelRes.Body.String())
}
queueReq := httptest.NewRequest(http.MethodGet, second.QueueURL, nil)
queueRes := httptest.NewRecorder()
server.Handler().ServeHTTP(queueRes, queueReq)
if queueRes.Code != http.StatusOK {
t.Fatalf("queue status = %d, want 200; body=%s", queueRes.Code, queueRes.Body.String())
}
var ready queueStatusResponse
if err := json.Unmarshal(queueRes.Body.Bytes(), &ready); err != nil {
t.Fatalf("decode queue status: %v", err)
}
if ready.Status != "ready" || ready.QueuePosition != 0 {
t.Fatalf("queue status = %#v, want promoted ready session", ready)
}
}
func TestCancelQueuedTestRemovesItAndUpdatesPositions(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
firstReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":30}`))
firstRes := httptest.NewRecorder()
server.Handler().ServeHTTP(firstRes, firstReq)
if firstRes.Code != http.StatusAccepted {
t.Fatalf("first status = %d, want 202; body=%s", firstRes.Code, firstRes.Body.String())
}
secondReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
secondRes := httptest.NewRecorder()
server.Handler().ServeHTTP(secondRes, secondReq)
if secondRes.Code != http.StatusAccepted {
t.Fatalf("second status = %d, want 202; body=%s", secondRes.Code, secondRes.Body.String())
}
var second createTestResponse
if err := json.Unmarshal(secondRes.Body.Bytes(), &second); err != nil {
t.Fatalf("decode second response: %v", err)
}
thirdReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.net","durationSeconds":1}`))
thirdRes := httptest.NewRecorder()
server.Handler().ServeHTTP(thirdRes, thirdReq)
if thirdRes.Code != http.StatusAccepted {
t.Fatalf("third status = %d, want 202; body=%s", thirdRes.Code, thirdRes.Body.String())
}
var third createTestResponse
if err := json.Unmarshal(thirdRes.Body.Bytes(), &third); err != nil {
t.Fatalf("decode third response: %v", err)
}
if third.QueuePosition != 2 {
t.Fatalf("third position = %d, want 2 before cancel", third.QueuePosition)
}
cancelReq := httptest.NewRequest(http.MethodPost, second.CancelURL, nil)
cancelRes := httptest.NewRecorder()
server.Handler().ServeHTTP(cancelRes, cancelReq)
if cancelRes.Code != http.StatusOK {
t.Fatalf("cancel status = %d, want 200; body=%s", cancelRes.Code, cancelRes.Body.String())
}
secondQueueReq := httptest.NewRequest(http.MethodGet, second.QueueURL, nil)
secondQueueRes := httptest.NewRecorder()
server.Handler().ServeHTTP(secondQueueRes, secondQueueReq)
if secondQueueRes.Code != http.StatusNotFound {
t.Fatalf("canceled queue status = %d, want 404; body=%s", secondQueueRes.Code, secondQueueRes.Body.String())
}
thirdQueueReq := httptest.NewRequest(http.MethodGet, third.QueueURL, nil)
thirdQueueRes := httptest.NewRecorder()
server.Handler().ServeHTTP(thirdQueueRes, thirdQueueReq)
if thirdQueueRes.Code != http.StatusOK {
t.Fatalf("third queue status = %d, want 200; body=%s", thirdQueueRes.Code, thirdQueueRes.Body.String())
}
var queued queueStatusResponse
if err := json.Unmarshal(thirdQueueRes.Body.Bytes(), &queued); err != nil {
t.Fatalf("decode third queue status: %v", err)
}
if queued.Status != "queued" || queued.QueuePosition != 1 {
t.Fatalf("third queue status = %#v, want queued position 1 after cancel", queued)
}
}
func TestCreateIperf3SessionStartsOneOffServerAndUsesGlobalLock(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, Iperf3Path: fakeIperf3(t)})
req := httptest.NewRequest(http.MethodPost, "/api/iperf3/sessions", jsonBody(`{"durationSeconds":1,"protocol":"tcp"}`))
req.RemoteAddr = "203.0.113.20:54321"
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body=%s", res.Code, res.Body.String())
}
var payload createIperf3Response
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.ID == "" || payload.Port < 20000 || payload.CompleteURL != "/api/iperf3/sessions/"+payload.ID+"/complete" {
t.Fatalf("payload = %#v, want id, random high port, and complete URL", payload)
}
secondReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
secondRes := httptest.NewRecorder()
server.Handler().ServeHTTP(secondRes, secondReq)
if secondRes.Code != http.StatusAccepted {
t.Fatalf("second status = %d, want 202 queued while iperf3 session is active; body=%s", secondRes.Code, secondRes.Body.String())
}
var queued createTestResponse
if err := json.Unmarshal(secondRes.Body.Bytes(), &queued); err != nil {
t.Fatalf("decode queued response: %v", err)
}
if queued.Status != "queued" || queued.QueuePosition != 1 {
t.Fatalf("queued response = %#v, want queued position 1", queued)
}
}
func TestCompleteIperf3SessionPersistsRecordAndReleasesLock(t *testing.T) {
store := &memoryStore{}
server := New(Options{Store: store, Runner: noopRunner, Iperf3Path: fakeIperf3(t), ClientResolver: staticResolver})
createReq := httptest.NewRequest(http.MethodPost, "/api/iperf3/sessions", jsonBody(`{"durationSeconds":1,"protocol":"tcp","reverse":true}`))
createReq.RemoteAddr = "203.0.113.20:54321"
createRes := httptest.NewRecorder()
server.Handler().ServeHTTP(createRes, createReq)
if createRes.Code != http.StatusAccepted {
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
}
var created createIperf3Response
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
body := `{
"startedAt":"2026-06-06T01:00:00Z",
"finishedAt":"2026-06-06T01:00:01Z",
"samples":[{"at":"2026-06-06T01:00:01Z","kind":"download","success":true,"bytes":1048576,"mbps":88.5}]
}`
completeReq := httptest.NewRequest(http.MethodPost, created.CompleteURL, jsonBody(body))
completeRes := httptest.NewRecorder()
server.Handler().ServeHTTP(completeRes, completeReq)
if completeRes.Code != http.StatusOK {
t.Fatalf("complete status = %d, want 200; body=%s", completeRes.Code, completeRes.Body.String())
}
var completed completeTestResponse
if err := json.Unmarshal(completeRes.Body.Bytes(), &completed); err != nil {
t.Fatalf("decode complete response: %v", err)
}
if completed.Summary.AvgDownloadMbps != 88.5 || !strings.Contains(completed.Summary.Target, "iperf3/tcp") {
t.Fatalf("summary = %#v, want iperf3 download summary", completed.Summary)
}
records := store.snapshot()
if len(records) != 1 || records[0].Client.IP != "203.0.113.*" || len(records[0].Samples) != 1 {
t.Fatalf("records = %#v, want one persisted iperf3 record with anonymized client", records)
}
nextReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
nextRes := httptest.NewRecorder()
server.Handler().ServeHTTP(nextRes, nextReq)
if nextRes.Code != http.StatusAccepted {
t.Fatalf("next status = %d, want 202 after iperf3 completion releases lock; body=%s", nextRes.Code, nextRes.Body.String())
}
}
func TestBrowserSessionQueuesDuringDownloadAndUploadWindows(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
firstReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":30}`))
firstRes := httptest.NewRecorder()
server.Handler().ServeHTTP(firstRes, firstReq)
if firstRes.Code != http.StatusAccepted {
t.Fatalf("first status = %d, want 202; body=%s", firstRes.Code, firstRes.Body.String())
}
server.mu.Lock()
request := server.sessions[server.activeID]
request.CreatedAt = time.Now().UTC().Add(-90 * time.Second)
server.sessions[server.activeID] = request
server.mu.Unlock()
secondReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
secondRes := httptest.NewRecorder()
server.Handler().ServeHTTP(secondRes, secondReq)
if secondRes.Code != http.StatusAccepted {
t.Fatalf("second status = %d, want 202 queued while 30s download + 30s upload window is active; body=%s", secondRes.Code, secondRes.Body.String())
}
var queued createTestResponse
if err := json.Unmarshal(secondRes.Body.Bytes(), &queued); err != nil {
t.Fatalf("decode queued response: %v", err)
}
if queued.Status != "queued" || queued.QueuePosition != 1 {
t.Fatalf("queued response = %#v, want queued position 1", queued)
}
}
func TestSSEStreamsSamplesSummaryAndPersistsRecord(t *testing.T) {
store := &memoryStore{}
start := time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC)
runner := func(ctx context.Context, request TestRequest, emit func(Event) error) (probe.Summary, error) {
sample := probe.Sample{
At: start,
Kind: "tcp",
Success: true,
Latency: 25 * time.Millisecond,
LatencyMS: 25,
}
if err := emit(Event{Type: "sample", Sample: &sample}); err != nil {
return probe.Summary{}, err
}
return probe.Summary{
Target: "example.com:80",
StartedAt: start,
FinishedAt: start.Add(time.Second),
Samples: 1,
Successes: 1,
Score: 98,
}, nil
}
server := New(Options{Store: store, Runner: runner, ClientResolver: staticResolver})
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
createReq.RemoteAddr = "203.0.113.20:54321"
createRes := httptest.NewRecorder()
server.Handler().ServeHTTP(createRes, createReq)
if createRes.Code != http.StatusAccepted {
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
}
var created createTestResponse
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
eventsReq := httptest.NewRequest(http.MethodGet, created.EventsURL, nil)
eventsRes := httptest.NewRecorder()
server.Handler().ServeHTTP(eventsRes, eventsReq)
if eventsRes.Code != http.StatusOK {
t.Fatalf("events status = %d, want 200; body=%s", eventsRes.Code, eventsRes.Body.String())
}
if contentType := eventsRes.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/event-stream") {
t.Fatalf("Content-Type = %q, want text/event-stream", contentType)
}
body := eventsRes.Body.String()
if !strings.Contains(body, "event: sample") || !strings.Contains(body, `"latencyMs":25`) {
t.Fatalf("events body missing sample event: %s", body)
}
if !strings.Contains(body, "event: summary") || !strings.Contains(body, `"score":98`) {
t.Fatalf("events body missing summary event: %s", body)
}
records := store.snapshot()
if len(records) != 1 {
t.Fatalf("persisted records = %d, want 1", len(records))
}
if records[0].ID != created.ID {
t.Fatalf("persisted id = %q, want %q", records[0].ID, created.ID)
}
if records[0].Client.IP != "203.0.113.*" || records[0].Client.Region != "Beijing" || records[0].Client.ISP != "Example Mobile" {
t.Fatalf("persisted client = %#v, want enriched client data", records[0].Client)
}
if len(records[0].Samples) != 1 || records[0].Samples[0].LatencyMS != 25 {
t.Fatalf("persisted samples = %#v, want sample curve", records[0].Samples)
}
}
func TestSessionLockReleasesAfterSSECompletes(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
firstReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
firstRes := httptest.NewRecorder()
server.Handler().ServeHTTP(firstRes, firstReq)
if firstRes.Code != http.StatusAccepted {
t.Fatalf("first status = %d, want 202; body=%s", firstRes.Code, firstRes.Body.String())
}
var first createTestResponse
if err := json.Unmarshal(firstRes.Body.Bytes(), &first); err != nil {
t.Fatalf("decode first response: %v", err)
}
eventsReq := httptest.NewRequest(http.MethodGet, first.EventsURL, nil)
eventsRes := httptest.NewRecorder()
server.Handler().ServeHTTP(eventsRes, eventsReq)
if eventsRes.Code != http.StatusOK {
t.Fatalf("events status = %d, want 200; body=%s", eventsRes.Code, eventsRes.Body.String())
}
nextReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
nextRes := httptest.NewRecorder()
server.Handler().ServeHTTP(nextRes, nextReq)
if nextRes.Code != http.StatusAccepted {
t.Fatalf("next status = %d, want 202 after prior session completes; body=%s", nextRes.Code, nextRes.Body.String())
}
}
func TestCompleteEndpointPersistsBrowserSpeedRecordAndReleasesLock(t *testing.T) {
store := &memoryStore{}
server := New(Options{Store: store, Runner: noopRunner, ClientResolver: staticResolver})
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":30}`))
createReq.RemoteAddr = "203.0.113.20:54321"
createRes := httptest.NewRecorder()
server.Handler().ServeHTTP(createRes, createReq)
if createRes.Code != http.StatusAccepted {
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
}
var created createTestResponse
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
body := `{
"startedAt":"2026-06-05T08:00:00Z",
"finishedAt":"2026-06-05T08:00:05Z",
"samples":[
{"at":"2026-06-05T08:00:01Z","kind":"latency","success":true,"latencyMs":20},
{"at":"2026-06-05T08:00:02Z","kind":"download","success":true,"bytes":4194304,"mbps":75.5},
{"at":"2026-06-05T08:00:03Z","kind":"upload","success":true,"bytes":2097152,"mbps":42.25}
]
}`
completeReq := httptest.NewRequest(http.MethodPost, created.CompleteURL, jsonBody(body))
completeRes := httptest.NewRecorder()
server.Handler().ServeHTTP(completeRes, completeReq)
if completeRes.Code != http.StatusOK {
t.Fatalf("complete status = %d, want 200; body=%s", completeRes.Code, completeRes.Body.String())
}
var completed completeTestResponse
if err := json.Unmarshal(completeRes.Body.Bytes(), &completed); err != nil {
t.Fatalf("decode complete response: %v", err)
}
if completed.Summary.AvgDownloadMbps != 75.5 || completed.Summary.AvgUploadMbps != 42.25 {
t.Fatalf("summary speeds = %.2f/%.2f, want 75.5/42.25", completed.Summary.AvgDownloadMbps, completed.Summary.AvgUploadMbps)
}
records := store.snapshot()
if len(records) != 1 {
t.Fatalf("records = %d, want 1", len(records))
}
if records[0].Client.IP != "203.0.113.*" {
t.Fatalf("record client IP = %q, want 203.0.113.*", records[0].Client.IP)
}
if len(records[0].Samples) != 3 {
t.Fatalf("record samples = %d, want 3", len(records[0].Samples))
}
nextReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
nextRes := httptest.NewRecorder()
server.Handler().ServeHTTP(nextRes, nextReq)
if nextRes.Code != http.StatusAccepted {
t.Fatalf("next status = %d, want 202 after complete releases lock; body=%s", nextRes.Code, nextRes.Body.String())
}
}
func TestCompleteEndpointAcceptsLargeSamplePayload(t *testing.T) {
store := &memoryStore{}
server := New(Options{Store: store, Runner: noopRunner, ClientResolver: staticResolver})
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":30}`))
createReq.RemoteAddr = "203.0.113.20:54321"
createRes := httptest.NewRecorder()
server.Handler().ServeHTTP(createRes, createReq)
if createRes.Code != http.StatusAccepted {
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
}
var created createTestResponse
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
completeReq := httptest.NewRequest(http.MethodPost, created.CompleteURL, largeCompleteBody(t, 30000))
completeRes := httptest.NewRecorder()
server.Handler().ServeHTTP(completeRes, completeReq)
if completeRes.Code != http.StatusOK {
t.Fatalf("complete status = %d, want 200 for large sample payload; body=%s", completeRes.Code, completeRes.Body.String())
}
records := store.snapshot()
if len(records) != 1 || len(records[0].Samples) != 30000 {
t.Fatalf("records = %d samples=%d, want one record with 30000 samples", len(records), len(records[0].Samples))
}
}
func TestCompleteEndpointCanRetryWhenPersistFails(t *testing.T) {
store := &flakyStore{fail: true}
server := New(Options{Store: store, Runner: noopRunner, ClientResolver: staticResolver})
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":30}`))
createReq.RemoteAddr = "203.0.113.20:54321"
createRes := httptest.NewRecorder()
server.Handler().ServeHTTP(createRes, createReq)
if createRes.Code != http.StatusAccepted {
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
}
var created createTestResponse
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
firstComplete := httptest.NewRequest(http.MethodPost, created.CompleteURL, jsonBody(`{"samples":[{"kind":"download","success":true,"bytes":1024,"mbps":10}]}`))
firstRes := httptest.NewRecorder()
server.Handler().ServeHTTP(firstRes, firstComplete)
if firstRes.Code != http.StatusInternalServerError {
t.Fatalf("first complete status = %d, want 500; body=%s", firstRes.Code, firstRes.Body.String())
}
store.fail = false
secondComplete := httptest.NewRequest(http.MethodPost, created.CompleteURL, jsonBody(`{"samples":[{"kind":"download","success":true,"bytes":1024,"mbps":10}]}`))
secondRes := httptest.NewRecorder()
server.Handler().ServeHTTP(secondRes, secondComplete)
if secondRes.Code != http.StatusOK {
t.Fatalf("retry complete status = %d, want 200 after store recovers; body=%s", secondRes.Code, secondRes.Body.String())
}
if records := store.snapshot(); len(records) != 1 {
t.Fatalf("records = %d, want one record after retry", len(records))
}
}
func TestBrowserSummaryUsesSustainedTransferRatesForMax(t *testing.T) {
start := time.Date(2026, 6, 10, 8, 0, 0, 0, time.UTC)
request := TestRequest{
ID: "test-1",
Target: probe.Target{Address: "example.com:443"},
CreatedAt: start,
}
raw := completeTestRequest{
StartedAt: start,
FinishedAt: start.Add(5 * time.Second),
Samples: []probe.Sample{
{At: start.Add(1 * time.Second), Kind: "download", Success: true, LatencyMS: 1000, Bytes: 5_000_000, Mbps: 40},
{At: start.Add(2 * time.Second), Kind: "download", Success: true, LatencyMS: 1000, Bytes: 5_000_000, Mbps: 1000},
{At: start.Add(3 * time.Second), Kind: "download", Success: true, LatencyMS: 1000, Bytes: 5_000_000, Mbps: 40},
{At: start.Add(4 * time.Second), Kind: "download", Success: true, LatencyMS: 1000, Bytes: 5_000_000, Mbps: 40},
},
}
summary := summarizeBrowserSamples(request, raw)
if summary.AvgDownloadMbps != 40 {
t.Fatalf("AvgDownloadMbps = %.2f, want sustained 40 Mbps", summary.AvgDownloadMbps)
}
if summary.MaxDownloadMbps != 40 {
t.Fatalf("MaxDownloadMbps = %.2f, want sustained 40 Mbps instead of single-sample spike", summary.MaxDownloadMbps)
}
}
func TestBrowserSummaryDoesNotTreatShortBurstsAsSustainedMax(t *testing.T) {
start := time.Date(2026, 6, 10, 8, 0, 0, 0, time.UTC)
var samples []probe.Sample
elapsed := time.Duration(0)
for i := 0; i < 10; i++ {
latency := 50 * time.Millisecond
mbps := 671.09
if i%2 == 1 {
latency = 1750 * time.Millisecond
mbps = 19.17
}
elapsed += latency
samples = append(samples, probe.Sample{
At: start.Add(elapsed),
Kind: "download",
Success: true,
LatencyMS: float64(latency.Milliseconds()),
Bytes: 4 * 1024 * 1024,
Mbps: mbps,
})
}
request := TestRequest{
ID: "test-1",
Target: probe.Target{Address: "example.com:443"},
CreatedAt: start,
}
raw := completeTestRequest{
StartedAt: start,
FinishedAt: start.Add(elapsed),
Samples: samples,
}
summary := summarizeBrowserSamples(request, raw)
if summary.MaxDownloadMbps > 60 {
t.Fatalf("MaxDownloadMbps = %.2f, want sustained max below short burst rates", summary.MaxDownloadMbps)
}
if summary.AvgDownloadMbps < 35 || summary.AvgDownloadMbps > 39 {
t.Fatalf("AvgDownloadMbps = %.2f, want long-run average around 37 Mbps", summary.AvgDownloadMbps)
}
}
func TestSSEUnknownSessionReturnsNotFound(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
req := httptest.NewRequest(http.MethodGet, "/api/tests/missing/events", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", res.Code)
}
}
func TestHandlerServesStaticFilesFromFS(t *testing.T) {
staticFS := http.FS(fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<html>dashboard</html>")},
})
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, StaticFS: staticFS})
req := httptest.NewRequest(http.MethodGet, "/", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
}
if !strings.Contains(res.Body.String(), "dashboard") {
t.Fatalf("body = %q, want dashboard content", res.Body.String())
}
}
func TestDownloadsEndpointServesPackagedLinuxBinary(t *testing.T) {
downloadsDir := t.TempDir()
packagePath := filepath.Join(downloadsDir, "netstable-linux-amd64.tar.gz")
if err := os.WriteFile(packagePath, []byte("fake-package"), 0644); err != nil {
t.Fatalf("write fake package: %v", err)
}
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, DownloadsDir: downloadsDir})
req := httptest.NewRequest(http.MethodGet, "/downloads/netstable-linux-amd64.tar.gz", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
}
if res.Body.String() != "fake-package" {
t.Fatalf("body = %q, want fake package", res.Body.String())
}
if !strings.Contains(res.Header().Get("Content-Disposition"), "netstable-linux-amd64.tar.gz") {
t.Fatalf("Content-Disposition = %q, want filename", res.Header().Get("Content-Disposition"))
}
}
func TestDownloadsEndpointRejectsUnknownBinaryName(t *testing.T) {
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, DownloadsDir: t.TempDir()})
req := httptest.NewRequest(http.MethodGet, "/downloads/netstable-linux-riscv64", nil)
res := httptest.NewRecorder()
server.Handler().ServeHTTP(res, req)
if res.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", res.Code)
}
}
func TestSubFSHelperReturnsStaticRoot(t *testing.T) {
staticFS := fstest.MapFS{
"static/index.html": &fstest.MapFile{Data: []byte("<html>embedded</html>")},
}
root, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
if _, err := root.Open("index.html"); err != nil {
t.Fatalf("open index from sub fs: %v", err)
}
}
func TestDefaultRunnerEmitsSampleEvents(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer listener.Close()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
_ = conn.Close()
}
}()
target, err := probe.ParseTarget(listener.Addr().String())
if err != nil {
t.Fatalf("ParseTarget: %v", err)
}
var sampleEvents int
summary, err := DefaultRunner(context.Background(), TestRequest{
Target: target,
Duration: 80 * time.Millisecond,
Interval: 20 * time.Millisecond,
Timeout: 100 * time.Millisecond,
}, func(event Event) error {
if event.Type == "sample" && event.Sample != nil {
sampleEvents++
}
return nil
})
if err != nil {
t.Fatalf("DefaultRunner returned error: %v", err)
}
if sampleEvents == 0 {
t.Fatal("no sample events emitted")
}
if summary.Successes == 0 {
t.Fatalf("summary = %#v, want successes", summary)
}
}
func jsonBody(body string) *bytes.Buffer {
return bytes.NewBufferString(body)
}
func largeCompleteBody(t *testing.T, sampleCount int) *bytes.Buffer {
t.Helper()
var buffer bytes.Buffer
buffer.WriteString(`{"startedAt":"2026-06-05T08:00:00Z","finishedAt":"2026-06-05T08:00:30Z","samples":[`)
for i := 0; i < sampleCount; i++ {
if i > 0 {
buffer.WriteByte(',')
}
kind := "download"
if i%2 == 1 {
kind = "upload"
}
if err := json.NewEncoder(&buffer).Encode(probe.Sample{
At: time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Millisecond),
Kind: kind,
Success: true,
Bytes: 4 * 1024 * 1024,
Mbps: 42.5,
LatencyMS: 150,
}); err != nil {
t.Fatalf("encode sample: %v", err)
}
data := buffer.Bytes()
if len(data) > 0 && data[len(data)-1] == '\n' {
buffer.Truncate(len(data) - 1)
}
}
buffer.WriteString(`]}`)
return &buffer
}
type memoryStore struct {
mu sync.Mutex
records []recordstore.Record
}
func (s *memoryStore) Append(ctx context.Context, record recordstore.Record) error {
s.mu.Lock()
defer s.mu.Unlock()
s.records = append([]recordstore.Record{record}, s.records...)
return nil
}
func (s *memoryStore) List(ctx context.Context, limit int) ([]recordstore.Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
if limit < 1 || limit > len(s.records) {
limit = len(s.records)
}
out := make([]recordstore.Record, limit)
copy(out, s.records[:limit])
return out, nil
}
func (s *memoryStore) snapshot() []recordstore.Record {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]recordstore.Record, len(s.records))
copy(out, s.records)
return out
}
type flakyStore struct {
memoryStore
mu sync.Mutex
fail bool
}
func (s *flakyStore) Append(ctx context.Context, record recordstore.Record) error {
s.mu.Lock()
fail := s.fail
s.mu.Unlock()
if fail {
return errors.New("store unavailable")
}
return s.memoryStore.Append(ctx, record)
}
func noopRunner(ctx context.Context, request TestRequest, emit func(Event) error) (probe.Summary, error) {
return probe.Summary{Target: request.Target.Address, StartedAt: time.Now(), FinishedAt: time.Now()}, nil
}
func staticResolver(ctx context.Context, ip string) (recordstore.ClientInfo, error) {
return recordstore.ClientInfo{
IP: ip,
ISP: "Example Mobile",
Region: "Beijing",
Country: "China",
City: "Beijing",
ASN: "AS64512",
Source: "test",
}, nil
}
func fakeIperf3(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "iperf3")
script := `#!/bin/sh
for arg in "$@"; do
if [ "$arg" = "-s" ]; then
sleep 10
exit 0
fi
done
cat <<'JSON'
{"end":{"sum_sent":{"bytes":1048576,"bits_per_second":88000000},"sum_received":{"bytes":1048576,"bits_per_second":87000000}}}
JSON
`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
t.Fatalf("write fake iperf3: %v", err)
}
return path
}