feat(classes): weekly recurring schedules with lazy session materialization

class_schedules rules generate concrete session rows for a rolling 28-day
horizon — no cron; materialize runs idempotently on dashboard and public
reads via a partial unique index on (schedule_id, starts_at). Rule delete
stops future sessions; existing sessions keep bookings and overrides.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 17:31:17 +02:00
1 parent 7b9080eb1c
commit ad60167a31
15 files changed
+1029 -7

No files matched your search

@@ -797,3 +797,92 @@ func TestUpdateClassTemplate(t *testing.T) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}
func TestClassScheduleMaterializesSessions(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
schedules, err := svc.ListClassSchedules(ctx, owner)
if err != nil {
t.Fatalf("list schedules: %v", err)
}
if len(schedules) != 0 {
t.Fatalf("expected 0 schedules in fresh repo, got %d", len(schedules))
}
locID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7",
LocationID: &locID,
DayOfWeek: 1,
StartsLocal: "15:30",
})
if err != nil {
t.Fatalf("create schedule: %v", err)
}
if !created.Active || created.StartsLocal != "15:30:00" {
t.Fatalf("unexpected created schedule: %+v", created)
}
sessions, err := svc.ListClassSessions(ctx, owner)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
var generated int
for _, session := range sessions {
if session.ScheduleID != nil && *session.ScheduleID == created.ID {
generated++
if session.StartsAt.Weekday() != time.Monday {
t.Fatalf("generated session on wrong weekday: %v", session.StartsAt)
}
}
}
if generated < 3 {
t.Fatalf("expected >=3 materialized sessions over the horizon, got %d", generated)
}
sessions2, err := svc.ListClassSessions(ctx, owner)
if err != nil {
t.Fatalf("list sessions 2: %v", err)
}
var generated2 int
for _, session := range sessions2 {
if session.ScheduleID != nil && *session.ScheduleID == created.ID {
generated2++
}
}
if generated2 != generated {
t.Fatalf("materialize not idempotent: %d then %d", generated, generated2)
}
if err := svc.DeleteClassSchedule(ctx, owner, created.ID); err != nil {
t.Fatalf("delete schedule: %v", err)
}
schedules, _ = svc.ListClassSchedules(ctx, owner)
if len(schedules) != 0 {
t.Fatalf("expected 0 schedules after delete, got %d", len(schedules))
}
}
func TestCreateClassScheduleValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 7, StartsLocal: "10:00",
}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for bad weekday, got %v", err)
}
if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 2, StartsLocal: "25:00",
}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for bad time, got %v", err)
}
if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "00000000-0000-0000-0000-000000000099", DayOfWeek: 2, StartsLocal: "10:00",
}); !errors.Is(err, ErrClassTemplateNotFound) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}