Skip to content
Open
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
145 changes: 145 additions & 0 deletions cypress/e2e/feed-isr-caching.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference types="cypress" />

/**
* Feed ISR Caching e2e tests (unauthenticated users)
*
* Architecture overview:
* - Unauthenticated users are routed by proxy.ts to /[locale]/feeds/[type]/[id]/static/
* - That route uses `force-static` + `revalidate: 1209600` (14 days)
* - On the first visit, Next.js renders the page and caches it
* - On subsequent visits, Next.js serves the cached HTML without re-rendering
*
* How we detect cache hits/misses:
* - Next.js sets the `x-nextjs-cache` response header on ISR routes:
* MISS → page was freshly rendered (first visit or after revalidation)
* HIT → page was served from the ISR cache
* STALE → page was served from stale cache while revalidation runs in background
* - We intercept the browser's GET request to the feed page and inspect this header.
*
*/

export {};

const TEST_FEED_ID = 'test-516';
const TEST_FEED_DATA_TYPE = 'gtfs';
const FEED_URL = `/feeds/${TEST_FEED_DATA_TYPE}/${TEST_FEED_ID}`;

/**
* Calls the /api/revalidate endpoint to bust the ISR cache for the test feed.
* This simulates what happens when the backend triggers a revalidation webhook
* (e.g. after a feed update), which in production invalidates the cached page.
*
* The REVALIDATE_SECRET must match the value set in the Next.js server's env.
* It is read from Cypress env (loaded from .env.development via cypress.config.ts).
*/
function revalidateTestFeed(): void {
const secret = Cypress.env('REVALIDATE_SECRET') as string;
cy.request({
method: 'POST',
url: '/api/revalidate',
headers: {
'x-revalidate-secret': secret,
'content-type': 'application/json',
},
body: {
type: 'specific-feeds',
gtfsFeedIds: [TEST_FEED_ID],
gtfsRtFeedIds: [],
gbfsFeedIds: [],
},
})
.its('status')
.should('eq', 200);
}

describe('Feed ISR Caching - Unauthenticated', () => {
/**
* Ensure the ISR cache is busted before the suite runs so we always
* start from a known MISS state, regardless of prior test runs.
*/
before(() => {
revalidateTestFeed();
});

describe('First visit (cache MISS)', () => {
it('should render the page dynamically on the first visit', () => {
// Intercept the page request and capture the x-nextjs-cache header.
// The alias lets us assert on the response after cy.visit() resolves.
cy.intercept('GET', FEED_URL).as('feedPageRequest');

cy.visit(FEED_URL, { timeout: 30000 });

// Wait for the page request and assert the cache header is MISS.
// On the very first visit (or after revalidation), Next.js renders
// the page fresh and populates the ISR cache.
cy.wait('@feedPageRequest')
.its('response.headers.x-nextjs-cache')
// MISS means the page was freshly rendered (not served from cache).
// STALE is also acceptable here if a prior cached version existed but
// was invalidated — Next.js serves stale while revalidating in background.
.should('be.oneOf', ['MISS', 'STALE']);

// Sanity check: the page content is actually rendered
cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should(
'contain',
'Metropolitan Transit Authority (MTA)',
);
});
});

describe('Second visit (cache HIT)', () => {
it('should serve the page from the ISR cache on a revisit', () => {
// Intercept the page request again for the second visit.
cy.intercept('GET', FEED_URL).as('feedPageCacheHit');

// Visit the same URL again — Next.js should now serve from ISR cache.
cy.visit(FEED_URL, { timeout: 30000 });

cy.wait('@feedPageCacheHit')
.its('response.headers.x-nextjs-cache')
// HIT means the page was served from the ISR cache without re-rendering.
.should('eq', 'HIT');

// Content should still be correct when served from cache
cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should(
'contain',
'Metropolitan Transit Authority (MTA)',
);
});
});

describe('After revalidation (cache MISS again)', () => {
it('should bust the ISR cache when the revalidate endpoint is called', () => {
// First, confirm the page is currently cached (HIT) before we bust it.
cy.intercept('GET', FEED_URL).as('feedPageBeforeRevalidate');
cy.visit(FEED_URL, { timeout: 30000 });
cy.wait('@feedPageBeforeRevalidate')
.its('response.headers.x-nextjs-cache')
.should('eq', 'HIT');

// Trigger cache invalidation via the revalidate API endpoint.
// This simulates a backend webhook call after a feed update.
revalidateTestFeed();

// Visit the page again — the cache was busted, so Next.js should
// re-render the page (MISS or STALE).
cy.intercept('GET', FEED_URL).as('feedPageAfterRevalidate');
cy.visit(FEED_URL, { timeout: 30000 });

cy.wait('@feedPageAfterRevalidate')
.its('response.headers.x-nextjs-cache')
// After revalidation, the cache is invalidated. Next.js will either:
// - MISS: render fresh immediately
// - STALE: serve the old cache while re-rendering in background
// Either way, the cache was busted — a HIT here would be a failure.
.should('be.oneOf', ['MISS', 'STALE']);

// Content should still be correct after revalidation
cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should(
'contain',
'Metropolitan Transit Authority (MTA)',
);
});
});
});
68 changes: 68 additions & 0 deletions docs/feed-detail-caching-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
```mermaid
sequenceDiagram
autonumber
actor U as User
participant B as Browser
participant P as Next.js Proxy / Middleware
participant EC as Edge CDN (Page Cache)
participant S as Static Feed Pages (anon: /feeds/... and /feeds/.../map)
participant D as Dynamic Feed Pages (authed)
participant FC as Next Fetch Cache (Data Cache)
participant API as External Feed API
participant GCP as GCP Workflow
participant RV as Next.js Revalidate Endpoint

rect rgb(235,245,255)
note over U,API: Request flow (feed detail page)

U->>B: Navigate to /feeds/{type}/{id} (or /map)
B->>P: HTTP GET /feeds/{type}/{id}[/{subpath}]

P->>P: Check cookie "session_md"
alt Not authenticated (no/invalid session_md)
P->>EC: Lookup cached page response (key: full path)
alt Page Cache HIT (edge)
EC-->>B: Return cached HTML/headers
else Page Cache MISS
EC->>S: Render static page (anon)
note over S,FC: 1) Fetch data (cache to speed /map <-> base nav)\n2) Render page\n3) Cache full page at edge
S->>FC: fetch(feedData, cache key = feedId + public) (revalidate: e.g., 2 week)
alt Data Cache HIT
FC-->>S: Return cached data
else Data Cache MISS
FC->>API: GET feed data (public)
API-->>FC: Feed data
FC-->>S: Cached data stored
end
S-->>EC: Store rendered page (TTL ~ 2 week)
EC-->>B: Return rendered HTML
end

else Authenticated (valid session_md)
P->>D: Route to dynamic authed page
note over D,FC: Cache only the API call for 10 minutes\n(per-user-per-feed)
D->>FC: fetch(feedData, cache key = userId + feedId) (revalidate: 10 min)
alt Per-user Data Cache HIT (<=10 min)
FC-->>D: Return cached user-scoped data
else Per-user Data Cache MISS
FC->>API: GET feed data (authed token)
API-->>FC: Feed data
FC-->>D: Cached data stored (10 min)
end
D-->>B: Return fresh HTML (no shared edge page cache)
note over D,B: Authed page response should be private (not shared)\nbut data calls are cached per-user-per-feed
end
end

rect rgb(255,245,235)
note over GCP,RV: External revalidation (invalidate anon caches + data caches)

GCP->>GCP: Detect feed changes (diff / updated_at)
GCP->>RV: POST /api/revalidate (paths or tags) + secret
RV->>EC: Invalidate edge page cache (anon paths: base + /map)
RV->>FC: Invalidate data cache (public feed data tag/key)
FC-->>RV: OK
EC-->>RV: OK
RV-->>GCP: 200 success
end
```
2 changes: 0 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,6 @@
"cancel": "Cancel"
},
"fullMapView": {
"disabledTitle": "Full map view disabled",
"disabledDescription": "The full map view feature is disabled at the moment. Please try again later.",
"dataBlurb": "The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn't specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they're combined into one for display.",
"clearAll": "Clear All",
"hideStops": "Hide Stops",
Expand Down
2 changes: 0 additions & 2 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,6 @@
"cancel": "Cancel"
},
"fullMapView": {
"disabledTitle": "Full map view disabled",
"disabledDescription": "The full map view feature is disabled at the moment. Please try again later.",
"dataBlurb": "The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn't specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they're combined into one for display.",
"clearAll": "Clear All",
"hideStops": "Hide Stops",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "CI=true jest",
"e2e:setup": "concurrently -k -n \"next,firebase\" -c \"cyan,magenta\" \"NEXT_PUBLIC_API_MOCKING=enabled next dev -p 3001\" \"firebase emulators:start --only auth --project mobility-feeds-dev\"",
"e2e:setup": "next build && concurrently -k -n \"next,firebase\" -c \"cyan,magenta\" \"NEXT_PUBLIC_API_MOCKING=enabled next start -p 3001\" \"firebase emulators:start --only auth --project mobility-feeds-dev\"",
"e2e:run": "CYPRESS_BASE_URL=http://localhost:3001 cypress run",
"e2e:open": "CYPRESS_BASE_URL=http://localhost:3001 cypress open",
"firebase:auth:emulator:dev": "firebase emulators:start --only auth --project mobility-feeds-dev",
Expand Down
50 changes: 50 additions & 0 deletions src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type ReactNode } from 'react';
import { notFound } from 'next/navigation';
import { headers } from 'next/headers';
import { fetchCompleteFeedData } from '../lib/feed-data';
import { AUTHED_PROXY_HEADER } from '../../../../../utils/proxy-helpers';

/**
* Force dynamic rendering for authenticated route.
* This allows cookie() and headers() access.
*/
export const dynamic = 'force-dynamic';

interface Props {
children: ReactNode;
params: Promise<{ feedDataType: string; feedId: string }>;
}

/**
* Shared layout for AUTHENTICATED feed pages.
*
* This route is reached via proxy rewrite when a session cookie exists.
* It uses cookie-based auth to attach user identity to API calls.
*
* SECURITY: This route is protected from direct access by checking for a
* custom header that only the proxy sets. Direct navigation to /authed/...
* will return 404.
*
*/
export default async function AuthedFeedLayout({
children,
params,
}: Props): Promise<React.ReactElement> {
// Block direct access - only allow requests that came through the proxy
const headersList = await headers();
if (headersList.get(AUTHED_PROXY_HEADER) !== '1') {
notFound();
}

const { feedId, feedDataType } = await params;

// Fetch complete feed data (cached per-user)
// This will be reused by child pages without additional API calls
const feedData = await fetchCompleteFeedData(feedDataType, feedId);

if (feedData == null) {
notFound();
}

return <>{children}</>;
}
38 changes: 38 additions & 0 deletions src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import FullMapView from '../../../../../../screens/Feed/components/FullMapView';
import { type ReactElement } from 'react';
import { fetchCompleteFeedData } from '../../lib/feed-data';

interface Props {
params: Promise<{ feedDataType: string; feedId: string }>;
}

/**
* Force dynamic rendering for authenticated route.
* This allows cookie() and headers() access.
*/
export const dynamic = 'force-dynamic';

/**
* Full map view page for AUTHENTICATED users.
*
* This route is reached via proxy rewrite when a session cookie exists.
* Uses cookie-based auth to:
* - Attach user identity to API calls
* - Provide user session to FullMapView for user-specific features
*
* Pre-fetches feed data server-side (cached per-request via React cache())
* before rendering. FullMapView uses Redux for client-side state management.
*/
export default async function AuthedFullMapViewPage({
params,
}: Props): Promise<ReactElement> {
const { feedId, feedDataType } = await params;

const feedData = await fetchCompleteFeedData(feedDataType, feedId);

if (feedData == null) {
return <div>Feed not found</div>;
}

return <FullMapView feedData={feedData} />;
}
Loading