diff --git a/docs/plan.md b/docs/plan.md index 860acf0..57c8fc3 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -168,13 +168,14 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `week-card` | The Home's "This week" card, the hours logged against the target over a bar, moves with its formatting and the week's type to the Time app's own folder, `src/time`, as a component of its own, `WeekCard`, ahead of `time-page`, which shows it too; nothing changes on screen or in behaviour. The seventh of `time-tracking`'s thirteen steps (decision 183) | Done (#152) | | `time-page` | The Time page, at `/time`, shows the time report: this week's hours against the target on the Home's week card, then the last 7 days, a row each with the date, the weekday or "Today" and the hours logged, a Missing chip on each weekday before today without hours, and the missing days' count as the section's note. It loads the report each time it shows, says why the last search failed and when its hours are from, and says when Jira isn't set up or the report couldn't load. The eighth of `time-tracking`'s thirteen steps (decision 184) | Done (#153) | | `time-week` | The second contract between services, `bakhyard.time.v1`, whose `TimeService.GetWeek` answers this week's hours against the week's target, for the Home's "This week". The time service answers it from the latest read of my worklogs to succeed: with no week when Jira isn't set up, and with `UNAVAILABLE` while no read has succeeded. The ninth of `time-tracking`'s thirteen steps (decision 185) | Done (#154) | +| `home-time-week` | The Home's "This week" shows my real week: home asks the time service for it over the time contract, at the same time as it asks its peers for their items and with the same 1.5 s, and answers it as the Home's `week`, beside a new `weekState` that says how asking went, `ok` or `unavailable`. A week that time couldn't give still leaves "This week" out, until `week-unavailable` shows "Hours unavailable". The tenth of `time-tracking`'s fourteen steps (decision 186) | Done (#155) | | `nudge-merge` | Nudge, the user's phone-questions tool for agents (`codedogapp/nudge`), moves into Bakhyard: one app, not two (decision 157). The nudge session does the work, one small PR per step, from its plan, [`bakhyard-merge.md`](https://github.com/codedogapp/nudge/blob/main/docs/bakhyard-merge.md); this session doesn't stop its own steps for it | In review | After these: the product phases (§8.2), each split into steps. **Deferred until a step needs it**: TypeScript code from the contracts and `buf breaking` (decision 158), NATS and events, OpenTelemetry, Prometheus metrics, streaming, unix sockets, a shared kit, per-service Go modules and `go.work`, the git hook, most mise tasks, and process-compose's detached mode and API server (§4.9). -**Ideas**: `sonar-issues` could page past 200 issues, or warn when it cuts them off. Longer task scripts could move into files that `check` shellchecks; today only the workflow's `run:` scripts are checked. A gate self-test could lint known-bad fixtures and expect every gate to fire, so a config typo can't switch one off silently (§10). pnpm's `savePrefix` setting could be empty, so `pnpm add` pins exact versions without `-E`. `//apps/web:check` could run `pnpm dedupe --check`, so a lockfile that a `pnpm add` by hand leaves with an older copy of a package fails too; Renovate's updates are deduped already (decision 101). The Home's components each style their own big numbers and mono labels (the app counts, the week's hours, the section head's note); shared type styles could replace the copies. The header's nav marks an app's link `aria-current="page"` on the app's deeper pages too (`/notes/x`); ARIA and GOV.UK's service navigation keep "page" for the link's own page and use "true" for a section the page is in, as the tab bar's More does (decision 52). Screen readers aren't told when a page changes in place; a polite live region that reads out the new title after each navigation could tell them, as SvelteKit's route announcer does. Each service's port is written twice in `process-compose.yaml`, in its `BAKHYARD_LISTEN` and its probe, and the gateway's `BAKHYARD_PEERS` repeats the other services'; a check could keep them in step. Nothing checks that every address is on the loopback, though nothing should listen on the LAN (§3). Pushover could join Web Push as a second notification channel, for alerts that repeat until acknowledged (§2, decision 64). After signing out, Back may bring back the signed-in page from the browser's back-forward cache, data included; the app could check its session again when a page is restored (`pageshow` with `persisted`). The web app's dev server (`//apps/web:dev`) proxies `/api` to the gateway on the loopback, which has answered 403 there since `auth-tailscale` (decision 77), so it shows the app without data; local development needs a way through the lock. The Notes filter matches the notes' paths only; a full-text search could look inside them too. A note with a dot in its name gets the web service's 404 when its page is reloaded (decision 94); the service could serve the app for every missing path under `/notes/`. Back and Forward can't put a note's page where it was left: the browser restores the scroll position once the page shows, before the note has loaded; the page could restore it itself once the note is in (the Navigation API's `scroll: 'manual'` and `event.scroll()`). Footnote links in a note go nowhere: rehype-sanitize prefixes the footnotes' ids but not the links to them, which GitHub fixes with a script (the vault has no footnotes yet). A task's checkbox has no accessible name, as on GitHub; it could take its item's text. These link forms wait until the vault uses them (decision 97): a note shown inline, where `![[Note]]` is a link today; heading and block links into other notes, which open the note at its top; links in a list property; links to files that aren't notes; relative paths (`../`); and links that match a note's name in another case. An image embed's size waits the same way (decision 98): `![[x.png|300]]` and `![[x.png|300x200]]` show the image at its own size today. So do embeds of other files, such as audio, video and PDFs, Markdown images of the vault's files (`![](x.png)`), and a way to see an image larger. Callouts, `> [!note]`, wait until the vault has one (decision 99). KaTeX's red for a formula that doesn't parse, `#cc0000`, has about 3.2:1 contrast on the dark paper, under WCAG AA's 4.5:1; a colour of our own could replace it. Every note's page loads KaTeX with the renderer, though one note in the vault has math; KaTeX could load only for a note that has some. The build also ships KaTeX's woff and ttf fonts, which a browser loads only when it can't read woff2, the first format KaTeX's stylesheet lists. Svelte's docs advise against the `class:` directive since 5.16, when the `class` attribute began to take an object or an array; `todos-groups` uses the attribute, while `AppCards` and the Notes `Tree` still use the directive. No test runs a migration's Down; a test could take each service's database down a migration and up again, so a Down that doesn't undo its Up fails. A todo dragged near the screen's top or bottom edge could scroll the page, so a todo on a long list can go further than the screen shows; today the user scrolls first, or uses the editor's moves (decision 120). Escape could cancel a drag. A dragged todo's row could follow the pointer while the other rows slide aside to make room, as iOS lists do, rather than a line marking the place; across groups, the cards would have to grow and shrink as it passes (decision 120). A todo could be dropped into a priority that has no todos, which has no section to drop it in; the editor's priority does that (decision 117). Thirteen components copy the card's style, a 1 px `--rule` border on the `--surface` with the offset shadow, from the Home's "Needs you" to the Time page's days; a shared class could replace the copies, as `list.css` did for the rows (decision 124). The end-to-end tests keep no trace of a failed test, so a flaky one can't be looked into afterwards; `trace: 'retain-on-failure'` would keep one. In `todos-ticket`, the Move test's five-second wait for the second move ran out twice in `mise run check`, on battery in Low Power Mode, though the move did land just after; it passed in 660 runs alone or in the whole suite. A linked todo's badge could link to its ticket in Jira; it sits in the box's label, where a tap ticks the todo, so the link needs a target of its own, at least 24 px and apart from the label (WCAG 2.2 SC 2.5.8, decision 126). The Jira inbox keeps a dismissal for good, so a dismissed ticket that leaves my open tickets and comes back later, reassigned to me or reopened, stays out of the inbox; the service could forget a dismissal once a search that succeeds no longer finds its ticket (decision 128). An inbox row could link to its ticket in Jira, as the Jira section's rows do; the row isn't a link, since it holds an Add button, so the link needs a target of its own, as the badge's does (decision 129). The Notes page's Sync could also make Gitea mirror the vault at once, through Gitea's `POST /repos/{owner}/{repo}/mirror-sync`, so a note that the Mac pushes shows on the laptop within a minute rather than up to ten (decision 151). The images workflow builds and tags all six images on every change, so Flux restarts every service each time (decision 152); it could build only the images that a change touches. GitHub's CI could build the images on each PR too, without pushing them, so a Dockerfile that no longer builds fails before the merge rather than in Gitea after it. The five Go services' Dockerfiles differ only in the service's name; one Dockerfile with the name as a build argument could replace them (decision 153). The Jira inbox's rule lives in two places, the Todos page's `inboxTickets` and todos' attention lister (decision 160); the page could take the inbox from todos through a JSON route, so the rule lives in one. The services talk in plaintext inside the cluster, gRPC over h2c and HTTP/1.1, behind the NetworkPolicies (decisions 137 and 158); mutual TLS between them, from a service mesh or from cert-manager's certificates, would encrypt that traffic. SonarQube Cloud's rule for clear-text protocols, S5332, looks for an `http://` literal, so it doesn't see these calls (decision 168). The services' connections to Postgres are encrypted but don't check the server's certificate (`sslmode=require`); with CloudNativePG's CA mounted and `sslmode=verify-full`, they would (decision 170). pulls could find my Bitbucket workspaces itself, through `GET /2.0/user/workspaces`, which needs the scope `read:workspace:bitbucket` that today's token lacks, and drop `BAKHYARD_BITBUCKET_WORKSPACES`. The time report's target is 8 hours each weekday, so a public holiday or a day off shows as a missing day (decision 180); a setting could list the days without a target. +**Ideas**: `sonar-issues` could page past 200 issues, or warn when it cuts them off. Longer task scripts could move into files that `check` shellchecks; today only the workflow's `run:` scripts are checked. A gate self-test could lint known-bad fixtures and expect every gate to fire, so a config typo can't switch one off silently (§10). pnpm's `savePrefix` setting could be empty, so `pnpm add` pins exact versions without `-E`. `//apps/web:check` could run `pnpm dedupe --check`, so a lockfile that a `pnpm add` by hand leaves with an older copy of a package fails too; Renovate's updates are deduped already (decision 101). The Home's components each style their own big numbers and mono labels (the app counts, the week's hours, the section head's note); shared type styles could replace the copies. The header's nav marks an app's link `aria-current="page"` on the app's deeper pages too (`/notes/x`); ARIA and GOV.UK's service navigation keep "page" for the link's own page and use "true" for a section the page is in, as the tab bar's More does (decision 52). Screen readers aren't told when a page changes in place; a polite live region that reads out the new title after each navigation could tell them, as SvelteKit's route announcer does. Each service's port is written twice in `process-compose.yaml`, in its `BAKHYARD_LISTEN` and its probe, and the gateway's `BAKHYARD_PEERS` repeats the other services'; a check could keep them in step. Nothing checks that every address is on the loopback, though nothing should listen on the LAN (§3). Pushover could join Web Push as a second notification channel, for alerts that repeat until acknowledged (§2, decision 64). After signing out, Back may bring back the signed-in page from the browser's back-forward cache, data included; the app could check its session again when a page is restored (`pageshow` with `persisted`). The web app's dev server (`//apps/web:dev`) proxies `/api` to the gateway on the loopback, which has answered 403 there since `auth-tailscale` (decision 77), so it shows the app without data; local development needs a way through the lock. The Notes filter matches the notes' paths only; a full-text search could look inside them too. A note with a dot in its name gets the web service's 404 when its page is reloaded (decision 94); the service could serve the app for every missing path under `/notes/`. Back and Forward can't put a note's page where it was left: the browser restores the scroll position once the page shows, before the note has loaded; the page could restore it itself once the note is in (the Navigation API's `scroll: 'manual'` and `event.scroll()`). Footnote links in a note go nowhere: rehype-sanitize prefixes the footnotes' ids but not the links to them, which GitHub fixes with a script (the vault has no footnotes yet). A task's checkbox has no accessible name, as on GitHub; it could take its item's text. These link forms wait until the vault uses them (decision 97): a note shown inline, where `![[Note]]` is a link today; heading and block links into other notes, which open the note at its top; links in a list property; links to files that aren't notes; relative paths (`../`); and links that match a note's name in another case. An image embed's size waits the same way (decision 98): `![[x.png|300]]` and `![[x.png|300x200]]` show the image at its own size today. So do embeds of other files, such as audio, video and PDFs, Markdown images of the vault's files (`![](x.png)`), and a way to see an image larger. Callouts, `> [!note]`, wait until the vault has one (decision 99). KaTeX's red for a formula that doesn't parse, `#cc0000`, has about 3.2:1 contrast on the dark paper, under WCAG AA's 4.5:1; a colour of our own could replace it. Every note's page loads KaTeX with the renderer, though one note in the vault has math; KaTeX could load only for a note that has some. The build also ships KaTeX's woff and ttf fonts, which a browser loads only when it can't read woff2, the first format KaTeX's stylesheet lists. Svelte's docs advise against the `class:` directive since 5.16, when the `class` attribute began to take an object or an array; `todos-groups` uses the attribute, while `AppCards` and the Notes `Tree` still use the directive. No test runs a migration's Down; a test could take each service's database down a migration and up again, so a Down that doesn't undo its Up fails. A todo dragged near the screen's top or bottom edge could scroll the page, so a todo on a long list can go further than the screen shows; today the user scrolls first, or uses the editor's moves (decision 120). Escape could cancel a drag. A dragged todo's row could follow the pointer while the other rows slide aside to make room, as iOS lists do, rather than a line marking the place; across groups, the cards would have to grow and shrink as it passes (decision 120). A todo could be dropped into a priority that has no todos, which has no section to drop it in; the editor's priority does that (decision 117). Thirteen components copy the card's style, a 1 px `--rule` border on the `--surface` with the offset shadow, from the Home's "Needs you" to the Time page's days; a shared class could replace the copies, as `list.css` did for the rows (decision 124). The end-to-end tests keep no trace of a failed test, so a flaky one can't be looked into afterwards; `trace: 'retain-on-failure'` would keep one. In `todos-ticket`, the Move test's five-second wait for the second move ran out twice in `mise run check`, on battery in Low Power Mode, though the move did land just after; it passed in 660 runs alone or in the whole suite. A linked todo's badge could link to its ticket in Jira; it sits in the box's label, where a tap ticks the todo, so the link needs a target of its own, at least 24 px and apart from the label (WCAG 2.2 SC 2.5.8, decision 126). The Jira inbox keeps a dismissal for good, so a dismissed ticket that leaves my open tickets and comes back later, reassigned to me or reopened, stays out of the inbox; the service could forget a dismissal once a search that succeeds no longer finds its ticket (decision 128). An inbox row could link to its ticket in Jira, as the Jira section's rows do; the row isn't a link, since it holds an Add button, so the link needs a target of its own, as the badge's does (decision 129). The Notes page's Sync could also make Gitea mirror the vault at once, through Gitea's `POST /repos/{owner}/{repo}/mirror-sync`, so a note that the Mac pushes shows on the laptop within a minute rather than up to ten (decision 151). The images workflow builds and tags all six images on every change, so Flux restarts every service each time (decision 152); it could build only the images that a change touches. GitHub's CI could build the images on each PR too, without pushing them, so a Dockerfile that no longer builds fails before the merge rather than in Gitea after it. The five Go services' Dockerfiles differ only in the service's name; one Dockerfile with the name as a build argument could replace them (decision 153). The Jira inbox's rule lives in two places, the Todos page's `inboxTickets` and todos' attention lister (decision 160); the page could take the inbox from todos through a JSON route, so the rule lives in one. The services talk in plaintext inside the cluster, gRPC over h2c and HTTP/1.1, behind the NetworkPolicies (decisions 137 and 158); mutual TLS between them, from a service mesh or from cert-manager's certificates, would encrypt that traffic. SonarQube Cloud's rule for clear-text protocols, S5332, looks for an `http://` literal, so it doesn't see these calls (decision 168). The services' connections to Postgres are encrypted but don't check the server's certificate (`sslmode=require`); with CloudNativePG's CA mounted and `sslmode=verify-full`, they would (decision 170). pulls could find my Bitbucket workspaces itself, through `GET /2.0/user/workspaces`, which needs the scope `read:workspace:bitbucket` that today's token lacks, and drop `BAKHYARD_BITBUCKET_WORKSPACES`. The time report's target is 8 hours each weekday, so a public holiday or a day off shows as a missing day (decision 180); a setting could list the days without a target. JSON can't encode a due time outside the years 0 to 9999, so an item that a peer sends with one makes home cut the whole Home's answer short, as hours that aren't finite would (decision 186); home could count that peer as unavailable instead. ## 1. Goal and scope @@ -581,14 +582,14 @@ process-compose runs the services on the Mac only, for development; the laptop r ### 4.11 Home logic -> **v1.3:** as built in `home-fanout` (decision 167): home still answers `GET /home` with JSON rather than `GetHome`, and asks the peers in its own `BAKHYARD_PEERS` (§4.7), todos and pulls, rather than every service. Each call is gRPC over h2c with 1.5 s to answer. A failure other than `Unimplemented`, the timeout included, leaves that peer's items out and logs `list the items` with the peer's name, unless the request has ended. Since `home-sources` (decision 169), the answer's `sources` says how asking each peer went, `{"app","state"}` in `BAKHYARD_PEERS`' order, with the states in lower case, `ok` and `unavailable`, and no `message` yet. An item's app is its peer's name, and an item without an outside source has "Bakhyard" as its source. `late` is worked out once every peer has answered. +> **v1.3:** as built in `home-fanout` (decision 167): home still answers `GET /home` with JSON rather than `GetHome`, and asks the peers in its own `BAKHYARD_PEERS` (§4.7), todos, pulls and time, rather than every service. Each call is gRPC over h2c with 1.5 s to answer. A failure other than `Unimplemented`, the timeout included, leaves that peer's items out and logs `list the items` with the peer's name, unless the request has ended. Since `home-sources` (decision 169), the answer's `sources` says how asking each peer went, `{"app","state"}` in `BAKHYARD_PEERS`' order, with the states in lower case, `ok` and `unavailable`, and no `message` yet. An item's app is its peer's name, and an item without an outside source has "Bakhyard" as its source. `late` is worked out once every peer has answered. Since `home-time-week` (decision 186), home also asks the peer named `time` for `GetWeek`, at the same time as the items and with the same 1.5 s, and answers the week with `weekState`. - `GetHome` fans out `ListItems` to every peer except nats, concurrently (`wg.Go`), with a 1.5 s timeout each. - `Unimplemented` means that app has no items (OK). - Any other failure marks the source `UNAVAILABLE` with a message, is logged, and the rest still renders. Never a blank screen. - Item ids are `/`; `late = due is set && due < now`. - Sort order (`cmp.Or`): late first, then earliest due (no due date last), then title, then id. -- The week comes from `TimeService.GetWeek` on peer `time`, and is unset if `time` is down or isn't there yet: until the time service exists, home sends no week, and the Home leaves out "This week" (decision 161). +- The week comes from `TimeService.GetWeek` on peer `time` (decision 186). It's unset when time has no week, as without Jira; when time fails, doesn't answer in 1.5 s, or answers hours that aren't finite, which JSON can't encode, each logged as `get the week` unless the request has ended; and when home has no peer named `time`. `weekState` tells the first, `ok`, from the second, `unavailable`, and is left out in the third. Without a week, the Home leaves out "This week" (decision 161). - The name comes from `BAKHYARD_NAME`, without surrounding whitespace and with invalid UTF-8 replaced by U+FFFD; unset or blank, it falls back to "there". A name is personal, so it's set in the shell or in the gitignored `mise.local.toml` (`[env]`), never in git. - `WatchHome` sends immediately, again after each `bakhyard.attention.changed.*` event (250 ms debounce), and every 60 s. @@ -1199,7 +1200,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `pulls-reminders`: at 10:00 and 15:00 on weekdays, pulls pushes one reminder that lists my PRs under their required approvals (decision 177). Done (#144). - `homelab-pulls-notify`: done (codedogapp/homelab#40). In the homelab repo, pulls' `BAKHYARD_PEERS` names notify, `TZ` gives it the user's zone, and a NetworkPolicy lets pulls reach notify. - Later, with a database for pulls: snooze (2 h / tomorrow / until updated) and mark as nudged, from the PRs page. -- `time-tracking` [time]. Needs `todos-jira`. Reads and writes standard Jira worklogs (§2, decision 46), with no timers: Clockwork already has them inside Jira. Thirteen steps (decisions 180, 181 and 183): +- `time-tracking` [time]. Needs `todos-jira`. Reads and writes standard Jira worklogs (§2, decision 46), with no timers: Clockwork already has them inside Jira. Fourteen steps (decisions 180, 181, 183 and 186): - `shared-jira`: todos' Jira client moves to the root `internal/jira`, with the code that reads its settings and token, for the `time` service; todos' poller of my tickets moves to a package of its own, `tickets`. Nothing changes in behaviour. Done (#147). - `jira-worklogs`: the client reads my worklogs in a span of days: my account from `/myself`, the tickets I logged on from a `worklogAuthor` search, and each ticket's worklogs. Done (#148). - `time-report`: a package of the `time` service, `report`, works out the time report from my worklogs: the hours I logged on each of the last 7 days, with every weekday before today that has none marked as missing, plus this week's total against the target. Done (#149). @@ -1209,7 +1210,8 @@ Every item is pending unless it says done. The service an item lands in is in br - `week-card`: the Home's week card, its formatting and the week's type move to `src/time`, for the Time page to share; nothing changes on screen (decision 183). Done (#152). - `time-page`: the Time page shows the time report: this week's card, then the last 7 days with the missing ones marked (decision 184). Done (#153). - `time-week`: `time` answers `TimeService/GetWeek` (§4.5) with this week's hours against the target, from the latest read of the worklogs to succeed: no week when Jira isn't set up, and `UNAVAILABLE` until a read succeeds (decision 185). Done (#154). - - `home-time-week`: home asks `time` for the week, and the Home's "This week" shows it, or "Hours unavailable" when `time` doesn't answer (§7). + - `home-time-week`: home asks `time` for the week, and answers it with how asking went, `weekState`, so the Home's "This week" shows my real week (decision 186). Done (#155). + - `week-unavailable`: the Home's "This week" says "Hours unavailable" when home couldn't get the week from `time` (§7). - `time-attention`: `time` answers the attention contract with today's hours until they're logged, and each missing weekday. - `worklog-add`: the client adds a worklog, and `time` takes one at `POST /time/worklogs`. - `fill-day`: the Time page fills a missing day: pick one of my open tickets (assigned to me, not done) and the hours; Bakhyard shows the worklog it will add, starting at 09:00 local time, and writes it only after I confirm. @@ -1576,6 +1578,14 @@ Made on 2026-10-23: - **Code**: the server is `Server` in `services/time/internal/week`, a package of its own beside `api`, as pulls' `attention` is; `routes` mounts it on the router of the JSON API, with the same poller. - **The next step**: §8.2's `home-week` becomes `home-time-week`, since `home-week` is the id of a step done in Phase 1 (#24). - **Rejected**: no week until a read succeeds, which would make a failing Jira look unset; a zero week, which reads as nothing logged; `google.protobuf.Duration` fields, whose seconds the Home would turn back into hours; the read's time in the answer, which the Home doesn't show; the hours as two fields of the answer itself, so that "no week" needs both unset; the server inside `api`, which serves JSON. +186. **The Home's week from the time service** (`home-time-week`): the lead's choices (2026-10-29), for the user to review. Home asks the time service for this week's hours over the time contract (decision 185) and answers them as the Home's `week`, so "This week" shows my real week. + - **The split**: `home-time-week` brings the week and its state from home, and a new step, `week-unavailable`, brings the "Hours unavailable" card (§7) to the app, so `time-tracking` has fourteen steps (§8.2). Until then, a week that time couldn't give leaves "This week" out, as no week does (decision 161). + - **The peer**: home asks the peer named `time` in its own `BAKHYARD_PEERS` (§4.7), which the laptop's home already names (codedogapp/homelab#42), as `process-compose.yaml`'s now does; without one, it asks nothing. No setting of its own. + - **The call**: `GetWeek` over gRPC on h2c, with 1.5 s to answer, as each peer has to list its items (decision 167), and at the same time as the items, so the Home waits for the slower of the two rather than for both. + - **The answer**: `week` keeps its shape, `{"loggedHours","targetHours"}`, and gains a sibling, `weekState`, of the sources' state type (decision 169): `ok` when time answers, with a week or with none, as without Jira; `unavailable` when time fails, `UNAVAILABLE` included, doesn't answer in time, or answers hours that aren't finite: JSON can't encode NaN or an infinity, so home would cut the whole Home's answer short. A failure is logged as `get the week` with `peer=time`, unless the request has ended. Without a time peer, both members are left out. A week with nothing logged is still a week. + - **Any failure is unavailable**, `Unimplemented` included: a time service that doesn't answer the time contract doesn't work, whereas a peer without the attention contract has no items to list (decision 167). + - **Code**: a new package, `services/home/internal/week` (`Asker`, `New`, `Ask`), beside `feed`; `api.Register` takes both asks in an `api.Peers`, since a fifth parameter would break the four-parameter gate. + - **Rejected**: the state inside `week`, which an app still in a phone's cache would read as a week without hours; a `null` week for "unavailable", which the app reads as no week; a `weekUnavailable` boolean, where a state has a type of its own, as the sources' does; counting the week in `sources`, or in the top bar's count of missing items; asking for the week after the items, which would make the worst wait 3 s; a setting of its own for time's address. ## 10. Risks diff --git a/process-compose.yaml b/process-compose.yaml index bb7e3d9..20feb2f 100644 --- a/process-compose.yaml +++ b/process-compose.yaml @@ -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 diff --git a/services/home/internal/api/api.go b/services/home/internal/api/api.go index 8401649..9cd8619 100644 --- a/services/home/internal/api/api.go +++ b/services/home/internal/api/api.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json/v2" "net/http" + "sync" "time" "github.com/go-chi/chi/v5" @@ -12,18 +13,33 @@ import ( "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. diff --git a/services/home/internal/api/api_test.go b/services/home/internal/api/api_test.go index e03f86d..b3b3d51 100644 --- a/services/home/internal/api/api_test.go +++ b/services/home/internal/api/api_test.go @@ -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 { @@ -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) { @@ -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 { @@ -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) @@ -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) @@ -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)) diff --git a/services/home/internal/api/week_test.go b/services/home/internal/api/week_test.go new file mode 100644 index 0000000..ab6fc53 --- /dev/null +++ b/services/home/internal/api/week_test.go @@ -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 +} diff --git a/services/home/internal/screen/screen.go b/services/home/internal/screen/screen.go index 7d299f9..31d7499 100644 --- a/services/home/internal/screen/screen.go +++ b/services/home/internal/screen/screen.go @@ -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 diff --git a/services/home/internal/screen/status.go b/services/home/internal/screen/status.go index c1d479a..3ae9a2c 100644 --- a/services/home/internal/screen/status.go +++ b/services/home/internal/screen/status.go @@ -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" ) diff --git a/services/home/internal/screen/week.go b/services/home/internal/screen/week.go new file mode 100644 index 0000000..e56e1ec --- /dev/null +++ b/services/home/internal/screen/week.go @@ -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"` +} diff --git a/services/home/internal/week/week.go b/services/home/internal/week/week.go new file mode 100644 index 0000000..c7310ac --- /dev/null +++ b/services/home/internal/week/week.go @@ -0,0 +1,104 @@ +// Package week asks the time service for this week's hours, for the Home's "This week", over the +// time contract (proto/bakhyard/time/v1). +package week + +import ( + "context" + "fmt" + "log/slog" + "math" + "net/http" + "net/url" + "slices" + "time" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + + "github.com/codedogapp/bakhyard/internal/env" + timev1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1" + "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1/timev1connect" + "github.com/codedogapp/bakhyard/services/home/internal/screen" +) + +// peerName is the time service's name among home's peers. +const peerName = "time" + +// timeout is how long time has to answer before the Home goes without its week, as long as each +// peer has to list its items. +const timeout = 1500 * time.Millisecond + +// Asker asks the time service for this week's hours. Its zero value has no time service to ask. +type Asker struct { + client timev1connect.TimeServiceClient + logger *slog.Logger +} + +// New returns an Asker that asks the peer named time over gRPC on h2c, HTTP/2 without TLS, as the +// services serve it, and logs its failures to logger. Without a peer named time, it asks nothing. +func New(peers []env.Peer, logger *slog.Logger) Asker { + index := slices.IndexFunc(peers, func(peer env.Peer) bool { return peer.Name == peerName }) + if index < 0 { + return Asker{} + } + + h2c := new(http.Protocols) + h2c.SetUnencryptedHTTP2(true) + + httpClient := &http.Client{Transport: &http.Transport{Protocols: h2c}} + server := url.URL{Scheme: "http", Host: peers[index].Address} + transport := connecthttp.NewTransport(httpClient, server.String(), connecthttp.WithGRPC()) + + return Asker{ + client: timev1connect.NewTimeServiceClient(connect.NewClient(transport)), + logger: logger, + } +} + +// Ask asks time for this week's hours. It returns the week, or nil when time has none, and how +// asking went: OK once time answers, and unavailable when it fails, doesn't answer within +// timeout, or answers hours that aren't finite, which JSON can't encode. A failure is logged +// unless ctx has ended, since the client has gone then. Without a time service to ask, Ask +// returns no week and no state. +func (a Asker) Ask(ctx context.Context) (*screen.Week, screen.State) { + if a.client == nil { + return nil, "" + } + + callCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + res, err := a.client.GetWeek(callCtx, &timev1.GetWeekRequest{}) + if err != nil { + a.logFailure(ctx, err) + + return nil, screen.StateUnavailable + } + + if !res.HasWeek() { + return nil, screen.StateOK + } + + answer := res.GetWeek() + + logged, target := answer.GetLoggedHours(), answer.GetTargetHours() + if !finite(logged) || !finite(target) { + a.logFailure(ctx, fmt.Errorf("hours that aren't finite: %v of %v", logged, target)) + + return nil, screen.StateUnavailable + } + + return &screen.Week{LoggedHours: logged, TargetHours: target}, screen.StateOK +} + +// logFailure logs why asking failed, unless ctx has ended, since the client has gone then. +func (a Asker) logFailure(ctx context.Context, err error) { + if ctx.Err() == nil { + a.logger.ErrorContext(ctx, "get the week", "peer", peerName, "err", err) + } +} + +// finite reports whether hours is neither NaN nor infinite. +func finite(hours float64) bool { + return !math.IsNaN(hours) && !math.IsInf(hours, 0) +} diff --git a/services/home/internal/week/week_internal_test.go b/services/home/internal/week/week_internal_test.go new file mode 100644 index 0000000..d7d9254 --- /dev/null +++ b/services/home/internal/week/week_internal_test.go @@ -0,0 +1,70 @@ +package week + +import ( + "bytes" + "context" + "log/slog" + "strings" + "testing" + "testing/synctest" + "time" + + timev1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1" + "github.com/codedogapp/bakhyard/services/home/internal/screen" +) + +// These tests run in a bubble, whose fake clock moves only once every goroutine in it waits, so +// they can tell how long Ask waits. Real connections don't wait that way, hence the fake client. + +func TestAskWaitsForTimeOnlyUntilTheTimeout(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var logs bytes.Buffer + + asker := Asker{client: silent{}, logger: slog.New(slog.NewTextHandler(&logs, nil))} + + start := time.Now() + got, state := asker.Ask(t.Context()) + + if waited, want := time.Since(start), 1500*time.Millisecond; waited != want { + t.Errorf("Ask waited %v, want %v", waited, want) + } + + if got != nil || state != screen.StateUnavailable { + t.Errorf("got %+v and %q, want no week, unavailable", got, state) + } + + if !strings.Contains(logs.String(), "peer=time err=") { + t.Errorf("logged %q, want a line about time", logs.String()) + } + }) +} + +func TestAskLogsNothingOnceTheClientHasGone(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var logs bytes.Buffer + + asker := Asker{client: silent{}, logger: slog.New(slog.NewTextHandler(&logs, nil))} + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + + if got, state := asker.Ask(ctx); got != nil || state != screen.StateUnavailable { + t.Errorf("got %+v and %q, want no week, unavailable", got, state) + } + + if logs.Len() != 0 { + t.Errorf("logged %q, want nothing", logs.String()) + } + }) +} + +// silent never answers: it waits until the call is given up. +type silent struct{} + +func (silent) GetWeek( + ctx context.Context, _ *timev1.GetWeekRequest, +) (*timev1.GetWeekResponse, error) { + <-ctx.Done() + + return nil, ctx.Err() +} diff --git a/services/home/internal/week/week_test.go b/services/home/internal/week/week_test.go new file mode 100644 index 0000000..1d4d19a --- /dev/null +++ b/services/home/internal/week/week_test.go @@ -0,0 +1,190 @@ +package week_test + +import ( + "bytes" + "context" + "log/slog" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + + "github.com/codedogapp/bakhyard/internal/env" + timev1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1" + "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1/timev1connect" + "github.com/codedogapp/bakhyard/services/home/internal/screen" + "github.com/codedogapp/bakhyard/services/home/internal/week" +) + +func TestAskAnswersTimesWeek(t *testing.T) { + tests := map[string]struct { + answer *timev1.GetWeekResponse + want *screen.Week + }{ + "a week": { + answer: weekOf(31.5, 40), + want: &screen.Week{LoggedHours: 31.5, TargetHours: 40}, + }, + "a week with nothing logged": { + answer: weekOf(0, 40), + want: &screen.Week{LoggedHours: 0, TargetHours: 40}, + }, + "no week": {answer: &timev1.GetWeekResponse{}}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + address := serveTime(t, test.answer, nil) + got, state, logs := ask(t, env.Peer{Name: "time", Address: address}) + + if !sameWeek(got, test.want) || state != screen.StateOK || logs != "" { + t.Errorf("got %+v, %q and logs %q, want %+v, ok and no logs", got, state, logs, + test.want) + } + }) + } +} + +func TestAskMarksTheWeekUnavailableAndLogsIt(t *testing.T) { + // Hours that aren't finite would cut the whole Home's JSON short. + tests := map[string]string{ + "time fails": serveTime(t, nil, + connect.NewError(connect.CodeUnavailable, "the week isn't read yet")), + "time is down": downAddress(t), + "NaN hours logged": serveTime(t, weekOf(math.NaN(), 40), nil), + "an infinite target": serveTime(t, weekOf(31.5, math.Inf(1)), nil), + "minus infinite logged": serveTime(t, weekOf(math.Inf(-1), 40), nil), + } + + for name, address := range tests { + t.Run(name, func(t *testing.T) { + got, state, logs := ask(t, env.Peer{Name: "time", Address: address}) + + if got != nil || state != screen.StateUnavailable { + t.Errorf("got %+v and %q, want no week, unavailable", got, state) + } + + if !strings.Contains(logs, `level=ERROR msg="get the week" peer=time err=`) || + strings.Count(logs, "\n") != 1 { + t.Errorf("logged %q, want one line about time", logs) + } + }) + } +} + +func TestAskAsksThePeerNamedTime(t *testing.T) { + got, state, logs := ask(t, + env.Peer{Name: "todos", Address: downAddress(t)}, + env.Peer{Name: "time", Address: serveTime(t, weekOf(8, 37.5), nil)}, + env.Peer{Name: "pulls", Address: downAddress(t)}, + ) + + want := &screen.Week{LoggedHours: 8, TargetHours: 37.5} + if !sameWeek(got, want) || state != screen.StateOK || logs != "" { + t.Errorf("got %+v, %q and logs %q, want %+v, ok and no logs", got, state, logs, want) + } +} + +func TestAskWithoutTimeAsksNothing(t *testing.T) { + // A peer by another name that would answer a week, and no peers at all. + tests := map[string][]env.Peer{ + "another peer": {{Name: "todos", Address: serveTime(t, weekOf(8, 40), nil)}}, + "no peers": nil, + } + + for name, peers := range tests { + t.Run(name, func(t *testing.T) { + got, state, logs := ask(t, peers...) + + if got != nil || state != "" || logs != "" { + t.Errorf("got %+v, %q and logs %q, want no week, no state, no logs", got, state, + logs) + } + }) + } +} + +// ask returns what an Asker of peers answers, and what it logs. +func ask(t *testing.T, peers ...env.Peer) (*screen.Week, screen.State, string) { + t.Helper() + + var logs bytes.Buffer + + got, state := week.New(peers, slog.New(slog.NewTextHandler(&logs, nil))).Ask(t.Context()) + + return got, state, logs.String() +} + +func weekOf(logged, target float64) *timev1.GetWeekResponse { + return timev1.GetWeekResponse_builder{ + Week: timev1.Week_builder{LoggedHours: new(logged), TargetHours: new(target)}.Build(), + }.Build() +} + +func sameWeek(a, b *screen.Week) bool { + return a == b || a != nil && b != nil && *a == *b +} + +// answering answers GetWeek with its answer, or its error. +type answering struct { + answer *timev1.GetWeekResponse + err error +} + +func (a answering) GetWeek( + context.Context, *timev1.GetWeekRequest, +) (*timev1.GetWeekResponse, error) { + return a.answer, a.err +} + +// serveTime serves the time contract on h2c, gRPC only as the services speak it to each other +// (decision 158), answering GetWeek with answer or err, and returns its address. +func serveTime(t *testing.T, answer *timev1.GetWeekResponse, err error) string { + t.Helper() + + server := connect.NewServer() + timev1connect.RegisterTimeServiceHandler(server, answering{answer: answer, err: err}) + + mux := http.NewServeMux() + connecthttp.Mount(mux, server) + + h2c := new(http.Protocols) + h2c.SetUnencryptedHTTP2(true) + + listener := httptest.NewUnstartedServer(grpcOnly(mux)) + listener.Config.Protocols = h2c + listener.Start() + t.Cleanup(listener.Close) + + return listener.Listener.Addr().String() +} + +// grpcOnly refuses a call that isn't gRPC, though the contract's handlers answer Connect's +// protocol and gRPC-Web too. +func grpcOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contentType := r.Header.Get("Content-Type") + if contentType != "application/grpc" && + !strings.HasPrefix(contentType, "application/grpc+") { + http.Error(w, "gRPC only", http.StatusUnsupportedMediaType) + + return + } + + next.ServeHTTP(w, r) + }) +} + +// downAddress returns an address where nothing listens any more. +func downAddress(t *testing.T) string { + t.Helper() + + server := httptest.NewServer(http.NotFoundHandler()) + server.Close() + + return server.Listener.Addr().String() +} diff --git a/services/home/main.go b/services/home/main.go index ddcb1d0..55abf53 100644 --- a/services/home/main.go +++ b/services/home/main.go @@ -15,6 +15,7 @@ import ( "github.com/codedogapp/bakhyard/internal/service" "github.com/codedogapp/bakhyard/services/home/internal/api" "github.com/codedogapp/bakhyard/services/home/internal/feed" + "github.com/codedogapp/bakhyard/services/home/internal/week" ) func main() { @@ -32,12 +33,14 @@ func routes(_ context.Context, getenv func(string) string) (http.Handler, error) } homeFeed := feed.New(peers, slog.Default()) + homeWeek := week.New(peers, slog.Default()) router := chi.NewRouter() // Answer HEAD wherever GET is served. router.Use(middleware.GetHead) health.Register(router) - api.Register(router, getenv("BAKHYARD_NAME"), time.Now, homeFeed.Gather) + api.Register(router, getenv("BAKHYARD_NAME"), time.Now, + api.Peers{Items: homeFeed.Gather, Week: homeWeek.Ask}) return router, nil } diff --git a/services/home/main_test.go b/services/home/main_test.go new file mode 100644 index 0000000..4797a2b --- /dev/null +++ b/services/home/main_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "net/http" + "net/http/httptest" + "testing" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + + timev1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1" + "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1/timev1connect" +) + +func TestHomeAnswersTheWeekFromTime(t *testing.T) { + settings := map[string]string{"BAKHYARD_PEERS": "time=tcp://" + serveTime(t)} + + router, err := routes(t.Context(), func(name string) string { return settings[name] }) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/home", nil)) + + var got map[string]jsontext.Value + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("got the Home %s (%v), want JSON", rec.Body, err) + } + + week, state := string(got["week"]), string(got["weekState"]) + if want := `{"loggedHours":31.5,"targetHours":40}`; week != want || state != `"ok"` { + t.Errorf("week = %s and weekState = %s, want %s and \"ok\"", week, state, want) + } +} + +// timeStandIn answers GetWeek with 31.5 of 40 hours. +type timeStandIn struct{} + +func (timeStandIn) GetWeek( + context.Context, *timev1.GetWeekRequest, +) (*timev1.GetWeekResponse, error) { + return timev1.GetWeekResponse_builder{ + Week: timev1.Week_builder{LoggedHours: new(31.5), TargetHours: new(40.0)}.Build(), + }.Build(), nil +} + +// serveTime serves the stand-in over the time contract on h2c, as the services serve it, and +// returns its address. +func serveTime(t *testing.T) string { + t.Helper() + + server := connect.NewServer() + timev1connect.RegisterTimeServiceHandler(server, timeStandIn{}) + + mux := http.NewServeMux() + connecthttp.Mount(mux, server) + + h2c := new(http.Protocols) + h2c.SetUnencryptedHTTP2(true) + + listener := httptest.NewUnstartedServer(mux) + listener.Config.Protocols = h2c + listener.Start() + t.Cleanup(listener.Close) + + return listener.Listener.Addr().String() +}