diff --git a/.env.example b/.env.example index 2f81958..ca4158a 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,11 @@ YOUTUBE_API_KEY= YOUTUBE_CHANNEL_ID=UCOra1rXiO-BHzMDNlLd9hFQ FB_APP_ID= FB_APP_SECRET= +# Back-compat default page id (CollegeLutheran) used when /facebook/feed is +# called without ?pageId. Must be one of the keys in FB_PAGES. FB_PAGE_ID=202368653220334 -AUTH_ROLES={"song":["userType1","userType2"],"book":["userType1","userType2"],"user":["userType1", "userType2"],"facebook":["Developer","clc-admin"]} +# All Facebook pages served, as a pageId -> display-name map (one Meta app). The +# display name is used in the dead-token alert email. Add WebJamLLC's page id. +FB_PAGES={"202368653220334":"CollegeLutheran","365007513885497":"WebJamLLC"} +AUTH_ROLES={"song":["userType1","userType2"],"book":["userType1","userType2"],"user":["userType1", "userType2"],"facebook":["Developer","clc-admin","JaM-admin"]} APP_NAME=Example diff --git a/README.md b/README.md index 683fa98..46c6589 100644 --- a/README.md +++ b/README.md @@ -118,24 +118,49 @@ Setting a config var triggers a dyno restart automatically; the change is live w ## Facebook feed (`/facebook/*` routes) -Powers the CollegeLutheran homepage Facebook feed (CollegeLutheran#740 / web-jam-back#797), replacing the unreliable Page Plugin iframe. +Powers the CollegeLutheran and WebJamLLC homepage Facebook feeds (CollegeLutheran#740 / web-jam-back#797, multi-page web-jam-back#799), replacing the unreliable Page Plugin iframe. Multiple pages share one Meta app; each page has its own stored token and its own in-memory cache, keyed by `pageId`. -- **`GET /facebook/feed`** — public, no auth. Returns `{ posts, lastUpdated }` from an in-memory cache that is refreshed on startup and then hourly. Empty (`{ "posts": [], "lastUpdated": null }`) until a page token has been set, so it is safe to deploy before configuring anything; the UI falls back to a plain Facebook link. -- **`PUT /facebook/token`** — admin only (guarded by `AUTH_ROLES.facebook`). Body `{ "userToken": "" }` from the admin page's "Reconnect Facebook" button. The server exchanges it for a long-lived user token, reads the page token from `/me/accounts`, stores it in MongoDB (`FacebookToken` singleton), and refreshes the cache. The app secret never leaves the server, which is why the exchange can't happen in the browser. +- **`GET /facebook/feed?pageId=`** — public, no auth. Returns `{ posts, lastUpdated }` from that page's in-memory cache, refreshed on startup and then hourly. With no `pageId` it defaults to the CollegeLutheran page (`FB_PAGE_ID`) so the already-deployed CLC frontend keeps working until it passes the param. Empty (`{ "posts": [], "lastUpdated": null }`) until that page's token has been set, so it is safe to deploy before configuring anything; the UI falls back to a plain Facebook link. +- **`PUT /facebook/token`** — admin only (guarded by `AUTH_ROLES.facebook`). Body `{ "userToken": "", "pageId": "" }` from the admin page's "Reconnect Facebook" button. `pageId` selects which page is being reconnected (defaults to the CLC page for back-compat). The server exchanges the user token for a long-lived one, reads that page's token from `/me/accounts`, stores it in MongoDB (one `FacebookToken` doc per `pageId`), and refreshes that page's cache. The app secret never leaves the server, which is why the exchange can't happen in the browser. -When the page token dies (Graph OAuth `code 190` — e.g. Josh changed his Facebook password, logged out of all sessions, or hit a security checkpoint), the server emails `GMAIL_USER` **once per process** telling him to click Reconnect Facebook. The flag re-arms on the next healthy refresh; Heroku's ~daily dyno restart also resets it, so a dead token re-nags about once a day until fixed (intentional). The last good cache keeps serving throughout, so the feed just stops updating rather than breaking. +When a page token dies (Graph OAuth `code 190` — e.g. Josh changed his Facebook password, logged out of all sessions, or hit a security checkpoint), the server emails `GMAIL_USER` **once per page per outage**, naming the page that died, telling him to click Reconnect Facebook. The flag re-arms on that page's next healthy refresh; Heroku's ~daily dyno restart also resets it, so a dead token re-nags about once a day until fixed (intentional). The last good cache keeps serving throughout, so the feed just stops updating rather than breaking. + +The single-page era stored one token doc keyed `key: 'pageToken'`. On startup the service migrates that doc to the CLC `pageId` (and drops the stale `key_1` unique index) so CollegeLutheran survives the multi-page deploy without a manual reconnect. **Graph API version:** pinned in one constant (`FB_GRAPH_VERSION` in `FacebookController.ts`). Meta supports each version for at least 2 years; expired versions don't hard-fail (calls auto-forward to the oldest still-supported version), and the four fields used (`message`, `full_picture`, `permalink_url`, `created_time`) are stable core fields. Bump the constant when convenient — no scheduled maintenance needed. Env vars (set on the deployed environment and in your local `.env` for end-to-end testing): - `FB_APP_ID` / `FB_APP_SECRET` — the "Web Jam LLC" Meta app (Josh is app admin; the app stays in development mode, so no Meta app review is needed). **`FB_APP_SECRET` is secret — server-side only.** -- `FB_PAGE_ID` — the CollegeLutheran page id: `202368653220334`. -- `AUTH_ROLES` — add a `"facebook": ["Developer", "clc-admin"]` entry (same audience that can view the CLC admin page). Without it, any authenticated user could update the token. +- `FB_PAGE_ID` — the back-compat default page id served when `?pageId` is omitted: the CollegeLutheran page `202368653220334`. +- `FB_PAGES` — a JSON `pageId` → display-name map of every page served, e.g. `{"202368653220334":"CollegeLutheran","365007513885497":"WebJamLLC"}`. Drives the hourly refresh loop and the page name used in the dead-token alert email. If unset, the service falls back to the single `FB_PAGE_ID` (CollegeLutheran only). +- `AUTH_ROLES` — add a `"facebook": ["Developer", "clc-admin", "JaM-admin"]` entry (the CLC and JaM admins, plus Developer). Any of these can reconnect any page. Without the entry, any authenticated user could update a token. - `GMAIL_USER` / `GMAIL_APP_PASSWORD` — already used by the `/inquiry` route; reused for the token-death alert. In `NODE_ENV=test` no email is sent and no Graph calls are made. Set these the same way as the Livestream vars above (Heroku dashboard Config Vars or `heroku config:set ... -a webjamsalem`). +### Which Facebook var lives where (across all repos) + +There are really only four things; most "vars" just point at them. **Page access tokens are never env vars** — the backend derives them on reconnect and stores them in MongoDB, one per `pageId`. + +| Var | Repo(s) | Public / secret | Purpose | +|-----|---------|-----------------|---------| +| `FB_APP_ID` (`2207148322688942`, "Web Jam LLC" app) | web-jam-back **and** each frontend (JaMmusic, CollegeLutheran) at **build** time | **Public** — safe in the browser bundle | Identifies the Meta app; opens the FB login popup (frontends) and authorizes the token exchange (backend) | +| `FB_APP_SECRET` | web-jam-back **only** | **Secret** | Server-side token exchange; must never reach a frontend | +| `FB_PAGE_ID` | web-jam-back only | Public id | Default page when `GET /facebook/feed` omits `?pageId` (CollegeLutheran) | +| `FB_PAGES` | web-jam-back only | Public ids | `pageId`→name map of every page served; drives the refresh loop + alert email name | +| `AUTH_ROLES.facebook` | web-jam-back only | — | Roles allowed to `PUT /facebook/token` | + +Frontends need only two things: `FB_APP_ID` (build-injected) and the **page id** they show (JaMmusic hardcodes WebJamLLC's `365007513885497`; CollegeLutheran uses the backend default). Locally each repo sets its own `.env`; in production the backend vars live on the web-jam-back Heroku app(s), and `FB_APP_ID` must also be present at the frontend's **build** step (the web-jam-back app that compiles the frontend injects it). + +### Reconnecting a feed — check BOTH pages + +The Reconnect flow logs the page admin into Facebook, where the consent dialog lists the pages you manage. **That selection is a replace, not an add:** if you uncheck a page you previously granted, Facebook *revokes* the app's access to it and its stored token dies. So whenever you log in to reconnect *either* feed, **leave both the CollegeLutheran and WebJamLLC pages checked.** (Forgetting to check the page you're actually reconnecting just fails harmlessly with "page not found".) + +### Finding / verifying a page id + +The id stored in `FB_PAGES` must be the one Facebook returns from `/me/accounts` (that's what the token exchange matches against). To find or confirm it: in the [Graph API Explorer](https://developers.facebook.com/tools/explorer) generate a user token (scope `pages_show_list`, the page checked), then `GET /v20.0/me/accounts` and read the `id` next to the page name. A quick sanity check: `https://www.facebook.com/` should land on that page. The page's HTML `delegatePageID` is **not** reliable — it can differ from the Graph id under the New Pages Experience. + ## Test **`npm test`** runs the tests and generates a coverage report. diff --git a/src/model/facebook/FacebookController.ts b/src/model/facebook/FacebookController.ts index 5736581..fe236c9 100644 --- a/src/model/facebook/FacebookController.ts +++ b/src/model/facebook/FacebookController.ts @@ -25,58 +25,109 @@ export interface FbPost { interface FeedCache { posts: FbPost[]; lastUpdated: string | null } interface GraphError { code?: number; message?: string } -// Module-level state. `alertSent` keeps the token-death email to one per process -// per outage; it resets on the next healthy refresh. Heroku's ~daily dyno -// restart resets it too, so a dead token re-nags about once a day until fixed -// (intentional). -let cache: FeedCache = { posts: [], lastUpdated: null }; -let alertSent = false; +const EMPTY: FeedCache = { posts: [], lastUpdated: null }; + +// The registered pages, as a pageId -> display-name map from FB_PAGES (one Meta +// app, many pages: CollegeLutheran + WebJamLLC). During the env rollout, if +// FB_PAGES is unset we fall back to the legacy single FB_PAGE_ID so the service +// keeps serving CollegeLutheran until FB_PAGES is deployed. +function getPages(): Record { + let pages: Record = {}; + try { + const parsed = JSON.parse(process.env.FB_PAGES || '{}') as Record; + if (parsed && typeof parsed === 'object') pages = parsed; + } catch { /* malformed FB_PAGES — fall through to the FB_PAGE_ID fallback */ } + if (Object.keys(pages).length === 0 && process.env.FB_PAGE_ID) { + pages[process.env.FB_PAGE_ID] = 'CollegeLutheran'; + } + return pages; +} + +// Back-compat default for callers that omit pageId (the already-deployed CLC +// frontend): the CollegeLutheran page id in FB_PAGE_ID, else the first +// registered page. +function defaultPageId(): string { + return process.env.FB_PAGE_ID || Object.keys(getPages())[0] || ''; +} + +// Module-level state, keyed by pageId. `alertSent` keeps the token-death email +// to one per page per outage; it resets on that page's next healthy refresh. +// Heroku's ~daily dyno restart resets it too, so a dead token re-nags about +// once a day until fixed (intentional). +const caches = new Map(); +const alertSent = new Map(); let timer: ReturnType | null = null; -// test-only hooks +// test-only hooks. __getState defaults to the back-compat (CLC) page. export const __reset = (): void => { - cache = { posts: [], lastUpdated: null }; - alertSent = false; + caches.clear(); + alertSent.clear(); /* istanbul ignore if */ if (timer) { clearInterval(timer); timer = null; } }; -export const __getState = (): { cache: FeedCache; alertSent: boolean } => ({ cache, alertSent }); +export const __getState = (pageId = defaultPageId()): { cache: FeedCache; alertSent: boolean } => ({ + cache: caches.get(pageId) || EMPTY, + alertSent: alertSent.get(pageId) || false, +}); -async function readToken(): Promise { - const doc = await FacebookToken.findOne({ key: 'pageToken' }).lean().exec() as { value?: string } | null; +async function readToken(pageId: string): Promise { + const doc = await FacebookToken.findOne({ pageId }).lean().exec() as { value?: string } | null; return doc?.value || ''; } -async function writeToken(value: string): Promise { +async function writeToken(pageId: string, value: string): Promise { await FacebookToken.findOneAndUpdate( - { key: 'pageToken' }, - { value, updatedAt: new Date() }, + { pageId }, + { pageId, value, updatedAt: new Date() }, { upsert: true }, ).exec(); } -// Email Josh once per outage when the page token is dead (Graph OAuth code 190). -async function handleDeadToken(): Promise { - debug('facebook page token is dead (code 190)'); - if (alertSent) return; - alertSent = true; +// One-time migration of the single-page era doc (keyed `key: 'pageToken'`, +// web-jam-back#797) to the new pageId keying, so CollegeLutheran survives the +// multi-page deploy without a manual reconnect. Also drops the stale unique +// `key_1` index, which would otherwise reject a second page's doc (both null +// `key`). Idempotent. +export async function migrateLegacyToken(): Promise { + const coll = FacebookToken.collection; + /* istanbul ignore next */ + try { await coll.dropIndex('key_1'); } catch { /* already dropped */ } + const legacy = await coll.findOne({ key: 'pageToken' }) as { value?: string } | null; + if (!legacy?.value) return; + const pageId = defaultPageId(); + await coll.updateOne( + { pageId }, + { $set: { pageId, value: legacy.value, updatedAt: new Date() } }, + { upsert: true }, + ); + await coll.deleteOne({ key: 'pageToken' }); + debug('migrated legacy pageToken doc to pageId %s', pageId); +} + +// Email Josh once per outage when a page token is dead (Graph OAuth code 190). +// The alert names the page so he knows which one to reconnect. +async function handleDeadToken(pageId: string): Promise { + debug('facebook page token is dead (code 190) for %s', pageId); + if (alertSent.get(pageId)) return; + alertSent.set(pageId, true); + const name = getPages()[pageId] || pageId; try { await sendMail({ to: process.env.GMAIL_USER || /* istanbul ignore next */ '', - subject: 'CollegeLutheran: Facebook feed token is dead', - html: 'The CollegeLutheran Facebook feed page token has expired or been invalidated. ' - + 'Log into the CollegeLutheran admin page and click Reconnect Facebook to restore it.', + subject: `${name}: Facebook feed token is dead`, + html: `The ${name} Facebook feed page token has expired or been invalidated. ` + + `Log into the ${name} admin page and click Reconnect Facebook to restore it.`, }); } catch (err) /* istanbul ignore next */ { debug('alert email failed: %s', (err as Error).message); } } -// Refresh the in-memory feed cache from the page's published posts. On any +// Refresh one page's in-memory feed cache from its published posts. On any // failure the last good cache keeps serving; a 190 also triggers the alert. -export async function updateFacebookCache(): Promise { - const token = await readToken(); - if (!token) { debug('no page token stored yet; skipping refresh'); return; } +async function refreshPage(pageId: string): Promise { + const token = await readToken(pageId); + if (!token) { debug('no page token stored for %s; skipping refresh', pageId); return; } const params = new URLSearchParams({ fields: PAGE_FIELDS, limit: '5', @@ -84,23 +135,29 @@ export async function updateFacebookCache(): Promise { }); try { // `/posts` (not `/feed`) → page-published posts only, no visitor-post perms. - const res = await fetch(`${GRAPH}/${process.env.FB_PAGE_ID || ''}/posts?${params.toString()}`); + const res = await fetch(`${GRAPH}/${pageId}/posts?${params.toString()}`); const body = await res.json() as { data?: FbPost[]; error?: GraphError }; if (body.error) { - if (body.error.code === 190) await handleDeadToken(); - else debug('graph error: %o', body.error); + if (body.error.code === 190) await handleDeadToken(pageId); + else debug('graph error for %s: %o', pageId, body.error); return; // keep last good cache } - cache = { posts: body.data || [], lastUpdated: new Date().toISOString() }; - alertSent = false; // healthy again — re-arm the alert + caches.set(pageId, { posts: body.data || [], lastUpdated: new Date().toISOString() }); + alertSent.set(pageId, false); // healthy again — re-arm the alert } catch (err) { - debug('facebook refresh failed: %s', (err as Error).message); + debug('facebook refresh failed for %s: %s', pageId, (err as Error).message); } } -// short-lived user token -> long-lived user token -> never-expiring page token. -// The app secret stays server-side, which is why this can't happen in-browser. -async function exchangeForPageToken(userToken: string): Promise { +// Refresh every registered page. Called on startup and hourly. +export async function updateFacebookCache(): Promise { + await Promise.all(Object.keys(getPages()).map((pageId) => refreshPage(pageId))); +} + +// short-lived user token -> long-lived user token -> never-expiring page token, +// for the given page. The app secret stays server-side, which is why this can't +// happen in-browser. +async function exchangeForPageToken(userToken: string, pageId: string): Promise { const llParams = new URLSearchParams({ grant_type: 'fb_exchange_token', client_id: process.env.FB_APP_ID || '', @@ -114,39 +171,46 @@ async function exchangeForPageToken(userToken: string): Promise { const accRes = await fetch(`${GRAPH}/me/accounts?access_token=${encodeURIComponent(llBody.access_token)}`); const accBody = await accRes.json() as { data?: Array<{ id: string; access_token: string }>; error?: GraphError }; if (accBody.error) throw new Error(accBody.error.message || 'failed to list pages'); - const page = (accBody.data || []).find((p) => p.id === process.env.FB_PAGE_ID); - if (!page) throw new Error('CollegeLutheran page not found in /me/accounts'); + const page = (accBody.data || []).find((p) => p.id === pageId); + if (!page) throw new Error(`${getPages()[pageId] || pageId} page not found in /me/accounts`); return page.access_token; } -// Kick off the startup refresh + hourly interval. No-ops under test so unit -// tests never hit the network or leave a timer running. +// Kick off the legacy-token migration, startup refresh, and hourly interval. +// No-ops under test so unit tests never hit the network or leave a timer running. export function startFacebookRefresh(): void { /* istanbul ignore if */ if (process.env.NODE_ENV === 'test') return; /* istanbul ignore next */ - void updateFacebookCache(); + migrateLegacyToken() + .then(() => { void updateFacebookCache(); }) + .catch(() => { /* startup migration failed; the hourly refresh still runs */ }); /* istanbul ignore next */ timer = setInterval(() => { void updateFacebookCache(); }, REFRESH_INTERVAL_MS); } class FacebookController { - // Public, no auth. Serves the cached posts; empty until a token is set. - async getFeed(_req: Request, res: Response): Promise { - res.json(cache); + // Public, no auth. Serves the cached posts for ?pageId (default: CollegeLutheran + // for back-compat); empty until that page's token is set. + async getFeed(req: Request, res: Response): Promise { + const pageId = (req.query.pageId as string) || defaultPageId(); + res.json(caches.get(pageId) || EMPTY); } // Admin-only (guarded by routeUtils.makeAction + AUTH_ROLES.facebook). Takes a - // short-lived FB user token from the admin page, derives + stores the page - // token, and refreshes the cache immediately. + // short-lived FB user token + pageId from the admin page, derives + stores that + // page's token, and refreshes its cache immediately. pageId defaults to the CLC + // page so the existing CLC reconnect flow keeps working before it sends one. async updateToken(req: Request, res: Response): Promise { - const userToken = (req.body as { userToken?: string } | undefined)?.userToken; + const body = req.body as { userToken?: string; pageId?: string } | undefined; + const userToken = body?.userToken; if (!userToken) { res.status(400).json({ message: 'userToken is required' }); return; } + const pageId = body?.pageId || defaultPageId(); try { - const pageToken = await exchangeForPageToken(userToken); - await writeToken(pageToken); - await updateFacebookCache(); - res.json({ lastUpdated: cache.lastUpdated }); + const pageToken = await exchangeForPageToken(userToken, pageId); + await writeToken(pageId, pageToken); + await refreshPage(pageId); + res.json({ pageId, lastUpdated: caches.get(pageId)?.lastUpdated ?? null }); } catch (err) { res.status(400).json({ message: (err as Error).message }); } diff --git a/src/model/facebook/facebook-schema.ts b/src/model/facebook/facebook-schema.ts index 18923e4..7584415 100644 --- a/src/model/facebook/facebook-schema.ts +++ b/src/model/facebook/facebook-schema.ts @@ -2,14 +2,16 @@ import mongoose from 'mongoose'; const { Schema } = mongoose; -// Single-document store for the CollegeLutheran Facebook Page access token -// (CollegeLutheran#740 / web-jam-back#797). The token is a never-expiring page -// token; it is written only by the admin "Reconnect Facebook" flow (PUT -// /facebook/token) and read by the hourly feed-cache refresher. Keyed by a -// constant `key` so upsert always targets the same row. +// One document per Facebook Page access token, keyed by `pageId` +// (web-jam-back#797 single-page → #799 multi-page: CollegeLutheran + WebJamLLC, +// same Meta app). Each token is a never-expiring page token; it is written only +// by the admin "Reconnect Facebook" flow (PUT /facebook/token, carrying its +// pageId) and read by the hourly feed-cache refresher. The legacy single-page +// doc (key `pageToken`) is migrated to the CLC pageId on startup — see +// migrateLegacyToken in FacebookController.ts. const facebookTokenSchema = new Schema({ - key: { - type: String, required: true, unique: true, default: 'pageToken', + pageId: { + type: String, required: true, unique: true, }, value: { type: String, required: true }, updatedAt: { type: Date, default: Date.now }, diff --git a/src/model/facebook/index.ts b/src/model/facebook/index.ts index 635fda3..ae85661 100644 --- a/src/model/facebook/index.ts +++ b/src/model/facebook/index.ts @@ -6,7 +6,8 @@ import routeUtils, { Icontroller } from '../../lib/routeUtils.js'; const router = express.Router(); const controller = new FacebookController() as unknown as Icontroller; -// Public cached feed for the CollegeLutheran homepage. +// Public cached feed, selected by ?pageId (defaults to CollegeLutheran for +// back-compat). Serves CollegeLutheran and WebJamLLC homepages. router.route('/feed') .get((req, res) => { (async () => { await controller.getFeed(req, res); })(); }); diff --git a/test/unit/facebook/index.spec.ts b/test/unit/facebook/index.spec.ts index b65942f..8977336 100644 --- a/test/unit/facebook/index.spec.ts +++ b/test/unit/facebook/index.spec.ts @@ -6,7 +6,7 @@ import userModel from '../../../src/model/user/user-facade.js'; import FacebookToken from '../../../src/model/facebook/facebook-schema.js'; import * as mailer from '../../../src/lib/mailer.js'; import { - updateFacebookCache, __reset, __getState, FB_GRAPH_VERSION, + updateFacebookCache, migrateLegacyToken, __reset, __getState, FB_GRAPH_VERSION, } from '../../../src/model/facebook/FacebookController.js'; // Intercept only Graph API calls; pass the test client's own HTTP through. @@ -24,20 +24,25 @@ function stubGraph(...responses: any[]) { describe('Facebook feed API', () => { const allowedUrl = JSON.parse(process.env.AllowUrl || '{}').urls[0]; + const CLC = '202368653220334'; // back-compat default page (CollegeLutheran) + const WJ = '111111111111111'; // second page (WebJamLLC) let user: { _id: string }; const origPageId = process.env.FB_PAGE_ID; + const origPages = process.env.FB_PAGES; - beforeAll(async () => { - await userModel.deleteMany({}); + // Recreate the admin user before every test (scoped to this email so we don't + // disturb other specs that share the test DB), so a parallel spec deleting + // users mid-file can't make later auth-dependent tests 401. + beforeEach(async () => { + __reset(); + await FacebookToken.deleteMany({}); + await userModel.deleteMany({ email: 'fbadmin@example.com' }); const created = await userModel.create({ name: 'fbadmin', email: 'fbadmin@example.com', userType: 'Developer', }) as unknown as { _id: { toString(): string } }; user = { _id: created._id.toString() }; - }); - beforeEach(async () => { - __reset(); - await FacebookToken.deleteMany({}); - process.env.FB_PAGE_ID = '202368653220334'; + process.env.FB_PAGE_ID = CLC; + process.env.FB_PAGES = JSON.stringify({ [CLC]: 'CollegeLutheran', [WJ]: 'WebJamLLC' }); process.env.FB_APP_ID = 'appid'; process.env.FB_APP_SECRET = 'secret'; }); @@ -45,8 +50,9 @@ describe('Facebook feed API', () => { vi.unstubAllGlobals(); vi.restoreAllMocks(); if (origPageId === undefined) delete process.env.FB_PAGE_ID; else process.env.FB_PAGE_ID = origPageId; + if (origPages === undefined) delete process.env.FB_PAGES; else process.env.FB_PAGES = origPages; }); - afterAll(async () => { await userModel.deleteMany({}); await FacebookToken.deleteMany({}); }); + afterAll(async () => { await userModel.deleteMany({ email: 'fbadmin@example.com' }); await FacebookToken.deleteMany({}); }); it('pins a Graph API version', () => { expect(FB_GRAPH_VERSION).toMatch(/^v\d+\.\d+$/); }); @@ -75,7 +81,7 @@ describe('Facebook feed API', () => { const fb = stubGraph( jsonRes({ access_token: 'long-lived-user-token' }), // fb_exchange_token jsonRes({ data: [{ id: '202368653220334', access_token: 'PAGE-TOKEN' }] }), // /me/accounts - jsonRes({ data: [{ id: 'p1', message: 'Hello', permalink_url: 'http://fb/p1', created_time: '2026-06-01T00:00:00Z' }] }), // /posts + jsonRes({ data: [{ id: 'p1', message: 'Hello', permalink_url: 'https://fb/p1', created_time: '2026-06-01T00:00:00Z' }] }), // /posts ); const r = await request(app) .put('/facebook/token') @@ -86,7 +92,7 @@ describe('Facebook feed API', () => { expect(r.body.lastUpdated).toBeTruthy(); expect(fb).toHaveBeenCalledTimes(3); - const stored = await FacebookToken.findOne({ key: 'pageToken' }).lean().exec() as any; + const stored = await FacebookToken.findOne({ pageId: CLC }).lean().exec() as any; expect(stored.value).toBe('PAGE-TOKEN'); const feed = await request(app).get('/facebook/feed').set({ origin: allowedUrl }); @@ -128,7 +134,7 @@ describe('Facebook feed API', () => { it('updateFacebookCache keeps the last good cache and emails once on a dead token (190)', async () => { const mail = vi.spyOn(mailer, 'sendMail').mockResolvedValue(undefined); - await FacebookToken.create({ key: 'pageToken', value: 'PAGE-TOKEN' }); + await FacebookToken.create({ pageId: CLC, value: 'PAGE-TOKEN' }); // First a healthy refresh to populate the cache. stubGraph(jsonRes({ data: [{ id: 'p1', message: 'Good' }] })); @@ -147,7 +153,7 @@ describe('Facebook feed API', () => { it('re-arms the alert after a successful refresh', async () => { vi.spyOn(mailer, 'sendMail').mockResolvedValue(undefined); - await FacebookToken.create({ key: 'pageToken', value: 'PAGE-TOKEN' }); + await FacebookToken.create({ pageId: CLC, value: 'PAGE-TOKEN' }); stubGraph(jsonRes({ error: { code: 190, message: 'expired' } }), jsonRes({ data: [{ id: 'p2' }] })); await updateFacebookCache(); expect(__getState().alertSent).toBe(true); @@ -157,7 +163,7 @@ describe('Facebook feed API', () => { it('ignores non-190 Graph errors without emailing', async () => { const mail = vi.spyOn(mailer, 'sendMail').mockResolvedValue(undefined); - await FacebookToken.create({ key: 'pageToken', value: 'PAGE-TOKEN' }); + await FacebookToken.create({ pageId: CLC, value: 'PAGE-TOKEN' }); stubGraph(jsonRes({ error: { code: 4, message: 'rate limited' } })); await updateFacebookCache(); expect(mail).not.toHaveBeenCalled(); @@ -165,7 +171,7 @@ describe('Facebook feed API', () => { }); it('keeps the last good cache when fetch itself throws', async () => { - await FacebookToken.create({ key: 'pageToken', value: 'PAGE-TOKEN' }); + await FacebookToken.create({ pageId: CLC, value: 'PAGE-TOKEN' }); vi.stubGlobal('fetch', (url: any, init: any) => ( String(url).startsWith('https://graph.facebook.com') ? Promise.reject(new Error('network down')) @@ -175,6 +181,51 @@ describe('Facebook feed API', () => { expect(__getState().cache).toEqual({ posts: [], lastUpdated: null }); }); + it('serves a second page independently via ?pageId and stores its own token', async () => { + const fb = stubGraph( + jsonRes({ access_token: 'long-lived-user-token' }), + jsonRes({ data: [{ id: WJ, access_token: 'WJ-PAGE-TOKEN' }] }), + jsonRes({ data: [{ id: 'w1', message: 'WebJam post' }] }), + ); + const r = await request(app) + .put('/facebook/token') + .set({ origin: allowedUrl }) + .set('Authorization', `Bearer ${authUtils.createJWT({ _id: user._id })}`) + .send({ userToken: 'short-lived', pageId: WJ }); + expect(r.status).toBe(200); + expect(r.body.pageId).toBe(WJ); + expect(fb).toHaveBeenCalledTimes(3); + + const stored = await FacebookToken.findOne({ pageId: WJ }).lean().exec() as any; + expect(stored.value).toBe('WJ-PAGE-TOKEN'); + + const wj = await request(app).get('/facebook/feed').query({ pageId: WJ }).set({ origin: allowedUrl }); + expect(wj.body.posts[0].message).toBe('WebJam post'); + // The CLC (default) feed is untouched and still empty. + const clc = await request(app).get('/facebook/feed').set({ origin: allowedUrl }); + expect(clc.body).toEqual({ posts: [], lastUpdated: null }); + }); + + it('names the specific page in the dead-token alert email', async () => { + const mail = vi.spyOn(mailer, 'sendMail').mockResolvedValue(undefined); + await FacebookToken.create({ pageId: WJ, value: 'WJ-TOKEN' }); + stubGraph(jsonRes({ error: { code: 190, message: 'expired' } })); + await updateFacebookCache(); + expect(mail).toHaveBeenCalledTimes(1); + expect((mail.mock.calls[0][0] as { subject: string }).subject).toMatch(/WebJamLLC/); + expect(__getState(WJ).alertSent).toBe(true); + }); + + it('migrates the legacy single-page token doc to the CLC pageId (idempotent)', async () => { + await FacebookToken.collection.insertOne({ key: 'pageToken', value: 'LEGACY-TOKEN' }); + await migrateLegacyToken(); + const migrated = await FacebookToken.findOne({ pageId: CLC }).lean().exec() as any; + expect(migrated.value).toBe('LEGACY-TOKEN'); + expect(await FacebookToken.collection.findOne({ key: 'pageToken' })).toBeNull(); + await migrateLegacyToken(); // second run is a no-op + expect(await FacebookToken.countDocuments({ pageId: CLC })).toBe(1); + }); + it('should wait until async work settles before exiting', async () => { // eslint-disable-line vitest/expect-expect // eslint-disable-next-line no-promise-executor-return await new Promise((resolve) => setTimeout(() => resolve(true), 1000));