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
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ FACEBOOK_SECRET=41860e58c256a3d7ad8267d3c1939a4a
# FB Pixel ID is optional if you are trying to do customer rtracking
FACEBOOK_PIXEL_ID=

FOURSQUARE_ID=2STROLSFBMZLAHG3IBA141EM2HGRF0IRIBB4KXMOGA2EH3JG
FOURSQUARE_SECRET=UAABFAWTIHIUFBL0PDC3TDMSXJF2GTGWLD3BES1QHXKAIYQB
FOURSQUARE_APIKEY=foursquare-service-key

GITHUB_ID=cb448b1d4f0c743a1e36
GITHUB_SECRET=815aa4606f476444691c5f1c16b9c70da6714dc6
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,15 @@ Obtain SMTP credentials from a provider for transactional emails. Set the SMTP_U

<img src="https://imgur.com/2P4UMvC.png" height="75">

- Go to <a href="https://developer.foursquare.com" target="_blank">Foursquare for Developers</a> and log in
- Click on **My Apps** in the top menu
- Click the **Create A New App** button
- Enter _App Name_, _Welcome page url_,
- For **Redirect URI**: your BASE_URL value followed by /auth/foursquare/callback (i.e. `http://localhost:8080/auth/foursquare/callback` )
- Click **Save Changes**
- Copy and paste _Client ID_ and _Client Secret_ keys into `.env` file
- Go to <a href="https://foursquare.com/developers" target="_blank">Foursquare for Developers</a> and log in

- Click on **Create a new project** button
- Enter your _Organization_ and _Project Name_
- Click **Create**
- Navigate to your project
- Click **Settings** in the left-hand-side menu
- Generate a Service API Key
- Copy and paste the Service API Key as `FOURSQUARE_APIKEY` in your `.env` file

<hr>

Expand Down
47 changes: 29 additions & 18 deletions controllers/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const googlesheets = require('@googleapis/sheets');
const validator = require('validator');
const { Configuration: LobConfiguration, LetterEditable, LettersApi, ZipEditable, ZipLookupsApi } = require('@lob/lob-typescript-sdk');
const fs = require('fs');

/**
* GET /api
* List of API examples.
Expand All @@ -30,31 +29,43 @@ exports.getApi = (req, res) => {
* GET /api/foursquare
* Foursquare API example.
*/
exports.getFoursquare = async (req, res, next) => {
exports.getFoursquare = async (req, res) => {
try {
const headers = {
Authorization: `${process.env.FOURSQUARE_APIKEY}`,
const options = {
method: 'GET',
headers: {
accept: 'application/json',
'X-Places-Api-Version': '2025-06-17',
authorization: `Bearer ${process.env.FOURSQUARE_APIKEY}`,
},
};

const fetchJson = async (url, fetchOptions, label) => {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const text = await response.text().catch(() => '<unable to read body>');
throw new Error(`${label} failed: ${response.status} ${response.statusText} - ${text}`);
}
return response.json();
};

const [trendingVenuesRes, venueDetailRes, venuePhotosRes] = await Promise.all([
fetch('https://api.foursquare.com/v3/places/search?ll=47.609657,-122.342148&limit=10', {
headers,
}).then((res) => res.json()),
fetch('https://api.foursquare.com/v3/places/427ea800f964a520b1211fe3', {
headers,
}).then((res) => res.json()),
fetch('https://api.foursquare.com/v3/places/427ea800f964a520b1211fe3/photos', {
headers,
}).then((res) => res.json()),
const [trendingVenuesRes, venueDetailRes] = await Promise.all([
fetchJson('https://places-api.foursquare.com/places/search?ll=47.609657,-122.342148&limit=10', options, 'Foursquare search'),
fetchJson('https://places-api.foursquare.com/places/427ea800f964a520b1211fe3', options, 'Foursquare venue detail'),
]);
res.render('api/foursquare', {
title: 'Foursquare API (v3)',
trendingVenues: trendingVenuesRes.results,
title: 'Foursquare Places API',
trendingVenues: trendingVenuesRes.results || [],
venueDetail: venueDetailRes,
venuePhotos: venuePhotosRes.slice(0, 9), // Limit the photos to 9
});
} catch (error) {
next(error);
console.error('Foursquare API Error:', error);
return res.status(500).render('api/foursquare', {
title: 'Foursquare Places API',
trendingVenues: [],
venueDetail: null,
error: 'Failed to fetch Foursquare data',
});
}
};

Expand Down
124 changes: 124 additions & 0 deletions test/e2e/foursquare.e2e.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
process.env.API_TEST_FILE = 'e2e/foursquare.e2e.test.js';
const { test, expect } = require('@playwright/test');
const { registerTestInManifest, isInManifest } = require('../tools/fixture-helpers');

// Self-register this test in the manifest when recording
registerTestInManifest('e2e/foursquare.e2e.test.js');

// Skip this file during replay if it's not in the manifest
if (process.env.API_MODE === 'replay' && !isInManifest('e2e/foursquare.e2e.test.js')) {
console.log('[fixtures] skipping e2e/foursquare.e2e.test.js as it is not in manifest for replay mode - 2 tests');
test.skip(true, 'Not in manifest for replay mode');
}

test.describe('Foursquare Places API Integration', () => {
let sharedPage;

test.beforeAll(async ({ browser }) => {
sharedPage = await browser.newPage();
await sharedPage.goto('/api/foursquare');
await sharedPage.waitForLoadState('networkidle');
});

test.afterAll(async () => {
if (sharedPage) await sharedPage.close();
});

test('should render Trending Venues table with data', async () => {
// Table basics
const table = sharedPage.locator('table.table.table-striped.table-bordered');
await expect(table).toBeVisible();

const headers = table.locator('thead th');
await expect(headers).toHaveCount(5);
await expect(headers.nth(1)).toContainText('Name');
await expect(headers.nth(2)).toContainText('Category');
await expect(headers.nth(3)).toContainText('Address');
await expect(headers.nth(4)).toContainText('Distance');

// Should have 10 result rows (API limit for busy Downtown Seattle location)
const rows = table.locator('tbody tr');
const rowCount = await rows.count();
expect(rowCount).toBe(10);

// Validate first row structure and formats
const firstRowCells = rows.first().locator('td');
await expect(firstRowCells).toHaveCount(5);

// Icon cell: must have an icon image
const iconImgCount = await firstRowCells.nth(0).locator('img').count();
expect(iconImgCount).toBeGreaterThan(0);
const icon = firstRowCells.nth(0).locator('img');
await expect(icon).toHaveAttribute('src', /https?:\/\//);
await expect(icon).toHaveAttribute('alt', /\w+/);
const w = parseInt(await icon.getAttribute('width'), 10);
const h = parseInt(await icon.getAttribute('height'), 10);
expect(w).toBeGreaterThanOrEqual(32);
expect(w).toBeLessThanOrEqual(64);
expect(h).toBe(w);

// Name cell: non-empty
const venueName = (await firstRowCells.nth(1).textContent()).trim();
expect(venueName.length).toBeGreaterThan(0);

// Category cell: non-empty
const categoryText = (await firstRowCells.nth(2).textContent()).trim();
expect(categoryText.length).toBeGreaterThan(0);

// Address cell: non-empty
const addrText = (await firstRowCells.nth(3).textContent()).trim();
expect(addrText.length).toBeGreaterThan(0);

// Distance cell: numeric
const distanceText = (await firstRowCells.nth(4).textContent()).trim();
expect(distanceText).toMatch(/^\d+$/);
});

test('should render Venue Details with name, category, and coordinates', async () => {
// Section header
await expect(sharedPage.locator('h3.text-primary', { hasText: 'Venue Details' })).toBeVisible();

// The details paragraph contains <i><u>name</u></i>, optional category, and location + lat/long
const detailsPara = sharedPage.locator('h3.text-primary:has-text("Venue Details") + p');
await expect(detailsPara).toBeVisible();

// Name element
const nameElement = detailsPara.locator('i u');
await expect(nameElement).toBeVisible();
const detailName = (await nameElement.textContent()).trim();
expect(detailName.length).toBeGreaterThan(0);

// Check expected hardcoded values from Downtown Seattle location (ll=47.609657,-122.342148)
const detailsText = await detailsPara.textContent();

// Extract and validate longitude (allow wiggle room for minor GIS changes)
const longitudeMatch = detailsText.match(/longitude:\s*([-\d.]+)/i);
expect(longitudeMatch).toBeTruthy();
const longitude = parseFloat(longitudeMatch[1]);
expect(longitude).toBeGreaterThan(-122.35);
expect(longitude).toBeLessThan(-122.33);

// Extract and validate latitude (allow wiggle room for minor GIS changes)
const latitudeMatch = detailsText.match(/latitude:\s*([-\d.]+)/i);
expect(latitudeMatch).toBeTruthy();
const latitude = parseFloat(latitudeMatch[1]);
expect(latitude).toBeGreaterThan(47.6);
expect(latitude).toBeLessThan(47.62);

// Related venues: check for Pike Place Market with 10+ related venues
const relatedVenuesPara = sharedPage.locator('p', { hasText: 'Related venues or businesses to' });
await expect(relatedVenuesPara).toBeVisible();
const relatedVenuesText = await relatedVenuesPara.textContent();
expect(relatedVenuesText).toContain('Pike Place Market');

// Extract the comma-separated list from the next paragraph
const relatedListPara = relatedVenuesPara.locator('+ p');
await expect(relatedListPara).toBeVisible();
const relatedListText = (await relatedListPara.textContent()).trim();
const relatedVenuesList = relatedListText
.split(',')
.map((v) => v.trim())
.filter((v) => v.length > 0);
expect(relatedVenuesList.length).toBeGreaterThanOrEqual(10);
});
});
Loading
Loading