package app import ( "database/sql" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strconv" "strings" "testing" "time" ) func TestExistingDatabaseAddsNewColumns(t *testing.T) { path := filepath.Join(t.TempDir(), "legacy.db") db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } _, err = db.Exec(`CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT, display_name TEXT NOT NULL, email TEXT UNIQUE COLLATE NOCASE, role TEXT NOT NULL DEFAULT 'member', avatar_url TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); INSERT INTO users(username,display_name,role) VALUES('legacy-admin','Legacy Admin','admin'); CREATE TABLE board_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'backlog', assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL, creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, due_date TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); INSERT INTO board_tasks(title,assignee_id,creator_id) VALUES('Legacy card',1,1)`) if err != nil { t.Fatal(err) } if err := db.Close(); err != nil { t.Fatal(err) } store, err := OpenStore(path) if err != nil { t.Fatalf("open legacy database: %v", err) } defer store.Close() var active bool if err := store.db.QueryRow(`SELECT active FROM users WHERE username='legacy-admin'`).Scan(&active); err != nil { t.Fatalf("read migrated account state: %v", err) } if !active { t.Fatal("legacy account was not active after migration") } var archivedAt sql.NullString if err := store.db.QueryRow(`SELECT archived_at FROM board_tasks WHERE title='Legacy card'`).Scan(&archivedAt); err != nil { t.Fatalf("read migrated card archive state: %v", err) } if archivedAt.Valid { t.Fatalf("legacy card was unexpectedly archived: %q", archivedAt.String) } var importance string if err := store.db.QueryRow(`SELECT importance FROM board_tasks WHERE title='Legacy card'`).Scan(&importance); err != nil || importance != "normal" { t.Fatalf("legacy card importance: %q, %v", importance, err) } var migratedAssignees int if err := store.db.QueryRow(`SELECT COUNT(*) FROM board_task_assignees WHERE task_id=1 AND user_id=1`).Scan(&migratedAssignees); err != nil || migratedAssignees != 1 { t.Fatalf("legacy card assignees: %d, %v", migratedAssignees, err) } } func TestBoardTasksSortByCreationDate(t *testing.T) { store, err := OpenStore(filepath.Join(t.TempDir(), "sort.db")) if err != nil { t.Fatal(err) } defer store.Close() olderID, err := store.CreateBoardTask(1, "Created later", "", "backlog", "normal", nil, "") if err != nil { t.Fatal(err) } newerID, err := store.CreateBoardTask(1, "Created earlier", "", "backlog", "normal", nil, "") if err != nil { t.Fatal(err) } if _, err := store.db.Exec(`UPDATE board_tasks SET created_at=CASE id WHEN ? THEN ? ELSE ? END`, olderID, "2026-07-29 12:00:00", "2026-07-29 11:00:00"); err != nil { t.Fatal(err) } tasks, err := store.BoardTasks() if err != nil { t.Fatal(err) } if len(tasks) != 2 || tasks[0].ID != olderID || tasks[1].ID != newerID { t.Fatalf("cards were not sorted newest-created-first: %#v", tasks) } } func TestLoginAttendanceAndReportFlow(t *testing.T) { s, err := New(Config{ Addr: ":0", BaseURL: "http://example.test", DatabasePath: filepath.Join(t.TempDir(), "test.db"), }) if err != nil { t.Fatal(err) } defer s.Close() login := formRequest(t, s.http.Handler, "/login", url.Values{ "username": {"admin"}, "password": {"admin123"}, }, nil) if login.Code != http.StatusSeeOther { t.Fatalf("login status: got %d, body %s", login.Code, login.Body.String()) } cookies := login.Result().Cookies() if len(cookies) == 0 { t.Fatal("login did not set a session cookie") } session := cookies[0] dashboardReq := httptest.NewRequest(http.MethodGet, "/", nil) dashboardReq.AddCookie(session) dashboard := httptest.NewRecorder() s.http.Handler.ServeHTTP(dashboard, dashboardReq) if dashboard.Code != http.StatusOK || !strings.Contains(dashboard.Body.String(), "PERSIAN CALENDAR") { t.Fatalf("dashboard status/body: %d %s", dashboard.Code, dashboard.Body.String()) } if !strings.Contains(dashboard.Body.String(), "/day?date=") { t.Fatal("dashboard calendar days do not link to the team day view") } csrf := extractCSRF(t, dashboard.Body.String()) createUser := formRequest(t, s.http.Handler, "/admin/users", url.Values{ "csrf": {csrf}, "username": {"navid"}, "password": {"temporary-password"}, "display_name": {"Navid Somethingi"}, "email": {"navid@example.test"}, "role": {"member"}, }, session) if createUser.Code != http.StatusSeeOther { t.Fatalf("create user status: got %d, body %s", createUser.Code, createUser.Body.String()) } if _, err := s.store.Authenticate("navid", "temporary-password"); err != nil { t.Fatalf("created teammate could not authenticate: %v", err) } users, err := s.store.Users() if err != nil { t.Fatal(err) } var testuser User for _, user := range users { if user.Username == "navid" { testuser = user break } } if testuser.ID == 0 || !testuser.Active { t.Fatalf("created teammate missing or inactive: %#v", testuser) } testUserToken, _, err := s.store.CreateSession(testuser.ID) if err != nil { t.Fatal(err) } lockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(testuser.ID, 10)+"/status", url.Values{ "csrf": {csrf}, "action": {"lock"}, }, session) if lockUser.Code != http.StatusSeeOther || lockUser.Header().Get("Location") != "/admin/users?flash=Account+locked" { t.Fatalf("lock user: got %d location %q", lockUser.Code, lockUser.Header().Get("Location")) } if _, err := s.store.Authenticate("navid", "temporary-password"); err == nil || !strings.Contains(err.Error(), "locked") { t.Fatalf("locked teammate authentication result: %v", err) } if _, _, err := s.store.Session(testUserToken); err == nil { t.Fatal("locking a teammate did not revoke the active session") } if activeUsers, err := s.store.ActiveUsers(); err != nil || len(activeUsers) != 1 { t.Fatalf("locked teammate was not hidden from active users: %#v, %v", activeUsers, err) } if roster, err := s.store.DayRoster(time.Now().Format("2006-01-02")); err != nil || len(roster) != 1 { t.Fatalf("locked teammate was not hidden from the team roster: %#v, %v", roster, err) } usersRequest := httptest.NewRequest(http.MethodGet, "/admin/users", nil) usersRequest.AddCookie(session) usersResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(usersResponse, usersRequest) if usersResponse.Code != http.StatusOK || !strings.Contains(usersResponse.Body.String(), "Locked") || !strings.Contains(usersResponse.Body.String(), "Unlock") { t.Fatalf("locked account state missing from admin directory: %d %s", usersResponse.Code, usersResponse.Body.String()) } lockSelf := formRequest(t, s.http.Handler, "/admin/users/1/status", url.Values{ "csrf": {csrf}, "action": {"lock"}, }, session) if lockSelf.Code != http.StatusSeeOther || !strings.Contains(lockSelf.Header().Get("Location"), "error=") { t.Fatalf("administrator could lock their own account: %d %q", lockSelf.Code, lockSelf.Header().Get("Location")) } unlockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(testuser.ID, 10)+"/status", url.Values{ "csrf": {csrf}, "action": {"unlock"}, }, session) if unlockUser.Code != http.StatusSeeOther || unlockUser.Header().Get("Location") != "/admin/users?flash=Account+unlocked" { t.Fatalf("unlock user: got %d location %q", unlockUser.Code, unlockUser.Header().Get("Location")) } if _, err := s.store.Authenticate("navid", "temporary-password"); err != nil { t.Fatalf("unlocked teammate could not authenticate: %v", err) } newPassword := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(testuser.ID, 10)+"/password", url.Values{ "csrf": {csrf}, "password": {"changed-password"}, "password_confirm": {"changed-password"}, }, session) if newPassword.Code != http.StatusSeeOther || newPassword.Header().Get("Location") != "/admin/users?flash=Password+updated" { t.Fatalf("change password: got %d location %q", newPassword.Code, newPassword.Header().Get("Location")) } if _, err := s.store.Authenticate("navid", "temporary-password"); err == nil { t.Fatal("old password still authenticates after reset") } if _, err := s.store.Authenticate("navid", "changed-password"); err != nil { t.Fatalf("new password does not authenticate: %v", err) } deleteUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(testuser.ID, 10)+"/delete", url.Values{"csrf": {csrf}}, session) if deleteUser.Code != http.StatusSeeOther || deleteUser.Header().Get("Location") != "/admin/users?flash=Account+deleted" { t.Fatalf("delete user: got %d location %q", deleteUser.Code, deleteUser.Header().Get("Location")) } if _, err := s.store.Authenticate("navid", "changed-password"); err == nil { t.Fatal("deleted account still authenticates") } checkIn := formRequest(t, s.http.Handler, "/attendance/check-in", url.Values{ "csrf": {csrf}, "mode": {"office"}, }, session) if checkIn.Code != http.StatusSeeOther { t.Fatalf("check-in status: got %d, body %s", checkIn.Code, checkIn.Body.String()) } reportReq := httptest.NewRequest(http.MethodGet, "/reports/attendance.csv?start=2020-01-01&end=2030-01-01", nil) reportReq.AddCookie(session) report := httptest.NewRecorder() s.http.Handler.ServeHTTP(report, reportReq) if report.Code != http.StatusOK { t.Fatalf("report status: got %d, body %s", report.Code, report.Body.String()) } if got := report.Header().Get("Content-Type"); !strings.Contains(got, "text/csv") { t.Fatalf("report content type: %s", got) } if !strings.Contains(report.Body.String(), "Workspace Admin") { t.Fatalf("report did not contain attendance row: %s", report.Body.String()) } } func TestAttendanceTimesUseConfiguredTimezoneAndShowCheckout(t *testing.T) { s, err := New(Config{ Addr: ":0", BaseURL: "http://example.test", DatabasePath: filepath.Join(t.TempDir(), "timezone.db"), Timezone: "Asia/Tehran", }) if err != nil { t.Fatal(err) } defer s.Close() day := time.Now().Format("2006-01-02") legacyCheckIn := day + " 11:48:51.004976173 +0330 +0330 m=+18.184759346" checkOut := day + " 11:49:32.496818296 +0330 +0330 m=+59.676601465" if _, err := s.store.db.Exec( `INSERT INTO attendance(user_id,day,check_in,check_out,mode) VALUES(?,?,?,?,?)`, 1, day, legacyCheckIn, checkOut, "office", ); err != nil { t.Fatal(err) } token, _, err := s.store.CreateSession(1) if err != nil { t.Fatal(err) } request := httptest.NewRequest(http.MethodGet, "/", nil) request.AddCookie(&http.Cookie{Name: "teammate_session", Value: token}) response := httptest.NewRecorder() s.http.Handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("dashboard status: %d body %s", response.Code, response.Body.String()) } for _, expected := range []string{"11:48", "11:49", "Day complete"} { if !strings.Contains(response.Body.String(), expected) { t.Fatalf("dashboard does not include %q: %s", expected, response.Body.String()) } } if strings.Contains(response.Body.String(), `action="/attendance/check-out"`) { t.Fatal("completed attendance still shows the checkout action") } } func TestPersianRequestDateParsing(t *testing.T) { got, ok := parseUserDate("1405-05-06") if !ok || got != "2026-07-28" { t.Fatalf("got %q, %v; want 2026-07-28, true", got, ok) } if _, ok := parseUserDate("1400-12-30"); ok { t.Fatal("accepted an invalid non-leap Esfand date") } } func TestReviewedRequestCanBeListed(t *testing.T) { s, err := New(Config{ Addr: ":0", BaseURL: "http://example.test", DatabasePath: filepath.Join(t.TempDir(), "review.db"), }) if err != nil { t.Fatal(err) } defer s.Close() if err := s.store.CreateRequest(1, "leave", "2026-07-28", "2026-07-29", "Personal"); err != nil { t.Fatal(err) } pending, err := s.store.Requests(1, true, "pending") if err != nil || len(pending) != 1 { t.Fatalf("pending requests: %#v, %v", pending, err) } if err := s.store.ReviewRequest(t.Context(), pending[0].ID, 1, "approved", "Approved"); err != nil { t.Fatal(err) } reviewed, err := s.store.Requests(1, true, "") if err != nil { t.Fatalf("listing reviewed request failed: %v", err) } if len(reviewed) != 1 || reviewed[0].Status != "approved" { t.Fatalf("unexpected reviewed requests: %#v", reviewed) } token, _, err := s.store.CreateSession(1) if err != nil { t.Fatal(err) } adminRequest := httptest.NewRequest(http.MethodGet, "/admin/requests?flash=Request+reviewed", nil) adminRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token}) adminResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(adminResponse, adminRequest) if adminResponse.Code != http.StatusOK || !strings.Contains(adminResponse.Body.String(), "Request reviewed") { t.Fatalf("review redirect page: got %d, body %s", adminResponse.Code, adminResponse.Body.String()) } } func TestPublicRegistrationAndIdentifierLogin(t *testing.T) { s, err := New(Config{ Addr: ":0", BaseURL: "http://example.test", DatabasePath: filepath.Join(t.TempDir(), "registration.db"), }) if err != nil { t.Fatal(err) } defer s.Close() healthRequest := httptest.NewRequest(http.MethodGet, "/healthz", nil) healthResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(healthResponse, healthRequest) if healthResponse.Code != http.StatusOK || healthResponse.Body.String() != `{"status":"ok"}` { t.Fatalf("health endpoint: got %d, body %s", healthResponse.Code, healthResponse.Body.String()) } registerPage := httptest.NewRequest(http.MethodGet, "/register", nil) registerView := httptest.NewRecorder() s.http.Handler.ServeHTTP(registerView, registerPage) if registerView.Code != http.StatusOK || !strings.Contains(registerView.Body.String(), "Create your account") { t.Fatalf("register page: got %d, body %s", registerView.Code, registerView.Body.String()) } for _, expected := range []string{"Vazirmatn-font-face.css", "/static/theme.js", "data-theme-toggle"} { if !strings.Contains(registerView.Body.String(), expected) { t.Fatalf("register page does not include %q", expected) } } if !strings.Contains(registerView.Body.String(), `/static/favicon.svg`) { t.Fatal("register page does not include the favicon") } themeRequest := httptest.NewRequest(http.MethodGet, "/static/theme.js", nil) themeResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(themeResponse, themeRequest) if themeResponse.Code != http.StatusOK || !strings.Contains(themeResponse.Body.String(), "hamkar-theme") { t.Fatalf("theme asset: got %d, body %s", themeResponse.Code, themeResponse.Body.String()) } faviconRequest := httptest.NewRequest(http.MethodGet, "/static/favicon.svg", nil) faviconResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(faviconResponse, faviconRequest) if faviconResponse.Code != http.StatusOK || !strings.Contains(faviconResponse.Body.String(), `Saturday<") friday := strings.Index(body, ">Friday<") if saturday < 0 || friday < 0 || saturday >= friday { t.Fatal("week page is not ordered Saturday through Friday") } for _, expected := range []string{"Workspace Admin", "Remote Teammate", "Absent Teammate", "Approved remote day", "Approved time off"} { if !strings.Contains(body, expected) { t.Fatalf("week page does not include %q", expected) } } defaultRequest := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay, nil) defaultRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token}) defaultResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(defaultResponse, defaultRequest) if defaultResponse.Code != http.StatusOK || !strings.Contains(defaultResponse.Body.String(), "TEAM WEEK") { t.Fatalf("default team view is not weekly: %d %s", defaultResponse.Code, defaultResponse.Body.String()) } } func TestWorkUpdateSubmissionFeedAndCSV(t *testing.T) { s, err := New(Config{ Addr: ":0", BaseURL: "http://example.test", DatabasePath: filepath.Join(t.TempDir(), "updates.db"), }) if err != nil { t.Fatal(err) } defer s.Close() login := formRequest(t, s.http.Handler, "/login", url.Values{ "identifier": {"admin"}, "password": {"admin123"}, }, nil) session := login.Result().Cookies()[0] dashboardRequest := httptest.NewRequest(http.MethodGet, "/", nil) dashboardRequest.AddCookie(session) dashboardResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(dashboardResponse, dashboardRequest) csrf := extractCSRF(t, dashboardResponse.Body.String()) submission := formRequest(t, s.http.Handler, "/updates", url.Values{ "csrf": {csrf}, "period_start": {"1405-05-06"}, "period_end": {"1405-05-09"}, "status": {"blocked"}, "note": {"Completed task ABC.\nBlocked on staging access; task XYZ is pending."}, }, session) if submission.Code != http.StatusSeeOther || submission.Header().Get("Location") != "/updates?flash=Work+update+shared" { t.Fatalf("work update submission: got %d location %q body %s", submission.Code, submission.Header().Get("Location"), submission.Body.String()) } if err := s.store.CheckIn(1, "2026-07-28", "office"); err != nil { t.Fatal(err) } if err := s.store.CheckIn(1, "2026-07-29", "remote"); err != nil { t.Fatal(err) } if err := s.store.CreateRequest(1, "leave", "2026-07-30", "2026-07-31", "Time off"); err != nil { t.Fatal(err) } leaveRequests, err := s.store.Requests(1, false, "pending") if err != nil || len(leaveRequests) != 1 { t.Fatalf("leave request: %#v, %v", leaveRequests, err) } if err := s.store.ReviewRequest(t.Context(), leaveRequests[0].ID, 1, "approved", "Approved"); err != nil { t.Fatal(err) } feedRequest := httptest.NewRequest(http.MethodGet, "/updates", nil) feedRequest.AddCookie(session) feedResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(feedResponse, feedRequest) if feedResponse.Code != http.StatusOK { t.Fatalf("work update feed status: %d", feedResponse.Code) } for _, expected := range []string{"Completed task ABC.", "Blocked on staging access", "1405-05-06", "1405-05-09", "Blocked"} { if !strings.Contains(feedResponse.Body.String(), expected) { t.Fatalf("work update feed does not include %q", expected) } } exportRequest := httptest.NewRequest(http.MethodGet, "/reports/work-updates.csv?start=2026-07-01&end=2026-08-01", nil) exportRequest.AddCookie(session) exportResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(exportResponse, exportRequest) if exportResponse.Code != http.StatusOK || !strings.Contains(exportResponse.Body.String(), "Completed task ABC.") { t.Fatalf("work update CSV: got %d body %s", exportResponse.Code, exportResponse.Body.String()) } if !strings.Contains(exportResponse.Header().Get("Content-Disposition"), "work-updates-") { t.Fatalf("unexpected work update CSV filename: %s", exportResponse.Header().Get("Content-Disposition")) } reportRequest := httptest.NewRequest(http.MethodGet, "/reports?start=2026-07-01&end=2026-08-01", nil) reportRequest.AddCookie(session) reportResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(reportResponse, reportRequest) if reportResponse.Code != http.StatusOK || !strings.Contains(reportResponse.Body.String(), "ACCUMULATED RESULT") { t.Fatalf("accumulated report page: got %d body %s", reportResponse.Code, reportResponse.Body.String()) } summaryRequest := httptest.NewRequest(http.MethodGet, "/reports/summary.csv?start=2026-07-01&end=2026-08-01", nil) summaryRequest.AddCookie(session) summaryResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(summaryResponse, summaryRequest) if summaryResponse.Code != http.StatusOK { t.Fatalf("summary CSV status: %d body %s", summaryResponse.Code, summaryResponse.Body.String()) } if !strings.Contains(summaryResponse.Body.String(), "Workspace Admin,admin,1,1,2,0,1,0,1") { t.Fatalf("summary CSV did not contain accumulated values: %s", summaryResponse.Body.String()) } } func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { s, err := New(Config{ Addr: ":0", BaseURL: "http://example.test", DatabasePath: filepath.Join(t.TempDir(), "board.db"), }) if err != nil { t.Fatal(err) } defer s.Close() memberID, err := s.store.CreateUser("board-member", "secure-password", "Board Member", "board@example.test", "member") if err != nil { t.Fatal(err) } adminToken, adminCSRF, err := s.store.CreateSession(1) if err != nil { t.Fatal(err) } adminCookie := &http.Cookie{Name: "teammate_session", Value: adminToken} for _, tag := range []struct { name string color string }{ {"Backend", "green"}, {"Urgent", "red"}, } { response := formRequest(t, s.http.Handler, "/board/tags", url.Values{ "csrf": {adminCSRF}, "name": {tag.name}, "color": {tag.color}, }, adminCookie) if response.Code != http.StatusSeeOther { t.Fatalf("create board tag %s: %d %s", tag.name, response.Code, response.Body.String()) } } tags, err := s.store.BoardTags() if err != nil || len(tags) != 2 { t.Fatalf("board tags after create: %#v, %v", tags, err) } reusedTag, err := s.store.FindOrCreateBoardTag("backend", "red") if err != nil || reusedTag.ID != tags[0].ID || reusedTag.Color != "green" { t.Fatalf("existing inline tag was not reused: %#v, %v", reusedTag, err) } create := formRequest(t, s.http.Handler, "/board/tasks", url.Values{ "csrf": {adminCSRF}, "title": {"Ship the onboarding flow"}, "description": {"Finish QA and publish the release."}, "status": {"backlog"}, "assignee_ids": { "1", strconv.FormatInt(memberID, 10), }, "importance": {"high"}, "due_date": {"1405-05-06"}, "tag_ids": { strconv.FormatInt(tags[0].ID, 10), strconv.FormatInt(tags[1].ID, 10), }, "new_tag_name": {"Frontend"}, "new_tag_color": {"blue"}, }, adminCookie) if create.Code != http.StatusSeeOther || create.Header().Get("Location") != "/board?flash=Task+added+to+the+team+board" { t.Fatalf("create board task: got %d location %q body %s", create.Code, create.Header().Get("Location"), create.Body.String()) } tasks, err := s.store.BoardTasks() if err != nil || len(tasks) != 1 { t.Fatalf("board tasks after create: %#v, %v", tasks, err) } if len(tasks[0].Assignees) != 2 || tasks[0].Importance != "high" || tasks[0].DueDate != "2026-07-28" { t.Fatalf("unexpected assigned board task: %#v", tasks[0]) } if len(tasks[0].Tags) != 3 { t.Fatalf("task tags after create: %#v", tasks[0].Tags) } memberToken, memberCSRF, err := s.store.CreateSession(memberID) if err != nil { t.Fatal(err) } memberCookie := &http.Cookie{Name: "teammate_session", Value: memberToken} boardRequest := httptest.NewRequest(http.MethodGet, "/board", nil) boardRequest.AddCookie(memberCookie) boardResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(boardResponse, boardRequest) if boardResponse.Code != http.StatusOK { t.Fatalf("board page status: %d body %s", boardResponse.Code, boardResponse.Body.String()) } for _, expected := range []string{"Ship the onboarding flow", "Board Member", "Workspace Admin", "1405-05-06", "High priority", "Backlog", "To do", "Filters", "Manage tags", "Tags", "select multiple", "Create a new tag", "People & importance", "To-do list", "Backend", "Frontend", "Urgent", "/static/board.js", `data-board-column="backlog"`} { if !strings.Contains(boardResponse.Body.String(), expected) { t.Fatalf("board page does not include %q", expected) } } adminBoardRequest := httptest.NewRequest(http.MethodGet, "/board", nil) adminBoardRequest.AddCookie(adminCookie) adminBoardResponse := httptest.NewRecorder() s.http.Handler.ServeHTTP(adminBoardResponse, adminBoardRequest) for _, expected := range []string{"Archive", "Delete", "Delete permanently"} { if !strings.Contains(adminBoardResponse.Body.String(), expected) { t.Fatalf("card owner controls do not include %q", expected) } } tagPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/tags" setTags := formRequest(t, s.http.Handler, tagPath, url.Values{ "csrf": {memberCSRF}, "tag_ids": {strconv.FormatInt(tags[0].ID, 10)}, }, memberCookie) if setTags.Code != http.StatusSeeOther { t.Fatalf("set task tags status: %d", setTags.Code) } tasks, _ = s.store.BoardTasks() if len(tasks[0].Tags) != 1 || tasks[0].Tags[0].ID != tags[0].ID { t.Fatalf("task tags were not updated: %#v", tasks[0].Tags) } detailsPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/details" setDetails := formRequest(t, s.http.Handler, detailsPath, url.Values{ "csrf": {memberCSRF}, "assignee_ids": {strconv.FormatInt(memberID, 10)}, "importance": {"urgent"}, }, memberCookie) if setDetails.Code != http.StatusSeeOther || setDetails.Header().Get("Location") != "/board?flash=Card+details+updated" { t.Fatalf("set task details: got %d location %q", setDetails.Code, setDetails.Header().Get("Location")) } tasks, _ = s.store.BoardTasks() if len(tasks[0].Assignees) != 1 || tasks[0].Assignees[0].ID != memberID || tasks[0].Importance != "urgent" { t.Fatalf("task people or importance were not updated: %#v", tasks[0]) } todoPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/todos" createTodo := formRequest(t, s.http.Handler, todoPath, url.Values{ "csrf": {memberCSRF}, "body": {"Confirm the onboarding handoff"}, }, memberCookie) if createTodo.Code != http.StatusSeeOther || createTodo.Header().Get("Location") != "/board?flash=To-do+item+added" { t.Fatalf("create to-do: got %d location %q", createTodo.Code, createTodo.Header().Get("Location")) } tasks, _ = s.store.BoardTasks() if len(tasks[0].Todos) != 1 || tasks[0].Todos[0].Body != "Confirm the onboarding handoff" || tasks[0].TodoDone != 0 { t.Fatalf("created to-do was not loaded: %#v", tasks[0]) } toggleTodoPath := todoPath + "/" + strconv.FormatInt(tasks[0].Todos[0].ID, 10) + "/toggle" toggleTodo := formRequest(t, s.http.Handler, toggleTodoPath, url.Values{ "csrf": {memberCSRF}, "completed": {"on"}, }, memberCookie) if toggleTodo.Code != http.StatusSeeOther || toggleTodo.Header().Get("Location") != "/board?flash=To-do+item+updated" { t.Fatalf("toggle to-do: got %d location %q", toggleTodo.Code, toggleTodo.Header().Get("Location")) } tasks, _ = s.store.BoardTasks() if !tasks[0].Todos[0].Completed || tasks[0].TodoDone != 1 { t.Fatalf("to-do completion was not saved: %#v", tasks[0].Todos) } deleteTodoPath := todoPath + "/" + strconv.FormatInt(tasks[0].Todos[0].ID, 10) + "/delete" deleteTodo := formRequest(t, s.http.Handler, deleteTodoPath, url.Values{"csrf": {memberCSRF}}, memberCookie) if deleteTodo.Code != http.StatusSeeOther || deleteTodo.Header().Get("Location") != "/board?flash=To-do+item+removed" { t.Fatalf("delete to-do: got %d location %q", deleteTodo.Code, deleteTodo.Header().Get("Location")) } tasks, _ = s.store.BoardTasks() if len(tasks[0].Todos) != 0 { t.Fatalf("to-do was not deleted: %#v", tasks[0].Todos) } matchingFilterPath := "/board?tag=" + strconv.FormatInt(tags[0].ID, 10) + "&assignee=" + strconv.FormatInt(memberID, 10) + "&priority=urgent" matchingFilterRequest := httptest.NewRequest(http.MethodGet, matchingFilterPath, nil) matchingFilterRequest.AddCookie(memberCookie) matchingFilter := httptest.NewRecorder() s.http.Handler.ServeHTTP(matchingFilter, matchingFilterRequest) if matchingFilter.Code != http.StatusOK || !strings.Contains(matchingFilter.Body.String(), "Ship the onboarding flow") || !strings.Contains(matchingFilter.Body.String(), "Filters (3)") || !strings.Contains(matchingFilter.Body.String(), "Clear all") { t.Fatalf("matching board filter: %d %s", matchingFilter.Code, matchingFilter.Body.String()) } for _, path := range []string{ "/board?priority=high", "/board?assignee=1", "/board?tag=999999", } { request := httptest.NewRequest(http.MethodGet, path, nil) request.AddCookie(memberCookie) response := httptest.NewRecorder() s.http.Handler.ServeHTTP(response, request) if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "Ship the onboarding flow") || !strings.Contains(response.Body.String(), "No matching cards") { t.Fatalf("non-matching board filter %q: %d %s", path, response.Code, response.Body.String()) } } deleteTagPath := "/board/tags/" + strconv.FormatInt(tags[1].ID, 10) + "/delete" deleteTag := formRequest(t, s.http.Handler, deleteTagPath, url.Values{"csrf": {adminCSRF}}, adminCookie) if deleteTag.Code != http.StatusSeeOther { t.Fatalf("delete tag status: %d", deleteTag.Code) } if remainingTags, _ := s.store.BoardTags(); len(remainingTags) != 2 { t.Fatalf("tag manager did not delete tag: %#v", remainingTags) } movePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/move" move := formRequest(t, s.http.Handler, movePath, url.Values{ "csrf": {memberCSRF}, "status": {"in_progress"}, }, memberCookie) if move.Code != http.StatusSeeOther { t.Fatalf("move board task status: %d", move.Code) } tasks, _ = s.store.BoardTasks() if tasks[0].Status != "in_progress" { t.Fatalf("task did not move: %#v", tasks[0]) } deletePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/delete" deniedDelete := formRequest(t, s.http.Handler, deletePath, url.Values{"csrf": {memberCSRF}}, memberCookie) if deniedDelete.Code != http.StatusSeeOther || !strings.Contains(deniedDelete.Header().Get("Location"), "error=") { t.Fatalf("non-creator delete was not rejected: %d %q", deniedDelete.Code, deniedDelete.Header().Get("Location")) } if remaining, _ := s.store.BoardTasks(); len(remaining) != 1 { t.Fatal("non-creator removed a board task") } archivePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/archive" deniedArchive := formRequest(t, s.http.Handler, archivePath, url.Values{"csrf": {memberCSRF}}, memberCookie) if deniedArchive.Code != http.StatusSeeOther || !strings.Contains(deniedArchive.Header().Get("Location"), "error=") { t.Fatalf("non-creator archive was not rejected: %d %q", deniedArchive.Code, deniedArchive.Header().Get("Location")) } adminArchive := formRequest(t, s.http.Handler, archivePath, url.Values{"csrf": {adminCSRF}}, adminCookie) if adminArchive.Code != http.StatusSeeOther || adminArchive.Header().Get("Location") != "/board?flash=Task+archived" { t.Fatalf("admin archive: got %d location %q", adminArchive.Code, adminArchive.Header().Get("Location")) } if active, _ := s.store.BoardTasks(); len(active) != 0 { t.Fatalf("archived card remained on active board: %#v", active) } archived, err := s.store.ArchivedBoardTasks() if err != nil || len(archived) != 1 || archived[0].Title != "Ship the onboarding flow" { t.Fatalf("archived cards: %#v, %v", archived, err) } archivedPageRequest := httptest.NewRequest(http.MethodGet, "/board", nil) archivedPageRequest.AddCookie(adminCookie) archivedPage := httptest.NewRecorder() s.http.Handler.ServeHTTP(archivedPage, archivedPageRequest) for _, expected := range []string{"Archived cards", "Ship the onboarding flow", "Restore", "Delete permanently"} { if !strings.Contains(archivedPage.Body.String(), expected) { t.Fatalf("archived card panel does not include %q", expected) } } restorePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/restore" adminRestore := formRequest(t, s.http.Handler, restorePath, url.Values{"csrf": {adminCSRF}}, adminCookie) if adminRestore.Code != http.StatusSeeOther || adminRestore.Header().Get("Location") != "/board?flash=Task+restored" { t.Fatalf("admin restore: got %d location %q", adminRestore.Code, adminRestore.Header().Get("Location")) } if active, _ := s.store.BoardTasks(); len(active) != 1 { t.Fatalf("restored card did not return to board: %#v", active) } adminArchive = formRequest(t, s.http.Handler, archivePath, url.Values{"csrf": {adminCSRF}}, adminCookie) if adminArchive.Code != http.StatusSeeOther { t.Fatalf("second admin archive status: %d", adminArchive.Code) } adminDelete := formRequest(t, s.http.Handler, deletePath, url.Values{"csrf": {adminCSRF}}, adminCookie) if adminDelete.Code != http.StatusSeeOther || adminDelete.Header().Get("Location") != "/board?flash=Task+permanently+deleted" { t.Fatalf("admin delete: got %d location %q", adminDelete.Code, adminDelete.Header().Get("Location")) } if remaining, _ := s.store.BoardTasks(); len(remaining) != 0 { t.Fatal("admin could not remove the board task") } if remaining, _ := s.store.ArchivedBoardTasks(); len(remaining) != 0 { t.Fatal("permanently deleted card remained in archive") } } func formRequest(t *testing.T, handler http.Handler, path string, values url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if cookie != nil { req.AddCookie(cookie) } rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) return rec } func extractCSRF(t *testing.T, body string) string { t.Helper() re := regexp.MustCompile(`name="csrf" value="([^"]+)"`) match := re.FindStringSubmatch(body) if len(match) != 2 { t.Fatal("page did not contain a CSRF token") } return match[1] }