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
20 changes: 15 additions & 5 deletions docs/plan.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion process-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ processes:
command: bin/home
environment:
- BAKHYARD_LISTEN=tcp://127.0.0.1:8081
- BAKHYARD_PEERS=pulls=tcp://127.0.0.1:8085,todos=tcp://127.0.0.1:8084
- BAKHYARD_PEERS=pulls=tcp://127.0.0.1:8085,time=tcp://127.0.0.1:8088,todos=tcp://127.0.0.1:8084
readiness_probe:
http_get:
port: 8081
Expand Down
36 changes: 26 additions & 10 deletions services/home/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,41 @@ import (
"context"
"encoding/json/v2"
"net/http"
"sync"
"time"

"github.com/go-chi/chi/v5"

"github.com/codedogapp/bakhyard/services/home/internal/screen"
)

// Register serves GET /home on router for the user called name; other methods get 405. gather
// asks home's peers for what needs the user and how asking each went, and the items due before
// now(), read once they're in, are late.
func Register(
router chi.Router,
name string,
now func() time.Time,
gather func(context.Context) ([]screen.Item, []screen.SourceStatus),
) {
// Peers is how Register asks home's peers what the Home shows.
type Peers struct {
// Items asks for what needs the user, and how asking each peer went.
Items func(context.Context) ([]screen.Item, []screen.SourceStatus)
// Week asks time for this week's hours, and says how asking went.
Week func(context.Context) (*screen.Week, screen.State)
}

// Register serves GET /home on router for the user called name; other methods get 405. It asks
// peers for the items and the week at once, and the items due before now(), read once both are
// in, are late.
func Register(router chi.Router, name string, now func() time.Time, peers Peers) {
router.Get("/home", func(w http.ResponseWriter, r *http.Request) {
items, sources := gather(r.Context())
var (
asking sync.WaitGroup
week *screen.Week
weekState screen.State
)

asking.Go(func() { week, weekState = peers.Week(r.Context()) })

items, sources := peers.Items(r.Context())

asking.Wait()

home := screen.New(name, items, sources, now())
home.Week, home.WeekState = week, weekState

w.Header().Set("Content-Type", "application/json")
// The Home always encodes, so an error means the client has gone.
Expand Down
58 changes: 38 additions & 20 deletions services/home/internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func TestHomeGet(t *testing.T) {
}

func TestHomeWithNothingToDo(t *testing.T) {
rec := serve(t, http.MethodGet, listing())
rec := serve(t, http.MethodGet, nothing)

var got map[string]jsontext.Value
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
Expand All @@ -110,6 +110,10 @@ func TestHomeWithNothingToDo(t *testing.T) {
if week, found := got["week"]; found {
t.Errorf("week = %s, want none", week)
}

if state, found := got["weekState"]; found {
t.Errorf("weekState = %s, want none: home has no time service", state)
}
}

func TestHomeMarksLateOnceTheItemsAreIn(t *testing.T) {
Expand All @@ -120,7 +124,8 @@ func TestHomeMarksLateOnceTheItemsAreIn(t *testing.T) {
return []screen.Item{{ID: "todos/1", Due: at().Add(time.Millisecond)}}, nil
}

rec := serveWith(t, http.MethodGet, slow, func() time.Time { return clock })
rec := serveWith(t, http.MethodGet, api.Peers{Items: slow, Week: noTime},
func() time.Time { return clock })

var got home
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
Expand All @@ -136,27 +141,34 @@ func TestHomeAsksWithTheRequestsContext(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()

var asked error
var items, week error

gather := func(ctx context.Context) ([]screen.Item, []screen.SourceStatus) {
asked = ctx.Err()
peers := api.Peers{
Items: func(ctx context.Context) ([]screen.Item, []screen.SourceStatus) {
items = ctx.Err()

return nil, nil
},
Week: func(ctx context.Context) (*screen.Week, screen.State) {
week = ctx.Err()

return nil, nil
return nil, ""
},
}

router := chi.NewRouter()
api.Register(router, userName, at, gather)
api.Register(router, userName, at, peers)
router.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequestWithContext(ctx, http.MethodGet, "/home", nil))

if !errors.Is(asked, context.Canceled) {
t.Errorf("the items were asked for with a context that ended with %v, want %v",
asked, context.Canceled)
if !errors.Is(items, context.Canceled) || !errors.Is(week, context.Canceled) {
t.Errorf("the items and the week were asked for with contexts that ended with %v and "+
"%v, want %v", items, week, context.Canceled)
}
}

func TestHomeHead(t *testing.T) {
rec := serve(t, http.MethodHead, listing())
rec := serve(t, http.MethodHead, nothing)

if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
Expand All @@ -178,7 +190,7 @@ func TestHomeOtherMethods(t *testing.T) {

for _, method := range methods {
t.Run(method, func(t *testing.T) {
rec := serve(t, method, listing())
rec := serve(t, method, nothing)

if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
Expand All @@ -191,27 +203,33 @@ func TestHomeOtherMethods(t *testing.T) {
}
}

// listing returns a gatherer that lists items and no sources.
func listing(items ...screen.Item) gatherer {
return func(context.Context) ([]screen.Item, []screen.SourceStatus) { return items, nil }
// nothing lists no items and no sources.
func nothing(context.Context) ([]screen.Item, []screen.SourceStatus) {
return nil, nil
}

// noTime answers the week as home does without a time service: no week and no state.
func noTime(context.Context) (*screen.Week, screen.State) {
return nil, ""
}

// serve answers a request to /home with method, with what gather gathers, at the tests' time.
// serve answers a request to /home with method, with what gather gathers, at the tests' time,
// as home without a time service.
func serve(t *testing.T, method string, gather gatherer) *httptest.ResponseRecorder {
t.Helper()

return serveWith(t, method, gather, at)
return serveWith(t, method, api.Peers{Items: gather, Week: noTime}, at)
}

// serveWith is serve with the clock now.
// serveWith answers a request to /home with method, with what peers answer, at the clock now.
func serveWith(
t *testing.T, method string, gather gatherer, now func() time.Time,
t *testing.T, method string, peers api.Peers, now func() time.Time,
) *httptest.ResponseRecorder {
t.Helper()

router := chi.NewRouter()
router.Use(middleware.GetHead)
api.Register(router, userName, now, gather)
api.Register(router, userName, now, peers)

rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), method, "/home", nil))
Expand Down
94 changes: 94 additions & 0 deletions services/home/internal/api/week_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package api_test

import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"net/http"
"net/http/httptest"
"testing"
"testing/synctest"
"time"

"github.com/codedogapp/bakhyard/services/home/internal/api"
"github.com/codedogapp/bakhyard/services/home/internal/screen"
)

func TestHomeGetsTheWeek(t *testing.T) {
// A missing member reads as "".
tests := map[string]struct {
week *screen.Week
state screen.State
wantWeek, wantState string
}{
"a week": {
week: &screen.Week{LoggedHours: 31.5, TargetHours: 40}, state: screen.StateOK,
wantWeek: `{"loggedHours":31.5,"targetHours":40}`, wantState: `"ok"`,
},
"a week with nothing logged": {
week: &screen.Week{TargetHours: 40}, state: screen.StateOK,
wantWeek: `{"loggedHours":0,"targetHours":40}`, wantState: `"ok"`,
},
"no week": {state: screen.StateOK, wantState: `"ok"`},
"time unavailable": {state: screen.StateUnavailable, wantState: `"unavailable"`},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
got := members(t, serveWith(t, http.MethodGet,
api.Peers{Items: nothing, Week: weekAnswer(test.week, test.state)}, at))

week, state := string(got["week"]), string(got["weekState"])
if week != test.wantWeek || state != test.wantState {
t.Errorf("week = %s and weekState = %s, want %s and %s", week, state,
test.wantWeek, test.wantState)
}
})
}
}

func TestHomeAsksForTheItemsAndTheWeekAtOnce(t *testing.T) {
// In a bubble the fake clock moves only once every goroutine waits, so sleeps take no time.
synctest.Test(t, func(t *testing.T) {
items := func(context.Context) ([]screen.Item, []screen.SourceStatus) {
time.Sleep(time.Second)

return nil, nil
}
week := func(context.Context) (*screen.Week, screen.State) {
time.Sleep(2 * time.Second)

return &screen.Week{LoggedHours: 8, TargetHours: 40}, screen.StateOK
}

start := time.Now()
got := members(t, serveWith(t, http.MethodGet, api.Peers{Items: items, Week: week}, at))

if waited, want := time.Since(start), 2*time.Second; waited != want {
t.Errorf("the Home took %v, want %v: the slower ask alone", waited, want)
}

if week, want := string(got["week"]), `{"loggedHours":8,"targetHours":40}`; week != want {
t.Errorf("week = %s, want %s", week, want)
}
})
}

// weekAnswer returns a week ask that answers week and state.
func weekAnswer(
week *screen.Week, state screen.State,
) func(context.Context) (*screen.Week, screen.State) {
return func(context.Context) (*screen.Week, screen.State) { return week, state }
}

// members returns the Home's JSON members, by name.
func members(t *testing.T, rec *httptest.ResponseRecorder) map[string]jsontext.Value {
t.Helper()

var got map[string]jsontext.Value
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode body: %v", err)
}

return got
}
11 changes: 8 additions & 3 deletions services/home/internal/screen/screen.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,18 @@ type Item struct {
Late bool `json:"late"`
}

// Home is everything the Home screen shows. Sources says how asking each of home's peers went,
// so that a Home without some of its items doesn't look whole. It has no week until the time
// service logs hours (docs/plan.md decision 161).
// Home is everything the Home screen shows. Sources says how asking each of home's peers for its
// items went, so that a Home without some of its items doesn't look whole. WeekState says how
// asking the time service for the week went, so that the app can tell a week missing because
// time failed from one that time doesn't have (docs/plan.md decision 186).
type Home struct {
Name string `json:"name"`
Items []Item `json:"items"`
Sources []SourceStatus `json:"sources"`
// Week is nil when time has no week, when asking it failed, and when home has no time service.
Week *Week `json:"week,omitzero"`
// WeekState is empty when home has no time service to ask.
WeekState State `json:"weekState,omitempty"`
}

// New builds the Home at now for the user called name (see cleanName), with the items and the
Expand Down
10 changes: 6 additions & 4 deletions services/home/internal/screen/status.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package screen

// State says how asking one of home's peers what needs the user went.
// State says how asking one of home's peers went: any of them for its items, or time for the week.
type State string

// The states that a source can be in.
// The states that a source, or the week, can be in.
const (
// StateOK means the peer answered, or doesn't serve the contract and so has nothing to list.
// StateOK means the peer answered, or doesn't serve the attention contract and so has nothing
// to list.
StateOK State = "ok"
// StateUnavailable means the peer failed or didn't answer in time: its items are missing.
// StateUnavailable means the peer failed or didn't answer in time: its items, or the week, are
// missing.
StateUnavailable State = "unavailable"
)

Expand Down
7 changes: 7 additions & 0 deletions services/home/internal/screen/week.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package screen

// Week is the hours logged this week, against the weekly target, as the time service answers it.
type Week struct {
LoggedHours float64 `json:"loggedHours"`
TargetHours float64 `json:"targetHours"`
}
Loading
Loading