mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
GET /v1/public/calendar/{token} serves an RFC 5545 calendar under a
token-as-secret URL (private Google/Outlook link model). Staff feeds
contain only that member's bookings; the tenant feed tags each event
with the staff member. Window is -30d to +180d; cancelled bookings map
to STATUS:CANCELLED, waitlisted/pending to TENTATIVE + TRANSPARENT.
Summaries resolve service or class-session titles, descriptions carry
localized contact labels, locations resolve to names.
Feed tokens live on tenants/staff (migration 00015) and are managed via
GET /v1/calendar/feed plus POST /v1/calendar/feed/rotate — rotating a
scope invalidates the old URL. ICS rendering is hand-rolled: TEXT
escaping, CRLF, 75-octet folding on rune boundaries.
Settings gains a "Kalendář (ICS)" card with copyable URLs and two-step
rotation per scope; demo mode points at the real seeded tokens.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
250 lines
8.0 KiB
Go
250 lines
8.0 KiB
Go
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: "[email protected]", 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: "[email protected]",
|
|
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: "[email protected]",
|
|
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)
|
|
}
|
|
}
|