added kanban and reporting
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -295,6 +296,187 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()))
|
||||
|
||||
Reference in New Issue
Block a user