package calendartest import ( "context" "regexp" "strings" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // aHost is a person who has signed in and can own booking pages. func aHost(t *testing.T, app *calendar.App, address string) calendar.Person { t.Helper() app.SignInForTest(t, address) return app.PersonByAddressForTest(t, address) } var mondayLunch = []calendar.Window{ {Weekday: time.Monday, StartMin: 12 * 60, EndMin: 15 * 60}, {Weekday: time.Tuesday, StartMin: 12 * 60, EndMin: 14 * 60}, } func pageInput() calendar.BookingPageInput { return calendar.BookingPageInput{ Title: "Quick chat about the acquisition", Description: "Twenty minutes, no slides, bring the unmarked folder.", Availability: "mondays 12-3, tuesdays 12-2", DurationMin: 20, BufferMin: 10, NoticeMin: 120, HorizonDays: 30, } } // A page is made, read back with its text opened and its windows in // order, hidden from the public until it is published, and its text is // nowhere in the raw file. func TestABookingPageRoundTripsSealedWithItsWindows(t *testing.T) { app := newApp(t) host := aHost(t, app, "paul@example.com") store := app.StoreForTest() ctx := context.Background() if err := store.SaveHandle(ctx, host.ID, "paul"); err != nil { t.Fatalf("SaveHandle: %v", err) } in := pageInput() page, err := store.CreateBookingPage(ctx, host.ID, in, mondayLunch) if err != nil { t.Fatalf("CreateBookingPage: %v", err) } if !regexp.MustCompile(`^[a-z0-9]{8}$`).MatchString(page.Code) { t.Errorf("code %q is not eight of [a-z0-9]", page.Code) } if page.PersonID != host.ID { t.Errorf("page owned by %d, want %d", page.PersonID, host.ID) } // Unpublished: the owner sees it, the public does not. mine, err := store.BookingPage(ctx, host.ID, page.ID) if err != nil { t.Fatalf("BookingPage: %v", err) } if mine.Title != in.Title || mine.Description != in.Description || mine.Availability != in.Availability { t.Errorf("opened text differs: %+v", mine) } if mine.Published { t.Error("a new page reports Published") } if len(mine.Windows) != 2 || mine.Windows[0] != mondayLunch[0] || mine.Windows[1] != mondayLunch[1] { t.Errorf("windows = %+v, want %+v", mine.Windows, mondayLunch) } if _, _, err := store.PublicBookingPage(ctx, "paul", page.Code); !calendar.NotFound(err) { t.Errorf("unpublished page is public: err = %v", err) } if err := store.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil { t.Fatalf("PublishBookingPage: %v", err) } pub, who, err := store.PublicBookingPage(ctx, "paul", page.Code) if err != nil { t.Fatalf("PublicBookingPage after publish: %v", err) } if !pub.Published || pub.Title != in.Title || who.ID != host.ID { t.Errorf("public view = %+v host %d, want published, titled, host %d", pub, who.ID, host.ID) } if len(pub.Windows) != 2 { t.Errorf("public windows = %+v", pub.Windows) } // An update replaces the text and the windows wholesale. in.Title = "Longer chat" if err := store.UpdateBookingPage(ctx, host.ID, page.ID, in, mondayLunch[:1]); err != nil { t.Fatalf("UpdateBookingPage: %v", err) } list, err := store.BookingPages(ctx, host.ID) if err != nil { t.Fatalf("BookingPages: %v", err) } if len(list) != 1 || list[0].Title != "Longer chat" || len(list[0].Windows) != 1 { t.Errorf("after update: %+v", list) } if list[0].Code != page.Code { t.Errorf("an update changed the code from %q to %q", page.Code, list[0].Code) } if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { t.Fatalf("checkpoint: %v", err) } for _, needle := range []string{ "Quick chat about the acquisition", "Longer chat", "unmarked folder", "mondays 12-3", } { if found, where := app.GrepDatabaseForTest(t, needle); found { t.Errorf("%q appears in plaintext in %s", needle, where) } } } // Whatever is wrong — the handle, the code, or publication — the answer // is one error, so nothing can be enumerated by telling them apart. func TestUnknownHandleUnknownCodeAndUnpublishedAllVanishTheSameWay(t *testing.T) { app := newApp(t) host := aHost(t, app, "paul@example.com") store := app.StoreForTest() ctx := context.Background() if err := store.SaveHandle(ctx, host.ID, "paul"); err != nil { t.Fatalf("SaveHandle: %v", err) } page, err := store.CreateBookingPage(ctx, host.ID, pageInput(), mondayLunch) if err != nil { t.Fatalf("CreateBookingPage: %v", err) } if err := store.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil { t.Fatalf("publish: %v", err) } if _, _, err := store.PublicBookingPage(ctx, "paul", page.Code); err != nil { t.Fatalf("the published page should be public: %v", err) } if _, _, err := store.PublicBookingPage(ctx, "nobody", page.Code); !calendar.NotFound(err) { t.Errorf("unknown handle: err = %v, want ErrNotFound", err) } if _, _, err := store.PublicBookingPage(ctx, "paul", "zzzzzzzz"); !calendar.NotFound(err) { t.Errorf("unknown code: err = %v, want ErrNotFound", err) } if err := store.PublishBookingPage(ctx, host.ID, page.ID, false); err != nil { t.Fatalf("unpublish: %v", err) } if _, _, err := store.PublicBookingPage(ctx, "paul", page.Code); !calendar.NotFound(err) { t.Errorf("unpublished: err = %v, want ErrNotFound", err) } if _, err := store.PersonByHandle(ctx, "nobody"); !calendar.NotFound(err) { t.Errorf("PersonByHandle(nobody): err = %v, want ErrNotFound", err) } } // Every settings read or write is scoped to the owner, and a miss is // ErrNotFound rather than a different error for "exists but not yours". func TestAnotherPersonCannotReadEditOrPublishMyPage(t *testing.T) { app := newApp(t) paul := aHost(t, app, "paul@example.com") other := aHost(t, app, "other@example.com") store := app.StoreForTest() ctx := context.Background() page, err := store.CreateBookingPage(ctx, paul.ID, pageInput(), mondayLunch) if err != nil { t.Fatalf("CreateBookingPage: %v", err) } if _, err := store.BookingPage(ctx, other.ID, page.ID); !calendar.NotFound(err) { t.Errorf("read as another person: err = %v, want ErrNotFound", err) } if err := store.UpdateBookingPage(ctx, other.ID, page.ID, pageInput(), mondayLunch); !calendar.NotFound(err) { t.Errorf("update as another person: err = %v, want ErrNotFound", err) } if err := store.PublishBookingPage(ctx, other.ID, page.ID, true); !calendar.NotFound(err) { t.Errorf("publish as another person: err = %v, want ErrNotFound", err) } list, err := store.BookingPages(ctx, other.ID) if err != nil || len(list) != 0 { t.Errorf("another person's list = %+v, %v; want empty", list, err) } // None of that touched the page. mine, err := store.BookingPage(ctx, paul.ID, page.ID) if err != nil { t.Fatalf("my own read: %v", err) } if mine.Published { t.Error("another person's publish took effect") } if len(mine.Windows) != 2 { t.Errorf("another person's update touched the windows: %+v", mine.Windows) } } // The limits are bytes, because the event validator's are bytes and two // different units for "how long is a title" is a bug waiting for a // multibyte name. func TestPageLimitsAreBytes(t *testing.T) { app := newApp(t) paul := aHost(t, app, "paul@example.com") store := app.StoreForTest() ctx := context.Background() euro := strings.Repeat("€", 33) // 99 bytes, 33 runes over := pageInput() over.Title = euro + "ab" // 101 bytes, 35 runes if _, err := store.CreateBookingPage(ctx, paul.ID, over, mondayLunch); err == nil { t.Error("a 101-byte title was accepted") } else if v, ok := calendar.Invalid(err); !ok || v.Field != "title" { t.Errorf("a 101-byte title: err = %v, want a title ValidationError", err) } fits := pageInput() fits.Title = euro + "a" // 100 bytes if _, err := store.CreateBookingPage(ctx, paul.ID, fits, mondayLunch); err != nil { t.Errorf("a 100-byte title was refused: %v", err) } longDesc := pageInput() longDesc.Description = strings.Repeat("€", 333) + "ab" // 1001 bytes if err := calendar.ValidateBookingPageInput(&longDesc); err == nil { t.Error("a 1001-byte description was accepted") } longDesc.Description = strings.Repeat("€", 333) + "a" // 1000 bytes if err := calendar.ValidateBookingPageInput(&longDesc); err != nil { t.Errorf("a 1000-byte description was refused: %v", err) } // The knobs have ranges too. for _, tc := range []struct { name string mut func(*calendar.BookingPageInput) }{ {"empty title", func(in *calendar.BookingPageInput) { in.Title = " " }}, {"duration 4", func(in *calendar.BookingPageInput) { in.DurationMin = 4 }}, {"duration 481", func(in *calendar.BookingPageInput) { in.DurationMin = 481 }}, {"buffer 121", func(in *calendar.BookingPageInput) { in.BufferMin = 121 }}, {"buffer -1", func(in *calendar.BookingPageInput) { in.BufferMin = -1 }}, {"notice 10081", func(in *calendar.BookingPageInput) { in.NoticeMin = 10081 }}, {"horizon 0", func(in *calendar.BookingPageInput) { in.HorizonDays = 0 }}, {"horizon 91", func(in *calendar.BookingPageInput) { in.HorizonDays = 91 }}, } { in := pageInput() tc.mut(&in) if err := calendar.ValidateBookingPageInput(&in); err == nil { t.Errorf("%s was accepted", tc.name) } } if err := calendar.ValidateBookingPageInput(&calendar.BookingPageInput{ Title: "Edges", DurationMin: 480, BufferMin: 120, NoticeMin: 10080, HorizonDays: 90, }); err != nil { t.Errorf("the upper edges were refused: %v", err) } }