From ca5f5f95e2a7f12e45e9dce1da0cbf115ecc3f73 Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Wed, 13 May 2026 20:24:56 +0000 Subject: [PATCH] refactor: introduce clock to make tests reproducible --- cmd/wotra/main.go | 11 +++-- internal/clock/clock.go | 28 +++++++++++++ internal/service/day_service.go | 10 +++-- internal/service/day_service_test.go | 52 ++++++++++++------------ internal/service/entry_service.go | 6 ++- internal/service/entry_service_test.go | 56 ++++++++++++++------------ internal/service/settings_service.go | 12 +++--- internal/service/week_service.go | 10 +++-- internal/service/week_service_test.go | 39 ++++++++++-------- 9 files changed, 140 insertions(+), 84 deletions(-) create mode 100644 internal/clock/clock.go diff --git a/cmd/wotra/main.go b/cmd/wotra/main.go index 1b01d83..2463ca7 100644 --- a/cmd/wotra/main.go +++ b/cmd/wotra/main.go @@ -10,6 +10,7 @@ import ( "time" assets "github.com/wotra/wotra" + "github.com/wotra/wotra/internal/clock" "github.com/wotra/wotra/internal/config" "github.com/wotra/wotra/internal/handler" "github.com/wotra/wotra/internal/service" @@ -46,10 +47,12 @@ func main() { settingsStore := store.NewSettingsStore(db) syncStore := store.NewSyncStore(db) - entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz) - daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz) - settingsSvc := service.NewSettingsService(settingsStore, syncStore) - weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz) + clk := clock.Real() + + entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz, clk) + daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz, clk) + settingsSvc := service.NewSettingsService(settingsStore, syncStore, clk) + weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz, clk) // Background goroutine: auto-stop entries that cross midnight ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/clock/clock.go b/internal/clock/clock.go new file mode 100644 index 0000000..9419f16 --- /dev/null +++ b/internal/clock/clock.go @@ -0,0 +1,28 @@ +package clock + +import "time" + +// Clock is the time source injected into services. +// Production code uses Real(); tests use Fixed(). +type Clock interface { + Now() time.Time +} + +// Real returns a Clock backed by time.Now(). +func Real() Clock { return realClock{} } + +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } + +// FixedClock always returns T. Advance shifts T forward. +// Intended for tests only. +type FixedClock struct{ T time.Time } + +// Fixed returns a *FixedClock set to t. +func Fixed(t time.Time) *FixedClock { return &FixedClock{T: t} } + +func (f *FixedClock) Now() time.Time { return f.T } + +// Advance shifts the clock forward by d. +func (f *FixedClock) Advance(d time.Duration) { f.T = f.T.Add(d) } diff --git a/internal/service/day_service.go b/internal/service/day_service.go index dc689a2..aa683b0 100644 --- a/internal/service/day_service.go +++ b/internal/service/day_service.go @@ -6,6 +6,7 @@ import ( "errors" "time" + "github.com/wotra/wotra/internal/clock" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/store" ) @@ -24,6 +25,7 @@ type DayService struct { settings *store.SettingsStore syncStore *store.SyncStore tz *time.Location + clock clock.Clock } func NewDayService( @@ -33,6 +35,7 @@ func NewDayService( settings *store.SettingsStore, syncStore *store.SyncStore, tz *time.Location, + clk clock.Clock, ) *DayService { return &DayService{ entries: entries, @@ -41,6 +44,7 @@ func NewDayService( settings: settings, syncStore: syncStore, tz: tz, + clock: clk, } } @@ -76,7 +80,7 @@ func (s *DayService) recomputeWeek(ctx context.Context, dayKey string) error { } cw.WorkedMs = totalWorkedMs cw.DeltaMs = totalWorkedMs - cw.ExpectedMs - cw.UpdatedAt = time.Now().UnixMilli() + cw.UpdatedAt = s.clock.Now().UnixMilli() if err := s.closedWeeks.Upsert(ctx, cw); err != nil { return err } @@ -130,7 +134,7 @@ func (s *DayService) CloseDay(ctx context.Context, dayKey string) (*domain.Close totalMs += e.DurationMs() } - now := time.Now().UnixMilli() + now := s.clock.Now().UnixMilli() cd := &domain.ClosedDay{ DayKey: dayKey, StartTime: &minStart, @@ -172,7 +176,7 @@ func (s *DayService) MarkDay(ctx context.Context, dayKey string, kind domain.Day } } - now := time.Now().UnixMilli() + now := s.clock.Now().UnixMilli() cd := &domain.ClosedDay{ DayKey: dayKey, WorkedMs: workedMs, diff --git a/internal/service/day_service_test.go b/internal/service/day_service_test.go index 8aa3531..377e458 100644 --- a/internal/service/day_service_test.go +++ b/internal/service/day_service_test.go @@ -5,12 +5,16 @@ import ( "testing" "time" + "github.com/wotra/wotra/internal/clock" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/service" "github.com/wotra/wotra/internal/store" ) -func newTestDayServices(t *testing.T) (*service.EntryService, *service.DayService, *service.SettingsService) { +// dayTestAnchor is a fixed Tuesday used as "now" across day service tests. +var dayTestAnchor = time.Date(2026, 5, 13, 10, 0, 0, 0, time.UTC) + +func newTestDayServices(t *testing.T) (*service.EntryService, *service.DayService, *service.SettingsService, *clock.FixedClock) { t.Helper() db, err := store.Open(":memory:") if err != nil { @@ -24,18 +28,18 @@ func newTestDayServices(t *testing.T) (*service.EntryService, *service.DayServic settingsStore := store.NewSettingsStore(db) syncStore := store.NewSyncStore(db) tz, _ := time.LoadLocation("UTC") + clk := clock.Fixed(dayTestAnchor) - entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz) - daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz) - settingsSvc := service.NewSettingsService(settingsStore, syncStore) - return entrySvc, daySvc, settingsSvc + entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz, clk) + daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz, clk) + settingsSvc := service.NewSettingsService(settingsStore, syncStore, clk) + return entrySvc, daySvc, settingsSvc, clk } func TestCloseDayBasic(t *testing.T) { ctx := context.Background() - entrySvc, daySvc, _ := newTestDayServices(t) + entrySvc, daySvc, _, clk := newTestDayServices(t) - // Start and stop an entry _, err := entrySvc.Start(ctx, "work") if err != nil { t.Fatal(err) @@ -45,7 +49,7 @@ func TestCloseDayBasic(t *testing.T) { t.Fatal(err) } - today := time.Now().UTC().Format("2006-01-02") + today := clk.Now().UTC().Format("2006-01-02") cd, err := daySvc.CloseDay(ctx, today) if err != nil { t.Fatalf("CloseDay: %v", err) @@ -60,14 +64,14 @@ func TestCloseDayBasic(t *testing.T) { func TestCloseDayWithRunningEntryFails(t *testing.T) { ctx := context.Background() - entrySvc, daySvc, _ := newTestDayServices(t) + entrySvc, daySvc, _, clk := newTestDayServices(t) _, err := entrySvc.Start(ctx, "") if err != nil { t.Fatal(err) } - today := time.Now().UTC().Format("2006-01-02") + today := clk.Now().UTC().Format("2006-01-02") _, err = daySvc.CloseDay(ctx, today) if err == nil { t.Fatal("expected error closing day with running entry") @@ -79,11 +83,11 @@ func TestCloseDayWithRunningEntryFails(t *testing.T) { func TestCloseDayTwiceFails(t *testing.T) { ctx := context.Background() - entrySvc, daySvc, _ := newTestDayServices(t) + entrySvc, daySvc, _, clk := newTestDayServices(t) entrySvc.Start(ctx, "") entrySvc.Stop(ctx) - today := time.Now().UTC().Format("2006-01-02") + today := clk.Now().UTC().Format("2006-01-02") daySvc.CloseDay(ctx, today) _, err := daySvc.CloseDay(ctx, today) @@ -94,9 +98,9 @@ func TestCloseDayTwiceFails(t *testing.T) { func TestMarkDayHoliday(t *testing.T) { ctx := context.Background() - _, daySvc, _ := newTestDayServices(t) + _, daySvc, _, clk := newTestDayServices(t) - today := time.Now().UTC().Format("2006-01-02") + today := clk.Now().UTC().Format("2006-01-02") cd, err := daySvc.MarkDay(ctx, today, domain.DayKindHoliday) if err != nil { t.Fatalf("MarkDay: %v", err) @@ -104,32 +108,28 @@ func TestMarkDayHoliday(t *testing.T) { if cd.Kind != domain.DayKindHoliday { t.Errorf("expected kind=holiday, got %s", cd.Kind) } - // Monday-Friday = 40h/5 = 8h = 28800000ms expected - if today == time.Now().UTC().Format("2006-01-02") { - wd := int(time.Now().UTC().Weekday()) - // workdays Mon-Fri (mask=31): weekdays 1-5 - if wd >= 1 && wd <= 5 { - if cd.WorkedMs != 8*3600*1000 { - t.Errorf("expected 8h worked_ms for holiday on workday, got %d", cd.WorkedMs) - } + // dayTestAnchor is a Tuesday (weekday 2): Mon-Fri workdays → 40h/5 = 8h = 28800000ms + wd := int(clk.Now().UTC().Weekday()) + if wd >= 1 && wd <= 5 { + if cd.WorkedMs != 8*3600*1000 { + t.Errorf("expected 8h worked_ms for holiday on workday, got %d", cd.WorkedMs) } } } func TestReopenDay(t *testing.T) { ctx := context.Background() - entrySvc, daySvc, _ := newTestDayServices(t) + entrySvc, daySvc, _, clk := newTestDayServices(t) entrySvc.Start(ctx, "") entrySvc.Stop(ctx) - today := time.Now().UTC().Format("2006-01-02") + today := clk.Now().UTC().Format("2006-01-02") daySvc.CloseDay(ctx, today) if err := daySvc.ReopenDay(ctx, today); err != nil { t.Fatalf("ReopenDay: %v", err) } - // Should be closeable again _, err := daySvc.CloseDay(ctx, today) if err != nil { t.Fatalf("CloseDay after reopen: %v", err) @@ -138,7 +138,7 @@ func TestReopenDay(t *testing.T) { func TestSettingsUpsertAndHistory(t *testing.T) { ctx := context.Background() - _, _, settingsSvc := newTestDayServices(t) + _, _, settingsSvc, _ := newTestDayServices(t) set, err := settingsSvc.Upsert(ctx, service.UpsertSettingsInput{ EffectiveFrom: "2024-01-01", diff --git a/internal/service/entry_service.go b/internal/service/entry_service.go index 35739b2..7b85c65 100644 --- a/internal/service/entry_service.go +++ b/internal/service/entry_service.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/wotra/wotra/internal/clock" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/store" ) @@ -31,6 +32,7 @@ type EntryService struct { settings *store.SettingsStore syncStore *store.SyncStore tz *time.Location + clock clock.Clock } func NewEntryService( @@ -39,6 +41,7 @@ func NewEntryService( settings *store.SettingsStore, syncStore *store.SyncStore, tz *time.Location, + clk clock.Clock, ) *EntryService { return &EntryService{ entries: entries, @@ -46,11 +49,12 @@ func NewEntryService( settings: settings, syncStore: syncStore, tz: tz, + clock: clk, } } func (s *EntryService) nowMs() int64 { - return time.Now().UnixMilli() + return s.clock.Now().UnixMilli() } func (s *EntryService) dayKeyForMs(ms int64) string { diff --git a/internal/service/entry_service_test.go b/internal/service/entry_service_test.go index db34be7..6dcc2de 100644 --- a/internal/service/entry_service_test.go +++ b/internal/service/entry_service_test.go @@ -6,11 +6,15 @@ import ( "testing" "time" + "github.com/wotra/wotra/internal/clock" "github.com/wotra/wotra/internal/service" "github.com/wotra/wotra/internal/store" ) -func newTestServices(t *testing.T) *service.EntryService { +// testAnchor is a fixed Tuesday used as "now" across entry service tests. +var testAnchor = time.Date(2026, 5, 13, 10, 0, 0, 0, time.UTC) + +func newTestServices(t *testing.T) (*service.EntryService, *clock.FixedClock) { t.Helper() db, err := store.Open(":memory:") if err != nil { @@ -23,12 +27,14 @@ func newTestServices(t *testing.T) *service.EntryService { settingsStore := store.NewSettingsStore(db) tz, _ := time.LoadLocation("UTC") - return service.NewEntryService(entryStore, closedDayStore, settingsStore, store.NewSyncStore(db), tz) + clk := clock.Fixed(testAnchor) + svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, store.NewSyncStore(db), tz, clk) + return svc, clk } func TestStartStop(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, _ := newTestServices(t) entry, err := svc.Start(ctx, "test entry") if err != nil { @@ -55,7 +61,7 @@ func TestStartStop(t *testing.T) { func TestStartTwiceFails(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, _ := newTestServices(t) if _, err := svc.Start(ctx, ""); err != nil { t.Fatal(err) @@ -71,7 +77,7 @@ func TestStartTwiceFails(t *testing.T) { func TestStopWithoutStart(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, _ := newTestServices(t) _, err := svc.Stop(ctx) if err != service.ErrEntryNotRunning { @@ -81,7 +87,7 @@ func TestStopWithoutStart(t *testing.T) { func TestUpdateEntry(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, _ := newTestServices(t) entry, _ := svc.Start(ctx, "initial note") stopped, _ := svc.Stop(ctx) @@ -99,7 +105,7 @@ func TestUpdateEntry(t *testing.T) { func TestDeleteEntry(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, _ := newTestServices(t) entry, _ := svc.Start(ctx, "") svc.Stop(ctx) @@ -119,14 +125,14 @@ func TestDeleteEntry(t *testing.T) { func TestListEntries(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, clk := newTestServices(t) for i := 0; i < 3; i++ { svc.Start(ctx, "") svc.Stop(ctx) } - today := time.Now().UTC().Format("2006-01-02") + today := clk.Now().UTC().Format("2006-01-02") entries, err := svc.List(ctx, today, today) if err != nil { t.Fatal(err) @@ -138,9 +144,9 @@ func TestListEntries(t *testing.T) { func TestCreateInterval(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, clk := newTestServices(t) - now := time.Now().UTC() + now := clk.Now().UTC() startMs := now.Add(-2 * time.Hour).UnixMilli() endMs := now.Add(-1 * time.Hour).UnixMilli() @@ -165,9 +171,9 @@ func TestCreateInterval(t *testing.T) { func TestCreateIntervalEndBeforeStart(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, clk := newTestServices(t) - now := time.Now().UTC().UnixMilli() + now := clk.Now().UTC().UnixMilli() _, err := svc.CreateInterval(ctx, service.CreateIntervalInput{ StartTime: now, EndTime: now - 1000, @@ -179,11 +185,11 @@ func TestCreateIntervalEndBeforeStart(t *testing.T) { func TestCreateIntervalCrossesMidnight(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, clk := newTestServices(t) - yesterday := time.Now().UTC().Add(-24 * time.Hour) + yesterday := clk.Now().UTC().Add(-24 * time.Hour) startMs := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 0, 0, 0, time.UTC).UnixMilli() - endMs := time.Now().UTC().Add(time.Hour).UnixMilli() + endMs := clk.Now().UTC().Add(time.Hour).UnixMilli() _, err := svc.CreateInterval(ctx, service.CreateIntervalInput{ StartTime: startMs, @@ -212,13 +218,14 @@ func TestUpdateRejectsClosedDay(t *testing.T) { settingsStore := store.NewSettingsStore(db) syncStore := store.NewSyncStore(db) tz, _ := time.LoadLocation("UTC") - svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz) - daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz) + clk := clock.Fixed(testAnchor) + svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz, clk) + daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz, clk) entry, _ := svc.Start(ctx, "") svc.Stop(ctx) - today := time.Now().UTC().Format("2006-01-02") + today := clk.Now().UTC().Format("2006-01-02") if _, err := daySvc.CloseDay(ctx, today); err != nil { t.Fatalf("CloseDay: %v", err) } @@ -235,10 +242,9 @@ func TestUpdateRejectsClosedDay(t *testing.T) { func TestCreateIntervalRejectsFutureDay(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, clk := newTestServices(t) - // Build a start_time that is tomorrow. - tomorrow := time.Now().UTC().AddDate(0, 0, 1) + tomorrow := clk.Now().UTC().AddDate(0, 0, 1) startMs := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 9, 0, 0, 0, time.UTC).UnixMilli() endMs := startMs + 3_600_000 // +1h @@ -257,10 +263,9 @@ func TestCreateIntervalRejectsFutureDay(t *testing.T) { func TestUpdateRejectsMoveToFutureDay(t *testing.T) { ctx := context.Background() - svc := newTestServices(t) + svc, clk := newTestServices(t) - // Create a valid interval for today. - now := time.Now().UTC() + now := clk.Now().UTC() startMs := time.Date(now.Year(), now.Month(), now.Day(), 8, 0, 0, 0, time.UTC).UnixMilli() endMs := startMs + 3_600_000 entry, err := svc.CreateInterval(ctx, service.CreateIntervalInput{ @@ -271,7 +276,6 @@ func TestUpdateRejectsMoveToFutureDay(t *testing.T) { t.Fatalf("CreateInterval: %v", err) } - // Try to move start_time to tomorrow. tomorrow := now.AddDate(0, 0, 1) futureStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 8, 0, 0, 0, time.UTC).UnixMilli() _, err = svc.Update(ctx, entry.ID, service.UpdateEntryInput{ diff --git a/internal/service/settings_service.go b/internal/service/settings_service.go index d802f0a..911d0fc 100644 --- a/internal/service/settings_service.go +++ b/internal/service/settings_service.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/wotra/wotra/internal/clock" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/store" ) @@ -24,15 +25,16 @@ var ( type SettingsService struct { store *store.SettingsStore syncStore *store.SyncStore + clock clock.Clock } -func NewSettingsService(s *store.SettingsStore, syncStore *store.SyncStore) *SettingsService { - return &SettingsService{store: s, syncStore: syncStore} +func NewSettingsService(s *store.SettingsStore, syncStore *store.SyncStore, clk clock.Clock) *SettingsService { + return &SettingsService{store: s, syncStore: syncStore, clock: clk} } // Current returns settings effective as of today. func (s *SettingsService) Current(ctx context.Context) (*domain.Settings, error) { - today := time.Now().UTC().Format("2006-01-02") + today := s.clock.Now().UTC().Format("2006-01-02") set, err := s.store.Current(ctx, today) if err != nil { return nil, ErrNoSettings @@ -80,7 +82,7 @@ func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput) return nil, fmt.Errorf("invalid effective_from: %w", err) } - now := time.Now().UnixMilli() + now := s.clock.Now().UnixMilli() set := &domain.Settings{ ID: uuid.New().String(), EffectiveFrom: input.EffectiveFrom, @@ -135,7 +137,7 @@ func (s *SettingsService) UpdateSettings(ctx context.Context, id string, input U set.HoursPerWeek = input.HoursPerWeek set.WorkdaysMask = input.WorkdaysMask set.Timezone = input.Timezone - set.UpdatedAt = time.Now().UnixMilli() + set.UpdatedAt = s.clock.Now().UnixMilli() if err := s.store.Update(ctx, set); err != nil { return nil, err diff --git a/internal/service/week_service.go b/internal/service/week_service.go index 951e655..ede4063 100644 --- a/internal/service/week_service.go +++ b/internal/service/week_service.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "github.com/wotra/wotra/internal/clock" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/store" ) @@ -30,6 +31,7 @@ type WeekService struct { } rawDB *sql.DB tz *time.Location + clock clock.Clock } func NewWeekService( @@ -41,6 +43,7 @@ func NewWeekService( syncStore *store.SyncStore, rawDB *sql.DB, tz *time.Location, + clk clock.Clock, ) *WeekService { return &WeekService{ closedDays: closedDays, @@ -51,6 +54,7 @@ func NewWeekService( settings: settings, rawDB: rawDB, tz: tz, + clock: clk, } } @@ -185,7 +189,7 @@ func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.Cl // Get settings effective at close time (today), not necessarily at the // start of the week. This ensures settings changes made mid-week are // reflected when the week is closed. - set, err := s.settings.Current(ctx, time.Now().In(s.tz).Format("2006-01-02")) + set, err := s.settings.Current(ctx, s.clock.Now().In(s.tz).Format("2006-01-02")) if err != nil { return nil, ErrNoSettings } @@ -196,7 +200,7 @@ func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.Cl // Verify all past workdays that have entries are closed; collect worked ms. // Past workdays with no entries at all are skipped (they contribute 0h). // Future workdays in the week are always skipped. - todayKey := time.Now().In(s.tz).Format("2006-01-02") + todayKey := s.clock.Now().In(s.tz).Format("2006-01-02") var totalWorkedMs int64 for _, dk := range dayKeys { t, _ := time.ParseInLocation("2006-01-02", dk, s.tz) @@ -226,7 +230,7 @@ func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.Cl // No entries, no closed record → untracked day, counts as 0h. } - now := time.Now().UnixMilli() + now := s.clock.Now().UnixMilli() cw := &domain.ClosedWeek{ WeekKey: weekKey, ExpectedMs: expectedMs, diff --git a/internal/service/week_service_test.go b/internal/service/week_service_test.go index bd06f7a..bed8d7a 100644 --- a/internal/service/week_service_test.go +++ b/internal/service/week_service_test.go @@ -6,12 +6,16 @@ import ( "testing" "time" + "github.com/wotra/wotra/internal/clock" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/service" "github.com/wotra/wotra/internal/store" ) -func newFullServices(t *testing.T) (*service.EntryService, *service.DayService, *service.WeekService, *service.SettingsService) { +// weekTestAnchor is a fixed Tuesday (2026-W20) used as "now" across week service tests. +var weekTestAnchor = time.Date(2026, 5, 13, 10, 0, 0, 0, time.UTC) + +func newFullServices(t *testing.T) (*service.EntryService, *service.DayService, *service.WeekService, *service.SettingsService, *clock.FixedClock) { t.Helper() db, err := store.Open(":memory:") if err != nil { @@ -26,12 +30,13 @@ func newFullServices(t *testing.T) (*service.EntryService, *service.DayService, syncStore := store.NewSyncStore(db) settingsStore := store.NewSettingsStore(db) tz, _ := time.LoadLocation("UTC") + clk := clock.Fixed(weekTestAnchor) - entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz) - daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz) - weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz) - settingsSvc := service.NewSettingsService(settingsStore, syncStore) - return entrySvc, daySvc, weekSvc, settingsSvc + entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz, clk) + daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz, clk) + weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz, clk) + settingsSvc := service.NewSettingsService(settingsStore, syncStore, clk) + return entrySvc, daySvc, weekSvc, settingsSvc, clk } func TestWeekDayKeys(t *testing.T) { @@ -65,7 +70,7 @@ func TestWeekDayKeys(t *testing.T) { func TestCloseWeekBasic(t *testing.T) { ctx := context.Background() - entrySvc, daySvc, weekSvc, _ := newFullServices(t) + entrySvc, daySvc, weekSvc, _, _ := newFullServices(t) monday := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) weekKey := "2024-W03" @@ -98,7 +103,7 @@ func TestCloseWeekBasic(t *testing.T) { func TestCloseWeekMissingDayFails(t *testing.T) { ctx := context.Background() - entrySvc, daySvc, weekSvc, _ := newFullServices(t) + entrySvc, daySvc, weekSvc, _, _ := newFullServices(t) monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) @@ -125,7 +130,7 @@ func TestCloseWeekMissingDayFails(t *testing.T) { func TestCloseWeekTwiceFails(t *testing.T) { ctx := context.Background() - _, daySvc, weekSvc, _ := newFullServices(t) + _, daySvc, weekSvc, _, _ := newFullServices(t) monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) for i := 0; i < 5; i++ { @@ -142,7 +147,7 @@ func TestCloseWeekTwiceFails(t *testing.T) { func TestReopenWeek(t *testing.T) { ctx := context.Background() - _, daySvc, weekSvc, _ := newFullServices(t) + _, daySvc, weekSvc, _, _ := newFullServices(t) monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) for i := 0; i < 5; i++ { @@ -161,11 +166,13 @@ func TestReopenWeek(t *testing.T) { } func TestCloseWeekMidWeek(t *testing.T) { + // weekTestAnchor = 2026-05-13 (Tuesday, 2026-W20). + // Mon 2026-05-11 and Tue 2026-05-13 are past workdays; Wed-Fri are future. ctx := context.Background() - _, daySvc, weekSvc, _ := newFullServices(t) + _, daySvc, weekSvc, _, clk := newFullServices(t) tz, _ := time.LoadLocation("UTC") - now := time.Now().In(tz) + now := clk.Now().In(tz) isoYear, isoWeek := now.ISOWeek() weekKey := fmt.Sprintf("%d-W%02d", isoYear, isoWeek) @@ -191,7 +198,7 @@ func TestWeekSnapshotUpdatesWhenDayReopened(t *testing.T) { // Regression: closing a day after the week is already closed must update // the frozen worked_ms/delta_ms on the closed week. ctx := context.Background() - entrySvc, daySvc, weekSvc, _ := newFullServices(t) + entrySvc, daySvc, weekSvc, _, _ := newFullServices(t) weekKey := "2024-W03" monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) @@ -246,7 +253,7 @@ func TestWeekSnapshotUpdatesWhenDayReopened(t *testing.T) { func TestWeekServiceBalance(t *testing.T) { ctx := context.Background() - _, daySvc, weekSvc, _ := newFullServices(t) + _, daySvc, weekSvc, _, clk := newFullServices(t) // Empty — no closed weeks, no adjustments. bal, err := weekSvc.Balance(ctx) @@ -287,8 +294,8 @@ func TestWeekServiceBalance(t *testing.T) { t.Errorf("weeks-only total: want 0, got %+v", bal) } - // Add a +2h adjustment. - now := time.Now().UnixMilli() + // Add a +2h adjustment; use the fixed clock's time for timestamps. + now := clk.Now().UnixMilli() adj, err := weekSvc.CreateAdjustment(ctx, &domain.BalanceAdjustment{ ID: "adj-1", DeltaMs: 7_200_000, Note: "carry-over", EffectiveAt: now, CreatedAt: now, UpdatedAt: now,