7e8b7c4343
Test and publish / verify (push) Successful in 3m16s
Switch Vazirmatn font-face from TTF-only to WOFF2 to halve the font payload (~50 KB vs ~105 KB per weight). Add a staticHandler middleware that injects font/ttf and font/woff2 Content-Type headers so browsers don't reject them as application/octet-stream.
970 lines
39 KiB
Go
970 lines
39 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)
|
|
}
|
|
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(), `<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 & 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]
|
|
}
|
|
|
|
func TestStaticHandlerSetsFontContentTypes(t *testing.T) {
|
|
s, err := New(Config{
|
|
Addr: ":0",
|
|
BaseURL: "http://example.test",
|
|
DatabasePath: filepath.Join(t.TempDir(), "font.db"),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
|
|
tests := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{"/static/vendor/fonts/webfonts/Vazirmatn-400.ttf", "font/ttf"},
|
|
{"/static/vendor/fonts/webfonts/Vazirmatn-Bold.woff2", "font/woff2"},
|
|
{"/static/vendor/fontawesome.min.css", "text/css; charset=utf-8"},
|
|
}
|
|
for _, tt := range tests {
|
|
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
|
rec := httptest.NewRecorder()
|
|
s.http.Handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("%s: status %d", tt.path, rec.Code)
|
|
continue
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != tt.want {
|
|
t.Errorf("%s: Content-Type = %q, want %q", tt.path, ct, tt.want)
|
|
}
|
|
}
|
|
}
|