Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,40 @@ jobs:

- name: Build
run: pnpm build

e2e:
name: E2E
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- uses: arduino/setup-task@v2
with:
version: 3.x

- uses: pnpm/action-setup@v4
with:
package_json_file: ui/package.json

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml

- name: Install UI dependencies
working-directory: ui
run: pnpm install --frozen-lockfile

- name: Install Playwright's browser
working-directory: ui
run: pnpm exec playwright install --with-deps chromium

- name: Run E2E tests
working-directory: ui
run: pnpm test:e2e
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ build/
dist/
.task/
seed*.py
ui/test-results/
ui/playwright-report/
ui/blob-report/
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ cd ui
pnpm run dev # Vite on :5173, proxying /api/* to :3000
pnpm run build
pnpm run lint
pnpm test
pnpm test # Vitest
pnpm test:e2e # Playwright, against task run's production binary
```

Before saying a change is done:
Expand Down
87 changes: 87 additions & 0 deletions cmd/thoughts/controllers/notes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package controllers_test

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/ellgreen/thoughts/cmd/thoughts/auth"
"github.com/ellgreen/thoughts/cmd/thoughts/controllers"
"github.com/ellgreen/thoughts/cmd/thoughts/dal"
"github.com/ellgreen/thoughts/cmd/thoughts/model"
"github.com/ellgreen/thoughts/cmd/thoughts/testutil"
"github.com/gorilla/mux"
)

// The frontend now refetches this endpoint on every stage change, on the
// assumption that it always reflects the retro's *current* status rather
// than whatever status was in effect when a note was created.
func TestRetroNotesIndexReObfuscatesFromCurrentStatus(t *testing.T) {
ctx := context.Background()
db := testutil.NewDB(t)

retro, err := dal.RetroInsert(ctx, db, "A test retro", model.RetroColumns{
{Title: "Went well", Description: ""},
}, false)
if err != nil {
t.Fatalf("failed to seed retro: %v", err)
}

author, err := dal.UserInsert(ctx, db, "Author")
if err != nil {
t.Fatalf("failed to seed author: %v", err)
}

viewer, err := dal.UserInsert(ctx, db, "Viewer")
if err != nil {
t.Fatalf("failed to seed viewer: %v", err)
}

column := retro.GetColumns()[0]

if _, err := dal.NoteInsert(ctx, db, retro.ID, author.ID, column.ID, "a secret thought"); err != nil {
t.Fatalf("failed to insert note: %v", err)
}

fetchAsViewer := func() string {
t.Helper()

req := httptest.NewRequest(http.MethodGet, "/api/retros/"+retro.ID.String()+"/notes", nil)
req = mux.SetURLVars(req, map[string]string{"id": retro.ID.String()})
req = auth.RequestWithUser(req, viewer)

rec := httptest.NewRecorder()
controllers.RetroNotesIndex(db).ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}

var notes []struct {
Content string `json:"content"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &notes); err != nil {
t.Fatalf("failed to decode response: %v", err)
}

if len(notes) != 1 {
t.Fatalf("expected 1 note, got %d", len(notes))
}

return notes[0].Content
}

if got := fetchAsViewer(); got == "a secret thought" {
t.Errorf("expected another viewer's note to be obfuscated during brainstorm, got the real content")
}

if err := dal.RetroUpdateStatus(ctx, db, retro.ID, model.RetroStatusGroup); err != nil {
t.Fatalf("failed to advance the retro: %v", err)
}

if got := fetchAsViewer(); got != "a secret thought" {
t.Errorf("expected the note in the clear once brainstorm has ended, got %q", got)
}
}
3 changes: 2 additions & 1 deletion cmd/thoughts/dal/note.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ func NoteList(
retroID uuid.UUID,
) ([]*model.Note, error) {
notes := make([]*model.Note, 0)
if err := db.SelectContext(ctx, &notes, "select * from notes where retro_id = ?", retroID); err != nil {
if err := db.SelectContext(ctx, &notes,
"select * from notes where retro_id = ? order by created_at asc, id asc", retroID); err != nil {
return nil, fmt.Errorf("%w: failed to select notes: %w", ErrExecution, err)
}

Expand Down
107 changes: 107 additions & 0 deletions cmd/thoughts/dal/note_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package dal_test

import (
"context"
"testing"
"time"

"github.com/ellgreen/thoughts/cmd/thoughts/dal"
"github.com/ellgreen/thoughts/cmd/thoughts/model"
"github.com/ellgreen/thoughts/cmd/thoughts/testutil"
"github.com/google/uuid"
)

func TestNoteListReturnsNotesInCreationOrder(t *testing.T) {
ctx := context.Background()
db := testutil.NewDB(t)

retro, err := dal.RetroInsert(ctx, db, "A test retro", model.RetroColumns{
{Title: "Went well", Description: ""},
}, false)
if err != nil {
t.Fatalf("failed to seed retro: %v", err)
}

user, err := dal.UserInsert(ctx, db, "Author")
if err != nil {
t.Fatalf("failed to seed user: %v", err)
}

column := retro.GetColumns()[0]

var created []uuid.UUID
for _, content := range []string{"first", "second", "third"} {
note, err := dal.NoteInsert(ctx, db, retro.ID, user.ID, column.ID, content)
if err != nil {
t.Fatalf("failed to insert note %q: %v", content, err)
}

created = append(created, note.ID)

// NoteList orders by created_at; force distinct timestamps so the
// test doesn't depend on the platform's clock resolution.
time.Sleep(time.Millisecond)
}

notes, err := dal.NoteList(ctx, db, retro.ID)
if err != nil {
t.Fatalf("failed to list notes: %v", err)
}

if len(notes) != len(created) {
t.Fatalf("expected %d notes, got %d", len(created), len(notes))
}

for i, note := range notes {
if note.ID != created[i] {
t.Errorf("position %d: expected note %s, got %s", i, created[i], note.ID)
}
}
}

func TestNoteListOrderSurvivesAnUpdate(t *testing.T) {
ctx := context.Background()
db := testutil.NewDB(t)

retro, err := dal.RetroInsert(ctx, db, "A test retro", model.RetroColumns{
{Title: "Went well", Description: ""},
}, false)
if err != nil {
t.Fatalf("failed to seed retro: %v", err)
}

user, err := dal.UserInsert(ctx, db, "Author")
if err != nil {
t.Fatalf("failed to seed user: %v", err)
}

column := retro.GetColumns()[0]

var created []uuid.UUID
for _, content := range []string{"a", "b", "c"} {
note, err := dal.NoteInsert(ctx, db, retro.ID, user.ID, column.ID, content)
if err != nil {
t.Fatalf("failed to insert note %q: %v", content, err)
}

created = append(created, note.ID)
time.Sleep(time.Millisecond)
}

// Update the first note, as grouping or moving it during the retro
// would - this is what can shift a row's storage position in SQLite.
if _, err := dal.NoteUpdate(ctx, db, created[0], uuid.Nil, uuid.New(), "a, regrouped", "", false); err != nil {
t.Fatalf("failed to update note: %v", err)
}

notes, err := dal.NoteList(ctx, db, retro.ID)
if err != nil {
t.Fatalf("failed to list notes: %v", err)
}

for i, note := range notes {
if note.ID != created[i] {
t.Errorf("position %d: expected note %s, got %s - order changed after an update", i, created[i], note.ID)
}
}
}
11 changes: 10 additions & 1 deletion cmd/thoughts/resources/vote.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package resources

import (
"slices"
"strings"

"github.com/ellgreen/thoughts/cmd/thoughts/model"
"github.com/google/uuid"
"github.com/samber/lo"
Expand Down Expand Up @@ -30,7 +33,7 @@ func VotesWithCountFromModel(votes []*model.Vote) []*VoteWithCount {
groupVoteCounts[vote.GroupID]++
}

votesWithCount := make([]*VoteWithCount, 0)
votesWithCount := make([]*VoteWithCount, 0, len(groupVoteCounts))

for groupID, count := range groupVoteCounts {
votesWithCount = append(votesWithCount, &VoteWithCount{
Expand All @@ -39,5 +42,11 @@ func VotesWithCountFromModel(votes []*model.Vote) []*VoteWithCount {
})
}

// Map iteration order is randomised per run; sort so the response is
// stable instead of shuffling the group order on every request.
slices.SortFunc(votesWithCount, func(a, b *VoteWithCount) int {
return strings.Compare(a.GroupID.String(), b.GroupID.String())
})

return votesWithCount
}
49 changes: 49 additions & 0 deletions cmd/thoughts/resources/vote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package resources_test

import (
"testing"

"github.com/ellgreen/thoughts/cmd/thoughts/model"
"github.com/ellgreen/thoughts/cmd/thoughts/resources"
"github.com/google/uuid"
)

// Ranging over a map randomises Go's own iteration order, so this asserts
// the same input always produces the same output order - not just that the
// counts are right.
func TestVotesWithCountFromModelIsOrderedDeterministically(t *testing.T) {
groupA := uuid.New()
groupB := uuid.New()
groupC := uuid.New()

votes := []*model.Vote{
{GroupID: groupA}, {GroupID: groupB}, {GroupID: groupA},
{GroupID: groupC}, {GroupID: groupB}, {GroupID: groupB},
}

first := resources.VotesWithCountFromModel(votes)

for i := 0; i < 50; i++ {
got := resources.VotesWithCountFromModel(votes)

if len(got) != len(first) {
t.Fatalf("run %d: expected %d groups, got %d", i, len(first), len(got))
}

for j := range first {
if got[j].GroupID != first[j].GroupID || got[j].Count != first[j].Count {
t.Fatalf("run %d: order changed at position %d: expected %+v, got %+v",
i, j, first[j], got[j])
}
}
}

counts := map[uuid.UUID]int{}
for _, v := range first {
counts[v.GroupID] = v.Count
}

if counts[groupA] != 2 || counts[groupB] != 3 || counts[groupC] != 1 {
t.Errorf("unexpected counts: %+v", counts)
}
}
Loading
Loading