Files
wotra/internal/service/week_service_test.go

418 lines
14 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 Wednesday (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 (Wednesday, 2026-W20).
// MonWed are tracked with real intervals and closed. Thu is pre-marked as
// holiday, Fri as vacation. CloseWeek is called mid-week: the service skips
// future workdays (Thu, Fri), so only Mon+Tue+Wed intervals count.
ctx := context.Background()
entrySvc, daySvc, weekSvc, _, _ := newFullServices(t)
const weekKey = "2026-W20"
mon := time.Date(2026, 5, 11, 0, 0, 0, 0, time.UTC)
tue := time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC)
wed := time.Date(2026, 5, 13, 0, 0, 0, 0, time.UTC)
thu := time.Date(2026, 5, 14, 0, 0, 0, 0, time.UTC)
fri := time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC)
// at returns the unix-ms for a given day at hh:mm UTC.
at := func(base time.Time, h, m int) int64 {
return time.Date(base.Year(), base.Month(), base.Day(), h, m, 0, 0, time.UTC).UnixMilli()
}
// Monday: 3 spans — 1.5h + 1h + 2h = 4.5h
for _, s := range [][2][2]int{{{9, 0}, {10, 30}}, {{11, 0}, {12, 0}}, {{13, 0}, {15, 0}}} {
if _, err := entrySvc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: at(mon, s[0][0], s[0][1]),
EndTime: at(mon, s[1][0], s[1][1]),
}); err != nil {
t.Fatalf("CreateInterval mon: %v", err)
}
}
if _, err := daySvc.CloseDay(ctx, mon.Format("2006-01-02")); err != nil {
t.Fatalf("CloseDay mon: %v", err)
}
// Tuesday: 1 span — 4h
if _, err := entrySvc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: at(tue, 9, 0),
EndTime: at(tue, 13, 0),
}); err != nil {
t.Fatalf("CreateInterval tue: %v", err)
}
if _, err := daySvc.CloseDay(ctx, tue.Format("2006-01-02")); err != nil {
t.Fatalf("CloseDay tue: %v", err)
}
// Wednesday: 2 spans — 2h + 2h = 4h
for _, s := range [][2][2]int{{{9, 0}, {11, 0}}, {{14, 0}, {16, 0}}} {
if _, err := entrySvc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: at(wed, s[0][0], s[0][1]),
EndTime: at(wed, s[1][0], s[1][1]),
}); err != nil {
t.Fatalf("CreateInterval wed: %v", err)
}
}
if _, err := daySvc.CloseDay(ctx, wed.Format("2006-01-02")); err != nil {
t.Fatalf("CloseDay wed: %v", err)
}
// Thursday and Friday are future — mark them ahead of time, so we can
// finish early.
if _, err := daySvc.MarkDay(ctx, thu.Format("2006-01-02"), domain.DayKindHoliday); err != nil {
t.Fatalf("MarkDay thu: %v", err)
}
if _, err := daySvc.MarkDay(ctx, fri.Format("2006-01-02"), domain.DayKindVacation); err != nil {
t.Fatalf("MarkDay fri: %v", err)
}
cw, err := weekSvc.CloseWeek(ctx, weekKey)
if err != nil {
t.Fatalf("CloseWeek: %v", err)
}
// Only Mon+Tue+Wed contribute: 4.5h + 4h + 4h + 8h + 8h = 28.5h
const wantWorked = 28*time.Hour + 30*time.Minute
if cw.WorkedMs != int64(wantWorked/time.Millisecond) {
t.Errorf("worked hours: want %s, got %s", wantWorked, time.Duration(cw.WorkedMs)*time.Millisecond)
}
const wantExpected = 40*time.Hour
if cw.ExpectedMs != int64(wantExpected/time.Millisecond) {
t.Errorf("expected_ms: want %s, got %s", wantExpected, time.Duration(cw.ExpectedMs)*time.Millisecond)
}
const wantDelta = wantWorked - wantExpected // 11.5h
if cw.DeltaMs != int64(wantDelta) {
t.Errorf("delta_ms: want %s, got %s", wantDelta, time.Duration(cw.DeltaMs)*time.Millisecond)
}
}
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)
}
}