418 lines
14 KiB
Go
418 lines
14 KiB
Go
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).
|
||
// Mon–Wed 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/time.Millisecond) {
|
||
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 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, _, 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)
|
||
}
|
||
}
|