refactor: introduce clock to make tests reproducible

This commit is contained in:
2026-05-13 20:24:56 +00:00
parent 7e6c47a50e
commit ca5f5f95e2
9 changed files with 140 additions and 84 deletions

View File

@@ -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())

28
internal/clock/clock.go Normal file
View File

@@ -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) }

View File

@@ -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,

View File

@@ -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
// 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",

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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

View File

@@ -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,

View File

@@ -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,