Files
2026-06-10 17:52:55 +08:00

324 lines
9.1 KiB
Go

package client
import (
"context"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"net/http/httptest"
"speedtest/internal/probe"
recordstore "speedtest/internal/store"
appweb "speedtest/internal/web"
)
func TestRunCompletesBrowserCompatibleSpeedTest(t *testing.T) {
store := &memoryStore{}
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner})
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
summary, err := Run(context.Background(), Options{
ServerURL: httpServer.URL,
Duration: 20 * time.Millisecond,
Timeout: time.Second,
DownloadBytes: 1024,
UploadBytes: 512,
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.Target == "" || summary.AvgDownloadMbps <= 0 || summary.AvgUploadMbps <= 0 {
t.Fatalf("summary = %#v, want target and upload/download speeds", summary)
}
records := store.snapshot()
if len(records) != 1 {
t.Fatalf("records = %d, want 1", len(records))
}
if records[0].Summary.ID != summary.ID {
t.Fatalf("record summary id = %q, want %q", records[0].Summary.ID, summary.ID)
}
if !hasKind(records[0].Samples, "download") || !hasKind(records[0].Samples, "upload") {
t.Fatalf("samples = %#v, want download and upload samples", records[0].Samples)
}
}
func TestRunWaitsInQueueUntilBusySessionCompletes(t *testing.T) {
store := &memoryStore{}
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner})
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
busy := createBusySession(t, httpServer.URL)
go func() {
time.Sleep(30 * time.Millisecond)
completeBusySession(t, httpServer.URL, busy)
}()
var queueMessages int
summary, err := Run(context.Background(), Options{
ServerURL: httpServer.URL,
Duration: 20 * time.Millisecond,
Timeout: time.Second,
DownloadBytes: 1024,
UploadBytes: 512,
QueueInterval: 10 * time.Millisecond,
OnQueueWaiting: func(message string) { queueMessages++ },
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.ID == "" || summary.AvgDownloadMbps <= 0 || summary.AvgUploadMbps <= 0 {
t.Fatalf("summary = %#v, want completed queued test", summary)
}
if queueMessages == 0 {
t.Fatal("OnQueueWaiting was not called while queued")
}
}
func TestRunCancelsQueuedSessionWhenContextStops(t *testing.T) {
store := &memoryStore{}
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner})
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
busy := createBusySession(t, httpServer.URL)
ctx, cancel := context.WithCancel(context.Background())
_, err := Run(ctx, Options{
ServerURL: httpServer.URL,
Duration: 20 * time.Millisecond,
Timeout: time.Second,
DownloadBytes: 1024,
UploadBytes: 512,
QueueInterval: time.Hour,
OnQueueWaiting: func(message string) {
cancel()
},
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run error = %v, want context canceled", err)
}
completeBusySession(t, httpServer.URL, busy)
serverURL, err := url.Parse(httpServer.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
session, err := createSession(context.Background(), &http.Client{Timeout: time.Second}, serverURL, Options{
ServerURL: httpServer.URL,
Target: serverURL.Host,
Duration: time.Second,
Timeout: time.Second,
DownloadBytes: 1024,
UploadBytes: 512,
QueueInterval: time.Millisecond,
OnQueueWaiting: nil,
})
if err != nil {
t.Fatalf("create next session: %v", err)
}
if session.Status != "ready" || session.QueuePosition != 0 {
t.Fatalf("next session = %#v, want ready after canceled queued session was removed", session)
}
}
func TestRunPersistsPartialRecordWhenCanceledAfterSamples(t *testing.T) {
store := &memoryStore{}
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner})
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
canceled := false
_, err := Run(ctx, Options{
ServerURL: httpServer.URL,
Duration: time.Second,
Timeout: time.Second,
DownloadBytes: 1024,
UploadBytes: 512,
OnSample: func(sample probe.Sample) {
if sample.Kind == "download" && !canceled {
canceled = true
cancel()
}
},
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run error = %v, want context canceled", err)
}
records := store.snapshot()
if len(records) != 1 {
t.Fatalf("records = %d, want one partial record", len(records))
}
if !hasKind(records[0].Samples, "download") {
t.Fatalf("samples = %#v, want partial download sample persisted", records[0].Samples)
}
if records[0].Summary.Samples != len(records[0].Samples) {
t.Fatalf("summary samples = %d, want %d", records[0].Summary.Samples, len(records[0].Samples))
}
}
func TestRunIperf3CompletesOneOffServerSession(t *testing.T) {
store := &memoryStore{}
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner, Iperf3Path: fakeIperf3(t)})
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
summary, err := RunIperf3(context.Background(), Iperf3Options{
ServerURL: httpServer.URL,
Duration: time.Second,
Protocol: "tcp",
Reverse: true,
Iperf3Path: fakeIperf3(t),
})
if err != nil {
t.Fatalf("RunIperf3 returned error: %v", err)
}
if summary.AvgDownloadMbps != 87 || !strings.Contains(summary.Target, "iperf3/tcp") {
t.Fatalf("summary = %#v, want parsed iperf3 download result", summary)
}
records := store.snapshot()
if len(records) != 1 || records[0].Summary.ID != summary.ID {
t.Fatalf("records = %#v, want stored iperf3 summary", records)
}
}
func TestRunIperf3FailedCommandReleasesOneOffServerSession(t *testing.T) {
server := appweb.New(appweb.Options{Store: &memoryStore{}, Runner: noopRunner, Iperf3Path: fakeIperf3(t)})
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
_, err := RunIperf3(context.Background(), Iperf3Options{
ServerURL: httpServer.URL,
Duration: time.Second,
Protocol: "tcp",
Iperf3Path: fakeFailingIperf3Client(t),
})
if err == nil || !strings.Contains(err.Error(), "iperf3 failed") {
t.Fatalf("RunIperf3 error = %v, want iperf3 failure", err)
}
createBusySession(t, httpServer.URL)
}
func createBusySession(t *testing.T, serverURL string) createResponse {
t.Helper()
server, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
httpClient := http.Client{Timeout: time.Second}
session, err := createSession(context.Background(), &httpClient, server, Options{
Target: server.Host,
Duration: time.Second,
Timeout: time.Second,
DownloadBytes: 1024,
UploadBytes: 512,
})
if err != nil {
t.Fatalf("create busy session: %v", err)
}
return session
}
func completeBusySession(t *testing.T, serverURL string, session createResponse) {
t.Helper()
server, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
httpClient := http.Client{Timeout: time.Second}
now := time.Now().UTC()
if _, err := completeSession(context.Background(), &httpClient, server, session.CompleteURL, now, now, nil); err != nil {
t.Errorf("complete busy session: %v", err)
}
}
func hasKind(samples []probe.Sample, kind string) bool {
for _, sample := range samples {
if sample.Kind == kind {
return true
}
}
return false
}
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
}
func noopRunner(ctx context.Context, request appweb.TestRequest, emit func(appweb.Event) error) (probe.Summary, error) {
return probe.Summary{Target: request.Target.Address, StartedAt: time.Now(), FinishedAt: time.Now()}, 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
}
func fakeFailingIperf3Client(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "iperf3")
script := `#!/bin/sh
echo "simulated iperf3 failure" >&2
exit 1
`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
t.Fatalf("write fake failing iperf3: %v", err)
}
return path
}