package store import ( "context" "errors" "testing" "time" ) func TestCreateAndAcceptProposedRoutine(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC() // Create id, err := s.CreateProposedRoutine(ctx, "refill", "cat_water", 7.0, now) if err != nil { t.Fatalf("CreateProposedRoutine: %v", err) } if id == 0 { t.Fatal("expected non-zero id") } // Duplicate should fail _, err = s.CreateProposedRoutine(ctx, "refill", "cat_water", 7.0, now) if !errors.Is(err, ErrProposedRoutineExists) { t.Fatalf("want ErrProposedRoutineExists, got %v", err) } // Lookup r, err := s.LookupProposedRoutine(ctx, "refill", "cat_water") if err != nil { t.Fatalf("LookupProposedRoutine: %v", err) } if r == nil { t.Fatal("want non-nil routine") } if r.Action != "refill" || r.Object != "cat_water" || r.Status != "proposed" { t.Fatalf("got %+v", r) } if r.IntervalDays != 7.0 { t.Fatalf("want interval_days=7.0, got %f", r.IntervalDays) } // Accept if err := s.AcceptProposedRoutine(ctx, id, now); err != nil { t.Fatalf("AcceptProposedRoutine: %v", err) } // Verify accepted r, err = s.LookupProposedRoutine(ctx, "refill", "cat_water") if err != nil { t.Fatalf("LookupProposedRoutine: %v", err) } if r.Status != "accepted" { t.Fatalf("want status=accepted, got %s", r.Status) } if r.AcceptedTs == nil || !r.AcceptedTs.Equal(now.Truncate(time.Millisecond)) { t.Fatalf("want accepted_ts=%v, got %v", now, r.AcceptedTs) } if r.LastFiredTs != nil { t.Fatalf("a freshly accepted routine has not fired yet, got %v", r.LastFiredTs) } } // TestAcceptedRoutineFiredTimestamp — the tick loop's two reads: the accepted // list, and the last-fired stamp it writes back after a nudge. func TestAcceptedRoutineFiredTimestamp(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Millisecond) id, err := s.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, now) if err != nil { t.Fatalf("CreateProposedRoutine: %v", err) } if err := s.AcceptProposedRoutine(ctx, id, now); err != nil { t.Fatalf("AcceptProposedRoutine: %v", err) } list, err := s.ListAcceptedRoutines(ctx) if err != nil { t.Fatalf("ListAcceptedRoutines: %v", err) } if len(list) != 1 || list[0].ID != id { t.Fatalf("want the one accepted routine, got %+v", list) } if list[0].IntervalDays != 3.0 { t.Fatalf("want interval_days=3, got %v", list[0].IntervalDays) } fired := now.Add(3 * 24 * time.Hour) if err := s.MarkRoutineFired(ctx, id, fired); err != nil { t.Fatalf("MarkRoutineFired: %v", err) } list, err = s.ListAcceptedRoutines(ctx) if err != nil { t.Fatalf("ListAcceptedRoutines: %v", err) } if list[0].LastFiredTs == nil || !list[0].LastFiredTs.Equal(fired) { t.Fatalf("want last_fired_ts=%v, got %v", fired, list[0].LastFiredTs) } } func TestDismissProposedRoutine(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC() id, err := s.CreateProposedRoutine(ctx, "feed", "cat", 1.0, now) if err != nil { t.Fatalf("CreateProposedRoutine: %v", err) } if err := s.DismissProposedRoutine(ctx, id); err != nil { t.Fatalf("DismissProposedRoutine: %v", err) } r, err := s.LookupProposedRoutine(ctx, "feed", "cat") if err != nil { t.Fatalf("LookupProposedRoutine: %v", err) } if r.Status != "dismissed" { t.Fatalf("want status=dismissed, got %s", r.Status) } } func TestListProposedRoutines(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC() // No routines yet list, err := s.ListProposedRoutines(ctx) if err != nil { t.Fatalf("ListProposedRoutines: %v", err) } if len(list) != 0 { t.Fatalf("want 0, got %d", len(list)) } // Create two _, err = s.CreateProposedRoutine(ctx, "refill", "water", 7.0, now) if err != nil { t.Fatalf("CreateProposedRoutine: %v", err) } _, err = s.CreateProposedRoutine(ctx, "feed", "cat", 1.0, now.Add(time.Hour)) if err != nil { t.Fatalf("CreateProposedRoutine: %v", err) } list, err = s.ListProposedRoutines(ctx) if err != nil { t.Fatalf("ListProposedRoutines: %v", err) } if len(list) != 2 { t.Fatalf("want 2, got %d", len(list)) } // Dismiss one _ = s.DismissProposedRoutine(ctx, list[0].ID) list, err = s.ListProposedRoutines(ctx) if err != nil { t.Fatalf("ListProposedRoutines: %v", err) } if len(list) != 1 { t.Fatalf("want 1 proposed after dismissing one, got %d", len(list)) } } // A dismissed routine must never be proposed again. The detector will keep // finding the same pattern; the store is what stops maven nagging about it. func TestDismissedProposedRoutineStaysDismissed(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC() id, err := s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now) if err != nil { t.Fatalf("CreateProposedRoutine: %v", err) } if err := s.DismissProposedRoutine(ctx, id); err != nil { t.Fatalf("DismissProposedRoutine: %v", err) } // The detector re-proposes the same pattern. _, err = s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now.Add(24*time.Hour)) if !errors.Is(err, ErrProposedRoutineExists) { t.Fatalf("want ErrProposedRoutineExists on re-propose, got %v", err) } // And it must not reappear on the review page. list, err := s.ListProposedRoutines(ctx) if err != nil { t.Fatalf("ListProposedRoutines: %v", err) } if len(list) != 0 { t.Fatalf("want 0 proposed, got %d", len(list)) } // Dismissing again is a no-op, and accepting is refused. if err := s.DismissProposedRoutine(ctx, id); err != nil { t.Fatalf("second DismissProposedRoutine: %v", err) } if err := s.AcceptProposedRoutine(ctx, id, time.Now().UTC()); !errors.Is(err, ErrProposedRoutineNotFound) { t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err) } r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box") if err != nil { t.Fatalf("LookupProposedRoutine: %v", err) } if r.Status != RoutineDismissed { t.Fatalf("want status=dismissed, got %s", r.Status) } } func TestListProposedRoutinesByStatus(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC() keep, err := s.CreateProposedRoutine(ctx, "water", "plants", 4.0, now) if err != nil { t.Fatalf("CreateProposedRoutine: %v", err) } drop, err := s.CreateProposedRoutine(ctx, "walk", "dog", 1.0, now.Add(time.Hour)) if err != nil { t.Fatalf("CreateProposedRoutine: %v", err) } if err := s.AcceptProposedRoutine(ctx, keep, now); err != nil { t.Fatalf("AcceptProposedRoutine: %v", err) } if err := s.DismissProposedRoutine(ctx, drop); err != nil { t.Fatalf("DismissProposedRoutine: %v", err) } cases := []struct { status RoutineStatus want int }{ {RoutineProposed, 0}, {RoutineAccepted, 1}, {RoutineDismissed, 1}, {"", 2}, // empty status ⇒ every row } for _, c := range cases { list, err := s.ListProposedRoutinesByStatus(ctx, c.status) if err != nil { t.Fatalf("ListProposedRoutinesByStatus(%q): %v", c.status, err) } if len(list) != c.want { t.Fatalf("status %q: want %d, got %d", c.status, c.want, len(list)) } } } func TestLookupMissingProposedRoutine(t *testing.T) { s := newTestStore(t) ctx := context.Background() r, err := s.LookupProposedRoutine(ctx, "nonexistent", "nothing") if err != nil { t.Fatalf("LookupProposedRoutine: %v", err) } if r != nil { t.Fatal("want nil for missing routine") } } // Every legal status must survive the database. The status column is written // by three different UPDATE statements with the value spelled inline, so a // constant that drifts from its SQL is exactly the failure this catches: the // row would come back in a state no Go code compares equal to. func TestRoutineStatusRoundTrip(t *testing.T) { ctx := context.Background() s := newTestStore(t) now := time.Now().UTC() write := map[RoutineStatus]func(id int64) error{ RoutineProposed: func(int64) error { return nil }, RoutineAccepted: func(id int64) error { return s.AcceptProposedRoutine(ctx, id, now) }, RoutineDismissed: func(id int64) error { return s.DismissProposedRoutine(ctx, id) }, } for _, want := range RoutineStatuses { if !want.Valid() { t.Fatalf("%q is in RoutineStatuses but not Valid()", want) } object := "object-" + want.String() id, err := s.CreateProposedRoutine(ctx, "полить", object, 3, now) if err != nil { t.Fatalf("CreateProposedRoutine(%s): %v", want, err) } if err := write[want](id); err != nil { t.Fatalf("move to %s: %v", want, err) } got, err := s.LookupProposedRoutine(ctx, "полить", object) if err != nil || got == nil { t.Fatalf("LookupProposedRoutine(%s): %v", want, err) } if got.Status != want { t.Errorf("status = %q, want %q", got.Status, want) } list, err := s.ListProposedRoutinesByStatus(ctx, want) if err != nil { t.Fatalf("ListProposedRoutinesByStatus(%s): %v", want, err) } if len(list) != 1 { t.Errorf("status %s: listed %d rows, want 1", want, len(list)) } } } // A typo used to read as "nothing is in that state", which is the same answer // a correct query gives on an empty table. func TestListByStatusRefusesAnUnknownStatus(t *testing.T) { s := newTestStore(t) if _, err := s.ListProposedRoutinesByStatus(context.Background(), "accpeted"); !errors.Is(err, ErrRoutineStatus) { t.Fatalf("err = %v, want ErrRoutineStatus", err) } if RoutineStatus("accpeted").Valid() { t.Error("a typo must not be Valid()") } }