Files
wotra/internal/service/entry_service_test.go
Andreas Schneider bf0c728818 fix(entries): reject create/update of intervals in the future
- Add ErrFutureDay sentinel error
- CreateInterval: rejects if startDayKey > todayKey (400)
- Update: rejects if new start_time moves entry to a future day (400)
- Handler maps ErrFutureDay → 400 Bad Request for both endpoints
- Add TestCreateIntervalRejectsFutureDay
- Add TestUpdateRejectsMoveToFutureDay
- UI already gates this via dayCapabilities (canAddInterval=false,
  canEditEntries=false for future days), but server now enforces it too
2026-04-30 19:09:22 +02:00

286 lines
7.1 KiB
Go

package service_test
import (
"context"
"errors"
"testing"
"time"
"github.com/wotra/wotra/internal/service"
"github.com/wotra/wotra/internal/store"
)
func newTestServices(t *testing.T) *service.EntryService {
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)
settingsStore := store.NewSettingsStore(db)
tz, _ := time.LoadLocation("UTC")
return service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
}
func TestStartStop(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
entry, err := svc.Start(ctx, "test entry")
if err != nil {
t.Fatalf("Start: %v", err)
}
if entry.ID == "" {
t.Fatal("expected non-empty ID")
}
if entry.EndTime != nil {
t.Fatal("expected running entry (nil EndTime)")
}
stopped, err := svc.Stop(ctx)
if err != nil {
t.Fatalf("Stop: %v", err)
}
if stopped.EndTime == nil {
t.Fatal("expected entry to be stopped")
}
if *stopped.EndTime < stopped.StartTime {
t.Fatal("end_time before start_time")
}
}
func TestStartTwiceFails(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
if _, err := svc.Start(ctx, ""); err != nil {
t.Fatal(err)
}
_, err := svc.Start(ctx, "")
if err == nil {
t.Fatal("expected error starting second entry")
}
if err != service.ErrEntryRunning {
t.Fatalf("expected ErrEntryRunning, got %v", err)
}
}
func TestStopWithoutStart(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
_, err := svc.Stop(ctx)
if err != service.ErrEntryNotRunning {
t.Fatalf("expected ErrEntryNotRunning, got %v", err)
}
}
func TestUpdateEntry(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
entry, _ := svc.Start(ctx, "initial note")
stopped, _ := svc.Stop(ctx)
note := "updated note"
updated, err := svc.Update(ctx, stopped.ID, service.UpdateEntryInput{Note: &note})
if err != nil {
t.Fatalf("Update: %v", err)
}
if updated.Note != "updated note" {
t.Errorf("expected 'updated note', got %q", updated.Note)
}
_ = entry
}
func TestDeleteEntry(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
entry, _ := svc.Start(ctx, "")
svc.Stop(ctx)
if err := svc.Delete(ctx, entry.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
entries, err := svc.List(ctx, "0000-01-01", "9999-12-31")
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("expected 0 entries after delete, got %d", len(entries))
}
}
func TestListEntries(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
for i := 0; i < 3; i++ {
svc.Start(ctx, "")
svc.Stop(ctx)
}
today := time.Now().UTC().Format("2006-01-02")
entries, err := svc.List(ctx, today, today)
if err != nil {
t.Fatal(err)
}
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
}
func TestCreateInterval(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
now := time.Now().UTC()
startMs := now.Add(-2 * time.Hour).UnixMilli()
endMs := now.Add(-1 * time.Hour).UnixMilli()
entry, err := svc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: startMs,
EndTime: endMs,
Note: "manual entry",
})
if err != nil {
t.Fatalf("CreateInterval: %v", err)
}
if entry.EndTime == nil {
t.Fatal("expected EndTime to be set")
}
if *entry.EndTime != endMs {
t.Errorf("expected EndTime %d, got %d", endMs, *entry.EndTime)
}
if entry.Note != "manual entry" {
t.Errorf("expected note 'manual entry', got %q", entry.Note)
}
}
func TestCreateIntervalEndBeforeStart(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
now := time.Now().UTC().UnixMilli()
_, err := svc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: now,
EndTime: now - 1000,
})
if err == nil {
t.Fatal("expected error for end_time before start_time")
}
}
func TestCreateIntervalCrossesMidnight(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
yesterday := time.Now().UTC().Add(-24 * time.Hour)
startMs := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 0, 0, 0, time.UTC).UnixMilli()
endMs := time.Now().UTC().Add(time.Hour).UnixMilli()
_, err := svc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: startMs,
EndTime: endMs,
})
if err == nil {
t.Fatal("expected ErrCrossesMidnight")
}
if !errors.Is(err, service.ErrCrossesMidnight) {
t.Fatalf("expected ErrCrossesMidnight, got %v", err)
}
}
func TestUpdateRejectsClosedDay(t *testing.T) {
ctx := context.Background()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
entryStore := store.NewEntryStore(db)
closedDayStore := store.NewClosedDayStore(db)
closedWeekStore := store.NewClosedWeekStore(db)
settingsStore := store.NewSettingsStore(db)
tz, _ := time.LoadLocation("UTC")
svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz)
entry, _ := svc.Start(ctx, "")
svc.Stop(ctx)
today := time.Now().UTC().Format("2006-01-02")
if _, err := daySvc.CloseDay(ctx, today); err != nil {
t.Fatalf("CloseDay: %v", err)
}
note := "should fail"
_, err = svc.Update(ctx, entry.ID, service.UpdateEntryInput{Note: &note})
if err == nil {
t.Fatal("expected error updating entry in closed day")
}
if !errors.Is(err, service.ErrDayAlreadyClosed) {
t.Fatalf("expected ErrDayAlreadyClosed, got %v", err)
}
}
func TestCreateIntervalRejectsFutureDay(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
// Build a start_time that is tomorrow.
tomorrow := time.Now().UTC().AddDate(0, 0, 1)
startMs := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 9, 0, 0, 0, time.UTC).UnixMilli()
endMs := startMs + 3_600_000 // +1h
_, err := svc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: startMs,
EndTime: endMs,
Note: "future",
})
if err == nil {
t.Fatal("expected error creating interval in the future")
}
if !errors.Is(err, service.ErrFutureDay) {
t.Fatalf("expected ErrFutureDay, got %v", err)
}
}
func TestUpdateRejectsMoveToFutureDay(t *testing.T) {
ctx := context.Background()
svc := newTestServices(t)
// Create a valid interval for today.
now := time.Now().UTC()
startMs := time.Date(now.Year(), now.Month(), now.Day(), 8, 0, 0, 0, time.UTC).UnixMilli()
endMs := startMs + 3_600_000
entry, err := svc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: startMs,
EndTime: endMs,
})
if err != nil {
t.Fatalf("CreateInterval: %v", err)
}
// Try to move start_time to tomorrow.
tomorrow := now.AddDate(0, 0, 1)
futureStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 8, 0, 0, 0, time.UTC).UnixMilli()
_, err = svc.Update(ctx, entry.ID, service.UpdateEntryInput{
StartTime: &futureStart,
})
if err == nil {
t.Fatal("expected error moving entry to future day")
}
if !errors.Is(err, service.ErrFutureDay) {
t.Fatalf("expected ErrFutureDay, got %v", err)
}
}