package service_test import ( "context" "fmt" "testing" "time" "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) { t.Helper() db, err := store.Open(":memory:") if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) entryStore := store.NewEntryStore(db) closedDayStore := store.NewClosedDayStore(db) closedWeekStore := store.NewClosedWeekStore(db) settingsStore := store.NewSettingsStore(db) tz, _ := time.LoadLocation("UTC") entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, db, tz) settingsSvc := service.NewSettingsService(settingsStore) return entrySvc, daySvc, weekSvc, settingsSvc } func TestWeekDayKeys(t *testing.T) { tz, _ := time.LoadLocation("UTC") cases := []struct { weekKey string wantMon string wantSun string }{ {"2024-W03", "2024-01-15", "2024-01-21"}, // Jan 4 = Thursday {"2026-W18", "2026-04-27", "2026-05-03"}, // Jan 4 = Sunday — was broken {"2023-W01", "2023-01-02", "2023-01-08"}, // Jan 4 = Wednesday } for _, tc := range cases { keys, err := service.WeekDayKeysExported(tc.weekKey, tz) if err != nil { t.Fatalf("%s: %v", tc.weekKey, err) } if len(keys) != 7 { t.Fatalf("%s: expected 7 keys, got %d", tc.weekKey, len(keys)) } if keys[0] != tc.wantMon { t.Errorf("%s: Monday want %s, got %s", tc.weekKey, tc.wantMon, keys[0]) } if keys[6] != tc.wantSun { t.Errorf("%s: Sunday want %s, got %s", tc.weekKey, tc.wantSun, keys[6]) } } } func TestCloseWeekBasic(t *testing.T) { ctx := context.Background() entrySvc, daySvc, weekSvc, _ := newFullServices(t) monday := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) weekKey := "2024-W03" for i := 0; i < 5; i++ { dk := monday.AddDate(0, 0, i).Format("2006-01-02") _, err := daySvc.MarkDay(ctx, dk, domain.DayKindHoliday) if err != nil { t.Fatalf("MarkDay %s: %v", dk, err) } } cw, err := weekSvc.CloseWeek(ctx, weekKey) if err != nil { t.Fatalf("CloseWeek: %v", err) } if cw.ExpectedMs != 40*3_600_000 { t.Errorf("expected 40h expected_ms, got %d ms", cw.ExpectedMs) } if cw.WorkedMs != 40*3_600_000 { t.Errorf("expected 40h worked_ms, got %d ms", cw.WorkedMs) } if cw.DeltaMs != 0 { t.Errorf("expected 0 delta_ms, got %d", cw.DeltaMs) } fmt.Printf("WeekKey=%s Expected=%dh Worked=%dh Delta=%dms\n", cw.WeekKey, cw.ExpectedMs/3_600_000, cw.WorkedMs/3_600_000, cw.DeltaMs) _ = entrySvc } func TestCloseWeekMissingDayFails(t *testing.T) { ctx := context.Background() entrySvc, daySvc, weekSvc, _ := newFullServices(t) monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) for i := 0; i < 4; i++ { dk := monday.AddDate(0, 0, i).Format("2006-01-02") daySvc.MarkDay(ctx, dk, domain.DayKindHoliday) } // Friday: add an entry but don't close the day. fridayStart := time.Date(2024, 1, 19, 9, 0, 0, 0, time.UTC).UnixMilli() fridayEnd := time.Date(2024, 1, 19, 17, 0, 0, 0, time.UTC).UnixMilli() if _, err := entrySvc.CreateInterval(ctx, service.CreateIntervalInput{ StartTime: fridayStart, EndTime: fridayEnd, }); err != nil { t.Fatalf("CreateInterval: %v", err) } _, err := weekSvc.CloseWeek(ctx, "2024-W03") if err == nil { t.Fatal("expected error: friday has entries but is not closed") } } func TestCloseWeekTwiceFails(t *testing.T) { ctx := context.Background() _, daySvc, weekSvc, _ := newFullServices(t) monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) for i := 0; i < 5; i++ { dk := monday.AddDate(0, 0, i).Format("2006-01-02") daySvc.MarkDay(ctx, dk, domain.DayKindHoliday) } weekSvc.CloseWeek(ctx, "2024-W03") _, err := weekSvc.CloseWeek(ctx, "2024-W03") if err != service.ErrWeekAlreadyClosed { t.Fatalf("expected ErrWeekAlreadyClosed, got %v", err) } } func TestReopenWeek(t *testing.T) { ctx := context.Background() _, daySvc, weekSvc, _ := newFullServices(t) monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) for i := 0; i < 5; i++ { dk := monday.AddDate(0, 0, i).Format("2006-01-02") daySvc.MarkDay(ctx, dk, domain.DayKindHoliday) } weekSvc.CloseWeek(ctx, "2024-W03") if err := weekSvc.ReopenWeek(ctx, "2024-W03"); err != nil { t.Fatalf("ReopenWeek: %v", err) } _, err := weekSvc.CloseWeek(ctx, "2024-W03") if err != nil { t.Fatalf("CloseWeek after reopen: %v", err) } } func TestCloseWeekMidWeek(t *testing.T) { ctx := context.Background() _, daySvc, weekSvc, _ := newFullServices(t) tz, _ := time.LoadLocation("UTC") now := time.Now().In(tz) isoYear, isoWeek := now.ISOWeek() weekKey := fmt.Sprintf("%d-W%02d", isoYear, isoWeek) monday := now.AddDate(0, 0, -int(now.Weekday()-time.Monday)) today := now.Format("2006-01-02") for d := monday; d.Format("2006-01-02") <= today; d = d.AddDate(0, 0, 1) { wd := d.Weekday() if wd == time.Saturday || wd == time.Sunday { continue } dk := d.Format("2006-01-02") if _, err := daySvc.MarkDay(ctx, dk, domain.DayKindHoliday); err != nil { t.Fatalf("MarkDay %s: %v", dk, err) } } if _, err := weekSvc.CloseWeek(ctx, weekKey); err != nil { t.Fatalf("CloseWeek mid-week: %v", err) } } 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) weekKey := "2024-W03" monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) // Close Mon–Thu as holiday (8h each), leave Friday untracked. for i := 0; i < 4; i++ { dk := monday.AddDate(0, 0, i).Format("2006-01-02") if _, err := daySvc.MarkDay(ctx, dk, domain.DayKindHoliday); err != nil { t.Fatalf("MarkDay: %v", err) } } // Close week: 4 * 8h = 32h worked, expected 40h, delta -8h. cw, err := weekSvc.CloseWeek(ctx, weekKey) if err != nil { t.Fatalf("CloseWeek: %v", err) } if cw.WorkedMs != 32*3_600_000 { t.Errorf("initial worked_ms: want 32h, got %dms", cw.WorkedMs) } // Reopen Thursday, add a 9h entry, re-close it. thursdayKey := monday.AddDate(0, 0, 3).Format("2006-01-02") if err := daySvc.ReopenDay(ctx, thursdayKey); err != nil { t.Fatalf("ReopenDay: %v", err) } thuStart := time.Date(2024, 1, 18, 8, 0, 0, 0, time.UTC).UnixMilli() thuEnd := time.Date(2024, 1, 18, 17, 0, 0, 0, time.UTC).UnixMilli() // 9h if _, err := entrySvc.CreateInterval(ctx, service.CreateIntervalInput{ StartTime: thuStart, EndTime: thuEnd, }); err != nil { t.Fatalf("CreateInterval: %v", err) } if _, err := daySvc.CloseDay(ctx, thursdayKey); err != nil { t.Fatalf("CloseDay: %v", err) } // Week snapshot must now reflect 3*8h + 9h = 33h worked. cw2, err := weekSvc.GetWeek(ctx, weekKey) if err != nil || cw2 == nil { t.Fatalf("GetWeek: %v", err) } wantWorked := int64(3*8+9) * 3_600_000 if cw2.WorkedMs != wantWorked { t.Errorf("updated worked_ms: want %dms (%dh), got %dms", wantWorked, wantWorked/3_600_000, cw2.WorkedMs) } wantDelta := wantWorked - cw2.ExpectedMs if cw2.DeltaMs != wantDelta { t.Errorf("updated delta_ms: want %dms, got %dms", wantDelta, cw2.DeltaMs) } } func TestWeekServiceBalance(t *testing.T) { ctx := context.Background() _, daySvc, weekSvc, _ := newFullServices(t) // Empty — no closed weeks yet. bal, err := weekSvc.Balance(ctx) if err != nil { t.Fatalf("Balance (empty): %v", err) } if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 { t.Errorf("empty: want {0,0}, got {%d,%d}", bal.TotalDeltaMs, bal.ClosedWeekCount) } // Close two weeks as holiday (worked = expected → delta = 0 each). for _, spec := range []struct { weekKey string monday time.Time }{ {"2024-W03", time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)}, {"2024-W04", time.Date(2024, 1, 22, 0, 0, 0, 0, time.UTC)}, } { for i := 0; i < 5; i++ { dk := spec.monday.AddDate(0, 0, i).Format("2006-01-02") if _, err := daySvc.MarkDay(ctx, dk, domain.DayKindHoliday); err != nil { t.Fatalf("MarkDay %s: %v", dk, err) } } if _, err := weekSvc.CloseWeek(ctx, spec.weekKey); err != nil { t.Fatalf("CloseWeek %s: %v", spec.weekKey, err) } } bal, err = weekSvc.Balance(ctx) if err != nil { t.Fatalf("Balance (populated): %v", err) } if bal.ClosedWeekCount != 2 { t.Errorf("count: want 2, got %d", bal.ClosedWeekCount) } // Both weeks: worked == expected (holiday marks = full hours), delta = 0. if bal.TotalDeltaMs != 0 { t.Errorf("total_delta_ms: want 0, got %d", bal.TotalDeltaMs) } }