Files
wotra/internal/service/week_service_test.go
Andreas Schneider 3214f48a6f Add balance adjustments (M8)
- New balance_adjustments table with CRUD store, sync logging, and service methods
- SQL migrations restructured: embed fs.FS from internal/store/migrations/, apply in order via user_version
- WeekService.Balance combines closed-weeks delta + adjustments delta; BalanceSummary breakdown
- Four REST routes: GET/POST /api/balance/adjustments, PUT/DELETE /api/balance/adjustments/{id}
- Dexie schema v2 + sync apply cases for balance_adjustments
- API client: BalanceAdjustment type, balance namespace (list/create/update/delete)
- utils: composeDeltaMs / decomposeDeltaMs helpers + 8 new Vitest tests (19 total, all passing)
- History page: balance card breakdown line + full adjustments section with inline add/edit/delete
2026-04-30 21:50:57 +02:00

354 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/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)
adjustmentStore := store.NewBalanceAdjustmentStore(db)
syncStore := store.NewSyncStore(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, adjustmentStore, syncStore, db, tz)
settingsSvc := service.NewSettingsService(settingsStore)
return entrySvc, daySvc, weekSvc, settingsSvc
}
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) {
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)
}
}
func TestWeekServiceBalance(t *testing.T) {
ctx := context.Background()
_, daySvc, weekSvc, _ := 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.
now := time.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)
}
}