790 lines
25 KiB
Go
790 lines
25 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
tablomodel "xtablo-backend/internal/tablos"
|
|
taskmodel "xtablo-backend/internal/tasks"
|
|
"xtablo-backend/internal/web/views"
|
|
)
|
|
|
|
func TestGetTasksPageRendersEtapesAndSansEtapeSections(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, userID)
|
|
etape := mustCreateEtape(t, repo, userID, tablo.ID, "Production")
|
|
_ = mustCreateTask(t, repo, userID, tablo.ID, &etape.ID, "Cut footage")
|
|
_ = mustCreateTask(t, repo, userID, tablo.ID, nil, "Inbox task")
|
|
|
|
pageReq := httptest.NewRequest(http.MethodGet, "/tasks", nil)
|
|
pageReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.GetTasksPage().ServeHTTP(rec, pageReq)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
for _, want := range []string{"Mes Tâches", "Production", "Sans étape", "Inbox task", "Cut footage"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("expected body to contain %q, got %q", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetTasksPageDefaultsToKanbanView(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, userID)
|
|
_ = mustCreateTask(t, repo, userID, tablo.ID, nil, "Shell task")
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/tasks", nil)
|
|
req.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.GetTasksPage().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`data-current-view="kanban"`,
|
|
`Nouvelle tâche`,
|
|
`Tableau`,
|
|
`Liste`,
|
|
`Roadmap`,
|
|
`Calendrier`,
|
|
`Bientôt`,
|
|
`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4`,
|
|
`draggable="true"`,
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"`,
|
|
`/tasks/`,
|
|
`Supprimer la tâche Shell task`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("expected body to contain %q, got %q", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetTasksPageIgnoresInvalidQueryState(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/tasks?view=nope&roadmap_mode=nope&status=bad", nil)
|
|
req.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.GetTasksPage().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `data-current-view="kanban"`) {
|
|
t.Fatalf("expected normalized kanban view, got %q", body)
|
|
}
|
|
if strings.Contains(body, `value="bad" selected`) {
|
|
t.Fatalf("expected invalid status filter to be dropped, got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestTasksListGroupsByStatusAcrossTablos(t *testing.T) {
|
|
now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
|
|
tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha", Color: "#111111"}
|
|
tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta", Color: "#222222"}
|
|
|
|
records := []taskmodel.Record{
|
|
{ID: uuid.New(), TabloID: tabloA.ID, Title: "Write copy", Status: taskmodel.StatusTodo, CreatedAt: now},
|
|
{ID: uuid.New(), TabloID: tabloB.ID, Title: "Ship docs", Status: taskmodel.StatusTodo, CreatedAt: now.Add(time.Minute)},
|
|
{ID: uuid.New(), TabloID: tabloB.ID, Title: "Review launch", Status: taskmodel.StatusInReview, CreatedAt: now.Add(2 * time.Minute)},
|
|
}
|
|
|
|
vm := views.NewTasksPageViewModel(
|
|
[]tablomodel.Record{tabloA, tabloB},
|
|
records,
|
|
nil,
|
|
taskmodel.TaskPageState{View: taskmodel.TaskViewList, RoadmapMode: taskmodel.TaskRoadmapModeWeek},
|
|
now,
|
|
)
|
|
|
|
if len(vm.List.Groups) != 4 {
|
|
t.Fatalf("expected four status groups, got %d", len(vm.List.Groups))
|
|
}
|
|
if got := len(vm.List.Groups[0].Tasks); got != 2 {
|
|
t.Fatalf("expected two todo tasks across tablos, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestTasksRoadmapCreatesPerTabloSansEtapeLanes(t *testing.T) {
|
|
now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
|
|
tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha"}
|
|
tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta"}
|
|
due := now.AddDate(0, 0, 3)
|
|
|
|
records := []taskmodel.Record{
|
|
{ID: uuid.New(), TabloID: tabloA.ID, Title: "A task", Status: taskmodel.StatusTodo, DueDate: &due, CreatedAt: now},
|
|
{ID: uuid.New(), TabloID: tabloB.ID, Title: "B task", Status: taskmodel.StatusTodo, DueDate: &due, CreatedAt: now},
|
|
}
|
|
|
|
vm := views.NewTasksPageViewModel(
|
|
[]tablomodel.Record{tabloA, tabloB},
|
|
records,
|
|
nil,
|
|
taskmodel.TaskPageState{View: taskmodel.TaskViewRoadmap, RoadmapMode: taskmodel.TaskRoadmapModeWeek},
|
|
now,
|
|
)
|
|
|
|
if len(vm.Roadmap.Lanes) != 2 {
|
|
t.Fatalf("expected one Sans étape lane per tablo, got %d", len(vm.Roadmap.Lanes))
|
|
}
|
|
}
|
|
|
|
func TestPostTasksCreatesEtape(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
tablo := mustCreateOwnedTablo(t, repo, userID)
|
|
|
|
form := url.Values{}
|
|
form.Set("tablo_id", tablo.ID.String())
|
|
form.Set("title", "Launch")
|
|
form.Set("status", string(taskmodel.StatusTodo))
|
|
form.Set("is_etape", "true")
|
|
|
|
postReq := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
postReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.PostTasks().ServeHTTP(rec, postReq)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
tasks, err := repo.ListTasksByOwner(context.Background(), userID)
|
|
if err != nil {
|
|
t.Fatalf("list tasks: %v", err)
|
|
}
|
|
|
|
var created *TaskRecord
|
|
for i := range tasks {
|
|
if tasks[i].Title == "Launch" {
|
|
created = &tasks[i]
|
|
break
|
|
}
|
|
}
|
|
if created == nil {
|
|
t.Fatalf("expected created etape to be persisted")
|
|
}
|
|
if !created.IsEtape {
|
|
t.Fatalf("expected created record to be an etape, got %#v", created)
|
|
}
|
|
if created.ParentTaskID != nil {
|
|
t.Fatalf("expected created etape to have no parent, got %#v", created.ParentTaskID)
|
|
}
|
|
}
|
|
|
|
func TestPostTasksRejectsNonEtapeParent(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
tablo := mustCreateOwnedTablo(t, repo, userID)
|
|
parentTask := mustCreateTask(t, repo, userID, tablo.ID, nil, "Regular task")
|
|
|
|
form := url.Values{}
|
|
form.Set("tablo_id", tablo.ID.String())
|
|
form.Set("title", "Should fail")
|
|
form.Set("status", string(taskmodel.StatusTodo))
|
|
form.Set("parent_task_id", parentTask.ID.String())
|
|
|
|
postReq := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
postReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.PostTasks().ServeHTTP(rec, postReq)
|
|
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("expected status 422, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetEditTaskModalRendersCurrentValues(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
assigneeID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
|
|
Email: "modal-assignee@xtablo.com",
|
|
EncryptedPassword: "hash",
|
|
DisplayName: "modal assignee",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create assignee: %v", err)
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, userID)
|
|
task := mustCreateTaskWithAssignee(t, repo, userID, tablo.ID, nil, "Editable", &assigneeID)
|
|
|
|
editReq := httptest.NewRequest(http.MethodGet, "/tasks/"+task.ID.String()+"/edit", nil)
|
|
editReq.SetPathValue("taskID", task.ID.String())
|
|
editReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.GetEditTaskModal().ServeHTTP(rec, editReq)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
for _, want := range []string{"Editable", string(task.Status), assigneeID.String()} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("expected edit modal to contain %q, got %q", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPatchTaskUpdatesEditableFields(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
assigneeID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
|
|
Email: "patch-assignee@xtablo.com",
|
|
EncryptedPassword: "hash",
|
|
DisplayName: "patch assignee",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create assignee: %v", err)
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, userID)
|
|
etape := mustCreateEtape(t, repo, userID, tablo.ID, "Editing")
|
|
task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Old title")
|
|
|
|
form := url.Values{}
|
|
form.Set("tablo_id", tablo.ID.String())
|
|
form.Set("title", "New title")
|
|
form.Set("description", "New description")
|
|
form.Set("status", string(taskmodel.StatusInReview))
|
|
form.Set("due_date", "2026-05-20")
|
|
form.Set("assignee_id", assigneeID.String())
|
|
form.Set("parent_task_id", etape.ID.String())
|
|
|
|
patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String(), strings.NewReader(form.Encode()))
|
|
patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
patchReq.SetPathValue("taskID", task.ID.String())
|
|
patchReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.PatchTask().ServeHTTP(rec, patchReq)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
updated, err := repo.GetTaskByID(context.Background(), task.ID, userID)
|
|
if err != nil {
|
|
t.Fatalf("get updated task: %v", err)
|
|
}
|
|
if updated.Title != "New title" || updated.Description != "New description" {
|
|
t.Fatalf("expected title/description to update, got %#v", updated)
|
|
}
|
|
if updated.Status != taskmodel.StatusInReview {
|
|
t.Fatalf("expected status to update, got %q", updated.Status)
|
|
}
|
|
if updated.AssigneeID == nil || *updated.AssigneeID != assigneeID {
|
|
t.Fatalf("expected assignee to update, got %#v", updated.AssigneeID)
|
|
}
|
|
if updated.ParentTaskID == nil || *updated.ParentTaskID != etape.ID {
|
|
t.Fatalf("expected parent etape to update, got %#v", updated.ParentTaskID)
|
|
}
|
|
if updated.DueDate == nil || updated.DueDate.Format("2006-01-02") != "2026-05-20" {
|
|
t.Fatalf("expected due date to update, got %#v", updated.DueDate)
|
|
}
|
|
}
|
|
|
|
func TestPatchTaskRejectsParentEtapeFromAnotherTablo(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
tabloA := mustCreateOwnedTablo(t, repo, userID)
|
|
tabloB := mustCreateOwnedTablo(t, repo, userID)
|
|
task := mustCreateTask(t, repo, userID, tabloA.ID, nil, "Alpha task")
|
|
foreignEtape := mustCreateEtape(t, repo, userID, tabloB.ID, "Beta stage")
|
|
|
|
form := url.Values{}
|
|
form.Set("tablo_id", tabloA.ID.String())
|
|
form.Set("title", task.Title)
|
|
form.Set("status", string(taskmodel.StatusTodo))
|
|
form.Set("parent_task_id", foreignEtape.ID.String())
|
|
|
|
patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String(), strings.NewReader(form.Encode()))
|
|
patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
patchReq.SetPathValue("taskID", task.ID.String())
|
|
patchReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.PatchTask().ServeHTTP(rec, patchReq)
|
|
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("expected 422 for cross-tablo parent, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetEditTaskModalShowsOnlyEtapesFromSelectedTablo(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
tabloA := mustCreateOwnedTablo(t, repo, userID)
|
|
tabloB := mustCreateOwnedTablo(t, repo, userID)
|
|
task := mustCreateTask(t, repo, userID, tabloA.ID, nil, "Editable")
|
|
etapeA := mustCreateEtape(t, repo, userID, tabloA.ID, "Stage A")
|
|
_ = mustCreateEtape(t, repo, userID, tabloB.ID, "Stage B")
|
|
|
|
editReq := httptest.NewRequest(http.MethodGet, "/tasks/"+task.ID.String()+"/edit", nil)
|
|
editReq.SetPathValue("taskID", task.ID.String())
|
|
editReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.GetEditTaskModal().ServeHTTP(rec, editReq)
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, etapeA.ID.String()) {
|
|
t.Fatalf("expected same-tablo etape in edit form, got %q", body)
|
|
}
|
|
if strings.Contains(body, "Stage B") {
|
|
t.Fatalf("did not expect foreign tablo etape in edit form, got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestDeleteTaskSoftDeletesRegularTask(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, userID)
|
|
task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Delete me")
|
|
|
|
deleteReq := httptest.NewRequest(http.MethodDelete, "/tasks/"+task.ID.String(), nil)
|
|
deleteReq.SetPathValue("taskID", task.ID.String())
|
|
deleteReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.DeleteTask().ServeHTTP(rec, deleteReq)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
if _, err := repo.GetTaskByID(context.Background(), task.ID, userID); err == nil {
|
|
t.Fatal("expected deleted task to become unavailable")
|
|
}
|
|
}
|
|
|
|
func TestGetTasksPageRendersListViewAcrossTablos(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
tabloA := mustCreateOwnedTablo(t, repo, userID)
|
|
tabloB := mustCreateOwnedTablo(t, repo, userID)
|
|
_ = mustCreateTask(t, repo, userID, tabloA.ID, nil, "Alpha task")
|
|
_ = mustCreateTask(t, repo, userID, tabloB.ID, nil, "Beta task")
|
|
|
|
pageReq := httptest.NewRequest(http.MethodGet, "/tasks?view=list", nil)
|
|
pageReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.GetTasksPage().ServeHTTP(rec, pageReq)
|
|
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`data-current-view="list"`,
|
|
`Alpha task`,
|
|
`Beta task`,
|
|
`Liste`,
|
|
`À faire`,
|
|
`data-task-list`,
|
|
`data-status-group="todo"`,
|
|
`Tablo`,
|
|
`Assignée`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("expected body to contain %q, got %q", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetTasksPageRendersRoadmapWeekMode(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, userID)
|
|
etape := mustCreateEtape(t, repo, userID, tablo.ID, "Préparation")
|
|
task := mustCreateTask(t, repo, userID, tablo.ID, &etape.ID, "Planifier")
|
|
due := time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC)
|
|
_, err := repo.UpdateTask(context.Background(), UpdateTaskInput{
|
|
ID: task.ID,
|
|
OwnerID: userID,
|
|
TabloID: tablo.ID,
|
|
Title: task.Title,
|
|
Description: task.Description,
|
|
Status: task.Status,
|
|
DueDate: &due,
|
|
ParentTaskID: &etape.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update task due date: %v", err)
|
|
}
|
|
|
|
pageReq := httptest.NewRequest(http.MethodGet, "/tasks?view=roadmap&roadmap_mode=week", nil)
|
|
pageReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.GetTasksPage().ServeHTTP(rec, pageReq)
|
|
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`data-current-view="roadmap"`,
|
|
`Roadmap`,
|
|
`Préparation`,
|
|
`Planifier`,
|
|
`Semaine`,
|
|
`Mois`,
|
|
`data-task-roadmap`,
|
|
`data-roadmap-lane=`,
|
|
`data-roadmap-bucket=`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("expected body to contain %q, got %q", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPatchTaskRendersCurrentTaskQueryState(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
handler := NewAuthHandler(repo)
|
|
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie)
|
|
userID, ok := handler.currentUserID(req.Context(), req)
|
|
if !ok {
|
|
t.Fatal("expected authenticated user")
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, userID)
|
|
task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Stateful")
|
|
|
|
form := url.Values{}
|
|
form.Set("tablo_id", tablo.ID.String())
|
|
form.Set("title", "Stateful")
|
|
form.Set("status", string(taskmodel.StatusDone))
|
|
|
|
patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String()+"?view=list&status=done", strings.NewReader(form.Encode()))
|
|
patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
patchReq.SetPathValue("taskID", task.ID.String())
|
|
patchReq.AddCookie(sessionCookie)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.PatchTask().ServeHTTP(rec, patchReq)
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `data-current-view="list"`) {
|
|
t.Fatalf("expected response to preserve current list view, got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestInMemoryTasksListExcludesSoftDeletedRows(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
|
|
if err != nil {
|
|
t.Fatalf("expected demo user, got error %v", err)
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, user.ID)
|
|
etape := mustCreateEtape(t, repo, user.ID, tablo.ID, "Etape 1")
|
|
task := mustCreateTask(t, repo, user.ID, tablo.ID, &etape.ID, "Task 1")
|
|
|
|
if err := repo.SoftDeleteTask(context.Background(), task.ID, user.ID); err != nil {
|
|
t.Fatalf("soft delete task: %v", err)
|
|
}
|
|
|
|
records, err := repo.ListTasksByTablo(context.Background(), ListTasksByTabloInput{
|
|
OwnerID: user.ID,
|
|
TabloID: tablo.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list tasks: %v", err)
|
|
}
|
|
|
|
if len(records) != 1 || records[0].ID != etape.ID {
|
|
t.Fatalf("expected only etape to remain visible, got %#v", records)
|
|
}
|
|
}
|
|
|
|
func TestInMemoryDeleteEtapeClearsChildParentID(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
|
|
if err != nil {
|
|
t.Fatalf("expected demo user, got error %v", err)
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, user.ID)
|
|
etape := mustCreateEtape(t, repo, user.ID, tablo.ID, "Launch")
|
|
child := mustCreateTask(t, repo, user.ID, tablo.ID, &etape.ID, "Ship copy")
|
|
|
|
if err := repo.SoftDeleteTask(context.Background(), etape.ID, user.ID); err != nil {
|
|
t.Fatalf("delete etape: %v", err)
|
|
}
|
|
|
|
updated, err := repo.GetTaskByID(context.Background(), child.ID, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("get child task: %v", err)
|
|
}
|
|
if updated.ParentTaskID != nil {
|
|
t.Fatalf("expected child task to move to Sans etape, got parent %v", *updated.ParentTaskID)
|
|
}
|
|
}
|
|
|
|
func TestInMemoryEtapeCannotHaveParent(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
|
|
if err != nil {
|
|
t.Fatalf("expected demo user, got error %v", err)
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, user.ID)
|
|
parent := mustCreateEtape(t, repo, user.ID, tablo.ID, "Parent")
|
|
|
|
_, err = repo.CreateTask(context.Background(), CreateTaskInput{
|
|
OwnerID: user.ID,
|
|
TabloID: tablo.ID,
|
|
Title: "Invalid child etape",
|
|
IsEtape: true,
|
|
Status: taskmodel.StatusTodo,
|
|
ParentTaskID: &parent.ID,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected etape with parent to fail")
|
|
}
|
|
}
|
|
|
|
func TestInMemoryTaskParentMustBeEtape(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
|
|
if err != nil {
|
|
t.Fatalf("expected demo user, got error %v", err)
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, user.ID)
|
|
taskParent := mustCreateTask(t, repo, user.ID, tablo.ID, nil, "Not an etape")
|
|
|
|
_, err = repo.CreateTask(context.Background(), CreateTaskInput{
|
|
OwnerID: user.ID,
|
|
TabloID: tablo.ID,
|
|
Title: "Invalid child task",
|
|
Status: taskmodel.StatusTodo,
|
|
ParentTaskID: &taskParent.ID,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected task with non-etape parent to fail")
|
|
}
|
|
}
|
|
|
|
func TestInMemoryTaskAssigneePersistsAndCanBeCleared(t *testing.T) {
|
|
repo := NewInMemoryAuthRepository()
|
|
user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
|
|
if err != nil {
|
|
t.Fatalf("expected demo user, got error %v", err)
|
|
}
|
|
|
|
assigneeID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
|
|
Email: "assignee@xtablo.com",
|
|
EncryptedPassword: "hash",
|
|
DisplayName: "assignee",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create assignee: %v", err)
|
|
}
|
|
|
|
tablo := mustCreateOwnedTablo(t, repo, user.ID)
|
|
task := mustCreateTaskWithAssignee(t, repo, user.ID, tablo.ID, nil, "Assigned", &assigneeID)
|
|
|
|
if task.AssigneeID == nil || *task.AssigneeID != assigneeID {
|
|
t.Fatalf("expected assignee to persist, got %#v", task.AssigneeID)
|
|
}
|
|
|
|
updated, err := repo.UpdateTask(context.Background(), UpdateTaskInput{
|
|
ID: task.ID,
|
|
OwnerID: user.ID,
|
|
Title: task.Title,
|
|
Description: task.Description,
|
|
Status: task.Status,
|
|
AssigneeID: nil,
|
|
DueDate: task.DueDate,
|
|
ParentTaskID: task.ParentTaskID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("clear assignee: %v", err)
|
|
}
|
|
|
|
if updated.AssigneeID != nil {
|
|
t.Fatalf("expected assignee to clear, got %#v", updated.AssigneeID)
|
|
}
|
|
}
|
|
|
|
func mustCreateOwnedTablo(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID) TabloRecord {
|
|
t.Helper()
|
|
|
|
tablo, err := repo.CreateTablo(context.Background(), CreateTabloInput{
|
|
OwnerID: ownerID,
|
|
Name: "Owned Tablo",
|
|
Color: "#3B82F6",
|
|
Status: TabloStatusTodo,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create tablo: %v", err)
|
|
}
|
|
return tablo
|
|
}
|
|
|
|
func mustCreateEtape(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID, tabloID uuid.UUID, title string) TaskRecord {
|
|
t.Helper()
|
|
|
|
record, err := repo.CreateTask(context.Background(), CreateTaskInput{
|
|
OwnerID: ownerID,
|
|
TabloID: tabloID,
|
|
Title: title,
|
|
IsEtape: true,
|
|
Status: taskmodel.StatusTodo,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create etape: %v", err)
|
|
}
|
|
return record
|
|
}
|
|
|
|
func mustCreateTask(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID, tabloID uuid.UUID, parentTaskID *uuid.UUID, title string) TaskRecord {
|
|
t.Helper()
|
|
|
|
return mustCreateTaskWithAssignee(t, repo, ownerID, tabloID, parentTaskID, title, nil)
|
|
}
|
|
|
|
func mustCreateTaskWithAssignee(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID, tabloID uuid.UUID, parentTaskID *uuid.UUID, title string, assigneeID *uuid.UUID) TaskRecord {
|
|
t.Helper()
|
|
|
|
record, err := repo.CreateTask(context.Background(), CreateTaskInput{
|
|
OwnerID: ownerID,
|
|
TabloID: tabloID,
|
|
Title: title,
|
|
Status: taskmodel.StatusTodo,
|
|
ParentTaskID: parentTaskID,
|
|
AssigneeID: assigneeID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create task: %v", err)
|
|
}
|
|
return record
|
|
}
|