package main import ( "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" ) func TestMediaHealthAndSessionLifecycle(t *testing.T) { store, err := newSessionStore(t.TempDir()) if err != nil { t.Fatalf("newSessionStore: %v", err) } server := newMediaServer(store) healthReq := httptest.NewRequest(http.MethodGet, "/media/health", nil) healthRes := httptest.NewRecorder() server.routes().ServeHTTP(healthRes, healthReq) if healthRes.Code != http.StatusOK { t.Fatalf("expected health 200, got %d", healthRes.Code) } session, err := store.createSession(CreateSessionRequest{ UserID: "1", Title: "Test Session", Format: "webm", MimeType: "video/webm", QualityPreset: "balanced", FacingMode: "environment", DeviceKind: "desktop", }) if err != nil { t.Fatalf("createSession: %v", err) } if _, err := store.updateSession(session.ID, func(current *Session) error { current.Segments = append(current.Segments, SegmentMeta{ Sequence: 0, Filename: "000000.webm", DurationMS: 60000, SizeBytes: 7, ContentType: "video/webm", }) current.Markers = append(current.Markers, Marker{ ID: "marker-1", Type: "manual", Label: "关键片段", Timestamp: 5000, CreatedAt: "2026-03-14T00:00:00Z", }) return nil }); err != nil { t.Fatalf("updateSession: %v", err) } if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil { t.Fatalf("write segment: %v", err) } current, err := store.getSession(session.ID) if err != nil { t.Fatalf("getSession: %v", err) } if current.UploadedSegments != 1 { t.Fatalf("expected uploaded segment count to be recomputed") } if current.UploadedBytes != 7 { t.Fatalf("expected uploaded bytes to be recomputed, got %d", current.UploadedBytes) } } func TestProcessSessionArchivesPlayback(t *testing.T) { tempDir := t.TempDir() store, err := newSessionStore(tempDir) if err != nil { t.Fatalf("newSessionStore: %v", err) } session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Archive Session"}) if err != nil { t.Fatalf("createSession: %v", err) } if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil { t.Fatalf("write segment: %v", err) } if _, err := store.updateSession(session.ID, func(current *Session) error { current.Segments = append(current.Segments, SegmentMeta{ Sequence: 0, Filename: "000000.webm", DurationMS: 60000, SizeBytes: 7, ContentType: "video/webm", }) current.ArchiveStatus = ArchiveQueued return nil }); err != nil { t.Fatalf("updateSession: %v", err) } fakeFFmpeg := filepath.Join(tempDir, "ffmpeg") script := "#!/bin/sh\ninput=''\noutput=''\nprev=''\nfor arg in \"$@\"; do\n if [ \"$prev\" = '-i' ]; then input=\"$arg\"; fi\n prev=\"$arg\"\n output=\"$arg\"\ndone\nif [ -n \"$input\" ] && [ -f \"$input\" ]; then cp \"$input\" \"$output\"; else : > \"$output\"; fi\n" if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil { t.Fatalf("write fake ffmpeg: %v", err) } originalPath := os.Getenv("PATH") t.Setenv("PATH", tempDir+string(os.PathListSeparator)+originalPath) if err := processSession(store, session.ID); err != nil { t.Fatalf("processSession: %v", err) } archived, err := store.getSession(session.ID) if err != nil { t.Fatalf("getSession: %v", err) } if archived.ArchiveStatus != ArchiveCompleted { t.Fatalf("expected archive completed, got %s", archived.ArchiveStatus) } if archived.Playback.WebMURL == "" || !strings.HasSuffix(archived.Playback.WebMURL, ".webm") { t.Fatalf("expected webm playback url, got %#v", archived.Playback) } } func TestRefreshFromDiskPicksUpSessionsCreatedAfterWorkerStartup(t *testing.T) { tempDir := t.TempDir() workerStore, err := newSessionStore(tempDir) if err != nil { t.Fatalf("newSessionStore(worker): %v", err) } if got := len(workerStore.listProcessableSessions()); got != 0 { t.Fatalf("expected no processable sessions at startup, got %d", got) } appStore, err := newSessionStore(tempDir) if err != nil { t.Fatalf("newSessionStore(app): %v", err) } session, err := appStore.createSession(CreateSessionRequest{UserID: "1", Title: "Queued Session"}) if err != nil { t.Fatalf("createSession: %v", err) } if err := os.WriteFile(filepath.Join(appStore.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil { t.Fatalf("write segment: %v", err) } if _, err := appStore.updateSession(session.ID, func(current *Session) error { current.Segments = append(current.Segments, SegmentMeta{ Sequence: 0, Filename: "000000.webm", DurationMS: 60000, SizeBytes: 7, ContentType: "video/webm", }) current.ArchiveStatus = ArchiveQueued current.Status = StatusFinalizing return nil }); err != nil { t.Fatalf("updateSession: %v", err) } if err := workerStore.refreshFromDisk(); err != nil { t.Fatalf("refreshFromDisk: %v", err) } processable := workerStore.listProcessableSessions() if len(processable) != 1 { t.Fatalf("expected worker to pick up queued session after refresh, got %d", len(processable)) } if processable[0].ID != session.ID { t.Fatalf("expected session %s, got %s", session.ID, processable[0].ID) } } func TestHandleSessionGetRefreshesSessionStateFromDisk(t *testing.T) { tempDir := t.TempDir() serverStore, err := newSessionStore(tempDir) if err != nil { t.Fatalf("newSessionStore(server): %v", err) } server := newMediaServer(serverStore) writerStore, err := newSessionStore(tempDir) if err != nil { t.Fatalf("newSessionStore(writer): %v", err) } session, err := writerStore.createSession(CreateSessionRequest{UserID: "1", Title: "Fresh Session"}) if err != nil { t.Fatalf("createSession: %v", err) } if _, err := writerStore.updateSession(session.ID, func(current *Session) error { current.Status = StatusFinalizing current.ArchiveStatus = ArchiveQueued return nil }); err != nil { t.Fatalf("queue session: %v", err) } getReq := httptest.NewRequest(http.MethodGet, "/media/sessions/"+session.ID, nil) getRes := httptest.NewRecorder() server.routes().ServeHTTP(getRes, getReq) if getRes.Code != http.StatusOK { t.Fatalf("expected get session 200, got %d", getRes.Code) } var queuedResponse struct { Session Session `json:"session"` } if err := json.NewDecoder(getRes.Body).Decode(&queuedResponse); err != nil { t.Fatalf("decode queued response: %v", err) } if queuedResponse.Session.ArchiveStatus != ArchiveQueued { t.Fatalf("expected queued archive status, got %s", queuedResponse.Session.ArchiveStatus) } if _, err := writerStore.updateSession(session.ID, func(current *Session) error { current.Status = StatusArchived current.ArchiveStatus = ArchiveCompleted current.Playback = PlaybackInfo{ WebMURL: "/media/assets/sessions/" + session.ID + "/recording.webm", Ready: true, } return nil }); err != nil { t.Fatalf("complete session: %v", err) } refreshReq := httptest.NewRequest(http.MethodGet, "/media/sessions/"+session.ID, nil) refreshRes := httptest.NewRecorder() server.routes().ServeHTTP(refreshRes, refreshReq) if refreshRes.Code != http.StatusOK { t.Fatalf("expected refreshed get session 200, got %d", refreshRes.Code) } var completedResponse struct { Session Session `json:"session"` } if err := json.NewDecoder(refreshRes.Body).Decode(&completedResponse); err != nil { t.Fatalf("decode completed response: %v", err) } if completedResponse.Session.ArchiveStatus != ArchiveCompleted { t.Fatalf("expected completed archive status, got %s", completedResponse.Session.ArchiveStatus) } if !completedResponse.Session.Playback.Ready { t.Fatalf("expected playback ready after refresh") } } func TestViewerSignalReturnsConflictBeforePublisherTrackReady(t *testing.T) { store, err := newSessionStore(t.TempDir()) if err != nil { t.Fatalf("newSessionStore: %v", err) } server := newMediaServer(store) session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Viewer Pending"}) if err != nil { t.Fatalf("createSession: %v", err) } req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/viewer-signal", strings.NewReader(`{"type":"offer","sdp":"mock-offer"}`)) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() server.routes().ServeHTTP(res, req) if res.Code != http.StatusConflict { t.Fatalf("expected viewer-signal 409 before video track is ready, got %d", res.Code) } } func TestLiveFrameUploadPublishesRelayFrame(t *testing.T) { store, err := newSessionStore(t.TempDir()) if err != nil { t.Fatalf("newSessionStore: %v", err) } server := newMediaServer(store) session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay Session"}) if err != nil { t.Fatalf("createSession: %v", err) } req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/live-frame", strings.NewReader("jpeg-frame")) req.Header.Set("Content-Type", "image/jpeg") res := httptest.NewRecorder() server.routes().ServeHTTP(res, req) if res.Code != http.StatusAccepted { t.Fatalf("expected live-frame upload 202, got %d", res.Code) } current, err := store.getSession(session.ID) if err != nil { t.Fatalf("getSession: %v", err) } if current.LiveFrameURL == "" || current.LiveFrameUpdated == "" { t.Fatalf("expected live frame metadata to be recorded, got %#v", current) } if !current.StreamConnected { t.Fatalf("expected session stream connected after frame upload") } framePath := store.liveFramePath(session.ID) body, err := os.ReadFile(framePath) if err != nil { t.Fatalf("read live frame: %v", err) } if string(body) != "jpeg-frame" { t.Fatalf("unexpected live frame content: %q", string(body)) } } func TestRelaySegmentUploadKeepsOnlyLatestMinute(t *testing.T) { store, err := newSessionStore(t.TempDir()) if err != nil { t.Fatalf("newSessionStore: %v", err) } server := newMediaServer(store) session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay Buffer", Purpose: "relay"}) if err != nil { t.Fatalf("createSession: %v", err) } for sequence := 0; sequence < 3; sequence += 1 { req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/segments?sequence="+strconv.Itoa(sequence)+"&durationMs=30000", strings.NewReader("segment")) req.Header.Set("Content-Type", "video/webm") res := httptest.NewRecorder() server.routes().ServeHTTP(res, req) if res.Code != http.StatusAccepted { t.Fatalf("expected segment upload 202 for sequence %d, got %d", sequence, res.Code) } } current, err := store.getSession(session.ID) if err != nil { t.Fatalf("getSession: %v", err) } if current.Purpose != PurposeRelay { t.Fatalf("expected relay purpose, got %s", current.Purpose) } if len(current.Segments) != 2 { t.Fatalf("expected latest 2 relay segments to remain, got %d", len(current.Segments)) } if current.Segments[0].Sequence != 1 || current.Segments[1].Sequence != 2 { t.Fatalf("expected relay segments 1 and 2 to remain, got %#v", current.Segments) } if _, err := os.Stat(filepath.Join(store.segmentsDir(session.ID), "000000.webm")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected earliest relay segment to be pruned from disk, got %v", err) } } func TestProcessRelayPreviewPublishesBufferedWebM(t *testing.T) { tempDir := t.TempDir() store, err := newSessionStore(tempDir) if err != nil { t.Fatalf("newSessionStore: %v", err) } session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay Preview", Purpose: "relay"}) if err != nil { t.Fatalf("createSession: %v", err) } if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil { t.Fatalf("write segment: %v", err) } if _, err := store.updateSession(session.ID, func(current *Session) error { current.Segments = append(current.Segments, SegmentMeta{ Sequence: 0, Filename: "000000.webm", DurationMS: 60000, SizeBytes: 7, ContentType: "video/webm", }) current.Purpose = PurposeRelay return nil }); err != nil { t.Fatalf("updateSession: %v", err) } if err := processRollingPreview(store, session.ID); err != nil { t.Fatalf("processRollingPreview: %v", err) } current, err := store.getSession(session.ID) if err != nil { t.Fatalf("getSession: %v", err) } if current.Playback.PreviewURL == "" || !strings.HasSuffix(current.Playback.PreviewURL, "/preview.webm") { t.Fatalf("expected relay preview webm url, got %#v", current.Playback) } if current.Playback.MP4URL != "" { t.Fatalf("expected relay preview to skip mp4 generation, got %#v", current.Playback) } } func TestPruneExpiredRelaySessionsRemovesOldCache(t *testing.T) { store, err := newSessionStore(t.TempDir()) if err != nil { t.Fatalf("newSessionStore: %v", err) } session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Old Relay", Purpose: "relay"}) if err != nil { t.Fatalf("createSession: %v", err) } if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil { t.Fatalf("write segment: %v", err) } if err := os.MkdirAll(store.publicDir(session.ID), 0o755); err != nil { t.Fatalf("mkdir public dir: %v", err) } if err := os.WriteFile(filepath.Join(store.publicDir(session.ID), "preview.webm"), []byte("preview"), 0o644); err != nil { t.Fatalf("write preview: %v", err) } store.mu.Lock() store.sessions[session.ID].Purpose = PurposeRelay store.sessions[session.ID].UpdatedAt = time.Now().UTC().Add(-31 * time.Minute).Format(time.RFC3339) store.mu.Unlock() if err := store.pruneExpiredRelaySessions(relayCacheTTL, time.Now().UTC()); err != nil { t.Fatalf("pruneExpiredRelaySessions: %v", err) } if _, err := store.getSession(session.ID); err == nil { t.Fatalf("expected relay session to be removed from store") } if _, err := os.Stat(store.sessionDir(session.ID)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected relay session directory to be removed, got %v", err) } if _, err := os.Stat(store.publicDir(session.ID)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected relay public directory to be removed, got %v", err) } }