refactor: introduce clock to make tests reproducible
This commit is contained in:
28
internal/clock/clock.go
Normal file
28
internal/clock/clock.go
Normal 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) }
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user