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
232 lines
6.9 KiB
Go
232 lines
6.9 KiB
Go
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 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)
|
||
}
|
||
}
|