Files
wotra/internal/service/week_service_test.go
Andreas Schneider 47c7a97d47 fix: keep closed week snapshot in sync when days change
When a day is closed, re-closed, or reopened, DayService now
recomputes worked_ms and delta_ms on the closed week containing
that day (if the week is already closed). This prevents stale
delta values after editing entries and re-closing a day.

- DayService.recomputeWeek: sums worked_ms from all closed_days
  in the week, updates closed_weeks row preserving expected_ms
- NewDayService now takes ClosedWeekStore
- WeekKeyForDayKey exported helper (used by DayService)
- TestWeekSnapshotUpdatesWhenDayReopened regression test
2026-04-30 18:16:22 +02:00

232 lines
6.9 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/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")
keys, err := service.WeekDayKeysExported("2024-W03", tz)
if err != nil {
t.Fatal(err)
}
if len(keys) != 7 {
t.Fatalf("expected 7 keys, got %d", len(keys))
}
if keys[0] != "2024-01-15" {
t.Errorf("expected Monday 2024-01-15, got %s", keys[0])
}
if keys[6] != "2024-01-21" {
t.Errorf("expected Sunday 2024-01-21, got %s", 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 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)
}
}