package biz import ( "context" "encoding/json" "errors" "sync" "testing" "time" ) type applicationTaskRepo struct { mu sync.Mutex deleted bool deleteErr error createdID uint toggleCalls int updateCalls int createdCalls int nameExists bool nameExistsErr error deleteObserved chan struct{} } func (r *applicationTaskRepo) CreateTask(_ context.Context, value *TimedTask) error { r.mu.Lock() defer r.mu.Unlock() r.createdCalls++ if value.ID == 0 { value.ID = r.createdID } return nil } func (r *applicationTaskRepo) UpdateTask(context.Context, *TimedTask) error { r.mu.Lock() defer r.mu.Unlock() r.updateCalls++ return nil } func (r *applicationTaskRepo) DeleteTask(context.Context, uint) error { r.mu.Lock() defer r.mu.Unlock() if r.deleteErr != nil { return r.deleteErr } r.deleted = true if r.deleteObserved != nil { close(r.deleteObserved) } return nil } func (r *applicationTaskRepo) FindTask(context.Context, uint) (*TimedTask, error) { return nil, errors.New("not implemented") } func (r *applicationTaskRepo) ListTasks(context.Context, int, int, *TimedTask) ([]*TimedTask, int64, error) { return nil, 0, nil } func (r *applicationTaskRepo) ToggleTask(context.Context, uint, bool) error { r.mu.Lock() defer r.mu.Unlock() r.toggleCalls++ return nil } func (r *applicationTaskRepo) RecordTaskLog(context.Context, *TimedTaskLog) error { return nil } func (r *applicationTaskRepo) ListTaskLogs(context.Context, int, int, uint, string) ([]*TimedTaskLog, int64, error) { return nil, 0, nil } func (r *applicationTaskRepo) CleanupLogs(context.Context) error { return nil } func (r *applicationTaskRepo) TaskNameExists(context.Context, string, uint) (bool, error) { return r.nameExists, r.nameExistsErr } type applicationTaskRuntime struct { mu sync.Mutex scheduleErr error reloadErr error scheduleCalls int reloadCalls int removeCalls int removeBeforeDelete bool repo *applicationTaskRepo reloadContextActive bool } func (r *applicationTaskRuntime) ScheduleID(context.Context, uint) error { r.mu.Lock() defer r.mu.Unlock() r.scheduleCalls++ return r.scheduleErr } func (r *applicationTaskRuntime) Remove(uint) { r.mu.Lock() defer r.mu.Unlock() r.removeCalls++ if r.repo != nil { r.repo.mu.Lock() r.removeBeforeDelete = !r.repo.deleted r.repo.mu.Unlock() } } func (r *applicationTaskRuntime) TriggerID(context.Context, uint) error { return nil } func (r *applicationTaskRuntime) NextRuns() map[uint]time.Time { return nil } func (r *applicationTaskRuntime) Reload(ctx context.Context) error { r.mu.Lock() defer r.mu.Unlock() r.reloadCalls++ r.reloadContextActive = ctx.Err() == nil return r.reloadErr } func (r *applicationTaskRuntime) Subscribe(uint) chan []byte { return make(chan []byte) } func (r *applicationTaskRuntime) Unsubscribe(uint, chan []byte) {} func validApplicationTask(id uint) *TimedTask { const methodName = "biz-test-application-task" RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { return nil }) return &TimedTask{ID: id, Name: "test", Spec: "0 0 * * *", ExecutorType: TaskExecutorMethod, MethodName: methodName, Enabled: true} } func TestTaskApplicationRepairsRuntimeAfterScheduleFailure(t *testing.T) { repo := &applicationTaskRepo{createdID: 41} runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")} uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) ctx, cancel := context.WithCancel(context.Background()) cancel() if err := uc.Create(ctx, validApplicationTask(0)); err != nil { t.Fatalf("Create() error = %v, want successful reload compensation", err) } if runtime.scheduleCalls != 1 || runtime.reloadCalls != 1 { t.Fatalf("runtime calls = schedule:%d reload:%d, want 1 and 1", runtime.scheduleCalls, runtime.reloadCalls) } if !runtime.reloadContextActive { t.Fatal("reload compensation inherited the canceled request context") } } func TestTaskApplicationReturnsScheduleErrorWhenRepairFails(t *testing.T) { repo := &applicationTaskRepo{createdID: 42} runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed"), reloadErr: errors.New("reload failed")} uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) err := uc.Create(context.Background(), validApplicationTask(0)) if !errors.Is(err, ErrTaskSchedule) { t.Fatalf("Create() error = %v, want ErrTaskSchedule", err) } } func TestTaskApplicationUpdateAndToggleUseRuntimeRepair(t *testing.T) { for _, test := range []struct { name string run func(*TaskApplicationUsecase) error }{ {name: "update", run: func(uc *TaskApplicationUsecase) error { return uc.Update(context.Background(), validApplicationTask(7)) }}, {name: "toggle", run: func(uc *TaskApplicationUsecase) error { return uc.Toggle(context.Background(), 7, true) }}, } { t.Run(test.name, func(t *testing.T) { repo := &applicationTaskRepo{} runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")} uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) if err := test.run(uc); err != nil { t.Fatalf("operation error = %v", err) } if runtime.scheduleCalls != 1 || runtime.reloadCalls != 1 { t.Fatalf("runtime calls = schedule:%d reload:%d, want 1 and 1", runtime.scheduleCalls, runtime.reloadCalls) } }) } } func TestTaskApplicationDeleteRemovesRuntimeAfterDatabase(t *testing.T) { repo := &applicationTaskRepo{} runtime := &applicationTaskRuntime{repo: repo} uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) if err := uc.Delete(context.Background(), 8); err != nil { t.Fatalf("Delete() error = %v", err) } if runtime.removeBeforeDelete { t.Fatal("runtime entry was removed before the database row") } if runtime.removeCalls != 1 { t.Fatalf("Remove() calls = %d, want 1", runtime.removeCalls) } } func TestTaskApplicationDeleteKeepsRuntimeWhenDatabaseFails(t *testing.T) { repo := &applicationTaskRepo{deleteErr: errors.New("delete failed")} runtime := &applicationTaskRuntime{repo: repo} uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) if err := uc.Delete(context.Background(), 8); err == nil { t.Fatal("Delete() error = nil, want database error") } if runtime.removeCalls != 0 { t.Fatalf("Remove() calls = %d, want 0", runtime.removeCalls) } }