1157 lines
43 KiB
Go
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
|
|
}
|