Files
team-presence/internal/app/server_test.go
T
2026-07-28 02:20:13 +03:30

501 lines
18 KiB
Go

package app
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
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": {"sara"},
"password": {"temporary-password"},
"display_name": {"Sara Ahmadi"},
"email": {"sara@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("sara", "temporary-password"); err != nil {
t.Fatalf("created 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 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)
}
}
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())
}
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, 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)
}
}
}
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}
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_id": {strconv.FormatInt(memberID, 10)},
"due_date": {"1405-05-06"},
}, 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 tasks[0].AssigneeName != "Board Member" || tasks[0].DueDate != "2026-07-28" {
t.Fatalf("unexpected assigned board task: %#v", tasks[0])
}
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", "1405-05-06", "/static/board.js", `data-board-column="backlog"`} {
if !strings.Contains(boardResponse.Body.String(), expected) {
t.Fatalf("board page does not include %q", expected)
}
}
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")
}
adminDelete := formRequest(t, s.http.Handler, deletePath, url.Values{"csrf": {adminCSRF}}, adminCookie)
if adminDelete.Code != http.StatusSeeOther {
t.Fatalf("admin delete status: %d", adminDelete.Code)
}
if remaining, _ := s.store.BoardTasks(); len(remaining) != 0 {
t.Fatal("admin could not remove the board task")
}
}
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]
}