package postgres import ( "database/sql" "errors" "os" "sync" "testing" "time" "gardomatic.kleiax.de/internal/storage" ) func TestGeneratedTaskInsertIsIdempotentUnderConcurrency(t *testing.T) { dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") if dsn == "" { t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") } db, err := sql.Open("postgres", dsn) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) if err := db.Ping(); err != nil { t.Fatal(err) } var userID, gardenID, speciesID, plantID, templateID int if err := db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Generator integration', $1, 'hash', true) RETURNING id`, "generator-"+time.Now().Format("150405.000000000")+"@example.com").Scan(&userID); err != nil { t.Fatal(err) } if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Generator integration') RETURNING id`).Scan(&gardenID); err != nil { t.Fatal(err) } t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID) _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }) if err := db.QueryRow(`INSERT INTO species (garden_id, common_name) VALUES ($1, 'Parallelrose') RETURNING id`, gardenID).Scan(&speciesID); err != nil { t.Fatal(err) } if err := db.QueryRow(`INSERT INTO plants (garden_id, species_id, name) VALUES ($1, $2, 'Rose') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil { t.Fatal(err) } if err := db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, month_to) VALUES ($1, 'Schneiden', 'month_of_year', 2, 3) RETURNING id`, speciesID).Scan(&templateID); err != nil { t.Fatal(err) } generatedFor := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) dueStart, dueEnd := generatedFor, time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC) model := TaskModel{DB: db} const workers = 12 results := make(chan error, workers) var wg sync.WaitGroup for range workers { wg.Add(1) go func() { defer wg.Done() _, err := model.Insert(storage.Task{GardenID: gardenID, PlantID: &plantID, TemplateID: &templateID, Title: "Schneiden", DueAtStart: &dueStart, DueAtEnd: &dueEnd, GeneratedFor: &generatedFor, CreatedBy: userID}) results <- err }() } wg.Wait() close(results) successes, conflicts := 0, 0 for err := range results { if err == nil { successes++ } else if errors.Is(err, storage.ErrConflict) { conflicts++ } else { t.Fatalf("insert error: %v", err) } } if successes != 1 || conflicts != workers-1 { t.Fatalf("successes=%d conflicts=%d", successes, conflicts) } var count int if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE plant_id = $1 AND template_id = $2 AND generated_for = $3`, plantID, templateID, generatedFor).Scan(&count); err != nil || count != 1 { t.Fatalf("count=%d err=%v", count, err) } }