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
92 changes: 91 additions & 1 deletion src/app/api/revalidate/route.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @jest-environment node
*/
import { POST } from './route';
import { GET, POST } from './route';
import { revalidatePath, revalidateTag } from 'next/cache';

// Mock Next.js cache
Expand All @@ -15,6 +15,96 @@ jest.mock('../../../i18n/routing', () => ({
AVAILABLE_LOCALES: ['en', 'fr'],
}));

describe('GET /api/revalidate', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't it a POST?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Vercel Cron job they use GET, for external API we use POST

const mockRevalidatePath = revalidatePath as jest.MockedFunction<
typeof revalidatePath
>;
const mockRevalidateTag = revalidateTag as jest.MockedFunction<
typeof revalidateTag
>;
const originalEnv = process.env;

beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv };
});

afterAll(() => {
process.env = originalEnv;
});

it('returns 500 when CRON_SECRET is not configured', async () => {
delete process.env.CRON_SECRET;

const request = new Request('http://localhost:3000/api/revalidate', {
method: 'GET',
headers: {
authorization: 'Bearer some-secret',
},
});

const response = await GET(request);
const json = await response.json();

expect(response.status).toBe(500);
expect(json).toEqual({
ok: false,
error: 'Server misconfigured: CRON_SECRET missing',
});
expect(mockRevalidatePath).not.toHaveBeenCalled();
expect(mockRevalidateTag).not.toHaveBeenCalled();
});

it('returns 401 when authorization header does not match', async () => {
process.env.CRON_SECRET = 'correct-secret';

const request = new Request('http://localhost:3000/api/revalidate', {
method: 'GET',
headers: {
authorization: 'Bearer wrong-secret',
},
});

const response = await GET(request);
const json = await response.json();

expect(response.status).toBe(401);
expect(json).toEqual({
ok: false,
error: 'Unauthorized',
});
expect(mockRevalidatePath).not.toHaveBeenCalled();
expect(mockRevalidateTag).not.toHaveBeenCalled();
});

it('revalidates only GBFS feed pages for authorized cron requests', async () => {
process.env.CRON_SECRET = 'test-secret';

const request = new Request('http://localhost:3000/api/revalidate', {
method: 'GET',
headers: {
authorization: 'Bearer test-secret',
},
});

const response = await GET(request);
const json = await response.json();

expect(response.status).toBe(200);
expect(json).toEqual({
ok: true,
message: 'All GBFS feeds revalidated successfully',
});
expect(mockRevalidateTag).toHaveBeenCalledWith('feed-type-gbfs', 'max');
expect(mockRevalidatePath).toHaveBeenCalledWith(
'/[locale]/feeds/gbfs/[feedId]',
'layout',
);
expect(mockRevalidateTag).toHaveBeenCalledTimes(1);
expect(mockRevalidatePath).toHaveBeenCalledTimes(1);
});
});

describe('POST /api/revalidate', () => {
const mockRevalidatePath = revalidatePath as jest.MockedFunction<
typeof revalidatePath
Expand Down
10 changes: 5 additions & 5 deletions src/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ const defaultRevalidateOptions: RevalidateBody = {
};

/**
* GET handler for the Vercel cron job that revalidates all feed pages once a day.
* GET handler for the Vercel cron job that revalidates all GBFS feed pages.
* Vercel automatically passes Authorization: Bearer <CRON_SECRET> with each invocation.
* Configured in vercel.json under "crons" (schedule: 0 9 * * * = 4am EST / 9am UTC).
* Configured in vercel.json under "crons" for 4am UTC Monday-Saturday and 7am UTC Sunday.
*/
export async function GET(req: Request): Promise<NextResponse> {
const authHeader = req.headers.get('authorization');
Expand All @@ -52,13 +52,13 @@ export async function GET(req: Request): Promise<NextResponse> {
}

try {
revalidateAllFeeds();
revalidateAllGbfsFeeds();
console.log(
'[cron] revalidate /api/revalidate: all-feeds revalidation triggered',
'[cron] revalidate /api/revalidate: all-gbfs-feeds revalidation triggered',
);
return NextResponse.json({
ok: true,
message: 'All feeds revalidated successfully',
message: 'All GBFS feeds revalidated successfully',
});
} catch (error) {
console.error(
Expand Down
6 changes: 5 additions & 1 deletion vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
"crons": [
{
"path": "/api/revalidate",
"schedule": "0 9 * * *"
"schedule": "0 4 * * 1-6"
},
{
"path": "/api/revalidate",
"schedule": "0 7 * * 0"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] these two schedules are specific to gbfs feeds. is it specified anywhere?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's documented in src/app/api/revalidate/route.ts since we can't add comments in json

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so calling /api/revalidate with no parameters automatically revalidates all gbfs feeds?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for the GET request which is blocked by process.env.CRON_SECRET yes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current approach couples the GBFS scope to "no params passed," which feels a bit implicit. A few concerns:

  • The endpoint name /api/revalidate reads as generic, but calling it without params actually means "revalidate all GBFS feeds", which is surprising behavior for anyone not already familiar with the route.
  • Documenting this in route.ts works for contributors reading the code, but the schedule config itself gives no indication that it's GBFS-specific.
  • If we ever add another feed type with its own revalidation schedule, this pattern doesn't scale cleanly.

Would it make sense to either:

  1. Use an explicit query param like /api/revalidate?type=gbfs, or
  2. Split into a dedicated route like /api/revalidate/gbfs?

Either would make the schedules self-documenting and avoid relying on the "no params = GBFS" convention. wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that at the moment it's a little generic, adding gbfs in the path or as a param would make it much more clear when reading at a glance

}
]
}
Loading