diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e80a33c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.idea +.DS_Store +**/bin/ +**/obj/ +output/ +device-simulator-mac-arm64 +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f2a8137 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# --- Build the Go API --- +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go/go.mod ./ +COPY go/ ./ +RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server + +# --- API runtime --- +FROM alpine:3.20 AS api +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=build /out/server /app/server +COPY --from=build /src/devices.csv /app/devices.csv +EXPOSE 6733 +HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=15 \ + CMD curl -sf http://127.0.0.1:6733/healthz || exit 1 +ENTRYPOINT ["/app/server"] + +# --- Device simulator (writes results.txt into /output) --- +FROM alpine:3.20 AS simulator +COPY device-simulator-linux-arm64 /usr/local/bin/device-simulator +RUN chmod +x /usr/local/bin/device-simulator \ + && mkdir -p /output \ + && chmod 0777 /output +WORKDIR /output +VOLUME ["/output"] +ENTRYPOINT ["device-simulator"] diff --git a/Dockerfile.dotnet b/Dockerfile.dotnet new file mode 100644 index 0000000..9087457 --- /dev/null +++ b/Dockerfile.dotnet @@ -0,0 +1,30 @@ +# --- Build the .NET API --- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY dotnet/SafelyYou/SafelyYou.csproj dotnet/SafelyYou/ +RUN dotnet restore dotnet/SafelyYou/SafelyYou.csproj +COPY dotnet/SafelyYou/ dotnet/SafelyYou/ +RUN dotnet publish dotnet/SafelyYou/SafelyYou.csproj -c Release -o /app/publish --no-restore + +# --- API runtime --- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS api +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:6733 +EXPOSE 6733 +HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=15 \ + CMD curl -s -o /dev/null http://127.0.0.1:6733/ || exit 1 +ENTRYPOINT ["dotnet", "SafelyYou.dll"] + +# --- Device simulator (writes results.txt into /output) --- +FROM alpine:3.20 AS simulator +COPY device-simulator-linux-arm64 /usr/local/bin/device-simulator +RUN chmod +x /usr/local/bin/device-simulator \ + && mkdir -p /output \ + && chmod 0777 /output +WORKDIR /output +VOLUME ["/output"] +ENTRYPOINT ["device-simulator"] diff --git a/docker-compose.dotnet.yml b/docker-compose.dotnet.yml new file mode 100644 index 0000000..c20e013 --- /dev/null +++ b/docker-compose.dotnet.yml @@ -0,0 +1,27 @@ +services: + api: + build: + context: . + target: api + ports: + - "6733:6733" + healthcheck: + test: ["CMD", "curl", "-s", "-o", "/dev/null", "http://127.0.0.1:6733/"] + interval: 2s + timeout: 2s + retries: 15 + start_period: 5s + + simulator: + build: + context: . + target: simulator + depends_on: + api: + condition: service_healthy + command: ["-host", "api", "-port", "6733"] + working_dir: /output + volumes: + - ./output:/output + # Ensure the bind-mounted host dir is writable for results.txt + user: "0:0" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dca2897 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + api: + build: + context: . + target: api + ports: + - "6733:6733" + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:6733/healthz"] + interval: 2s + timeout: 2s + retries: 15 + start_period: 5s + + simulator: + build: + context: . + target: simulator + depends_on: + api: + condition: service_healthy + command: ["-host", "api", "-port", "6733"] + working_dir: /output + volumes: + - ./output:/output + # Ensure the bind-mounted host dir is writable for results.txt + user: "0:0" diff --git a/go/cmd/server/main.go b/go/cmd/server/main.go new file mode 100644 index 0000000..34fcea6 --- /dev/null +++ b/go/cmd/server/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/csv" + "fmt" + "io" + "log" + "net/http" + "os" + + "github.com/michaeldileo/SafelyYouCodingChallenge/internal/api" + "github.com/michaeldileo/SafelyYouCodingChallenge/internal/store" +) + +func main() { + deviceIDs, err := loadDeviceIDs("devices.csv") + if err != nil { + log.Fatalf("load devices: %v", err) + } + + s := store.New(deviceIDs) + log.Printf("loaded %d known devices", len(deviceIDs)) + + srv := api.NewServer(s) + + addr := ":6733" + fmt.Println("listening on", addr) + if err := http.ListenAndServe(addr, srv.Handler()); err != nil { + log.Fatal(err) + } +} + +// loadDeviceIDs reads devices.csv (header + one device_id per row). +// Lives in main on purpose: startup I/O stays at the process edge; +// the store only receives already-parsed IDs. +func loadDeviceIDs(path string) ([]store.DeviceID, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + r := csv.NewReader(f) + + // Skip header: device_id + if _, err := r.Read(); err != nil { + return nil, fmt.Errorf("read header: %w", err) + } + + var ids []store.DeviceID + for { + record, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if len(record) == 0 || record[0] == "" { + continue + } + ids = append(ids, store.DeviceID(record[0])) + } + return ids, nil +} diff --git a/go/devices.csv b/go/devices.csv new file mode 100644 index 0000000..5ed9930 --- /dev/null +++ b/go/devices.csv @@ -0,0 +1,6 @@ +device_id +60-6b-44-84-dc-64 +b4-45-52-a2-f1-3c +26-9a-66-01-33-83 +18-b8-87-e7-1f-06 +38-4e-73-e0-33-59 diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..25f1c24 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module github.com/michaeldileo/SafelyYouCodingChallenge + +go 1.26.3 diff --git a/go/internal/api/handlers.go b/go/internal/api/handlers.go new file mode 100644 index 0000000..87c0829 --- /dev/null +++ b/go/internal/api/handlers.go @@ -0,0 +1,130 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/michaeldileo/SafelyYouCodingChallenge/internal/store" +) + +// Server wires HTTP handlers to the in-memory store. +// Constructed in main and passed dependencies explicitly (no DI container). +type Server struct { + store *store.Store +} + +func NewServer(s *store.Store) *Server { + return &Server{store: s} +} + +// Handler returns the HTTP routes for this API. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", s.healthz) + mux.HandleFunc("POST /api/v1/devices/{device_id}/heartbeat", s.postHeartbeat) + mux.HandleFunc("POST /api/v1/devices/{device_id}/stats", s.postStats) + mux.HandleFunc("GET /api/v1/devices/{device_id}/stats", s.getStats) + return mux +} + +type heartbeatRequest struct { + SentAt time.Time `json:"sent_at"` +} + +type uploadStatsRequest struct { + SentAt time.Time `json:"sent_at"` // accepted for contract; ignored in average + UploadTime int64 `json:"upload_time"` // nanoseconds +} + +type getDeviceStatsResponse struct { + AvgUploadTime string `json:"avg_upload_time"` + Uptime float64 `json:"uptime"` +} + +// OpenAPI NotFoundResponse / ErrorResponse both use {"msg": "..."}. +type msgResponse struct { + Msg string `json:"msg"` +} + +func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +func (s *Server) postHeartbeat(w http.ResponseWriter, r *http.Request) { + id := store.DeviceID(r.PathValue("device_id")) + if !s.store.Known(id) { + writeJSON(w, http.StatusNotFound, msgResponse{ + Msg: fmt.Sprintf("Device ID %s not found", id), + }) + return + } + + var req heartbeatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusInternalServerError, msgResponse{ + Msg: "invalid request body", + }) + return + } + + if err := s.store.AddHeartbeat(id, req.SentAt); err != nil { + if errors.Is(err, store.ErrOutOfSequence) { + writeJSON(w, http.StatusInternalServerError, msgResponse{ + Msg: "Attempted to add a timestamp that was out of sequence or invalid.", + }) + return + } + writeJSON(w, http.StatusInternalServerError, msgResponse{Msg: err.Error()}) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) postStats(w http.ResponseWriter, r *http.Request) { + id := store.DeviceID(r.PathValue("device_id")) + if !s.store.Known(id) { + writeJSON(w, http.StatusNotFound, msgResponse{ + Msg: fmt.Sprintf("Device ID %s not found", id), + }) + return + } + + var req uploadStatsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusInternalServerError, msgResponse{ + Msg: "invalid request body", + }) + return + } + + // sent_at is decoded above but intentionally unused for the average. + s.store.AddUpload(id, time.Duration(req.UploadTime)) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) getStats(w http.ResponseWriter, r *http.Request) { + id := store.DeviceID(r.PathValue("device_id")) + if !s.store.Known(id) { + writeJSON(w, http.StatusNotFound, msgResponse{ + Msg: fmt.Sprintf("Device ID %s not found", id), + }) + return + } + + avg := s.store.AverageUploadTime(id) + writeJSON(w, http.StatusOK, getDeviceStatsResponse{ + AvgUploadTime: avg.String(), // HTTP-edge formatting (Q13) + Uptime: s.store.Uptime(id), + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/go/internal/api/handlers_test.go b/go/internal/api/handlers_test.go new file mode 100644 index 0000000..08befb5 --- /dev/null +++ b/go/internal/api/handlers_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/michaeldileo/SafelyYouCodingChallenge/internal/store" +) + +func TestPostHeartbeat_UnknownDevice_404(t *testing.T) { + s := store.New([]store.DeviceID{"known"}) + srv := NewServer(s) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/devices/missing/heartbeat", strings.NewReader(`{"sent_at":"2024-01-01T12:00:00Z"}`)) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + var body msgResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Msg == "" { + t.Fatal("expected non-empty msg") + } +} + +func TestPostAndGetStats_RoundTrip(t *testing.T) { + id := store.DeviceID("device-1") + s := store.New([]store.DeviceID{id}) + srv := NewServer(s) + h := srv.Handler() + + t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + // two heartbeats one minute apart → uptime 200 + postJSON(t, h, http.MethodPost, "/api/v1/devices/device-1/heartbeat", + `{"sent_at":"`+t0.Format(time.RFC3339)+`"}`, http.StatusNoContent) + postJSON(t, h, http.MethodPost, "/api/v1/devices/device-1/heartbeat", + `{"sent_at":"`+t0.Add(time.Minute).Format(time.RFC3339)+`"}`, http.StatusNoContent) + + // upload 197331667813 ns → "3m17.331667813s" + postJSON(t, h, http.MethodPost, "/api/v1/devices/device-1/stats", + `{"sent_at":"2024-01-01T12:00:00Z","upload_time":197331667813}`, http.StatusNoContent) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/devices/device-1/stats", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got getDeviceStatsResponse + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Uptime != 200 { + t.Fatalf("uptime = %v, want 200", got.Uptime) + } + if got.AvgUploadTime != "3m17.331667813s" { + t.Fatalf("avg_upload_time = %q, want 3m17.331667813s", got.AvgUploadTime) + } +} + +func postJSON(t *testing.T, h http.Handler, method, path, body string, wantStatus int) { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != wantStatus { + t.Fatalf("%s %s status = %d, want %d; body=%s", method, path, rec.Code, wantStatus, rec.Body.String()) + } +} diff --git a/go/internal/store/heartbeat.go b/go/internal/store/heartbeat.go new file mode 100644 index 0000000..7a85edc --- /dev/null +++ b/go/internal/store/heartbeat.go @@ -0,0 +1,94 @@ +package store + +import ( + "errors" + "sync" + "time" +) + +// ErrOutOfSequence is returned when a heartbeat timestamp is earlier than +// the latest accepted heartbeat for that device. +var ErrOutOfSequence = errors.New("heartbeat out of sequence") + +// heartbeatHistory tracks one device's heartbeat timeline. +// Unexported: only Store methods expose behavior. +type heartbeatHistory struct { + mu sync.Mutex + count int64 + first time.Time + last time.Time +} + +func (h *heartbeatHistory) add(sentAt time.Time) error { + h.mu.Lock() + defer h.mu.Unlock() + + if h.first.IsZero() { + h.first = sentAt + h.last = sentAt + h.count = 1 + return nil + } + + // Equal timestamps are allowed (matches C#: sentAt < LastHeartBeat only). + if sentAt.Before(h.last) { + return ErrOutOfSequence + } + + h.last = sentAt + h.count++ + return nil +} + +func (h *heartbeatHistory) uptime() float64 { + h.mu.Lock() + defer h.mu.Unlock() + + if h.first.IsZero() { + return 0 + } + + minutes := h.last.Sub(h.first).Minutes() + // Single heartbeat (or identical timestamps): treat span as one minute. + if minutes == 0 { + minutes = 1 + } + + return float64(h.count) / minutes * 100 +} + +// historyFor returns the per-device history, creating it if needed. +// The store-level lock only covers map insert/lookup; callers then use +// the history's own lock for mutations/reads. +func (s *Store) historyFor(id DeviceID) *heartbeatHistory { + s.mu.Lock() + defer s.mu.Unlock() + + h, ok := s.heartbeats[id] + if !ok { + h = &heartbeatHistory{} + s.heartbeats[id] = h + } + return h +} + +// AddHeartbeat records a heartbeat for deviceID. +// Returns ErrOutOfSequence if sentAt is earlier than the last accepted beat. +func (s *Store) AddHeartbeat(deviceID DeviceID, sentAt time.Time) error { + return s.historyFor(deviceID).add(sentAt) +} + +// Uptime returns uptime percent for deviceID: +// +// (heartbeatCount / minutesBetweenFirstAndLast) * 100 +// +// Unknown / never-seen devices return 0. A zero-length span is treated as 1 minute. +func (s *Store) Uptime(deviceID DeviceID) float64 { + s.mu.Lock() + h, ok := s.heartbeats[deviceID] + s.mu.Unlock() + if !ok { + return 0 + } + return h.uptime() +} diff --git a/go/internal/store/heartbeat_test.go b/go/internal/store/heartbeat_test.go new file mode 100644 index 0000000..2f39bf1 --- /dev/null +++ b/go/internal/store/heartbeat_test.go @@ -0,0 +1,106 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestAddHeartbeat_FirstSetsFirstLastAndCount(t *testing.T) { + s := New(nil) + id := DeviceID("device-1") + t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + if err := s.AddHeartbeat(id, t0); err != nil { + t.Fatalf("AddHeartbeat: %v", err) + } + if got := s.Uptime(id); got != 100 { + t.Fatalf("uptime = %v, want 100 (1 beat / 1 minute)", got) + } +} + +func TestAddHeartbeat_LaterUpdatesLast(t *testing.T) { + s := New(nil) + id := DeviceID("device-1") + t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Minute) + + _ = s.AddHeartbeat(id, t0) + if err := s.AddHeartbeat(id, t1); err != nil { + t.Fatalf("AddHeartbeat: %v", err) + } + // (2 beats / 1 minute) * 100 + if got := s.Uptime(id); got != 200 { + t.Fatalf("uptime = %v, want 200", got) + } +} + +func TestAddHeartbeat_SameTimestampAccepted(t *testing.T) { + s := New(nil) + id := DeviceID("device-1") + t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + _ = s.AddHeartbeat(id, t0) + if err := s.AddHeartbeat(id, t0); err != nil { + t.Fatalf("same timestamp should be accepted: %v", err) + } + // span still zero → 1 minute floor; 2 beats → 200 + if got := s.Uptime(id); got != 200 { + t.Fatalf("uptime = %v, want 200", got) + } +} + +func TestAddHeartbeat_OutOfSequence(t *testing.T) { + s := New(nil) + id := DeviceID("device-1") + t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + t1 := t0.Add(time.Minute) + + _ = s.AddHeartbeat(id, t1) + err := s.AddHeartbeat(id, t0) + if !errors.Is(err, ErrOutOfSequence) { + t.Fatalf("err = %v, want ErrOutOfSequence", err) + } + // rejected beat must not affect uptime: still 1 beat → 100 + if got := s.Uptime(id); got != 100 { + t.Fatalf("uptime = %v, want 100", got) + } +} + +func TestUptime_TwoHeartbeatsOverTwoMinutes(t *testing.T) { + s := New(nil) + id := DeviceID("device-1") + t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + _ = s.AddHeartbeat(id, t0) + _ = s.AddHeartbeat(id, t0.Add(2*time.Minute)) + + if got := s.Uptime(id); got != 100 { + t.Fatalf("uptime = %v, want 100", got) + } +} + +func TestUptime_UnknownDevice(t *testing.T) { + s := New(nil) + if got := s.Uptime(DeviceID("missing")); got != 0 { + t.Fatalf("uptime = %v, want 0", got) + } +} + +func TestUptime_IsPerDevice(t *testing.T) { + s := New(nil) + a := DeviceID("device-1") + b := DeviceID("device-2") + t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + _ = s.AddHeartbeat(a, t0) + _ = s.AddHeartbeat(a, t0.Add(time.Minute)) + _ = s.AddHeartbeat(b, t0) + + if got := s.Uptime(a); got != 200 { + t.Fatalf("device-1 uptime = %v, want 200", got) + } + if got := s.Uptime(b); got != 100 { + t.Fatalf("device-2 uptime = %v, want 100 (single beat = 1 minute)", got) + } +} diff --git a/go/internal/store/store.go b/go/internal/store/store.go new file mode 100644 index 0000000..5d81d49 --- /dev/null +++ b/go/internal/store/store.go @@ -0,0 +1,38 @@ +package store + +import "sync" + +// DeviceID is a typed device identifier. +// Using a named type (instead of plain string) makes call sites clearer +// and prevents accidentally mixing unrelated string values. +type DeviceID string + +// Store holds in-memory fleet metrics for known devices. +type Store struct { + known map[DeviceID]struct{} + + // mu protects the heartbeats/uploads maps (insert/lookup of entries only). + // Each per-device history has its own mutex for mutations. + mu sync.Mutex + heartbeats map[DeviceID]*heartbeatHistory + uploads map[DeviceID]*uploadHistory +} + +// New creates a Store seeded with the given known device IDs. +func New(known []DeviceID) *Store { + m := make(map[DeviceID]struct{}, len(known)) + for _, id := range known { + m[id] = struct{}{} + } + return &Store{ + known: m, + heartbeats: make(map[DeviceID]*heartbeatHistory), + uploads: make(map[DeviceID]*uploadHistory), + } +} + +// Known reports whether deviceID was present in the startup device list. +func (s *Store) Known(deviceID DeviceID) bool { + _, ok := s.known[deviceID] + return ok +} diff --git a/go/internal/store/upload.go b/go/internal/store/upload.go new file mode 100644 index 0000000..9c9748f --- /dev/null +++ b/go/internal/store/upload.go @@ -0,0 +1,61 @@ +package store + +import ( + "sync" + "time" +) + +// uploadHistory tracks running total/count of upload durations for one device. +type uploadHistory struct { + mu sync.Mutex + total time.Duration + count int64 +} + +func (u *uploadHistory) add(d time.Duration) { + u.mu.Lock() + defer u.mu.Unlock() + u.total += d + u.count++ +} + +func (u *uploadHistory) average() time.Duration { + u.mu.Lock() + defer u.mu.Unlock() + if u.count == 0 { + return 0 + } + // Truncating integer division — same idea as C# BigInteger total / count. + return u.total / time.Duration(u.count) +} + +func (s *Store) uploadFor(id DeviceID) *uploadHistory { + s.mu.Lock() + defer s.mu.Unlock() + + u, ok := s.uploads[id] + if !ok { + u = &uploadHistory{} + s.uploads[id] = u + } + return u +} + +// AddUpload records an upload duration (nanoseconds as time.Duration) for deviceID. +// sent_at from the API is intentionally ignored here; the HTTP layer still accepts it. +func (s *Store) AddUpload(deviceID DeviceID, uploadTime time.Duration) { + s.uploadFor(deviceID).add(uploadTime) +} + +// AverageUploadTime returns the mean upload duration for deviceID. +// Formatting for the API (duration.String()) belongs at the HTTP edge. +// Unknown / never-seen devices return 0. +func (s *Store) AverageUploadTime(deviceID DeviceID) time.Duration { + s.mu.Lock() + u, ok := s.uploads[deviceID] + s.mu.Unlock() + if !ok { + return 0 + } + return u.average() +} diff --git a/go/internal/store/upload_test.go b/go/internal/store/upload_test.go new file mode 100644 index 0000000..950d2a7 --- /dev/null +++ b/go/internal/store/upload_test.go @@ -0,0 +1,69 @@ +package store + +import ( + "testing" + "time" +) + +func TestAddUpload_AverageOfOne(t *testing.T) { + s := New(nil) + id := DeviceID("device-1") + d := 197331667813 * time.Nanosecond // ~3m17.331667813s + + s.AddUpload(id, d) + + if got := s.AverageUploadTime(id); got != d { + t.Fatalf("avg = %v, want %v", got, d) + } +} + +func TestAddUpload_AverageOfMany(t *testing.T) { + s := New(nil) + id := DeviceID("device-1") + + s.AddUpload(id, 100*time.Nanosecond) + s.AddUpload(id, 200*time.Nanosecond) + s.AddUpload(id, 300*time.Nanosecond) + + // (100+200+300)/3 = 200 + if got := s.AverageUploadTime(id); got != 200*time.Nanosecond { + t.Fatalf("avg = %v, want 200ns", got) + } +} + +func TestAverageUploadTime_UnknownDevice(t *testing.T) { + s := New(nil) + if got := s.AverageUploadTime(DeviceID("missing")); got != 0 { + t.Fatalf("avg = %v, want 0", got) + } +} + +func TestAverageUploadTime_IsPerDevice(t *testing.T) { + s := New(nil) + a := DeviceID("device-1") + b := DeviceID("device-2") + + s.AddUpload(a, 100*time.Nanosecond) + s.AddUpload(a, 300*time.Nanosecond) + s.AddUpload(b, 50*time.Nanosecond) + + if got := s.AverageUploadTime(a); got != 200*time.Nanosecond { + t.Fatalf("device-1 avg = %v, want 200ns", got) + } + if got := s.AverageUploadTime(b); got != 50*time.Nanosecond { + t.Fatalf("device-2 avg = %v, want 50ns", got) + } +} + +func TestAverageUploadTime_TruncatingDivision(t *testing.T) { + s := New(nil) + id := DeviceID("device-1") + + // 10+11 = 21; 21/2 = 10 in integer division + s.AddUpload(id, 10*time.Nanosecond) + s.AddUpload(id, 11*time.Nanosecond) + + if got := s.AverageUploadTime(id); got != 10*time.Nanosecond { + t.Fatalf("avg = %v, want 10ns (truncating)", got) + } +}