package media import ( "context" "errors" "strings" "testing" "yovision.local/sense/app/sense/adapters/mediamtx" "yovision.local/sense/app/sense/adapters/rtsp" "yovision.local/sense/app/sense/admission" "yovision.local/sense/app/sense/device" "yovision.local/sense/app/sense/identity" ) type fakeProcess struct { state mediamtx.ProcessState startErr error stopped bool } func (p *fakeProcess) Start(context.Context) error { if p.startErr != nil { return p.startErr } p.state.Running = true p.state.PID = 123 return nil } func (p *fakeProcess) Stop(context.Context) error { p.stopped = true p.state.Running = false p.state.PID = 0 return nil } func (p *fakeProcess) State() mediamtx.ProcessState { return p.state } type fakeController struct { applyErr error status mediamtx.PathStatus } type refreshController struct{ status mediamtx.PathStatus } func (f refreshController) Apply(context.Context, mediamtx.Source) error { return nil } func (f refreshController) Status(context.Context, string) (mediamtx.PathStatus, error) { return f.status, nil } func (f fakeController) Apply(context.Context, mediamtx.Source) error { return f.applyErr } func (f fakeController) Status(context.Context, string) (mediamtx.PathStatus, error) { if f.status.Name == "error" { return mediamtx.PathStatus{}, errors.New("timeout") } if !f.status.Ready { return mediamtx.PathStatus{Exists: true, Ready: true, Readers: 2}, nil } f.status.Exists = true return f.status, nil } func TestStoreAndProcessBoundary(t *testing.T) { store := NewMemoryStore() process := &fakeProcess{} service := NewService(store, process, fakeController{}) route := Route{ID: "dev:main", DeviceID: "dev", ProfileToken: "main", Path: "sense_dev_main", Desired: "running", Actual: "pending", Version: 1} if err := store.Save(context.Background(), route); err != nil { t.Fatal(err) } items, err := service.List(context.Background()) if err != nil || len(items) != 1 { t.Fatalf("items=%#v err=%v", items, err) } if process.State().Running { t.Fatal("process started without explicit reconcile") } } func preparedService(t *testing.T, process *fakeProcess, controller fakeController) (*Service, Route) { t.Helper() service := NewService(NewMemoryStore(), process, controller) service.profile = func(string, string) (admission.Profile, error) { return admission.Profile{StreamURI: "rtsp://camera.invalid/main"}, nil } service.credential = func(context.Context, string) (device.Credential, error) { return device.Credential{Username: "fixture", Password: "fixture"}, nil } route, err := service.Configure(context.Background(), identity.Principal{}, "device", "main") if err != nil { t.Fatal(err) } return service, route } func TestReconcileReadyAndStopOwnedProcess(t *testing.T) { process := &fakeProcess{} service, route := preparedService(t, process, fakeController{status: mediamtx.PathStatus{Ready: true, Readers: 2}}) result, err := service.Reconcile(context.Background(), identity.Principal{}, route.ID) if err != nil || result.Actual != "ready" || result.Readers != 2 { t.Fatalf("result=%#v err=%v", result, err) } if _, err := service.Stop(context.Background(), identity.Principal{}, route.ID); err != nil || !process.stopped { t.Fatalf("stop err=%v stopped=%v", err, process.stopped) } } func TestReconcileRecordsFailureStates(t *testing.T) { process := &fakeProcess{startErr: errors.New("restart limit reached")} service, route := preparedService(t, process, fakeController{}) result, err := service.Reconcile(context.Background(), identity.Principal{}, route.ID) if err != nil || result.Actual != "process_failed" { t.Fatalf("result=%#v err=%v", result, err) } process.startErr = nil service.controller = fakeController{applyErr: errors.New("control timeout")} result, err = service.Reconcile(context.Background(), identity.Principal{}, route.ID) if err != nil || result.Actual != "apply_failed" { t.Fatalf("result=%#v err=%v", result, err) } } func TestConfigureProfilesIsIdempotentAndReportsMediaState(t *testing.T) { process := &fakeProcess{startErr: errors.New("binary missing")} service := NewService(NewMemoryStore(), process, fakeController{}) service.profile = func(_ string, token string) (admission.Profile, error) { return admission.Profile{Token: token, StreamURI: "rtsp://camera.invalid/" + token}, nil } service.credential = func(context.Context, string) (device.Credential, error) { return device.Credential{Username: "fixture", Password: "fixture"}, nil } result := admission.Result{DeviceID: "device", Profiles: []admission.Profile{{Token: "main", Verification: rtspReady()}, {Token: "sub", Verification: rtspReady()}}} outcome := service.ConfigureProfiles(context.Background(), identity.Principal{}, result) if outcome.Status != "needs_attention" { t.Fatalf("outcome=%#v", outcome) } outcome = service.ConfigureProfiles(context.Background(), identity.Principal{}, result) items, err := service.List(context.Background()) if err != nil || len(items) != 2 || outcome.Status != "needs_attention" { t.Fatalf("items=%#v outcome=%#v err=%v", items, outcome, err) } for _, item := range items { if item.Actual != "process_failed" || strings.Contains(item.Detail, "binary missing") { t.Fatalf("unsafe route detail=%#v", item) } } process.startErr = nil outcome = service.ConfigureProfiles(context.Background(), identity.Principal{}, result) if outcome.Status != "ready" { t.Fatalf("ready outcome=%#v", outcome) } } func TestRefreshTracksOnDemandReaderWithoutReapplyingRoute(t *testing.T) { store := NewMemoryStore() route := Route{ID: "device:main", DeviceID: "device", ProfileToken: "main", Path: "sense_device_main", Desired: "running", Actual: "waiting", Detail: "等待上游拉流", Version: 2} if err := store.Save(context.Background(), route); err != nil { t.Fatal(err) } service := NewService(store, &fakeProcess{}, refreshController{status: mediamtx.PathStatus{Name: route.Path, Exists: true, Ready: true, Readers: 1}}) result, err := service.Refresh(context.Background(), route.ID) if err != nil || result.Actual != "ready" || result.Readers != 1 || result.Detail != "上游拉流正常" { t.Fatalf("result=%#v err=%v", result, err) } unchanged, err := service.Refresh(context.Background(), route.ID) if err != nil || unchanged.Version != result.Version { t.Fatalf("unchanged=%#v err=%v", unchanged, err) } } func TestRefreshReportsMissingPathInsteadOfWaiting(t *testing.T) { store := NewMemoryStore() route := Route{ID: "device:main", Path: "sense_device_main", Desired: "running", Actual: "waiting", Version: 1} if err := store.Save(context.Background(), route); err != nil { t.Fatal(err) } service := NewService(store, &fakeProcess{}, refreshController{status: mediamtx.PathStatus{Name: route.Path, Exists: false}}) result, err := service.Refresh(context.Background(), route.ID) if err != nil || result.Actual != "apply_failed" || !strings.Contains(result.Detail, "不存在") { t.Fatalf("result=%#v err=%v", result, err) } } func TestRestoreReconcilesDesiredRoutes(t *testing.T) { process := &fakeProcess{} service, route := preparedService(t, process, fakeController{}) if err := service.Restore(context.Background()); err != nil { t.Fatal(err) } restored, err := service.store.Get(context.Background(), route.ID) if err != nil || !process.State().Running || restored.Actual != "ready" { t.Fatalf("restored=%#v process=%#v err=%v", restored, process.State(), err) } } func rtspReady() rtsp.Result { return rtsp.Result{Status: "ready"} }