diff --git a/player/agent/internal/statusreporter/reporter.go b/player/agent/internal/statusreporter/reporter.go new file mode 100644 index 0000000..1a1d68d --- /dev/null +++ b/player/agent/internal/statusreporter/reporter.go @@ -0,0 +1,101 @@ +package statusreporter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type Snapshot struct { + Status string + ScreenID string + ServerBaseURL string + MQTTBroker string + HeartbeatEverySeconds int + StartedAt time.Time + LastHeartbeatAt time.Time +} + +type statusPayload struct { + ScreenID string `json:"screen_id"` + Timestamp string `json:"ts"` + Status string `json:"status"` + ServerURL string `json:"server_url"` + MQTTBroker string `json:"mqtt_broker"` + HeartbeatEverySeconds int `json:"heartbeat_every_seconds"` + StartedAt string `json:"started_at,omitempty"` + LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"` +} + +type Reporter struct { + baseURL string + client *http.Client + now func() time.Time +} + +func New(baseURL string, client *http.Client, now func() time.Time) *Reporter { + if client == nil { + client = &http.Client{Timeout: 5 * time.Second} + } + + if now == nil { + now = time.Now + } + + return &Reporter{ + baseURL: strings.TrimRight(baseURL, "/"), + client: client, + now: now, + } +} + +func (r *Reporter) Send(ctx context.Context, snapshot Snapshot) error { + payload := buildPayload(snapshot, r.now()) + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal status payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+"/api/v1/player/status", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build status request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := r.client.Do(req) + if err != nil { + return fmt.Errorf("send status request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status response: %s", resp.Status) + } + + return nil +} + +func buildPayload(snapshot Snapshot, now time.Time) statusPayload { + payload := statusPayload{ + ScreenID: snapshot.ScreenID, + Timestamp: now.Format(time.RFC3339), + Status: snapshot.Status, + ServerURL: snapshot.ServerBaseURL, + MQTTBroker: snapshot.MQTTBroker, + HeartbeatEverySeconds: snapshot.HeartbeatEverySeconds, + } + + if !snapshot.StartedAt.IsZero() { + payload.StartedAt = snapshot.StartedAt.Format(time.RFC3339) + } + + if !snapshot.LastHeartbeatAt.IsZero() { + payload.LastHeartbeatAt = snapshot.LastHeartbeatAt.Format(time.RFC3339) + } + + return payload +} diff --git a/player/agent/internal/statusreporter/reporter_test.go b/player/agent/internal/statusreporter/reporter_test.go new file mode 100644 index 0000000..7ca17cc --- /dev/null +++ b/player/agent/internal/statusreporter/reporter_test.go @@ -0,0 +1,84 @@ +package statusreporter + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestBuildPayloadFromSnapshot(t *testing.T) { + startedAt := time.Date(2026, 3, 22, 15, 59, 30, 0, time.UTC) + lastHeartbeatAt := time.Date(2026, 3, 22, 16, 0, 0, 0, time.UTC) + snapshot := Snapshot{ + Status: "running", + ScreenID: "info01-dev", + ServerBaseURL: "http://127.0.0.1:8080", + MQTTBroker: "tcp://127.0.0.1:1883", + HeartbeatEverySeconds: 30, + StartedAt: startedAt, + LastHeartbeatAt: lastHeartbeatAt, + } + + payload := buildPayload(snapshot, lastHeartbeatAt) + + if got, want := payload.ScreenID, "info01-dev"; got != want { + t.Fatalf("ScreenID = %q, want %q", got, want) + } + + if got, want := payload.Timestamp, lastHeartbeatAt.Format(time.RFC3339); got != want { + t.Fatalf("Timestamp = %q, want %q", got, want) + } + + if got, want := payload.StartedAt, startedAt.Format(time.RFC3339); got != want { + t.Fatalf("StartedAt = %q, want %q", got, want) + } + + if got, want := payload.LastHeartbeatAt, lastHeartbeatAt.Format(time.RFC3339); got != want { + t.Fatalf("LastHeartbeatAt = %q, want %q", got, want) + } +} + +func TestReporterSendStatus(t *testing.T) { + var received statusPayload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, http.MethodPost; got != want { + t.Fatalf("method = %s, want %s", got, want) + } + + if got, want := r.URL.Path, "/api/v1/player/status"; got != want { + t.Fatalf("path = %s, want %s", got, want) + } + + if err := json.NewDecoder(r.Body).Decode(&received); err != nil { + t.Fatalf("Decode() error = %v", err) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"accepted"}`)) + })) + defer server.Close() + + reporter := New(server.URL, server.Client(), func() time.Time { + return time.Date(2026, 3, 22, 16, 0, 0, 0, time.UTC) + }) + + err := reporter.Send(context.Background(), Snapshot{ + Status: "running", + ScreenID: "info01-dev", + ServerBaseURL: "http://127.0.0.1:8080", + MQTTBroker: "tcp://127.0.0.1:1883", + HeartbeatEverySeconds: 30, + StartedAt: time.Date(2026, 3, 22, 15, 59, 30, 0, time.UTC), + LastHeartbeatAt: time.Date(2026, 3, 22, 15, 59, 55, 0, time.UTC), + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + + if got, want := received.ScreenID, "info01-dev"; got != want { + t.Fatalf("received.ScreenID = %q, want %q", got, want) + } +}