Files
yovision/Sense/server/app/sense/media/service_test.go
T

197 lines
7.4 KiB
Go

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"} }