package calendar import ( "context" "strings" "testing" "time" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" ) const ( demoTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148" demoStaffID = "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" demoServiceID = "d5d76a61-3d49-467c-8dd4-bf61ee754e39" demoLocationID = "659f1cc0-a850-46d6-b3b8-cb15d55d8daf" demoSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435" ) var owner = domain.Principal{Subject: "demo-owner", Email: "demo@bookra.dev", Role: "owner"} func seedAppointment(t *testing.T, repo *db.MemoryRepository, staffID *string, status string) db.CreatedBooking { t.Helper() startsAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Minute) created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ TenantID: demoTenantID, ServiceID: &[]string{demoServiceID}[0], StaffID: staffID, LocationID: &[]string{demoLocationID}[0], BookingMode: "appointment", CustomerName: "Feed Customer", CustomerEmail: "feed@example.com", CustomerPhone: "+420111222333", StartsAt: startsAt, EndsAt: startsAt.Add(time.Hour), Status: status, Reference: "BK-FEED-" + status, ManageToken: "feed-token-" + status, }) if err != nil { t.Fatalf("seed booking: %v", err) } return created } func TestRenderCalendarEscapesAndFolds(t *testing.T) { body := RenderCalendar("Studio, Atelier; test", "Europe/Prague", []Event{{ UID: "abc@bookra", StartsAt: time.Date(2026, 9, 20, 7, 0, 0, 0, time.UTC), EndsAt: time.Date(2026, 9, 20, 8, 0, 0, 0, time.UTC), Summary: "Massage; comma, back\\slash\nnewline", Description: "Line one\nLine two", Status: "cancelled", }}) for _, want := range []string{ "BEGIN:VCALENDAR", "END:VCALENDAR", "BEGIN:VEVENT", "END:VEVENT", "DTSTART:20260920T070000Z", "DTEND:20260920T080000Z", "SUMMARY:Massage\\; comma\\, back\\\\slash\\nnewline", "DESCRIPTION:Line one\\nLine two", "STATUS:CANCELLED", "X-WR-CALNAME:Studio\\, Atelier\\; test", "X-WR-TIMEZONE:Europe/Prague", } { if !strings.Contains(body, want) { t.Fatalf("expected %q in feed:\n%s", want, body) } } if strings.Contains(strings.ReplaceAll(body, "\r\n", ""), "\n") || !strings.Contains(body, "\r\n") { t.Fatalf("expected CRLF line endings only") } for _, line := range strings.Split(strings.TrimRight(body, "\r\n"), "\r\n") { if len(line) > 75 { t.Fatalf("line exceeds 75 octets (%d): %q", len(line), line) } } } func TestRenderCalendarFoldsLongLines(t *testing.T) { long := strings.Repeat("x", 200) body := RenderCalendar("test", "", []Event{{ UID: "a@b", StartsAt: time.Now(), EndsAt: time.Now(), Summary: long, Status: "confirmed", }}) for _, line := range strings.Split(strings.TrimRight(body, "\r\n"), "\r\n") { if len(line) > 75 { t.Fatalf("line exceeds 75 octets (%d): %q", len(line), line) } } if !strings.Contains(body, "\r\n ") { t.Fatalf("expected folded continuation lines") } } func TestFeedInfoReturnsTokens(t *testing.T) { repo := db.NewMemoryRepository() svc := NewService(repo) info, err := svc.FeedInfo(context.Background(), owner) if err != nil { t.Fatalf("feed info: %v", err) } if info.Tenant.Token == "" || !strings.HasPrefix(info.Tenant.FeedPath, "/v1/public/calendar/") { t.Fatalf("bad tenant entry: %+v", info.Tenant) } if len(info.Staff) != 1 || info.Staff[0].StaffID != demoStaffID || info.Staff[0].Token == "" { t.Fatalf("bad staff entries: %+v", info.Staff) } } func TestRenderFeedTenantIncludesAllBookings(t *testing.T) { repo := db.NewMemoryRepository() svc := NewService(repo) ctx := context.Background() seedAppointment(t, repo, &[]string{demoStaffID}[0], "confirmed") seedAppointment(t, repo, nil, "cancelled") ics, err := svc.RenderFeed(ctx, "demo-tenant-feed-token") if err != nil { t.Fatalf("render feed: %v", err) } if count := strings.Count(ics, "BEGIN:VEVENT"); count != 2 { t.Fatalf("expected 2 events, got %d", count) } if !strings.Contains(ics, "Signature treatment · Feed Customer · Demo Stylist") { t.Fatalf("expected service+customer+staff summary:\n%s", ics) } if !strings.Contains(ics, "STATUS:CANCELLED") { t.Fatalf("expected cancelled event status:\n%s", ics) } if !strings.Contains(ics, "Telefon: +420111222333") { t.Fatalf("expected czech description labels:\n%s", ics) } if !strings.Contains(ics, "LOCATION:Main Studio") { t.Fatalf("expected location:\n%s", ics) } } func TestRenderFeedStaffFilters(t *testing.T) { repo := db.NewMemoryRepository() svc := NewService(repo) ctx := context.Background() seedAppointment(t, repo, &[]string{demoStaffID}[0], "confirmed") seedAppointment(t, repo, nil, "confirmed") // unassigned — not in staff feed seedAppointment(t, repo, &[]string{"other-staff"}[0], "confirmed") ics, err := svc.RenderFeed(ctx, "demo-staff-feed-token") if err != nil { t.Fatalf("render feed: %v", err) } if count := strings.Count(ics, "BEGIN:VEVENT"); count != 1 { t.Fatalf("expected 1 event in staff feed, got %d", count) } if !strings.Contains(ics, "Studio Atelier · Demo Stylist") { t.Fatalf("expected staff feed name:\n%s", ics) } } func TestRenderFeedUnknownToken(t *testing.T) { repo := db.NewMemoryRepository() svc := NewService(repo) if _, err := svc.RenderFeed(context.Background(), "nonsense"); err != ErrFeedNotFound { t.Fatalf("expected ErrFeedNotFound, got %v", err) } } func TestRotateTokenInvalidatesOldFeed(t *testing.T) { repo := db.NewMemoryRepository() svc := NewService(repo) ctx := context.Background() newToken, err := svc.RotateToken(ctx, owner, "tenant", "") if err != nil { t.Fatalf("rotate tenant token: %v", err) } if newToken == "" || newToken == "demo-tenant-feed-token" { t.Fatalf("expected fresh token, got %q", newToken) } if _, err := svc.RenderFeed(ctx, "demo-tenant-feed-token"); err != ErrFeedNotFound { t.Fatalf("old token should 404, got %v", err) } if _, err := svc.RenderFeed(ctx, newToken); err != nil { t.Fatalf("new token should render: %v", err) } } func TestRotateStaffTokenAndScopeValidation(t *testing.T) { repo := db.NewMemoryRepository() svc := NewService(repo) ctx := context.Background() newToken, err := svc.RotateToken(ctx, owner, "staff", demoStaffID) if err != nil { t.Fatalf("rotate staff token: %v", err) } if _, err := svc.RenderFeed(ctx, newToken); err != nil { t.Fatalf("new staff token should render: %v", err) } if _, err := svc.RotateToken(ctx, owner, "staff", "not-our-staff"); err != ErrInvalidScope { t.Fatalf("expected ErrInvalidScope for foreign staff, got %v", err) } if _, err := svc.RotateToken(ctx, owner, "bogus", ""); err != ErrInvalidScope { t.Fatalf("expected ErrInvalidScope for bad scope, got %v", err) } if _, err := svc.RotateToken(ctx, domain.Principal{Subject: "stranger", Role: "owner"}, "tenant", ""); err != ErrTenantMembership { t.Fatalf("expected ErrTenantMembership, got %v", err) } } func TestRenderFeedWaitlistedIsTransparent(t *testing.T) { repo := db.NewMemoryRepository() svc := NewService(repo) ctx := context.Background() session, err := repo.GetClassSessionByID(ctx, demoSessionID) if err != nil { t.Fatalf("get session: %v", err) } _, err = repo.CreateBooking(ctx, db.CreateBookingParams{ TenantID: demoTenantID, ClassSessionID: &[]string{demoSessionID}[0], BookingMode: "class", CustomerName: "Queued Person", CustomerEmail: "queued@example.com", StartsAt: session.StartsAt, EndsAt: session.EndsAt, Status: "waitlisted", Reference: "BK-WAIT", ManageToken: "wait-token", }) if err != nil { t.Fatalf("seed waitlisted: %v", err) } ics, err := svc.RenderFeed(ctx, "demo-tenant-feed-token") if err != nil { t.Fatalf("render feed: %v", err) } if !strings.Contains(ics, "STATUS:TENTATIVE") || !strings.Contains(ics, "TRANSP:TRANSPARENT") { t.Fatalf("expected tentative+transparent waitlist event:\n%s", ics) } if !strings.Contains(ics, "(čekací listina)") { t.Fatalf("expected czech waitlist marker:\n%s", ics) } }