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
This commit is contained in:
@@ -52,6 +52,8 @@ func (h *EntryHandler) CreateInterval(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||||
case errors.Is(err, service.ErrDayAlreadyClosed):
|
case errors.Is(err, service.ErrDayAlreadyClosed):
|
||||||
writeError(w, http.StatusConflict, err.Error())
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, service.ErrFutureDay):
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
default:
|
default:
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
@@ -145,6 +147,8 @@ func (h *EntryHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
case errors.Is(err, service.ErrCrossesMidnight):
|
case errors.Is(err, service.ErrCrossesMidnight):
|
||||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||||
|
case errors.Is(err, service.ErrFutureDay):
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
default:
|
default:
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ var (
|
|||||||
ErrDayNotClosed = errors.New("day is not closed")
|
ErrDayNotClosed = errors.New("day is not closed")
|
||||||
ErrRunningEntryOnDay = errors.New("a running entry exists for this day; stop it first")
|
ErrRunningEntryOnDay = errors.New("a running entry exists for this day; stop it first")
|
||||||
ErrCrossesMidnight = errors.New("entry end_time must be on the same calendar day as start_time")
|
ErrCrossesMidnight = errors.New("entry end_time must be on the same calendar day as start_time")
|
||||||
|
ErrFutureDay = errors.New("intervals cannot be created or edited in the future")
|
||||||
)
|
)
|
||||||
|
|
||||||
// EntryService handles business logic for time entries.
|
// EntryService handles business logic for time entries.
|
||||||
@@ -186,8 +187,13 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI
|
|||||||
}
|
}
|
||||||
|
|
||||||
if input.StartTime != nil {
|
if input.StartTime != nil {
|
||||||
|
newDayKey := s.dayKeyForMs(*input.StartTime)
|
||||||
|
// Reject if the new start_time moves the entry into the future.
|
||||||
|
if newDayKey > s.dayKeyForMs(s.nowMs()) {
|
||||||
|
return nil, ErrFutureDay
|
||||||
|
}
|
||||||
e.StartTime = *input.StartTime
|
e.StartTime = *input.StartTime
|
||||||
e.DayKey = s.dayKeyForMs(e.StartTime)
|
e.DayKey = newDayKey
|
||||||
}
|
}
|
||||||
if input.EndTime != nil {
|
if input.EndTime != nil {
|
||||||
// Validate same-day
|
// Validate same-day
|
||||||
@@ -220,7 +226,7 @@ type CreateIntervalInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateInterval adds a completed interval with explicit start and end times.
|
// CreateInterval adds a completed interval with explicit start and end times.
|
||||||
// Rules: same-day, end > start, day not closed, no currently running entry on that day (check running globally).
|
// Rules: same-day, end > start, day not closed, day not in the future.
|
||||||
func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalInput) (*domain.Entry, error) {
|
func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalInput) (*domain.Entry, error) {
|
||||||
if input.EndTime <= input.StartTime {
|
if input.EndTime <= input.StartTime {
|
||||||
return nil, fmt.Errorf("end_time must be after start_time")
|
return nil, fmt.Errorf("end_time must be after start_time")
|
||||||
@@ -232,6 +238,12 @@ func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalI
|
|||||||
return nil, ErrCrossesMidnight
|
return nil, ErrCrossesMidnight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject future intervals.
|
||||||
|
todayKey := s.dayKeyForMs(s.nowMs())
|
||||||
|
if startDayKey > todayKey {
|
||||||
|
return nil, ErrFutureDay
|
||||||
|
}
|
||||||
|
|
||||||
// Check day is not closed.
|
// Check day is not closed.
|
||||||
closed, err := s.closedDays.GetByDayKey(ctx, startDayKey)
|
closed, err := s.closedDays.GetByDayKey(ctx, startDayKey)
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
|||||||
@@ -231,3 +231,55 @@ func TestUpdateRejectsClosedDay(t *testing.T) {
|
|||||||
t.Fatalf("expected ErrDayAlreadyClosed, got %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user