Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion shared/src/content.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { fetchNewsletter, mapEvent } from "./content.ts";
import { fetchNewsletter, fetchPressReleases, mapEvent } from "./content.ts";

// See openFoodFacts.test.ts for the same withFetch pattern — swaps globalThis.fetch for a stub.
function withFetch<T>(impl: typeof fetch, fn: () => Promise<T>): Promise<T> {
Expand Down Expand Up @@ -35,6 +35,43 @@ test("event mapping treats is_featured '1' as true", () => {
assert.equal(mapped.isFeatured, true);
});

// Real bug, real shape: GET /uapp/get_beacons_events and GET /uapp/get_press both return image
// URLs with a literal host of "default" (e.g. https://default/sites/default/files/press/images.png)
// -- present but unresolvable. Truthy, so `{#if image}` guards in web pass and render a visible
// broken-image glyph. Blank these at the source so every caller (web + mobile) gets a clean "no
// image" signal instead of a dead URL.
test("event mapping blanks a featured_image with an unresolvable 'default' host", () => {
const mapped = mapEvent({
title: "x",
featured_image: "https://default/sites/default/files/events/banner.jpg",
pdf_link: "",
external_link: "",
expiration_date: 0,
is_featured: "0",
});
assert.equal(mapped.featuredImage, "");
});

test("fetchPressReleases blanks an image with an unresolvable 'default' host", async () => {
const result = await withFetch(
async () =>
({
ok: true,
status: 200,
json: async () => [
{
title: "Real press release",
url: "https://umassdining.com/press/real",
image: "https://default/sites/default/files/press/images.png",
date: "2026-08-19",
},
],
}) as Response,
fetchPressReleases,
);
assert.equal(result[0]?.image, "");
});

// fetchNewsletter is a thin passthrough (field names already match NewsletterIssue), same as
// fetchPressReleases above — this just confirms the shape flows through untouched.
test("fetchNewsletter returns issues as-is, including entries with empty content", async () => {
Expand Down
22 changes: 20 additions & 2 deletions shared/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@ import type { DiningEvent, NewsletterIssue, PressRelease } from "./types.ts";

const BASE = "https://www.umassdining.com/uapp";

/**
* UMass Dining's press/events feeds sometimes return an image URL with a literal host of
* `default` (e.g. `https://default/sites/default/files/press/images.png`) -- present and
* truthy, but unresolvable. Blank anything without a plausible hostname (must contain a dot,
* or be localhost) so callers get a clean "no image" signal instead of a dead URL that trips
* `{#if image}`-style truthiness guards and renders a broken-image glyph.
*/
export function sanitizeImageUrl(url: string): string {
if (!url) return "";
try {
const { hostname } = new URL(url);
if (hostname === "localhost" || hostname.includes(".")) return url;
return "";
} catch {
return "";
}
}

interface PressApiItem {
title: string;
url: string;
Expand All @@ -14,7 +32,7 @@ export async function fetchPressReleases(): Promise<PressRelease[]> {
const res = await fetch(`${BASE}/get_press`);
if (!res.ok) throw new Error(`get_press ${res.status}`);
const data = (await res.json()) as PressApiItem[];
return data;
return data.map((item) => ({ ...item, image: sanitizeImageUrl(item.image) }));
}

interface EventsApiResponse {
Expand All @@ -33,7 +51,7 @@ interface EventsApiResponse {
export function mapEvent(e: EventsApiResponse["events"][number]): DiningEvent {
return {
title: e.title,
featuredImage: e.featured_image,
featuredImage: sanitizeImageUrl(e.featured_image),
pdfLink: e.pdf_link,
externalLink: e.external_link,
expirationDate: new Date(e.expiration_date * 1000).toISOString(),
Expand Down
242 changes: 242 additions & 0 deletions web/e2e/content-screens.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { test, expect, type Page } from "@playwright/test";
import type { DiningEvent, NewsletterIssue, PressRelease } from "@udine/shared";

// Coverage for #41: press/events/newsletter styled with #35/#43's shared component classes
// (.card, .page-title, .empty-state, .badge, .label-rule), sharing one list/card pattern across
// all three. FAQ and Staff Directory were cut from the product entirely (#50/#51, see #41's
// 2026-08-19 scope comment) — not covered here, don't add them back.
//
// These are public, anonymous-first content screens (CLAUDE.md) — no session/auth setup anywhere
// in this file, on purpose.

// Same hydration pitfall shell.spec.ts documents: a click before SvelteKit's client router has
// attached falls through to a native full-page nav, which runs the target load server-side
// (invisible to page.route()) instead of client-side, so a mocked /api/* response would never be
// consumed. Copied verbatim from shell.spec.ts rather than imported -- it's a small page-local
// proof, not shared app logic.
async function disablePreload(page: Page) {
await page.evaluate(() => document.body.setAttribute("data-sveltekit-preload-data", "off"));
}

async function proveHydrated(page: Page) {
const star = page.getByRole("button", { name: "favorite" }).first();
await expect(async () => {
await star.click();
await expect(star).toHaveText("★");
}).toPass({ timeout: 15_000 });
}

// Press/events/newsletter all live behind the "Dining Info" disclosure (see +layout.svelte) --
// this drives a real client-side nav into one of them from a hydrated home page, the same
// technique shell.spec.ts uses, so a mocked /api/* route is guaranteed to be the one consumed.
async function gotoContentPage(page: Page, linkName: string) {
await page.goto("/");
await disablePreload(page);
await proveHydrated(page);
await page.locator("details summary").click();
await page.getByRole("link", { name: linkName }).click();
}

const RELEASES: PressRelease[] = [
{
title: "UMass Dining Wins Statewide Award",
url: "https://umassdining.com/press/award",
image: "https://umassdining.com/sites/default/files/press/award.jpg",
date: "2026-08-01",
},
{
// No image -- must degrade gracefully instead of a broken <img>.
title: "New Executive Chef Announced",
url: "https://umassdining.com/press/chef",
image: "",
date: "2026-07-15",
},
];

const EVENTS: DiningEvent[] = [
{
title: "Fall Kickoff BBQ",
featuredImage: "https://umassdining.com/sites/default/files/events/bbq.jpg",
pdfLink: "https://umassdining.com/sites/default/files/events/bbq.pdf",
externalLink: "",
expirationDate: "2026-09-01T00:00:00.000Z",
isFeatured: true,
},
{
// No featured image, no pdf link -- must degrade gracefully, only the external link shows.
title: "Late Night Study Break",
featuredImage: "",
pdfLink: "",
externalLink: "https://umassdining.com/events/study-break",
expirationDate: "2026-09-05T00:00:00.000Z",
isFeatured: false,
},
];

const ISSUES: NewsletterIssue[] = [
{ period: "August 2026", link: "https://umassdining.us1.list-manage.com/track/click?u=abc&id=aug26", content: "" },
{ period: "July 2026", link: "https://umassdining.us1.list-manage.com/track/click?u=abc&id=jul26", content: "" },
];

test.describe("press", () => {
test("releases render as cards sharing the site's list/card pattern, with graceful no-image fallback", async ({ page }) => {
await page.route("**/api/press**", (route) => route.fulfill({ json: RELEASES }));
await gotoContentPage(page, "Press");
await expect(page).toHaveURL(/\/press$/);

const heading = page.getByRole("heading", { name: "Press" });
await expect(heading).toHaveClass(/page-title/);

const cards = page.locator("main li.card");
await expect(cards).toHaveCount(2);

const withImage = cards.filter({ hasText: RELEASES[0].title });
await expect(withImage.locator("img")).toHaveAttribute("src", RELEASES[0].image);
await expect(withImage.locator("img")).toHaveAttribute("alt", "");
const link1 = withImage.getByRole("link", { name: RELEASES[0].title });
await expect(link1).toHaveAttribute("href", RELEASES[0].url);
await expect(link1).toHaveAttribute("target", "_blank");
await expect(withImage).toContainText(RELEASES[0].date);

const withoutImage = cards.filter({ hasText: RELEASES[1].title });
await expect(withoutImage.locator("img")).toHaveCount(0);
await expect(withoutImage.getByRole("link", { name: RELEASES[1].title })).toHaveAttribute("href", RELEASES[1].url);
});

// Real bug, reviewer-verified against live data: GET /uapp/get_press returns image URLs with a
// literal host of "default" (e.g. https://default/sites/default/files/press/images.png) for
// every release -- present, so `{#if release.image}` alone doesn't catch it, but unresolvable,
// so the browser renders a visible broken-image glyph inside the fixed h-16 box. Primary fix is
// shared/src/content.ts's sanitizeImageUrl (see content.test.ts); this asserts the web app never
// shows the broken box even if a bad URL reaches this page's data by some other path.
test("a present but unresolvable image host renders no visible broken-image box", async ({ page }) => {
const releaseWithBadImage: PressRelease = {
title: "Bad Image Host Release",
url: "https://umassdining.com/press/bad-image",
image: "https://default/sites/default/files/press/images.png",
date: "2026-08-19",
};
await page.route("**/api/press**", (route) => route.fulfill({ json: [releaseWithBadImage] }));
await gotoContentPage(page, "Press");
await expect(page).toHaveURL(/\/press$/);

const card = page.locator("main li.card").filter({ hasText: releaseWithBadImage.title });
await expect(card).toBeVisible();
await expect(card.locator("img")).toHaveCount(0);
});

test("no releases shows the shared empty-state panel, not a bare paragraph", async ({ page }) => {
await page.route("**/api/press**", (route) => route.fulfill({ json: [] }));
await gotoContentPage(page, "Press");
await expect(page).toHaveURL(/\/press$/);

await expect(page.locator(".empty-state")).toBeVisible();
});

test("an upstream failure renders the shared error page, not a styled-but-broken press screen", async ({ page }) => {
await page.route("**/api/press**", (route) => route.fulfill({ status: 502, body: "bad gateway" }));
await gotoContentPage(page, "Press");

await page.waitForURL(/\/press$/, { timeout: 15_000 });
await expect(page.getByRole("heading", { name: /Couldn.t reach UMass Dining/ })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("link", { name: "Back to dining halls" })).toBeVisible();
});
});

test.describe("events", () => {
test("events render as cards with a featured badge and only the links that exist", async ({ page }) => {
await page.route("**/api/events**", (route) => route.fulfill({ json: EVENTS }));
await gotoContentPage(page, "Events");
await expect(page).toHaveURL(/\/events$/);

const heading = page.getByRole("heading", { name: "Events" });
await expect(heading).toHaveClass(/page-title/);

const cards = page.locator("main li.card");
await expect(cards).toHaveCount(2);

const featured = cards.filter({ hasText: EVENTS[0].title });
await expect(featured.locator(".badge")).toHaveText(/Featured/i);
await expect(featured.locator("img")).toHaveAttribute("src", EVENTS[0].featuredImage);
await expect(featured.getByRole("link", { name: "Details" })).toHaveAttribute("href", EVENTS[0].pdfLink);
await expect(featured.getByRole("link", { name: "More info" })).toHaveCount(0);

const plain = cards.filter({ hasText: EVENTS[1].title });
await expect(plain.locator(".badge")).toHaveCount(0);
await expect(plain.locator("img")).toHaveCount(0);
await expect(plain.getByRole("link", { name: "Details" })).toHaveCount(0);
await expect(plain.getByRole("link", { name: "More info" })).toHaveAttribute("href", EVENTS[1].externalLink);
});

// Same reviewer-verified bug as press's equivalent test above -- GET /uapp/get_beacons_events
// returns featured_image with the same unresolvable "default" host.
test("a present but unresolvable image host renders no visible broken-image box", async ({ page }) => {
const eventWithBadImage: DiningEvent = {
title: "Bad Image Host Event",
featuredImage: "https://default/sites/default/files/events/banner.jpg",
pdfLink: "",
externalLink: "",
expirationDate: "2026-09-01T00:00:00.000Z",
isFeatured: false,
};
await page.route("**/api/events**", (route) => route.fulfill({ json: [eventWithBadImage] }));
await gotoContentPage(page, "Events");
await expect(page).toHaveURL(/\/events$/);

const card = page.locator("main li.card").filter({ hasText: eventWithBadImage.title });
await expect(card).toBeVisible();
await expect(card.locator("img")).toHaveCount(0);
});

test("no events shows the shared empty-state panel", async ({ page }) => {
await page.route("**/api/events**", (route) => route.fulfill({ json: [] }));
await gotoContentPage(page, "Events");
await expect(page).toHaveURL(/\/events$/);

await expect(page.locator(".empty-state")).toBeVisible();
});

test("an upstream failure renders the shared error page", async ({ page }) => {
await page.route("**/api/events**", (route) => route.fulfill({ status: 502, body: "bad gateway" }));
await gotoContentPage(page, "Events");

await page.waitForURL(/\/events$/, { timeout: 15_000 });
await expect(page.getByRole("heading", { name: /Couldn.t reach UMass Dining/ })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("link", { name: "Back to dining halls" })).toBeVisible();
});
});

test.describe("newsletter", () => {
test("issues render as cards linking out to the external archive", async ({ page }) => {
await page.route("**/api/newsletter**", (route) => route.fulfill({ json: ISSUES }));
await gotoContentPage(page, "Newsletter");
await expect(page).toHaveURL(/\/newsletter$/);

const heading = page.getByRole("heading", { name: "Newsletter" });
await expect(heading).toHaveClass(/page-title/);

const cards = page.locator("main li.card");
await expect(cards).toHaveCount(2);

const link1 = cards.getByRole("link", { name: ISSUES[0].period });
await expect(link1).toHaveAttribute("href", ISSUES[0].link);
await expect(link1).toHaveAttribute("target", "_blank");
});

test("no issues shows the shared empty-state panel", async ({ page }) => {
await page.route("**/api/newsletter**", (route) => route.fulfill({ json: [] }));
await gotoContentPage(page, "Newsletter");
await expect(page).toHaveURL(/\/newsletter$/);

await expect(page.locator(".empty-state")).toBeVisible();
});

test("an upstream failure renders the shared error page", async ({ page }) => {
await page.route("**/api/newsletter**", (route) => route.fulfill({ status: 502, body: "bad gateway" }));
await gotoContentPage(page, "Newsletter");

await page.waitForURL(/\/newsletter$/, { timeout: 15_000 });
await expect(page.getByRole("heading", { name: /Couldn.t reach UMass Dining/ })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("link", { name: "Back to dining halls" })).toBeVisible();
});
});
51 changes: 38 additions & 13 deletions web/src/routes/events/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,44 @@
let { data }: PageProps = $props();
</script>

<h1>Events</h1>
<header>
<h1 class="page-title">Events</h1>
<div class="label-rule mt-2 text-gold-500"></div>
<p class="mt-3 max-w-prose text-ink-900/70">Dining hall events from UMass Dining.</p>
</header>

{#if data.events.length === 0}
<p>No events right now.</p>
<div class="empty-state mt-6">
<p class="font-display text-lg uppercase">No events right now.</p>
<p class="mt-2 text-sm">Check back later for dining hall events.</p>
</div>
{:else}
<ul class="mt-6 flex flex-col gap-3">
{#each data.events as event (event.title + event.expirationDate)}
<li class="card flex items-center gap-4 px-4 py-4">
{#if event.featuredImage}
<!-- UMass Dining's event images are wide banners (~1024x432), not square thumbnails like
press logos -- a fixed square with object-cover crops most of the banner off.
w-28 + object-contain shows the whole image instead of guessing a crop. -->
<img src={event.featuredImage} alt="" class="h-16 w-28 shrink-0 rounded-sm bg-cream-100 object-contain" />
{/if}
<div class="min-w-0 flex-1">
<p class="font-display text-lg font-semibold text-maroon-900">
{#if event.isFeatured}<span class="badge mr-2 bg-gold-500/20 text-maroon-900">Featured</span>{/if}
{event.title}
</p>
{#if event.pdfLink || event.externalLink}
<p class="mt-1 flex flex-wrap gap-3 text-sm">
{#if event.pdfLink}
<a href={event.pdfLink} target="_blank" rel="noreferrer">Details</a>
{/if}
{#if event.externalLink}
<a href={event.externalLink} target="_blank" rel="noreferrer">More info</a>
{/if}
</p>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
<ul>
{#each data.events as event (event.title + event.expirationDate)}
<li>
{#if event.isFeatured}<strong>★</strong>{/if}
{#if event.featuredImage}<img src={event.featuredImage} alt="" style="max-width:80px;vertical-align:middle" />{/if}
{event.title}
{#if event.pdfLink}<a href={event.pdfLink} target="_blank" rel="noreferrer">(details)</a>{/if}
{#if event.externalLink}<a href={event.externalLink} target="_blank" rel="noreferrer">(link)</a>{/if}
</li>
{/each}
</ul>
Loading
Loading