Files
team-presence/internal/app/server_test.go
T
nfel 9380f116d7
Test and publish / verify (push) Successful in 2m22s
fix workspace access and vendor correct fonts
2026-08-04 20:07:35 +03:30

916 lines
37 KiB
Go

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)
}
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(), `<svg`) {
t.Fatalf("favicon asset: got %d, body %s", faviconResponse.Code, faviconResponse.Body.String())
}
register := formRequest(t, s.http.Handler, "/register", url.Values{
"display_name": {"Neda Karimi"},
"email": {"neda@example.test"},
"password": {"secure-password"},
"password_confirm": {"secure-password"},
}, nil)
if register.Code != http.StatusSeeOther || register.Header().Get("Location") != "/?flash=Welcome+to+Hamkar" {
t.Fatalf("registration: got %d location %q body %s", register.Code, register.Header().Get("Location"), register.Body.String())
}
if len(register.Result().Cookies()) == 0 {
t.Fatal("registration did not sign the new member in")
}
user, err := s.store.Authenticate("neda@example.test", "secure-password")
if err != nil {
t.Fatalf("email login failed: %v", err)
}
if user.Username != "neda" || user.Role != "member" {
t.Fatalf("unexpected registered user: %#v", user)
}
if _, err := s.store.Authenticate("neda", "secure-password"); err != nil {
t.Fatalf("generated username login failed: %v", err)
}
}
func TestRequestPageIncludesJalaliPicker(t *testing.T) {
s, err := New(Config{
Addr: ":0",
BaseURL: "http://example.test",
DatabasePath: filepath.Join(t.TempDir(), "picker.db"),
})
if err != nil {
t.Fatal(err)
}
defer s.Close()
token, _, err := s.store.CreateSession(1)
if err != nil {
t.Fatal(err)
}
pageRequest := httptest.NewRequest(http.MethodGet, "/requests", nil)
pageRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token})
pageResponse := httptest.NewRecorder()
s.http.Handler.ServeHTTP(pageResponse, pageRequest)
if pageResponse.Code != http.StatusOK {
t.Fatalf("request page status: %d", pageResponse.Code)
}
for _, expected := range []string{"/static/jalali-picker.js", "data-jalali-picker", "jalali-trigger"} {
if !strings.Contains(pageResponse.Body.String(), expected) {
t.Fatalf("request page does not include %q", expected)
}
}
assetRequest := httptest.NewRequest(http.MethodGet, "/static/jalali-picker.js", nil)
assetResponse := httptest.NewRecorder()
s.http.Handler.ServeHTTP(assetResponse, assetRequest)
if assetResponse.Code != http.StatusOK || !strings.Contains(assetResponse.Body.String(), "gregorianToJalali") {
t.Fatalf("picker asset: got %d, body %s", assetResponse.Code, assetResponse.Body.String())
}
}
func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) {
s, err := New(Config{
Addr: ":0",
BaseURL: "http://example.test",
DatabasePath: filepath.Join(t.TempDir(), "day.db"),
})
if err != nil {
t.Fatal(err)
}
defer s.Close()
remoteID, err := s.store.CreateUser("remote-user", "secure-password", "Remote Teammate", "remote@example.test", "member")
if err != nil {
t.Fatal(err)
}
leaveID, err := s.store.CreateUser("leave-user", "secure-password", "Absent Teammate", "leave@example.test", "member")
if err != nil {
t.Fatal(err)
}
const selectedDay = "2030-01-02"
if err := s.store.CheckIn(1, selectedDay, "office"); err != nil {
t.Fatal(err)
}
if err := s.store.CreateRequest(remoteID, "remote", selectedDay, selectedDay, "Working from home"); err != nil {
t.Fatal(err)
}
if err := s.store.CreateRequest(leaveID, "leave", selectedDay, selectedDay, "Holiday"); err != nil {
t.Fatal(err)
}
for _, userID := range []int64{remoteID, leaveID} {
requests, err := s.store.Requests(userID, false, "pending")
if err != nil || len(requests) != 1 {
t.Fatalf("pending request for %d: %#v, %v", userID, requests, err)
}
if err := s.store.ReviewRequest(t.Context(), requests[0].ID, 1, "approved", "Approved"); err != nil {
t.Fatal(err)
}
}
token, _, err := s.store.CreateSession(1)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay+"&view=day", 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("day page status: %d, body %s", response.Code, response.Body.String())
}
for _, expected := range []string{
"Workspace Admin", "Remote Teammate", "Absent Teammate",
"Office · checked in", "Approved remote day", "Approved time off",
`class="presence-badge present"`, `class="presence-badge remote"`, `class="presence-badge absent"`,
} {
if !strings.Contains(response.Body.String(), expected) {
t.Fatalf("day page does not include %q", expected)
}
}
weekRequest := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay+"&view=week", nil)
weekRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token})
weekResponse := httptest.NewRecorder()
s.http.Handler.ServeHTTP(weekResponse, weekRequest)
if weekResponse.Code != http.StatusOK {
t.Fatalf("week page status: %d, body %s", weekResponse.Code, weekResponse.Body.String())
}
body := weekResponse.Body.String()
if !strings.Contains(body, "TEAM WEEK") || !strings.Contains(body, "SATURDAY — FRIDAY") {
t.Fatalf("week page header is missing: %s", body)
}
if count := strings.Count(body, `class="week-day-column`); count != 7 {
t.Fatalf("week page has %d day columns, want 7", count)
}
saturday := strings.Index(body, ">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 &amp; 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]
}