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 }