Files
wotra/internal/service/week_service_test.go

361 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service_test
import (
"context"
"fmt"
"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"
)
// 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 {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
entryStore := store.NewEntryStore(db)
closedDayStore := store.NewClosedDayStore(db)
closedWeekStore := store.NewClosedWeekStore(db)
adjustmentStore := store.NewBalanceAdjustmentStore(db)
syncStore := store.NewSyncStore(db)
settingsStore := store.NewSettingsStore(db)
tz, _ := time.LoadLocation("UTC")
clk := clock.Fixed(weekTestAnchor)
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) {
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) {
// 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, _, clk := newFullServices(t)
tz, _ := time.LoadLocation("UTC")
now := clk.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 MonThu 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, _, clk := newFullServices(t)
// Empty — no closed weeks, no adjustments.
bal, err := weekSvc.Balance(ctx)
if err != nil {
t.Fatalf("Balance (empty): %v", err)
}
if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 || bal.AdjustmentCount != 0 {
t.Errorf("empty: want all zeros, got %+v", bal)
}
// 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("closed_week_count: want 2, got %d", bal.ClosedWeekCount)
}
if bal.TotalDeltaMs != 0 || bal.WeeksDeltaMs != 0 {
t.Errorf("weeks-only total: want 0, got %+v", bal)
}
// 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,
})
if err != nil {
t.Fatalf("CreateAdjustment: %v", err)
}
if adj.ID != "adj-1" {
t.Errorf("CreateAdjustment ID: want adj-1, got %s", adj.ID)
}
bal, err = weekSvc.Balance(ctx)
if err != nil {
t.Fatalf("Balance with adjustment: %v", err)
}
if bal.TotalDeltaMs != 7_200_000 {
t.Errorf("total with adj: want 7200000, got %d", bal.TotalDeltaMs)
}
if bal.AdjustmentsDeltaMs != 7_200_000 {
t.Errorf("adjustments_delta_ms: want 7200000, got %d", bal.AdjustmentsDeltaMs)
}
if bal.AdjustmentCount != 1 {
t.Errorf("adjustment_count: want 1, got %d", bal.AdjustmentCount)
}
// Zero delta rejected.
_, err = weekSvc.CreateAdjustment(ctx, &domain.BalanceAdjustment{
ID: "adj-zero", DeltaMs: 0, EffectiveAt: now, CreatedAt: now, UpdatedAt: now,
})
if err != service.ErrZeroAdjustment {
t.Errorf("zero delta: want ErrZeroAdjustment, got %v", err)
}
// Update adjustment.
adj.DeltaMs = 3_600_000
adj.UpdatedAt = now + 1
updated, err := weekSvc.UpdateAdjustment(ctx, adj)
if err != nil {
t.Fatalf("UpdateAdjustment: %v", err)
}
if updated.DeltaMs != 3_600_000 {
t.Errorf("updated delta_ms: want 3600000, got %d", updated.DeltaMs)
}
bal, _ = weekSvc.Balance(ctx)
if bal.TotalDeltaMs != 3_600_000 {
t.Errorf("balance after update: want 3600000, got %d", bal.TotalDeltaMs)
}
// Delete adjustment.
if err := weekSvc.DeleteAdjustment(ctx, "adj-1"); err != nil {
t.Fatalf("DeleteAdjustment: %v", err)
}
bal, _ = weekSvc.Balance(ctx)
if bal.TotalDeltaMs != 0 || bal.AdjustmentCount != 0 {
t.Errorf("balance after delete: want 0/0, got %d/%d", bal.TotalDeltaMs, bal.AdjustmentCount)
}
// Delete missing.
if err := weekSvc.DeleteAdjustment(ctx, "no-such"); err != service.ErrAdjustmentNotFound {
t.Errorf("delete missing: want ErrAdjustmentNotFound, got %v", err)
}
}