mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(calendar): read-only ICS feeds for tenant and staff
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>
This commit is contained in:
1 parent
796c06ae66
commit
6c3b2bf6bf
14 files changed
+1354
-3
No files matched your search
@@ -0,0 +1,104 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Event is a single entry rendered into an ICS feed. Fields are already
|
||||
// resolved to display text; the renderer handles escaping and folding.
|
||||
type Event struct {
|
||||
UID string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Stamp time.Time
|
||||
Summary string
|
||||
Description string
|
||||
Location string
|
||||
Status string // "confirmed" | "tentative" | "cancelled"
|
||||
Transparent bool // TRANSP:TRANSPARENT — does not block free/busy
|
||||
}
|
||||
|
||||
const icsTimestampLayout = "20060102T150405Z"
|
||||
|
||||
// RenderCalendar produces an RFC 5545 VCALENDAR body with CRLF line endings
|
||||
// and 75-octet folding.
|
||||
func RenderCalendar(name string, timezone string, events []Event) string {
|
||||
var b strings.Builder
|
||||
writeLine(&b, "BEGIN:VCALENDAR")
|
||||
writeLine(&b, "VERSION:2.0")
|
||||
writeLine(&b, "PRODID:-//Bookra//Bookra//EN")
|
||||
writeLine(&b, "CALSCALE:GREGORIAN")
|
||||
writeLine(&b, "METHOD:PUBLISH")
|
||||
writeLine(&b, "X-WR-CALNAME:"+escapeText(name))
|
||||
if timezone != "" {
|
||||
writeLine(&b, "X-WR-TIMEZONE:"+timezone)
|
||||
}
|
||||
for _, event := range events {
|
||||
writeLine(&b, "BEGIN:VEVENT")
|
||||
writeLine(&b, "UID:"+event.UID)
|
||||
stamp := event.Stamp
|
||||
if stamp.IsZero() {
|
||||
stamp = event.StartsAt
|
||||
}
|
||||
writeLine(&b, "DTSTAMP:"+stamp.UTC().Format(icsTimestampLayout))
|
||||
writeLine(&b, "DTSTART:"+event.StartsAt.UTC().Format(icsTimestampLayout))
|
||||
writeLine(&b, "DTEND:"+event.EndsAt.UTC().Format(icsTimestampLayout))
|
||||
writeLine(&b, "SUMMARY:"+escapeText(event.Summary))
|
||||
if event.Description != "" {
|
||||
writeLine(&b, "DESCRIPTION:"+escapeText(event.Description))
|
||||
}
|
||||
if event.Location != "" {
|
||||
writeLine(&b, "LOCATION:"+escapeText(event.Location))
|
||||
}
|
||||
switch event.Status {
|
||||
case "cancelled":
|
||||
writeLine(&b, "STATUS:CANCELLED")
|
||||
case "tentative":
|
||||
writeLine(&b, "STATUS:TENTATIVE")
|
||||
}
|
||||
if event.Transparent {
|
||||
writeLine(&b, "TRANSP:TRANSPARENT")
|
||||
}
|
||||
writeLine(&b, "END:VEVENT")
|
||||
}
|
||||
writeLine(&b, "END:VCALENDAR")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// escapeText applies RFC 5545 §3.3.11 TEXT escaping.
|
||||
func escapeText(value string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
";", `\;`,
|
||||
",", `\,`,
|
||||
"\n", `\n`,
|
||||
"\r", "",
|
||||
)
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
// writeLine emits one content line folded at 75 octets per RFC 5545 §3.1.
|
||||
// Continuation lines start with a single space; folds happen on rune
|
||||
// boundaries so multi-byte characters are never split.
|
||||
func writeLine(b *strings.Builder, line string) {
|
||||
const maxOctets = 75
|
||||
octets := 0
|
||||
first := true
|
||||
for _, r := range line {
|
||||
width := len(string(r))
|
||||
limit := maxOctets
|
||||
if !first {
|
||||
limit-- // continuation lines carry a leading space
|
||||
}
|
||||
if octets+width > limit {
|
||||
b.WriteString("\r\n ")
|
||||
octets = 0
|
||||
first = false
|
||||
limit = maxOctets - 1
|
||||
}
|
||||
b.WriteRune(r)
|
||||
octets += width
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFeedNotFound = errors.New("calendar feed not found")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
ErrInvalidScope = errors.New("invalid feed scope")
|
||||
)
|
||||
|
||||
const (
|
||||
feedLookbackDays = 30
|
||||
feedLookaheadDays = 180
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo, now: time.Now}
|
||||
}
|
||||
|
||||
type FeedEntry struct {
|
||||
Token string `json:"token"`
|
||||
FeedPath string `json:"feedPath"`
|
||||
}
|
||||
|
||||
type StaffFeedEntry struct {
|
||||
StaffID string `json:"staffId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Token string `json:"token"`
|
||||
FeedPath string `json:"feedPath"`
|
||||
}
|
||||
|
||||
type FeedInfo struct {
|
||||
Tenant FeedEntry `json:"tenant"`
|
||||
Staff []StaffFeedEntry `json:"staff"`
|
||||
}
|
||||
|
||||
func feedPath(token string) string {
|
||||
return "/v1/public/calendar/" + token + ".ics"
|
||||
}
|
||||
|
||||
func (s *Service) tenantFor(ctx context.Context, principal domain.Principal) (db.TenantRecord, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return db.TenantRecord{}, ErrTenantMembership
|
||||
}
|
||||
return membership.Tenant, nil
|
||||
}
|
||||
|
||||
// FeedInfo returns the current feed tokens for the tenant and every staff
|
||||
// member. Tokens are secrets — this stays behind auth.
|
||||
func (s *Service) FeedInfo(ctx context.Context, principal domain.Principal) (FeedInfo, error) {
|
||||
tenant, err := s.tenantFor(ctx, principal)
|
||||
if err != nil {
|
||||
return FeedInfo{}, err
|
||||
}
|
||||
tenantToken, err := s.repo.GetTenantFeedToken(ctx, tenant.ID)
|
||||
if err != nil {
|
||||
return FeedInfo{}, err
|
||||
}
|
||||
staffTokens, err := s.repo.ListStaffFeedTokens(ctx, tenant.ID)
|
||||
if err != nil {
|
||||
return FeedInfo{}, err
|
||||
}
|
||||
info := FeedInfo{
|
||||
Tenant: FeedEntry{Token: tenantToken, FeedPath: feedPath(tenantToken)},
|
||||
Staff: []StaffFeedEntry{},
|
||||
}
|
||||
for _, entry := range staffTokens {
|
||||
info.Staff = append(info.Staff, StaffFeedEntry{
|
||||
StaffID: entry.StaffID,
|
||||
DisplayName: entry.DisplayName,
|
||||
Token: entry.FeedToken,
|
||||
FeedPath: feedPath(entry.FeedToken),
|
||||
})
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// RotateToken issues a fresh feed token, invalidating the previous URL.
|
||||
// Scope is "tenant" or "staff"; staff rotation requires staffID.
|
||||
func (s *Service) RotateToken(ctx context.Context, principal domain.Principal, scope string, staffID string) (string, error) {
|
||||
tenant, err := s.tenantFor(ctx, principal)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch scope {
|
||||
case "tenant":
|
||||
return s.repo.RotateTenantFeedToken(ctx, tenant.ID)
|
||||
case "staff":
|
||||
staffID = strings.TrimSpace(staffID)
|
||||
if staffID == "" {
|
||||
return "", ErrInvalidScope
|
||||
}
|
||||
member, err := s.repo.GetStaffByID(ctx, staffID)
|
||||
if err != nil || member.TenantID != tenant.ID {
|
||||
return "", ErrInvalidScope
|
||||
}
|
||||
return s.repo.RotateStaffFeedToken(ctx, staffID)
|
||||
default:
|
||||
return "", ErrInvalidScope
|
||||
}
|
||||
}
|
||||
|
||||
// RenderFeed resolves a public feed token to an ICS body. Staff tokens take
|
||||
// precedence; a miss on both lookups is a 404.
|
||||
func (s *Service) RenderFeed(ctx context.Context, token string) (string, error) {
|
||||
if member, err := s.repo.GetStaffByFeedToken(ctx, token); err == nil {
|
||||
return s.render(ctx, member.TenantID, &member)
|
||||
}
|
||||
if tenant, err := s.repo.GetTenantByFeedToken(ctx, token); err == nil {
|
||||
return s.render(ctx, tenant.ID, nil)
|
||||
}
|
||||
return "", ErrFeedNotFound
|
||||
}
|
||||
|
||||
func (s *Service) render(ctx context.Context, tenantID string, member *db.StaffRecord) (string, error) {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := s.now()
|
||||
bookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenantID, now.AddDate(0, 0, -feedLookbackDays), now.AddDate(0, 0, feedLookaheadDays))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
services, err := s.repo.ListServicesByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
staffList, err := s.repo.ListStaffByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
locations, err := s.repo.ListLocationsByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, now.AddDate(0, 0, -feedLookbackDays), 500)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
serviceNames := map[string]string{}
|
||||
for _, service := range services {
|
||||
serviceNames[service.ID] = service.Name
|
||||
}
|
||||
staffNames := map[string]string{}
|
||||
for _, staff := range staffList {
|
||||
staffNames[staff.ID] = staff.DisplayName
|
||||
}
|
||||
locationNames := map[string]string{}
|
||||
for _, location := range locations {
|
||||
locationNames[location.ID] = location.Name
|
||||
}
|
||||
sessionTitles := map[string]string{}
|
||||
for _, session := range sessions {
|
||||
sessionTitles[session.ID] = session.Title
|
||||
}
|
||||
|
||||
czech := strings.HasPrefix(tenant.Locale, "cs")
|
||||
labels := map[bool][3]string{
|
||||
true: {"Telefon", "E-mail", "Poznámky"},
|
||||
false: {"Phone", "Email", "Notes"},
|
||||
}[czech]
|
||||
waitlistLabel := map[bool]string{true: "čekací listina", false: "waitlist"}[czech]
|
||||
|
||||
var events []Event
|
||||
for _, booking := range bookings {
|
||||
if member != nil {
|
||||
if booking.StaffID == nil || *booking.StaffID != member.ID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
title := ""
|
||||
if booking.ServiceID != nil {
|
||||
title = serviceNames[*booking.ServiceID]
|
||||
}
|
||||
if title == "" && booking.ClassSessionID != nil {
|
||||
title = sessionTitles[*booking.ClassSessionID]
|
||||
}
|
||||
if title == "" {
|
||||
title = "Bookra"
|
||||
}
|
||||
|
||||
summary := title + " · " + booking.CustomerName
|
||||
if member == nil && booking.StaffID != nil {
|
||||
if name := staffNames[*booking.StaffID]; name != "" {
|
||||
summary += " · " + name
|
||||
}
|
||||
}
|
||||
|
||||
var description []string
|
||||
if booking.CustomerPhone != "" {
|
||||
description = append(description, labels[0]+": "+booking.CustomerPhone)
|
||||
}
|
||||
if booking.CustomerEmail != "" {
|
||||
description = append(description, labels[1]+": "+booking.CustomerEmail)
|
||||
}
|
||||
if booking.Notes != "" {
|
||||
description = append(description, labels[2]+": "+booking.Notes)
|
||||
}
|
||||
|
||||
location := ""
|
||||
if booking.LocationID != nil {
|
||||
location = locationNames[*booking.LocationID]
|
||||
}
|
||||
|
||||
event := Event{
|
||||
UID: booking.ID + "@bookra",
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Stamp: booking.CreatedAt,
|
||||
Summary: summary,
|
||||
Description: strings.Join(description, "\n"),
|
||||
Location: location,
|
||||
Status: "confirmed",
|
||||
}
|
||||
switch booking.Status {
|
||||
case "cancelled":
|
||||
event.Status = "cancelled"
|
||||
case "pending", "waitlisted":
|
||||
event.Status = "tentative"
|
||||
event.Transparent = true
|
||||
}
|
||||
if booking.Status == "waitlisted" {
|
||||
event.Summary += " (" + waitlistLabel + ")"
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool { return events[i].StartsAt.Before(events[j].StartsAt) })
|
||||
|
||||
name := fmt.Sprintf("Bookra — %s", tenant.Name)
|
||||
if member != nil {
|
||||
name = fmt.Sprintf("Bookra — %s · %s", tenant.Name, member.DisplayName)
|
||||
}
|
||||
return RenderCalendar(name, tenant.Timezone, events), nil
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user