package task import ( "context" "database/sql" "testing" "time" _ "github.com/mattn/go-sqlite3" "trankilou.fr/lassistanoque/backend/internal/adapter/database/turso" "trankilou.fr/lassistanoque/backend/internal/domain" ) type fakeChatRunner struct { prompts []string } func (f *fakeChatRunner) NewChat(userID, teamID, agentID, taskID string) (*domain.Chat, error) { return &domain.Chat{ID: "chat-1", TeamID: teamID, UserID: userID, TaskID: taskID}, nil } func (f *fakeChatRunner) AddChatMessage( ctx context.Context, userID, teamID, chatID, agentID, prompt string, params *domain.ChatParams, ) (*domain.Message, error) { f.prompts = append(f.prompts, prompt) return &domain.Message{}, nil } func newTestRepo(t *testing.T) domain.TaskRepository { t.Helper() // cache partagé : l'ordonnanceur tourne dans sa propre goroutine et // doit voir la même base que le test (une :memory: classique est // propre à chaque connexion du pool) db, err := sql.Open("sqlite3", "file::memory:?cache=shared&_busy_timeout=5000") if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) schema := ` create table tasks ( id text not null primary key, team_id text not null, model_id text not null, label text not null, prompt text not null, cron text not null, repeat integer not null default 1, status text not null, next_datetime numeric, last_datetime numeric, _date_created numeric not null default current_timestamp, _date_updated numeric, _version text not null); create table user_teams ( id text not null primary key, user_id text not null, team_id text not null, administrator bool default 0, _date_created numeric not null default current_timestamp, _date_updated numeric, _version text not null); insert into user_teams (id, user_id, team_id, _date_created, _version) values ('ut1','user-1','team-1','2026-01-01 10:00:00','v'); ` if _, err := db.Exec(schema); err != nil { t.Fatal(err) } return turso.NewTursoTaskRepository(db) } func TestCreateTaskValidation(t *testing.T) { svc := NewService(newTestRepo(t)) if _, err := svc.CreateTask("user-1", "team-1", &domain.Task{Label: "l", Prompt: "p", Cron: "invalid"}); err == nil { t.Errorf("invalid cron spec should fail") } if _, err := svc.CreateTask("user-1", "team-1", &domain.Task{Prompt: "p", Cron: "0 8 * * *"}); err == nil { t.Errorf("missing label should fail") } if _, err := svc.CreateTask("user-1", "team-1", &domain.Task{Label: "l", Cron: "0 8 * * *"}); err == nil { t.Errorf("missing prompt should fail") } } func TestCreateTaskRecurring(t *testing.T) { repo := newTestRepo(t) svc := NewService(repo) created, err := svc.CreateTask("user-1", "team-1", &domain.Task{ ModelID: "m1", Label: "Meteo du matin", Prompt: "Donne-moi la meteo", Cron: "0 8 * * *", Repeat: true, }) if err != nil { t.Fatal(err) } if created.Status != domain.TaskStatusActive { t.Errorf("status = %s", created.Status) } if created.NextDatetime == nil || created.NextDatetime.Before(time.Now()) { t.Errorf("next datetime should be in the future, got %v", created.NextDatetime) } tasks, err := svc.ListTasks("user-1", "team-1") if err != nil { t.Fatal(err) } if len(tasks) != 1 || tasks[0].ID != created.ID { t.Fatalf("expected 1 task %s, got %v", created.ID, tasks) } } func TestOneShotExecution(t *testing.T) { repo := newTestRepo(t) svc := NewService(repo) fake := &fakeChatRunner{} svc.SetChatService(fake) _, err := svc.CreateTask("user-1", "team-1", &domain.Task{ Label: "Ponctuel", Prompt: "Fais quelque chose", Cron: "@every 1s", Repeat: false, }) if err != nil { t.Fatal(err) } svc.Start() defer svc.Stop() // attend l'exécution deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if len(fake.prompts) > 0 { break } time.Sleep(100 * time.Millisecond) } if len(fake.prompts) == 0 || fake.prompts[0] != "Fais quelque chose" { t.Fatalf("task did not execute, prompts=%v", fake.prompts) } // statut "done" après une exécution sans répétition // (runTask persiste le statut juste après l'exécution : on l'attend) var done *domain.Task deadline = time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { tasks, _ := repo.ListTasks("user-1", "team-1") done = nil for _, tk := range tasks { done = tk } if done != nil && done.Status == domain.TaskStatusDone { break } time.Sleep(100 * time.Millisecond) } if done == nil || done.Status != domain.TaskStatusDone { t.Fatalf("expected status done, got %+v", done) } // pas de deuxième exécution time.Sleep(2 * time.Second) if len(fake.prompts) != 1 { t.Fatalf("one-shot task ran %d times", len(fake.prompts)) } } func TestUpdateTask(t *testing.T) { repo := newTestRepo(t) svc := NewService(repo) created, err := svc.CreateTask("user-1", "team-1", &domain.Task{ Label: "Ancien", Prompt: "ancien prompt", Cron: "0 8 * * *", Repeat: true, }) if err != nil { t.Fatal(err) } updated, err := svc.UpdateTask("user-1", "team-1", &domain.Task{ ID: created.ID, Label: "Nouveau", Prompt: "nouveau prompt", Cron: "0 6 * * *", Repeat: false, }) if err != nil { t.Fatal(err) } if updated.Label != "Nouveau" || updated.Prompt != "nouveau prompt" || updated.Cron != "0 6 * * *" || updated.Repeat { t.Fatalf("update not applied: %+v", updated) } if updated.NextDatetime == nil || updated.NextDatetime.Hour() != 6 { t.Fatalf("next datetime should be recalculated at 6am, got %v", updated.NextDatetime) } // cron invalide refusé if _, err := svc.UpdateTask("user-1", "team-1", &domain.Task{ ID: created.ID, Label: "x", Prompt: "y", Cron: "pasunecron", Repeat: true, }); err == nil { t.Errorf("invalid cron spec should fail") } // une tâche done est réactivée par une mise à jour done := updated done.Status = domain.TaskStatusDone if _, err := repo.UpdateTask(done); err != nil { t.Fatal(err) } reactivated, err := svc.UpdateTask("user-1", "team-1", &domain.Task{ ID: created.ID, Label: "Reprise", Prompt: "p", Cron: "0 9 * * *", Repeat: true, }) if err != nil { t.Fatal(err) } if reactivated.Status != domain.TaskStatusActive { t.Fatalf("done task should be reactivated, got %s", reactivated.Status) } } func TestDeleteTask(t *testing.T) { repo := newTestRepo(t) svc := NewService(repo) created, err := svc.CreateTask("user-1", "team-1", &domain.Task{ Label: "l", Prompt: "p", Cron: "0 8 * * *", Repeat: true, }) if err != nil { t.Fatal(err) } if err := svc.DeleteTask("user-1", "team-1", created.ID); err != nil { t.Fatal(err) } tasks, _ := svc.ListTasks("user-1", "team-1") if len(tasks) != 0 { t.Fatalf("expected 0 tasks after delete, got %d", len(tasks)) } } // createMissedRecurringTask insère une tâche récurrente dont la dernière // exécution remonte à plus d'une heure, avec une occurrence toutes les 30 // minutes : des exécutions ont été manquées, comme pendant un arrêt. func createMissedRecurringTask(t *testing.T, repo domain.TaskRepository, prompt string) *domain.Task { t.Helper() last := time.Now().Add(-65 * time.Minute) task := &domain.Task{ TeamID: "team-1", ModelID: "m1", Label: "Manquee", Prompt: prompt, Cron: "@every 30m", Repeat: true, Status: domain.TaskStatusActive, LastDatetime: &last, } created, err := repo.CreateTask(task) if err != nil { t.Fatal(err) } return created } // createMissedOneShotTask insère une tâche ponctuelle active dont l'échéance // est passée, comme après un arrêt du serveur. func createMissedOneShotTask(t *testing.T, repo domain.TaskRepository, prompt string) *domain.Task { t.Helper() past := time.Now().Add(-2 * time.Hour) task := &domain.Task{ TeamID: "team-1", ModelID: "m1", Label: "Manquee", Prompt: prompt, Cron: "@every 1h", Repeat: false, Status: domain.TaskStatusActive, NextDatetime: &past, } created, err := repo.CreateTask(task) if err != nil { t.Fatal(err) } return created } // waitPrompts attend que le fake chat runner ait enregistré n prompts. func waitPrompts(t *testing.T, fake *fakeChatRunner, n int) bool { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if len(fake.prompts) >= n { return true } time.Sleep(100 * time.Millisecond) } return false } func TestStartCatchUpMissedRecurringTask(t *testing.T) { repo := newTestRepo(t) svc := NewService(repo) fake := &fakeChatRunner{} svc.SetChatService(fake) // dernière exécution il y a 65 min, occurrence toutes les 30 min : // go-cron (WithPrev + MissedRunOnce) doit rattraper au démarrage createMissedRecurringTask(t, repo, "prompt rattrapage") svc.Start() defer svc.Stop() if !waitPrompts(t, fake, 1) { t.Fatalf("missed task did not execute at startup, prompts=%v", fake.prompts) } if fake.prompts[0] != "prompt rattrapage" { t.Fatalf("unexpected prompt %q", fake.prompts[0]) } // une seule exécution de rattrapage (pas une par occurrence manquée) time.Sleep(1 * time.Second) if len(fake.prompts) != 1 { t.Fatalf("catch-up ran %d times, expected 1", len(fake.prompts)) } } func TestStartCatchUpMissedOneShotTask(t *testing.T) { repo := newTestRepo(t) svc := NewService(repo) fake := &fakeChatRunner{} svc.SetChatService(fake) created := createMissedOneShotTask(t, repo, "ponctuel manque") svc.Start() defer svc.Stop() if !waitPrompts(t, fake, 1) { t.Fatalf("missed one-shot task did not execute at startup, prompts=%v", fake.prompts) } // la tâche passe "done" et n'est pas replanifiée deadline := time.Now().Add(5 * time.Second) var done *domain.Task for time.Now().Before(deadline) { done, _ = svc.GetTask("user-1", "team-1", created.ID) if done != nil && done.Status == domain.TaskStatusDone { break } time.Sleep(100 * time.Millisecond) } if done == nil || done.Status != domain.TaskStatusDone { t.Fatalf("expected status done after catch-up, got %+v", done) } time.Sleep(1 * time.Second) if len(fake.prompts) != 1 { t.Fatalf("one-shot catch-up ran %d times", len(fake.prompts)) } } func TestStartDoesNotCatchUpFutureTask(t *testing.T) { repo := newTestRepo(t) svc := NewService(repo) fake := &fakeChatRunner{} svc.SetChatService(fake) // dernière exécution récente, occurrence dans une heure : // rien à rattraper, pas d'exécution immédiate last := time.Now().Add(-10 * time.Minute) _, err := repo.CreateTask(&domain.Task{ TeamID: "team-1", ModelID: "m1", Label: "Future", Prompt: "trop tot", Cron: "@every 1h", Repeat: true, Status: domain.TaskStatusActive, LastDatetime: &last, }) if err != nil { t.Fatal(err) } svc.Start() defer svc.Stop() time.Sleep(1 * time.Second) if len(fake.prompts) != 0 { t.Fatalf("task with no missed occurrence should not run, prompts=%v", fake.prompts) } } func TestRunTaskPersistsLastAndNextDatetime(t *testing.T) { repo := newTestRepo(t) svc := NewService(repo) fake := &fakeChatRunner{} svc.SetChatService(fake) _, err := svc.CreateTask("user-1", "team-1", &domain.Task{ Label: "Repetitive", Prompt: "encore", Cron: "@every 1s", Repeat: true, }) if err != nil { t.Fatal(err) } svc.Start() defer svc.Stop() if !waitPrompts(t, fake, 1) { t.Fatalf("task did not execute, prompts=%v", fake.prompts) } // après une exécution, la dernière exécution et la prochaine échéance // doivent être persistées (last_datetime nil à la création) deadline := time.Now().Add(5 * time.Second) ok := false for time.Now().Before(deadline) { tasks, _ := repo.ListTasks("user-1", "team-1") if len(tasks) == 1 && tasks[0].LastDatetime != nil && tasks[0].NextDatetime != nil && tasks[0].NextDatetime.After(time.Now()) { ok = true break } time.Sleep(100 * time.Millisecond) } if !ok { t.Fatal("last_datetime/next_datetime should be persisted after each execution") } }