Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cebf8b4d79 |
+2
-17
@@ -79,7 +79,6 @@ type Auth struct {
|
||||
provider *oidc.Provider
|
||||
client *http.Client
|
||||
cookie loginCookie
|
||||
pending *PendingStates
|
||||
sessions Sessions
|
||||
allowed []string
|
||||
// endSessionURL is the provider's end_session_endpoint; logout ends
|
||||
@@ -158,7 +157,6 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
client: client,
|
||||
allowed: slices.Clone(cfg.AllowedGroups),
|
||||
cookie: newLoginCookie(cfg.SecretKey),
|
||||
pending: NewPendingStates(loginCookieTTL),
|
||||
sessions: sessions,
|
||||
endSessionURL: discovered.EndSessionURI,
|
||||
logger: logger,
|
||||
@@ -252,13 +250,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||
Verifier: oauth2.GenerateVerifier(),
|
||||
Return: returnTo,
|
||||
}
|
||||
now := time.Now()
|
||||
if err := a.pending.Add(state.State, now); err != nil {
|
||||
a.logger.WarnContext(r.Context(), "login refused", "error", err)
|
||||
http.Error(w, "too many sign-ins in progress; try again later", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := a.cookie.set(w, state, now); err != nil {
|
||||
if err := a.cookie.set(w, state, time.Now()); err != nil {
|
||||
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -270,7 +262,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// callback completes the flow: it checks state against the login cookie,
|
||||
// consumes it, exchanges the code, verifies the ID token and nonce, then starts a session
|
||||
// exchanges the code, verifies the ID token and nonce, then starts a session
|
||||
// if the account is in an allowed group. Membership is checked on every
|
||||
// login, so an account removed from the group is refused at its next one.
|
||||
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -291,13 +283,6 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// A copy of the cookie verifies until it expires, so the state is
|
||||
// single-use only because it is consumed here.
|
||||
if !a.pending.Consume(state.State) {
|
||||
a.logger.WarnContext(ctx, "login callback state already used")
|
||||
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if q.Get("error") != "" {
|
||||
// error and error_description are provider-supplied text; only the
|
||||
// fixed category is logged.
|
||||
|
||||
@@ -497,59 +497,6 @@ func TestCallbackDefaultReturnPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A saved copy of the login cookie still verifies until it expires, so a
|
||||
// replay with a second valid code is refused only because the server
|
||||
// consumed the state on first use.
|
||||
func TestCallbackRefusesReplayedLoginState(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
idToken := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
|
||||
|
||||
if resp := h.callback(l, l.query.Get("state"), idToken); resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("first callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
resp := h.callback(l, l.query.Get("state"), idToken)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("replayed callback status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 1 {
|
||||
t.Errorf("%d sessions started, want 1", n)
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
}
|
||||
|
||||
// Pending login states live in memory, so a login started before a restart
|
||||
// fails closed after it even though its cookie still verifies.
|
||||
func TestCallbackRefusesLoginStartedBeforeRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
|
||||
restarted, err := New(context.Background(), Config{
|
||||
Issuer: h.provider.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, h.sessions, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
restarted.Mount(r)
|
||||
h.router = r
|
||||
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback after restart status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 0 {
|
||||
t.Errorf("%d sessions started, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -872,44 +819,6 @@ func TestLoginCookieExpires(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := NewPendingStates(time.Minute)
|
||||
now := time.Now()
|
||||
if err := p.Add("first", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.Consume("first") {
|
||||
t.Error("Consume of a pending state = false")
|
||||
}
|
||||
if p.Consume("first") {
|
||||
t.Error("second Consume of a state = true")
|
||||
}
|
||||
if p.Consume("never-added") {
|
||||
t.Error("Consume of an unknown state = true")
|
||||
}
|
||||
|
||||
for i := range maxPendingStates - 1 {
|
||||
if err := p.Add(fmt.Sprint("s", i), now); err != nil {
|
||||
t.Fatalf("Add %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// "first" was consumed but has not expired, so it still counts.
|
||||
if err := p.Add("over", now); !errors.Is(err, ErrTooManyPending) {
|
||||
t.Errorf("Add past the bound = %v, want ErrTooManyPending", err)
|
||||
}
|
||||
// Once the earlier states expire, they are forgotten and a new one fits.
|
||||
if err := p.Add("later", now.Add(time.Minute)); err != nil {
|
||||
t.Errorf("Add after expiry: %v", err)
|
||||
}
|
||||
if p.Consume("s0") {
|
||||
t.Error("an expired state was still pending")
|
||||
}
|
||||
if !p.Consume("later") {
|
||||
t.Error("the state added after expiry is not pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsOffOriginReturn(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -154,73 +153,6 @@ func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxPendingStates bounds each flow's pending states. The login route is
|
||||
// unauthenticated, so without a bound anyone could grow the set without
|
||||
// limit; one household never has close to this many flows in progress.
|
||||
const maxPendingStates = 10000
|
||||
|
||||
// ErrTooManyPending means a flow's pending states are at maxPendingStates,
|
||||
// so a new flow is refused until older ones expire.
|
||||
var ErrTooManyPending = errors.New("too many sign-in flows in progress")
|
||||
|
||||
// PendingStates holds the state of every redirect flow started and not yet
|
||||
// completed, until its flow cookie expires. A copy of a flow cookie verifies
|
||||
// until it expires, so a flow is single-use only because its callback
|
||||
// consumes the state here. The states live in memory — MealQuest runs as one
|
||||
// process — so a flow started before a restart fails closed after it.
|
||||
type PendingStates struct {
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
states map[string]time.Time
|
||||
// order is every added state in the order added. Every state is pending
|
||||
// for the same ttl, so it is also expiry order and Add forgets expired
|
||||
// states from its front.
|
||||
order []pendingState
|
||||
}
|
||||
|
||||
type pendingState struct {
|
||||
state string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// NewPendingStates returns an empty set whose states are pending for ttl,
|
||||
// the flow cookie's lifetime.
|
||||
func NewPendingStates(ttl time.Duration) *PendingStates {
|
||||
return &PendingStates{ttl: ttl, states: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// Add records state, started at now, as pending and forgets expired ones.
|
||||
// It fails with ErrTooManyPending when maxPendingStates are pending.
|
||||
func (p *PendingStates) Add(state string, now time.Time) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
expired := 0
|
||||
for expired < len(p.order) && !now.Before(p.order[expired].expires) {
|
||||
delete(p.states, p.order[expired].state)
|
||||
expired++
|
||||
}
|
||||
p.order = p.order[expired:]
|
||||
// The bound counts every unexpired state, consumed or not: a consumed
|
||||
// state leaves states at once but order only when it expires.
|
||||
if len(p.order) >= maxPendingStates {
|
||||
return ErrTooManyPending
|
||||
}
|
||||
expires := now.Add(p.ttl)
|
||||
p.states[state] = expires
|
||||
p.order = append(p.order, pendingState{state: state, expires: expires})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consume removes state from the pending states, reporting whether it was
|
||||
// there.
|
||||
func (p *PendingStates) Consume(state string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_, ok := p.states[state]
|
||||
delete(p.states, state)
|
||||
return ok
|
||||
}
|
||||
|
||||
// returnPath validates the optional post-login return path. Empty means "/".
|
||||
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
|
||||
// relative "//" prefix, backslashes or control characters.
|
||||
|
||||
@@ -137,9 +137,10 @@ type Service struct {
|
||||
account sync.Mutex
|
||||
|
||||
// pending holds the state of every connect started and not yet
|
||||
// completed. Complete consumes a state once, so a replayed cookie finds
|
||||
// it gone.
|
||||
pending *auth.PendingStates
|
||||
// completed, with when it expires. Complete consumes a state once, so a
|
||||
// replayed cookie finds it gone.
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]time.Time
|
||||
}
|
||||
|
||||
// NewService returns the service over st, sealing refresh tokens with box.
|
||||
@@ -174,7 +175,7 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro
|
||||
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
|
||||
retryDelay: firstRetryDelay,
|
||||
now: time.Now,
|
||||
pending: auth.NewPendingStates(connectCookieTTL),
|
||||
pending: map[string]time.Time{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -209,12 +210,10 @@ func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string,
|
||||
Session: sessionBinding(session),
|
||||
}
|
||||
now := s.now()
|
||||
if err := s.pending.Add(state.State, now); err != nil {
|
||||
return "", fmt.Errorf("gcal: %w", err)
|
||||
}
|
||||
if err := s.cookie.Set(w, &state, now); err != nil {
|
||||
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
|
||||
}
|
||||
s.addPending(state.State, now)
|
||||
return s.oauth.AuthCodeURL(state.State,
|
||||
oauth2.AccessTypeOffline,
|
||||
oauth2.ApprovalForce,
|
||||
@@ -244,7 +243,7 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
|
||||
}
|
||||
// A copy of the cookie verifies until it expires, so the state is
|
||||
// single-use only because it is consumed here.
|
||||
if !s.pending.Consume(state.State) {
|
||||
if !s.consumePending(state.State) {
|
||||
return fmt.Errorf("%w: already used", ErrBadState)
|
||||
}
|
||||
if q.Get("error") != "" {
|
||||
@@ -277,6 +276,29 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
|
||||
return s.store(ctx, sealed)
|
||||
}
|
||||
|
||||
// addPending records state, started at now, as pending until the connect
|
||||
// cookie expires, and forgets expired ones.
|
||||
func (s *Service) addPending(state string, now time.Time) {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
for k, expires := range s.pending {
|
||||
if !now.Before(expires) {
|
||||
delete(s.pending, k)
|
||||
}
|
||||
}
|
||||
s.pending[state] = now.Add(connectCookieTTL)
|
||||
}
|
||||
|
||||
// consumePending removes state from the pending states, reporting whether
|
||||
// it was there.
|
||||
func (s *Service) consumePending(state string) bool {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
_, ok := s.pending[state]
|
||||
delete(s.pending, state)
|
||||
return ok
|
||||
}
|
||||
|
||||
// checkScopes fails with ErrScopes unless token's granted scopes include
|
||||
// every scope in Scopes. Google lets the household untick a permission on
|
||||
// the consent screen and reports what was granted in "scope".
|
||||
|
||||
@@ -1392,64 +1392,6 @@ describe('PlanPage', () => {
|
||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("still shows a swap's failure on coming back to the week, through another, and asks for the swap again only on Retry", async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET /api/v1/plans/2026-10-19`]: () => errorResponse(404, 'not_found'),
|
||||
...lists,
|
||||
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_not_plannable'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-19'))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-12'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||
expect(puts(fetchMock)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads the plan again when the dinner is already on another day, and offers to choose another rather than send it again', async () => {
|
||||
// Meanwhile lemon orzo was planned on Wednesday.
|
||||
let read = planBody('draft')
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, read),
|
||||
...lists,
|
||||
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const sheet = await openFriday()
|
||||
read = planBody('draft')
|
||||
read.days[2] = { ...read.days[2]!, dinner: { ...read.days[2]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true }
|
||||
fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. Lemon orzo is already on another day this week.")
|
||||
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(within(tile('WED · NEW')).getByText('Lemon orzo')).toBeInTheDocument())
|
||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||
|
||||
// Choosing again opens Friday's sheet on the plan read, where lemon
|
||||
// orzo is already this week; nothing is sent again.
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Choose another dinner' }))
|
||||
const again = screen.getByRole('dialog', { name: 'Swap Friday' })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
const orzo = within(within(again).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
|
||||
expect(within(orzo).getByText('Already this week')).toBeInTheDocument()
|
||||
expect(within(orzo).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(puts(fetchMock)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('waits for a pending swap before Rebuild week, keeping its failure', async () => {
|
||||
let refuse!: () => void
|
||||
const fetchMock = stubServer({
|
||||
|
||||
@@ -127,12 +127,6 @@ export function ago(at: Date, now: Date): string {
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||
}
|
||||
|
||||
// alreadyPlanned says whether a swap was refused because its dinner is
|
||||
// already on another day of the week: sending it again cannot succeed.
|
||||
function alreadyPlanned(error: Error): boolean {
|
||||
return error instanceof ApiError && error.code === 'dinner_already_planned'
|
||||
}
|
||||
|
||||
// pushedToCalendar says whether drafting over a plan of this status needs
|
||||
// confirm: it was pushed to Calendar.
|
||||
function pushedToCalendar(status: PlanStatus): boolean {
|
||||
@@ -404,18 +398,9 @@ function WeekBoard({
|
||||
</ErrorBanner>
|
||||
)
|
||||
) : swap.failed !== null ? (
|
||||
alreadyPlanned(swap.failed.error) ? (
|
||||
// The plan is read again (useSwapDay): the household chooses another
|
||||
// dinner from it rather than sending the refused swap again.
|
||||
<ErrorBanner onRetry={() => openSwap(swap.failed!.swap.date)} retryLabel="Choose another dinner">
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.swap.dinner.name} is already on another day
|
||||
this week.
|
||||
</ErrorBanner>
|
||||
) : (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
) : startDraft.isError ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||
) : result.error !== null ? (
|
||||
|
||||
@@ -127,17 +127,6 @@ describe('usePlan', () => {
|
||||
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
|
||||
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
|
||||
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
|
||||
// Date reads both, in this device's zone: neither says which instant.
|
||||
[
|
||||
'a sync time with no offset',
|
||||
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07T15:58:00'),
|
||||
'calendar_synced_at "2026-10-07T15:58:00" is not an instant',
|
||||
],
|
||||
[
|
||||
'a sync date with no time',
|
||||
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07'),
|
||||
'calendar_synced_at "2026-10-07" is not an instant',
|
||||
],
|
||||
])('refuses a plan with %s', async (_, change, said) => {
|
||||
const body = planBody()
|
||||
change(body)
|
||||
@@ -219,72 +208,6 @@ describe('useSwapDay', () => {
|
||||
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||
})
|
||||
|
||||
it('leaves a day a later plan read landed, the very day the swap wrote, when the swap of it fails', async () => {
|
||||
const friday = deferred()
|
||||
let read = planBody()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
// The read finds Friday lemon orzo, field for field the day the swap
|
||||
// wrote: the server has it, whatever this swap's PUT is answered with.
|
||||
const swapped = { date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(plan().days[4]).toEqual(swapped))
|
||||
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
await act(() => result.current.plan.refetch())
|
||||
await act(async () => friday.send(errorResponse(500, 'internal')))
|
||||
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(plan().days[4]).toEqual(swapped)
|
||||
expect(result.current.swap.failed?.error.message).toBe('internal message')
|
||||
})
|
||||
|
||||
it('puts the day back when the swap of it fails with no plan read since, after a later swap of another day', async () => {
|
||||
const friday = deferred()
|
||||
const monday = deferred()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
[`PUT ${MONDAY}`]: () => monday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
act(() => result.current.swap.mutate(FISH))
|
||||
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||
await act(async () => friday.send(errorResponse(500, 'internal')))
|
||||
|
||||
await waitFor(() => expect(plan().days[4]!.dinner.name).toBe('Dinner 4'))
|
||||
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
|
||||
await act(async () => monday.send(errorResponse(500, 'internal')))
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(plan().days.map((day) => day.dinner.name)).toEqual(planBody().days.map((day) => day.dinner.name))
|
||||
})
|
||||
|
||||
it('reads the plan again when the dinner is already on another day of the week, and sends the swap only once', async () => {
|
||||
let read = planBody()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
// Meanwhile lemon orzo was planned on Wednesday.
|
||||
read = planBody({ 2: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
|
||||
await waitFor(() => expect(plan().days[2]!.dinner.name).toBe('Lemon orzo'))
|
||||
expect(plan().days[4]!.dinner.name).toBe('Dinner 4')
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(result.current.swap.failed?.error.message).toBe('dinner_already_planned message')
|
||||
const sent = vi.mocked(fetch).mock.calls.map(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}`)
|
||||
expect(sent.filter((call) => call === `PUT ${FRIDAY}`)).toHaveLength(1)
|
||||
expect(sent.filter((call) => call === `GET ${PLAN}`)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refuses to dismiss a pending swap', async () => {
|
||||
const friday = deferred()
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
|
||||
@@ -430,61 +353,6 @@ describe('week mutations', () => {
|
||||
expect(weekMutations(queryClient, LATER)).toEqual([])
|
||||
})
|
||||
|
||||
it("leaves behind a week's settled swaps but for the failure that speaks for it, which stays until its week leaves it behind", async () => {
|
||||
let refuse = true
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, planBody())),
|
||||
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
|
||||
[`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week) }), {
|
||||
wrapper,
|
||||
initialProps: { week: WEEK },
|
||||
})
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
|
||||
// Two swaps settle while the week is shown: the earlier is needed for
|
||||
// ordering no longer, and only the latest stays.
|
||||
act(() => hook.result.current.swap.mutate(FISH))
|
||||
act(() => hook.result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap error']))
|
||||
|
||||
// Leaving the week, through two others, keeps the failure alone.
|
||||
for (const week of [NEXT, LATER, NEXT]) {
|
||||
hook.rerender({ week })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
|
||||
}
|
||||
|
||||
// Coming back shows it; it fails again, and again one stays.
|
||||
hook.rerender({ week: WEEK })
|
||||
expect(hook.result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||
act(() => hook.result.current.swap.dismiss())
|
||||
act(() => hook.result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(hook.result.current.swap.failed).not.toBeNull())
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
|
||||
|
||||
// A swap that succeeds is left behind once the week is.
|
||||
refuse = false
|
||||
act(() => hook.result.current.swap.dismiss())
|
||||
act(() => hook.result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(hook.result.current.swap.succeeded).not.toBeNull())
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap success'])
|
||||
hook.rerender({ week: NEXT })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
|
||||
const nextPush = deferred()
|
||||
stubServer({
|
||||
|
||||
+35
-102
@@ -1,14 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type Mutation,
|
||||
type Query,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from '@tanstack/react-query'
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||
@@ -92,16 +83,10 @@ function parseDay(where: string, body: unknown): PlanDay {
|
||||
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
|
||||
}
|
||||
|
||||
// RFC3339 is an RFC 3339 instant as the server writes it: a full date and
|
||||
// time, to the second or finer, and its offset, Z or ±hh:mm. A date alone,
|
||||
// or a time with no offset, which Date would read in this device's zone, is
|
||||
// not one.
|
||||
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
||||
|
||||
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
|
||||
function syncedAt(where: string, value: unknown): Date | null {
|
||||
if (value === null) return null
|
||||
const at = typeof value === 'string' && RFC3339.test(value) ? new Date(value) : null
|
||||
const at = typeof value === 'string' ? new Date(value) : null
|
||||
if (at === null || Number.isNaN(at.getTime())) {
|
||||
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
|
||||
}
|
||||
@@ -279,54 +264,38 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
|
||||
// time a week is shown, and is never removed, so a swap or push that settles
|
||||
// after its Draft board, or the whole Plan, is gone is still collected.
|
||||
//
|
||||
// A swap or push pending is kept, with every other of its kind and week:
|
||||
// an earlier pending one must see those sent after it, across leaving its
|
||||
// week and coming back. Once none of a kind and week is pending, only the
|
||||
// latest is needed, and the rest are removed, whenever a week is shown or
|
||||
// one settles. The latest itself stays while its week is shown. In a week
|
||||
// not shown it is removed, unless:
|
||||
//
|
||||
// - it is a failed swap: it stays, whatever is shown, until the week's
|
||||
// board leaves it behind (useWeekSwap, Rebuild week, Push to Calendar),
|
||||
// so coming back to the week shows the failure;
|
||||
// - it is a push whose Supply run was not made: the week has no shopping
|
||||
// list until one is made, which settles the push, or a push sent since
|
||||
// replaces it, and making it may still be under way when the week is
|
||||
// left;
|
||||
// - it is a failed push that settled after its week was left: it stays
|
||||
// until a week is next shown, so coming straight back shows it.
|
||||
//
|
||||
// So once none of its kind is pending, a week keeps at most one settled
|
||||
// swap and one settled push, and a week left keeps none but those above:
|
||||
// settled swaps and pushes never accumulate.
|
||||
// When a week is shown, the settled ones of every other week are removed,
|
||||
// unless that week still has one pending: a pending one, and those an
|
||||
// earlier pending one must see, stay across leaving its week and coming
|
||||
// back. When one settles, the same is done, except that a week's latest,
|
||||
// when it failed, stays until a week is next shown, so that coming straight
|
||||
// back to it shows the failure. A week's latest push whose Supply run was
|
||||
// not made stays whatever is shown: the week has no shopping list until one
|
||||
// is made, which settles the push, or a push sent since replaces it, and
|
||||
// making it may still be under way when the week is left. The cache holds
|
||||
// at most the weeks shown's, those of weeks left pending, one push of each
|
||||
// week whose Supply run was not made, and one failure of each week whose
|
||||
// last pending one settled since a week was last shown.
|
||||
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||
const kept = weeksShown.get(queryClient)
|
||||
if (kept !== undefined) return kept
|
||||
const mutations = queryClient.getMutationCache()
|
||||
// How many times each week is shown at once.
|
||||
const shown = new Map<string, number>()
|
||||
// keep says whether the latest of its kind and week, none of which is
|
||||
// pending, stays; settled says a mutation settling swept, not a week
|
||||
// shown.
|
||||
function keep(mutation: Mutation, of: { kind: 'swap' | 'push'; week: string }, settled: boolean): boolean {
|
||||
if (shown.has(of.week)) return true
|
||||
if (of.kind === 'swap') return failed(mutation)
|
||||
return unmade(mutation) || (settled && failed(mutation))
|
||||
}
|
||||
function sweep(settled: boolean) {
|
||||
// The mutations of each kind of each week, in the order sent.
|
||||
const sent = new Map<string, { of: { kind: 'swap' | 'push'; week: string }; week: Mutation[] }>()
|
||||
// The mutations of each kind of each week not shown, in the order sent.
|
||||
const left = new Map<string, Mutation[]>()
|
||||
for (const mutation of mutations.getAll()) {
|
||||
const of = weekMutation(mutation)
|
||||
if (of === null) continue
|
||||
if (of === null || shown.has(of.week)) continue
|
||||
const id = `${of.kind} ${of.week}`
|
||||
sent.set(id, { of, week: [...(sent.get(id)?.week ?? []), mutation] })
|
||||
left.set(id, [...(left.get(id) ?? []), mutation])
|
||||
}
|
||||
for (const { of, week } of sent.values()) {
|
||||
for (const week of left.values()) {
|
||||
if (week.some((mutation) => mutation.state.status === 'pending')) continue
|
||||
const latest = week.at(-1)
|
||||
for (const mutation of week) {
|
||||
if (mutation === latest && keep(mutation, of, settled)) continue
|
||||
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
|
||||
mutations.remove(mutation)
|
||||
}
|
||||
}
|
||||
@@ -355,30 +324,6 @@ function useWeekShown(weekStart: string) {
|
||||
useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart])
|
||||
}
|
||||
|
||||
// swapWrites counts, for each plan query, its updates that were a swap's
|
||||
// own optimistic write or rollback, not a plan read from the server.
|
||||
const swapWrites = new WeakMap<Query, number>()
|
||||
|
||||
// serverReads is how many plans read from the server have landed in the
|
||||
// plan query: its updates, less the swaps' own writes. A plan read lands
|
||||
// whether or not it differs from the plan shown.
|
||||
function serverReads(query: Query): number {
|
||||
return query.state.dataUpdateCount - (swapWrites.get(query) ?? 0)
|
||||
}
|
||||
|
||||
// writeSwap updates the plan query as a swap's own write, which serverReads
|
||||
// does not count.
|
||||
function writeSwap(queryClient: QueryClient, query: Query, update: (plan: Plan | null | undefined) => Plan | null | undefined) {
|
||||
const before = query.state.dataUpdateCount
|
||||
queryClient.setQueryData<Plan | null>(query.queryKey, update)
|
||||
swapWrites.set(query, (swapWrites.get(query) ?? 0) + query.state.dataUpdateCount - before)
|
||||
}
|
||||
|
||||
// planQuery is the plan query keyed key, if the query cache has it.
|
||||
function planQuery(queryClient: QueryClient, key: QueryKey): Query | undefined {
|
||||
return queryClient.getQueryCache().find({ queryKey: key, exact: true })
|
||||
}
|
||||
|
||||
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
||||
// cache, where they outlive the Draft board that sent them.
|
||||
function swapKey(weekStart: string) {
|
||||
@@ -400,13 +345,9 @@ export type SwapDay = {
|
||||
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
||||
// once, NEW when it came from the Quest log, with no start-by time until the
|
||||
// server answers with the plan as swapped, which then replaces it. A refused
|
||||
// swap puts the day back as it was, unless it has changed again meanwhile:
|
||||
// another swap wrote over it, or any plan read from the server landed
|
||||
// since, even one with the very day the swap wrote. A swap refused because
|
||||
// the dinner is already on another day of the week (409
|
||||
// dinner_already_planned) reads the plan again, so the household chooses
|
||||
// again from the plan as it is. However the swap ends, the Quest log is
|
||||
// read again: the server leaves a dinner on the plan out of it.
|
||||
// swap puts the day back as it was, unless it has changed again meanwhile.
|
||||
// However the swap ends, the Quest log is read again: the server leaves a
|
||||
// dinner on the plan out of it.
|
||||
//
|
||||
// The week's swaps live in the mutation cache, not in the component, so
|
||||
// leaving the week and coming back keeps them: pending and failed are read
|
||||
@@ -433,7 +374,7 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
function mutate(chosen: Swap) {
|
||||
// isLatest says whether no swap of the week was sent after this one.
|
||||
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay; query: Query; reads: number }>(queryClient, {
|
||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
||||
mutationKey,
|
||||
// Kept until dismissed or settled in a week not shown
|
||||
// (keepWeekMutations): a later swap must stay in the cache while an
|
||||
@@ -446,32 +387,24 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
},
|
||||
onMutate: async ({ date, dinner, fromQuestLog }) => {
|
||||
await queryClient.cancelQueries({ queryKey: key })
|
||||
const query = planQuery(queryClient, key)
|
||||
const plan = query?.state.data as Plan | null | undefined
|
||||
const plan = queryClient.getQueryData<Plan | null>(key)
|
||||
const previous = plan?.days.find((day) => day.date === date)
|
||||
if (query === undefined || !plan || previous === undefined) {
|
||||
throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||
}
|
||||
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
|
||||
writeSwap(queryClient, query, () => ({ ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }))
|
||||
return { previous, swapped, query, reads: serverReads(query) }
|
||||
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
|
||||
return { previous, swapped }
|
||||
},
|
||||
onSuccess: (plan) => {
|
||||
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
|
||||
},
|
||||
// The day goes back only while it is still, field for field, the day
|
||||
// this swap wrote, and no plan read from the server has landed since
|
||||
// (serverReads): a later swap that replaced it, or a read that did,
|
||||
// even with the same day, is left alone.
|
||||
onError: (error, _swap, context) => {
|
||||
if (context !== undefined && planQuery(queryClient, key) === context.query && serverReads(context.query) === context.reads) {
|
||||
writeSwap(queryClient, context.query, (plan) =>
|
||||
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||
)
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'dinner_already_planned') {
|
||||
void queryClient.invalidateQueries({ queryKey: key })
|
||||
}
|
||||
// this swap wrote: a later swap or plan read that replaced it is left
|
||||
// alone.
|
||||
onError: (_error, _swap, context) => {
|
||||
if (context === undefined) return
|
||||
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
||||
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||
)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||
})
|
||||
|
||||
@@ -80,10 +80,7 @@ export function RegularsSettingsPage() {
|
||||
// Which sheet is open, for which Regular.
|
||||
type Open = { sheet: 'edit' | 'fix'; id: string } | null
|
||||
|
||||
// RegularsManager lists, adds, edits and fixes the recipes of the
|
||||
// household's Regulars: this page's body, and the Settings "Regular
|
||||
// dinners" sub-tab at tablet and desktop widths.
|
||||
export function RegularsManager() {
|
||||
function RegularsManager() {
|
||||
const regulars = useRegulars()
|
||||
const add = useAddRegular()
|
||||
const remove = useRemoveRegular()
|
||||
|
||||
@@ -104,10 +104,7 @@ describe('SettingsPage', () => {
|
||||
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
// The rotation count and the hidden Regular dinners sub-tab both read
|
||||
// the Regulars.
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
REGULARS_PATH,
|
||||
REGULARS_PATH,
|
||||
CALENDAR_PATH,
|
||||
'/api/v1/settings/ai',
|
||||
@@ -120,7 +117,7 @@ describe('SettingsPage', () => {
|
||||
})
|
||||
|
||||
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
|
||||
stubSettings({ [REGULARS_PATH]: () => errorResponse(404, 'not_found') })
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
||||
expect(
|
||||
|
||||
@@ -101,3 +101,24 @@
|
||||
padding: 24px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ import {
|
||||
HOUSEHOLD_SETTINGS,
|
||||
NOTIFICATION_SETTINGS,
|
||||
RECIPE_SEARCH_SETTINGS,
|
||||
SETTINGS_REGULARS,
|
||||
} from '../test/ApiProviders.tsx'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||
import { fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { SettingsPage } from './SettingsPage.tsx'
|
||||
@@ -34,7 +32,6 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
|
||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
|
||||
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
@@ -124,7 +121,7 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
||||
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
|
||||
expect(tab('Regular dinners')).toHaveFocus()
|
||||
expect(panel('Regular dinners')).toBeVisible()
|
||||
expect(await within(panel('Regular dinners')).findByRole('heading', { name: 'On rotation · 1' })).toBeInTheDocument()
|
||||
expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument()
|
||||
expect(panel('Household')).not.toBeVisible()
|
||||
|
||||
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
|
||||
@@ -168,123 +165,6 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
||||
})
|
||||
})
|
||||
|
||||
const TACOS = dinnerBody('d1', 'Beef tacos', { method: 'stovetop', minutes_total: 40, difficulty: 'easy' })
|
||||
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
|
||||
|
||||
// stubRegulars answers the Regulars from server.regulars, so a write's
|
||||
// responder changes what the next read sees.
|
||||
function stubRegulars(regulars: DinnerBody[], responders: (server: { regulars: DinnerBody[] }) => Record<string, Responder>) {
|
||||
const server = { regulars }
|
||||
const fetchMock = stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: server.regulars }),
|
||||
...responders(server),
|
||||
})
|
||||
return { server, fetchMock }
|
||||
}
|
||||
|
||||
// writes lists the requests other than GETs, as method, path and body.
|
||||
function writes(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
||||
.map(([input, init]) => [init?.method, String(input), init?.body])
|
||||
}
|
||||
|
||||
// openRegulars shows the Regular dinners tab once its Regulars have loaded.
|
||||
async function openRegulars() {
|
||||
fireEvent.click(tab('Regular dinners'))
|
||||
const regulars = within(panel('Regular dinners'))
|
||||
await regulars.findByRole('heading', { level: 2, name: /^On rotation/ })
|
||||
return regulars
|
||||
}
|
||||
|
||||
function regularRow(name: string) {
|
||||
const item = within(screen.getByRole('list', { name: 'Regulars' }))
|
||||
.getAllByRole('listitem')
|
||||
.find((li) => within(li).queryByText(name) !== null)
|
||||
if (item === undefined) throw new Error(`no Regular row named ${name}`)
|
||||
return within(item)
|
||||
}
|
||||
|
||||
describe.each([1440, 1180])('SettingsPage Regular dinners tab at %i px (mock 2e)', (width) => {
|
||||
it('lists the Regulars as /settings/regulars does, under the page title rather than its own', async () => {
|
||||
stubRegulars([TACOS, PIZZA], () => ({}))
|
||||
await renderAt(width)
|
||||
const regulars = await openRegulars()
|
||||
expect(regulars.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||
expect(regularRow('Beef tacos').getByText('Stovetop · 40 min')).toBeInTheDocument()
|
||||
expect(regularRow('Beef tacos').getByText('Recipe found')).toBeInTheDocument()
|
||||
expect(regularRow('Pizza night').getByText('Needs a recipe')).toBeInTheDocument()
|
||||
expect(regulars.getByRole('textbox', { name: 'Add a Regular' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Settings'])
|
||||
expect(regulars.queryByRole('button', { name: 'Back to Settings' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a Regular by name', async () => {
|
||||
const { fetchMock } = stubRegulars([TACOS], (server) => ({
|
||||
'POST /api/v1/dinners': (body) => {
|
||||
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
||||
server.regulars = [...server.regulars, created]
|
||||
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||
},
|
||||
}))
|
||||
await renderAt(width)
|
||||
const regulars = await openRegulars()
|
||||
fireEvent.change(regulars.getByRole('textbox', { name: 'Add a Regular' }), { target: { value: 'Chicken enchiladas' } })
|
||||
fireEvent.click(regulars.getByRole('button', { name: 'Add Regular' }))
|
||||
|
||||
expect(await regulars.findByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||
expect(regularRow('Chicken enchiladas').getByText('Finding recipe…')).toBeInTheDocument()
|
||||
expect(writes(fetchMock)).toEqual([['POST', '/api/v1/dinners', JSON.stringify({ name: 'Chicken enchiladas' })]])
|
||||
})
|
||||
|
||||
it('edits a Regular in its sheet', async () => {
|
||||
const { fetchMock } = stubRegulars([TACOS], (server) => ({
|
||||
'PATCH /api/v1/dinners/d1': (body) => {
|
||||
const edited = { ...TACOS, ...(body as object) }
|
||||
server.regulars = [edited]
|
||||
return jsonResponse(200, edited)
|
||||
},
|
||||
}))
|
||||
await renderAt(width)
|
||||
await openRegulars()
|
||||
fireEvent.click(regularRow('Beef tacos').getByRole('button', { name: /^Beef tacos/ }))
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
|
||||
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(writes(fetchMock)).toEqual([['PATCH', '/api/v1/dinners/d1', JSON.stringify({ minutes_total: 25 })]])
|
||||
expect(await regularRow('Beef tacos').findByText('Stovetop · 25 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fixes the recipe of a Regular that needs one', async () => {
|
||||
const { fetchMock } = stubRegulars([PIZZA], (server) => ({
|
||||
'GET /api/v1/dinners/d3/recipe-candidates': () =>
|
||||
jsonResponse(200, {
|
||||
candidates: [
|
||||
{ candidate_id: 'https://example.com/pizza', title: 'Weeknight pizza', site_name: 'Example Kitchen', photo_url: null, minutes: 45 },
|
||||
],
|
||||
}),
|
||||
'POST /api/v1/dinners/d3/ground': () => {
|
||||
const fixed = { ...PIZZA, grounding_status: 'grounded', grounding_error: null }
|
||||
server.regulars = [fixed]
|
||||
return jsonResponse(200, fixed)
|
||||
},
|
||||
}))
|
||||
await renderAt(width)
|
||||
await openRegulars()
|
||||
fireEvent.click(regularRow('Pizza night').getByRole('button', { name: /^Pizza night/ }))
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||
fireEvent.click(await sheet.findByRole('button', { name: 'Use Weeknight pizza from Example Kitchen' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(writes(fetchMock)).toEqual([
|
||||
['POST', '/api/v1/dinners/d3/ground', JSON.stringify({ candidate_id: 'https://example.com/pizza' })],
|
||||
])
|
||||
expect(await regularRow('Pizza night').findByText('Recipe found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsPage reminder time across tabs', () => {
|
||||
it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => {
|
||||
const saved = { ...HOUSEHOLD_SETTINGS }
|
||||
@@ -315,7 +195,10 @@ describe('SettingsPage reminder time across tabs', () => {
|
||||
|
||||
describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
||||
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
})
|
||||
const resize = await renderAt(767)
|
||||
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
||||
@@ -326,7 +209,10 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
})
|
||||
|
||||
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
|
||||
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
})
|
||||
const resize = await renderAt(1180)
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
||||
target: { value: 'A draft' },
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { AIAssistantCard } from './AISettingsPage.tsx'
|
||||
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
||||
import { LeaveGuard } from './LeaveGuard.tsx'
|
||||
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
|
||||
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||
import { RegularsManager } from './RegularsSettingsPage.tsx'
|
||||
import styles from './SettingsTabs.module.css'
|
||||
|
||||
type Tab = 'household' | 'regulars' | 'notifications-ai'
|
||||
@@ -101,7 +101,13 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
|
||||
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
|
||||
|
||||
{panel('regulars', styles.single, <RegularsManager />)}
|
||||
{panel(
|
||||
'regulars',
|
||||
styles.single,
|
||||
// TODO(Feature 12): embed the Regular dinners screen here once Feature
|
||||
// 12 builds it.
|
||||
<Placeholder title="Regular dinners">Managing the household's Regulars arrives in a later update.</Placeholder>,
|
||||
)}
|
||||
|
||||
{panel(
|
||||
'notifications-ai',
|
||||
@@ -122,3 +128,17 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Placeholder is a neutral card holding a section's place until the feature
|
||||
// that owns it renders it.
|
||||
function Placeholder({ title, children }: { title: string; children: ReactNode }) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Card className={styles.placeholder} aria-labelledby={titleId} role="region">
|
||||
<h2 id={titleId} className={styles.cardTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className={styles.hint}>{children}</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { dinnerBody } from './dinners.ts'
|
||||
import { fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
|
||||
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
|
||||
export const AI_SETTINGS = {
|
||||
@@ -100,16 +99,12 @@ export const CHAT_WEBHOOK = {
|
||||
last_test_status: null,
|
||||
}
|
||||
|
||||
// SETTINGS_REGULARS is a GET /api/v1/dinners?status=regular body: one
|
||||
// Regular, its recipe found.
|
||||
export const SETTINGS_REGULARS = { dinners: [dinnerBody('r1', 'Beef tacos')] }
|
||||
|
||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||
// today's server does: one Regular on rotation, and neither Google Calendar
|
||||
// nor Google Chat connected. The home's sub-tabs stay mounted on mobile, so
|
||||
// it also reads the Regulars list, the AI models and recipe search settings.
|
||||
// today's server does: the dinners endpoint does not exist yet, and neither
|
||||
// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so
|
||||
// it also reads the AI models and recipe search settings.
|
||||
export const SETTINGS_API = {
|
||||
'/api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
|
||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
|
||||
|
||||
Reference in New Issue
Block a user