package app import ( "testing" "time" "github.com/google/uuid" "google.golang.org/grpc/codes" "gorm.io/gorm" "stream.api/internal/database/model" appv1 "stream.api/internal/gen/proto/app/v1" "stream.api/internal/video" runtimeservices "stream.api/internal/video/runtime/services" ) func TestListAdminJobsCursorPagination(t *testing.T) { db := newTestDB(t) ensureTestJobsTable(t, db) services := newTestAppServices(t, db) services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil)) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) baseTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC) seedTestJob(t, db, model.Job{ID: "job-300", CreatedAt: ptrTime(baseTime.Add(time.Minute)), UpdatedAt: ptrTime(baseTime.Add(time.Minute))}) seedTestJob(t, db, model.Job{ID: "job-200", CreatedAt: ptrTime(baseTime), UpdatedAt: ptrTime(baseTime)}) seedTestJob(t, db, model.Job{ID: "job-100", CreatedAt: ptrTime(baseTime), UpdatedAt: ptrTime(baseTime)}) conn, cleanup := newTestGRPCServer(t, services) defer cleanup() client := newAdminClient(conn) resp, err := client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{PageSize: 2}) if err != nil { t.Fatalf("ListAdminJobs(first page) error = %v", err) } assertAdminJobIDs(t, resp.GetJobs(), []string{"job-300", "job-200"}) if !resp.GetHasMore() { t.Fatal("ListAdminJobs(first page) has_more = false, want true") } if resp.GetNextCursor() == "" { t.Fatal("ListAdminJobs(first page) next_cursor is empty") } if resp.GetPageSize() != 2 { t.Fatalf("ListAdminJobs(first page) page_size = %d, want 2", resp.GetPageSize()) } nextCursor := resp.GetNextCursor() resp, err = client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{ Cursor: ptrString(nextCursor), PageSize: 2, }) if err != nil { t.Fatalf("ListAdminJobs(second page) error = %v", err) } assertAdminJobIDs(t, resp.GetJobs(), []string{"job-100"}) if resp.GetHasMore() { t.Fatal("ListAdminJobs(second page) has_more = true, want false") } if resp.GetNextCursor() != "" { t.Fatalf("ListAdminJobs(second page) next_cursor = %q, want empty", resp.GetNextCursor()) } } func TestListAdminJobsInvalidCursor(t *testing.T) { db := newTestDB(t) ensureTestJobsTable(t, db) services := newTestAppServices(t, db) services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil)) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) conn, cleanup := newTestGRPCServer(t, services) defer cleanup() client := newAdminClient(conn) _, err := client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{ Cursor: ptrString("not-a-valid-cursor"), PageSize: 1, }) assertGRPCCode(t, err, codes.InvalidArgument) } func TestListAdminJobsCursorRejectsAgentMismatch(t *testing.T) { db := newTestDB(t) ensureTestJobsTable(t, db) services := newTestAppServices(t, db) services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil)) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) baseTime := time.Date(2026, 3, 22, 11, 0, 0, 0, time.UTC) agentOne := int64(101) agentTwo := int64(202) seedTestJob(t, db, model.Job{ID: "job-b", AgentID: &agentOne, CreatedAt: ptrTime(baseTime.Add(time.Minute)), UpdatedAt: ptrTime(baseTime.Add(time.Minute))}) seedTestJob(t, db, model.Job{ID: "job-a", AgentID: &agentOne, CreatedAt: ptrTime(baseTime), UpdatedAt: ptrTime(baseTime)}) seedTestJob(t, db, model.Job{ID: "job-x", AgentID: &agentTwo, CreatedAt: ptrTime(baseTime.Add(2 * time.Minute)), UpdatedAt: ptrTime(baseTime.Add(2 * time.Minute))}) conn, cleanup := newTestGRPCServer(t, services) defer cleanup() client := newAdminClient(conn) resp, err := client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{ AgentId: ptrString("101"), PageSize: 1, }) if err != nil { t.Fatalf("ListAdminJobs(filtered first page) error = %v", err) } if resp.GetNextCursor() == "" { t.Fatal("ListAdminJobs(filtered first page) next_cursor is empty") } _, err = client.ListAdminJobs(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminJobsRequest{ AgentId: ptrString("202"), Cursor: ptrString(resp.GetNextCursor()), PageSize: 1, }) assertGRPCCode(t, err, codes.InvalidArgument) } func ensureTestJobsTable(t *testing.T, db *gorm.DB) { t.Helper() stmt := `CREATE TABLE jobs ( id TEXT PRIMARY KEY, status TEXT, priority INTEGER, input_url TEXT, output_url TEXT, total_duration INTEGER, current_time INTEGER, progress REAL, agent_id INTEGER, logs TEXT, config TEXT, cancelled BOOLEAN NOT NULL DEFAULT 0, retry_count INTEGER NOT NULL DEFAULT 0, max_retries INTEGER NOT NULL DEFAULT 3, created_at DATETIME NOT NULL, updated_at DATETIME, version INTEGER NOT NULL DEFAULT 1 )` if err := db.Exec(stmt).Error; err != nil { t.Fatalf("create jobs table: %v", err) } } func seedTestJob(t *testing.T, db *gorm.DB, job model.Job) model.Job { t.Helper() if job.Status == nil { job.Status = ptrString("pending") } if job.Priority == nil { job.Priority = ptrInt64(0) } if job.Config == nil { job.Config = ptrString(`{"name":"` + job.ID + `"}`) } if job.Cancelled == nil { job.Cancelled = ptrBool(false) } if job.RetryCount == nil { job.RetryCount = ptrInt64(0) } if job.MaxRetries == nil { job.MaxRetries = ptrInt64(3) } if job.CreatedAt == nil { job.CreatedAt = ptrTime(time.Now().UTC()) } if job.UpdatedAt == nil { job.UpdatedAt = ptrTime(job.CreatedAt.UTC()) } if job.Version == nil { job.Version = ptrInt64(1) } if err := db.Create(&job).Error; err != nil { t.Fatalf("create job %s: %v", job.ID, err) } return job } func assertAdminJobIDs(t *testing.T, jobs []*appv1.AdminJob, want []string) { t.Helper() if len(jobs) != len(want) { t.Fatalf("job count = %d, want %d", len(jobs), len(want)) } for i, job := range jobs { if job.GetId() != want[i] { t.Fatalf("job[%d].id = %q, want %q", i, job.GetId(), want[i]) } } } func ptrTime(v time.Time) *time.Time { return &v }