ReviewOS

also looking at this

stacks/postline

feat: crossposting — media (LinkedIn/IG/Threads), OAuth token refresh, and X/Twitter

#30
Merged glennmichael123 wants to merge feat/crosspost-media-and-token-refresh into main
28 files +1810 -109

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

app/Services/Social/Drivers/InstagramDriver.tsmodified+42-1
Changes to app/Services/Social/Drivers/InstagramDriver.ts
@@ -1,1 +1,42 @@
1export { InstagramApiError, InstagramPublishingDriver as InstagramDriver } from '@stacksjs/socials'
1import { InstagramApiError, InstagramPublishingDriver } from '@stacksjs/socials'
2
3export { InstagramApiError }
4
5export interface InstagramLongLivedToken {
6 accessToken: string
7 expiresIn?: number
8}
9
10/**
11 * App-owned Instagram driver. Extends the vendored `@stacksjs/socials` driver
12 * with the Facebook long-lived-token exchange. Kept in `app/` so a framework
13 * upgrade can't clobber it; publishing and account resolution are inherited.
14 *
15 * Instagram publishes with a Page access token derived from a user token. A
16 * Page token minted from a *long-lived* user token does not expire on its own,
17 * so the "keep the connection alive" step for Instagram is exchanging the
18 * short-lived user token for a long-lived one before resolving the account —
19 * there is no per-publish refresh grant the way Threads/LinkedIn have.
20 */
21export class InstagramDriver extends InstagramPublishingDriver {
22 /** Exchange a short-lived user token for a long-lived (~60-day) one. */
23 async exchangeLongLivedUserToken(clientId: string, clientSecret: string, shortLivedToken: string): Promise<InstagramLongLivedToken> {
24 const query = new URLSearchParams({
25 grant_type: 'fb_exchange_token',
26 client_id: clientId,
27 client_secret: clientSecret,
28 fb_exchange_token: shortLivedToken,
29 })
30
31 const payload = await this.graph<{ access_token?: string, expires_in?: number }>(
32 `/oauth/access_token?${query.toString()}`,
33 { method: 'GET' },
34 )
35
36 if (!payload.access_token) {
37 throw new InstagramApiError('Facebook did not return a long-lived token.', 400, JSON.stringify(payload))
38 }
39
40 return { accessToken: payload.access_token, expiresIn: payload.expires_in }
41 }
42}
app/Services/Social/Drivers/LinkedInDriver.tsmodified+87-1
Changes to app/Services/Social/Drivers/LinkedInDriver.ts
@@ -1,4 +1,4 @@
1import type { PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials'
1import type { LinkedInTokenExchangeInput, PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials'
22import { escapeLinkedInText, LinkedInApiError, LinkedInPublishingDriver } from '@stacksjs/socials'
33
44export { LinkedInApiError }
@@ -7,6 +7,20 @@ interface InitializeUploadResponse {
77 value?: { uploadUrl?: string, image?: string }
88}
99
10export interface LinkedInRefreshInput {
11 clientId: string
12 clientSecret: string
13 refreshToken: string
14}
15
16export interface LinkedInRefreshedToken {
17 accessToken: string
18 expiresIn?: number
19 /** Present only when the app is enrolled in LinkedIn's refresh-token program. */
20 refreshToken?: string
21 refreshTokenExpiresIn?: number
22}
23
1024/**
1125 * App-owned LinkedIn driver. Extends the vendored `@stacksjs/socials` driver to
1226 * add image posting — the base driver only supports text and article link
@@ -19,6 +33,78 @@ interface InitializeUploadResponse {
1933 * single `uploadImage` step is added.
2034 */
2135export class LinkedInDriver extends LinkedInPublishingDriver {
36 /**
37 * Exchange an authorization code for a token, additionally surfacing the
38 * `refresh_token` the base driver drops. LinkedIn only returns one when the
39 * app is enrolled in the refresh-token program; when absent, the field is
40 * simply undefined and the connection falls back to reconnect-on-expiry.
41 */
42 async exchangeCode(input: LinkedInTokenExchangeInput): Promise<LinkedInRefreshedToken & { scope?: string }> {
43 const body = new URLSearchParams({
44 grant_type: 'authorization_code',
45 code: input.code,
46 redirect_uri: input.redirectUrl,
47 client_id: input.clientId,
48 client_secret: input.clientSecret,
49 })
50
51 const payload = await this.request<{
52 access_token: string
53 expires_in?: number
54 scope?: string
55 refresh_token?: string
56 refresh_token_expires_in?: number
57 }>(`${this.authBase}/oauth/v2/accessToken`, {
58 method: 'POST',
59 headers: { 'content-type': 'application/x-www-form-urlencoded' },
60 body: body.toString(),
61 })
62
63 if (!payload.access_token) {
64 throw new LinkedInApiError('LinkedIn did not return an access token.', 400, JSON.stringify(payload))
65 }
66
67 return {
68 accessToken: payload.access_token,
69 expiresIn: payload.expires_in,
70 scope: payload.scope,
71 refreshToken: payload.refresh_token,
72 refreshTokenExpiresIn: payload.refresh_token_expires_in,
73 }
74 }
75
76 /** Mint a fresh access token from a stored refresh token. */
77 async refreshAccessToken(input: LinkedInRefreshInput): Promise<LinkedInRefreshedToken> {
78 const body = new URLSearchParams({
79 grant_type: 'refresh_token',
80 refresh_token: input.refreshToken,
81 client_id: input.clientId,
82 client_secret: input.clientSecret,
83 })
84
85 const payload = await this.request<{
86 access_token: string
87 expires_in?: number
88 refresh_token?: string
89 refresh_token_expires_in?: number
90 }>(`${this.authBase}/oauth/v2/accessToken`, {
91 method: 'POST',
92 headers: { 'content-type': 'application/x-www-form-urlencoded' },
93 body: body.toString(),
94 })
95
96 if (!payload.access_token) {
97 throw new LinkedInApiError('LinkedIn did not return a refreshed access token.', 400, JSON.stringify(payload))
98 }
99
100 return {
101 accessToken: payload.access_token,
102 expiresIn: payload.expires_in,
103 refreshToken: payload.refresh_token,
104 refreshTokenExpiresIn: payload.refresh_token_expires_in,
105 }
106 }
107
22108 /**
23109 * Register and upload one image via LinkedIn's versioned Images API, then
24110 * return its `urn:li:image:...` id for attachment.
app/Services/Social/Drivers/ThreadsDriver.tsmodified+66-1
Changes to app/Services/Social/Drivers/ThreadsDriver.ts
@@ -1,1 +1,66 @@
1export { ThreadsApiError, ThreadsPublishingDriver as ThreadsDriver } from '@stacksjs/socials'
1import { ThreadsApiError, ThreadsPublishingDriver } from '@stacksjs/socials'
2
3export { ThreadsApiError }
4
5export interface ThreadsLongLivedToken {
6 accessToken: string
7 expiresIn?: number
8}
9
10/**
11 * App-owned Threads driver. Extends the vendored `@stacksjs/socials` driver with
12 * long-lived-token exchange and refresh — the base driver's `exchangeCode` only
13 * mints a short-lived (~1h) token. Kept in `app/` so a framework upgrade can't
14 * clobber it; publishing and timeline are inherited unchanged.
15 *
16 * Both token endpoints are unversioned (no `/vX` prefix), so they can't go
17 * through the base `graph()` helper — they use a small local `tokenRequest`.
18 */
19export class ThreadsDriver extends ThreadsPublishingDriver {
20 /**
21 * Exchange a short-lived token for a long-lived (~60-day) one. Requires the
22 * app's client secret. Called once right after the OAuth code exchange.
23 */
24 async exchangeLongLivedToken(clientSecret: string, shortLivedToken: string): Promise<ThreadsLongLivedToken> {
25 const query = new URLSearchParams({
26 grant_type: 'th_exchange_token',
27 client_secret: clientSecret,
28 access_token: shortLivedToken,
29 })
30 const payload = await this.tokenRequest(`${this.graphBase}/access_token?${query.toString()}`)
31 return { accessToken: payload.access_token, expiresIn: payload.expires_in }
32 }
33
34 /**
35 * Refresh an unexpired long-lived token, extending it another ~60 days.
36 * Threads requires the token be at least 24h old and not yet expired — both
37 * hold when the proactive refresh fires inside the pre-expiry window.
38 */
39 async refreshLongLivedToken(accessToken: string): Promise<ThreadsLongLivedToken> {
40 const query = new URLSearchParams({
41 grant_type: 'th_refresh_token',
42 access_token: accessToken,
43 })
44 const payload = await this.tokenRequest(`${this.graphBase}/refresh_access_token?${query.toString()}`)
45 return { accessToken: payload.access_token, expiresIn: payload.expires_in }
46 }
47
48 private async tokenRequest(url: string): Promise<{ access_token: string, expires_in?: number }> {
49 const response = await fetch(url, { method: 'GET' })
50 const text = await response.text()
51 let json: any = {}
52 try {
53 json = text ? JSON.parse(text) : {}
54 }
55 catch {
56 json = {}
57 }
58
59 if (!response.ok || json?.error || !json?.access_token) {
60 const message = json?.error?.message || json?.error_message || text || response.statusText
61 throw new ThreadsApiError(`Threads token request failed (${response.status}): ${message}`, response.status, text)
62 }
63
64 return json
65 }
66}
app/Services/Social/InstagramService.tsmodified+19-3
Changes to app/Services/Social/InstagramService.ts
@@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
44import { InstagramApiError, InstagramDriver } from './Drivers/InstagramDriver'
5import { ensureAccount, now, uuid } from './support'
5import { ensureAccount, expiresAt, now, uuid } from './support'
66
77const database = db as any
88
@@ -15,6 +15,7 @@ type SocialIdentityRow = {
1515 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
1616 access_token?: string | null
1717 refresh_token?: string | null
18 token_expires_at?: string | null
1819 account_id?: number | null
1920 social_driver_id?: number | null
2021}
@@ -118,11 +119,25 @@ export class InstagramService {
118119 code,
119120 })
120121
121 const account = await this.driver.resolveAccount(token.accessToken)
122 // Exchange the short-lived user token for a long-lived (~60-day) one before
123 // resolving the Page — a Page token minted from a long-lived user token does
124 // not expire on its own, so there's no per-publish refresh grant to run.
125 // Best-effort: fall back to the short-lived token if the exchange fails.
126 let userToken = token.accessToken
127 let expiresIn: number | undefined
128 try {
129 const longLived = await this.driver.exchangeLongLivedUserToken(cfg.clientId, cfg.clientSecret, token.accessToken)
130 userToken = longLived.accessToken
131 expiresIn = longLived.expiresIn
132 }
133 catch {}
134
135 const account = await this.driver.resolveAccount(userToken)
122136 const identity = await this.saveSession({
123137 accessToken: account.pageAccessToken,
124138 igUserId: account.igUserId,
125139 username: account.username,
140 expiresIn,
126141 })
127142 return this.publicIdentity(identity)
128143 }
@@ -260,7 +275,7 @@ export class InstagramService {
260275 .executeTakeFirst()
261276 }
262277
263 private async saveSession(input: { accessToken: string, igUserId: string, username?: string }): Promise<SocialIdentityRow> {
278 private async saveSession(input: { accessToken: string, igUserId: string, username?: string, expiresIn?: number }): Promise<SocialIdentityRow> {
264279 const accountId = await ensureAccount()
265280 const driver = await this.ensureDriver()
266281 const existing = await this.findIdentity()
@@ -275,6 +290,7 @@ export class InstagramService {
275290 auth_status: 'connected',
276291 access_token: input.accessToken,
277292 refresh_token: null,
293 token_expires_at: expiresAt(input.expiresIn),
278294 account_id: accountId,
279295 social_driver_id: driver.id,
280296 updated_at: savedAt,
app/Services/Social/LinkedInService.tsmodified+45-4
Changes to app/Services/Social/LinkedInService.ts
@@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
44import { LinkedInApiError, LinkedInDriver } from './Drivers/LinkedInDriver'
5import { ensureAccount, now, uuid } from './support'
5import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support'
66
77const database = db as any
88
@@ -15,6 +15,7 @@ type SocialIdentityRow = {
1515 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
1616 access_token?: string | null
1717 refresh_token?: string | null
18 token_expires_at?: string | null
1819 account_id?: number | null
1920 social_driver_id?: number | null
2021}
@@ -125,6 +126,8 @@ export class LinkedInService {
125126
126127 const identity = await this.saveSession({
127128 accessToken: token.accessToken,
129 refreshToken: token.refreshToken,
130 expiresIn: token.expiresIn,
128131 authorUrn,
129132 name: profile?.name,
130133 })
@@ -239,7 +242,9 @@ export class LinkedInService {
239242
240243 private async requireIdentity(): Promise<SocialIdentityRow> {
241244 const existing = await this.findIdentity()
242 if (existing?.access_token && existing.auth_status === 'connected') return existing
245 if (existing?.access_token && existing.auth_status === 'connected') {
246 return await this.refreshIfExpiring(existing)
247 }
243248
244249 // Fall back to an env-configured token if one is available.
245250 if (this.config().accessToken) {
@@ -251,6 +256,41 @@ export class LinkedInService {
251256 throw new Error('Connect LinkedIn before publishing.')
252257 }
253258
259 /**
260 * Refresh the access token when it's within its pre-expiry window and a
261 * refresh token is on file (LinkedIn only issues one when the app is enrolled
262 * in the refresh-token program). Best-effort: any failure leaves the current
263 * token in place so a real auth failure still surfaces at publish time.
264 */
265 private async refreshIfExpiring(identity: SocialIdentityRow): Promise<SocialIdentityRow> {
266 const cfg = this.config()
267 if (!identity.refresh_token || !isExpiringSoon(identity.token_expires_at)) return identity
268 if (!cfg.clientId || !cfg.clientSecret) return identity
269
270 try {
271 const refreshed = await this.driver.refreshAccessToken({
272 clientId: cfg.clientId,
273 clientSecret: cfg.clientSecret,
274 refreshToken: identity.refresh_token,
275 })
276 const nextExpiry = expiresAt(refreshed.expiresIn)
277 // LinkedIn rotates the refresh token on some grants — keep the new one.
278 const nextRefresh = refreshed.refreshToken ?? identity.refresh_token
279 await database.updateTable('social_identities').set({
280 access_token: refreshed.accessToken,
281 refresh_token: nextRefresh,
282 token_expires_at: nextExpiry,
283 auth_status: 'connected',
284 updated_at: now(),
285 }).where('id', '=', identity.id).execute()
286
287 return { ...identity, access_token: refreshed.accessToken, refresh_token: nextRefresh, token_expires_at: nextExpiry }
288 }
289 catch {
290 return identity
291 }
292 }
293
254294 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
255295 return await database
256296 .selectFrom('social_identities')
@@ -260,7 +300,7 @@ export class LinkedInService {
260300 .executeTakeFirst()
261301 }
262302
263 private async saveSession(input: { accessToken: string, authorUrn: string, name?: string }): Promise<SocialIdentityRow> {
303 private async saveSession(input: { accessToken: string, authorUrn: string, name?: string, refreshToken?: string, expiresIn?: number }): Promise<SocialIdentityRow> {
264304 const accountId = await ensureAccount()
265305 const driver = await this.ensureDriver()
266306 const existing = await this.findIdentity()
@@ -274,7 +314,8 @@ export class LinkedInService {
274314 external_id: input.authorUrn,
275315 auth_status: 'connected',
276316 access_token: input.accessToken,
277 refresh_token: null,
317 refresh_token: input.refreshToken ?? null,
318 token_expires_at: expiresAt(input.expiresIn),
278319 account_id: accountId,
279320 social_driver_id: driver.id,
280321 updated_at: savedAt,
app/Services/Social/ThreadsService.tsmodified+48-4
Changes to app/Services/Social/ThreadsService.ts
@@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
44import { ThreadsApiError, ThreadsDriver } from './Drivers/ThreadsDriver'
5import { ensureAccount, now, uuid } from './support'
5import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support'
66
77const database = db as any
88
@@ -15,6 +15,7 @@ type SocialIdentityRow = {
1515 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
1616 access_token?: string | null
1717 refresh_token?: string | null
18 token_expires_at?: string | null
1819 account_id?: number | null
1920 social_driver_id?: number | null
2021}
@@ -118,11 +119,25 @@ export class ThreadsService {
118119 code,
119120 })
120121
121 const account = await this.driver.resolveAccount(token.accessToken)
122 // Upgrade the short-lived (~1h) token to a long-lived (~60-day) one so
123 // scheduled posts keep working, then resolve the account against it. If the
124 // exchange fails, fall back to the short-lived token — publishing still
125 // works, just briefly.
126 let accessToken = token.accessToken
127 let expiresIn = token.expiresIn
128 try {
129 const longLived = await this.driver.exchangeLongLivedToken(cfg.clientSecret, token.accessToken)
130 accessToken = longLived.accessToken
131 expiresIn = longLived.expiresIn
132 }
133 catch {}
134
135 const account = await this.driver.resolveAccount(accessToken)
122136 const identity = await this.saveSession({
123137 accessToken: account.accessToken,
124138 threadsUserId: account.threadsUserId,
125139 username: account.username,
140 expiresIn,
126141 })
127142 return this.publicIdentity(identity)
128143 }
@@ -235,7 +250,9 @@ export class ThreadsService {
235250
236251 private async requireIdentity(): Promise<SocialIdentityRow> {
237252 const existing = await this.findIdentity()
238 if (existing?.access_token && existing.auth_status === 'connected') return existing
253 if (existing?.access_token && existing.auth_status === 'connected') {
254 return await this.refreshIfExpiring(existing)
255 }
239256
240257 if (this.config().accessToken) {
241258 await this.connectFromEnv()
@@ -246,6 +263,32 @@ export class ThreadsService {
246263 throw new Error('Connect Threads before publishing.')
247264 }
248265
266 /**
267 * Refresh a long-lived token that's within its pre-expiry window, extending
268 * it another ~60 days. Best-effort: a refresh failure leaves the current
269 * token in place, so a genuinely dead token still surfaces at publish time
270 * (marking the identity expired and prompting a reconnect) rather than here.
271 */
272 private async refreshIfExpiring(identity: SocialIdentityRow): Promise<SocialIdentityRow> {
273 if (!identity.access_token || !isExpiringSoon(identity.token_expires_at)) return identity
274
275 try {
276 const refreshed = await this.driver.refreshLongLivedToken(identity.access_token)
277 const nextExpiry = expiresAt(refreshed.expiresIn)
278 await database.updateTable('social_identities').set({
279 access_token: refreshed.accessToken,
280 token_expires_at: nextExpiry,
281 auth_status: 'connected',
282 updated_at: now(),
283 }).where('id', '=', identity.id).execute()
284
285 return { ...identity, access_token: refreshed.accessToken, token_expires_at: nextExpiry }
286 }
287 catch {
288 return identity
289 }
290 }
291
249292 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
250293 return await database
251294 .selectFrom('social_identities')
@@ -255,7 +298,7 @@ export class ThreadsService {
255298 .executeTakeFirst()
256299 }
257300
258 private async saveSession(input: { accessToken: string, threadsUserId: string, username?: string }): Promise<SocialIdentityRow> {
301 private async saveSession(input: { accessToken: string, threadsUserId: string, username?: string, expiresIn?: number }): Promise<SocialIdentityRow> {
259302 const accountId = await ensureAccount()
260303 const driver = await this.ensureDriver()
261304 const existing = await this.findIdentity()
@@ -270,6 +313,7 @@ export class ThreadsService {
270313 auth_status: 'connected',
271314 access_token: input.accessToken,
272315 refresh_token: null,
316 token_expires_at: expiresAt(input.expiresIn),
273317 account_id: accountId,
274318 social_driver_id: driver.id,
275319 updated_at: savedAt,
app/Services/Social/support.tsmodified+23-0
Changes to app/Services/Social/support.ts
@@ -7,6 +7,29 @@ export function now(): string {
77 return new Date().toISOString().slice(0, 19).replace('T', ' ')
88}
99
10/**
11 * A SQLite `YYYY-MM-DD HH:MM:SS` (UTC) timestamp `secondsFromNow` in the
12 * future — used to record when an OAuth access token will expire. Returns null
13 * when the provider didn't report a lifetime, so callers can store NULL.
14 */
15export function expiresAt(secondsFromNow: number | null | undefined): string | null {
16 if (!secondsFromNow || secondsFromNow <= 0) return null
17 return new Date(Date.now() + secondsFromNow * 1000).toISOString().slice(0, 19).replace('T', ' ')
18}
19
20/**
21 * Whether a stored `token_expires_at` (SQLite `YYYY-MM-DD HH:MM:SS`, UTC) is
22 * already past or falls within `withinSeconds` of now — the trigger for a
23 * proactive token refresh before publishing. Unknown/blank/unparseable expiry
24 * returns false: there's nothing to refresh against, so leave the token alone.
25 */
26export function isExpiringSoon(value: string | null | undefined, withinSeconds = 7 * 24 * 60 * 60): boolean {
27 if (!value) return false
28 const expiry = Date.parse(`${String(value).replace(' ', 'T')}Z`)
29 if (Number.isNaN(expiry)) return false
30 return expiry - Date.now() <= withinSeconds * 1000
31}
32
1033export function uuid(): string {
1134 return crypto.randomUUID()
1235}
database/migrations/1784890991-add-token_expires_at-to-social_identities.sqladded+5-0
Changes to database/migrations/1784890991-add-token_expires_at-to-social_identities.sql
@@ -0,0 +1,5 @@
1-- Track OAuth access-token expiry so LinkedIn / Threads / Instagram tokens can
2-- be proactively refreshed (or exchanged for long-lived ones) before they
3-- lapse, instead of only failing at publish time. NULL means "no known expiry"
4-- (e.g. Bluesky app passwords, Mastodon tokens, env-configured tokens).
5ALTER TABLE "social_identities" ADD COLUMN "token_expires_at" TEXT;
tests/unit/instagram-driver.test.tsadded+47-0
Changes to tests/unit/instagram-driver.test.ts
@@ -0,0 +1,47 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { InstagramApiError, InstagramDriver } from '../../app/Services/Social/Drivers/InstagramDriver'
3
4const realFetch = globalThis.fetch
5afterEach(() => { globalThis.fetch = realFetch })
6
7interface Captured { url: string, method: string }
8
9function mockGraphApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void {
10 globalThis.fetch = (async (input: any, init: any = {}) => {
11 const url = String(input)
12 const over = overrides(url)
13 if (over) { captured.push({ url, method: init.method || 'GET' }); return over }
14
15 if (url.includes('fb_exchange_token')) {
16 captured.push({ url, method: init.method || 'GET' })
17 return new Response(JSON.stringify({ access_token: 'LONG_LIVED_USER', token_type: 'bearer', expires_in: 5184000 }), { status: 200 })
18 }
19 captured.push({ url, method: init.method || 'GET' })
20 return new Response('{}', { status: 404 })
21 }) as typeof fetch
22}
23
24describe('InstagramDriver.exchangeLongLivedUserToken', () => {
25 test('exchanges a short-lived user token for a long-lived one', async () => {
26 const cap: Captured[] = []; mockGraphApi(cap)
27 const driver = new InstagramDriver()
28 const result = await driver.exchangeLongLivedUserToken('client-1', 'secret-2', 'short-user-token')
29
30 expect(result).toEqual({ accessToken: 'LONG_LIVED_USER', expiresIn: 5184000 })
31 const call = cap.find(c => c.url.includes('fb_exchange_token'))
32 expect(call?.url).toContain('graph.facebook.com')
33 expect(call?.url).toContain('/oauth/access_token')
34 expect(call?.url).toContain('client_id=client-1')
35 expect(call?.url).toContain('fb_exchange_token=short-user-token')
36 })
37
38 test('surfaces a Graph error as InstagramApiError', async () => {
39 const cap: Captured[] = []
40 mockGraphApi(cap, () => new Response(JSON.stringify({ error: { message: 'invalid token' } }), { status: 400 }))
41 const driver = new InstagramDriver()
42 let caught: unknown
43 await driver.exchangeLongLivedUserToken('c', 's', 'bad').catch((e) => { caught = e })
44 expect(caught).toBeInstanceOf(InstagramApiError)
45 expect((caught as InstagramApiError).status).toBe(400)
46 })
47})
tests/unit/linkedin-driver.test.tsmodified+48-0
Changes to tests/unit/linkedin-driver.test.ts
@@ -165,3 +165,51 @@ describe('LinkedInDriver.publish', () => {
165165 expect(postRequest(cap)).toBeUndefined()
166166 })
167167})
168
169interface TokenCall { url: string, body: string }
170
171function mockTokenEndpoint(captured: TokenCall[], payload: Record<string, unknown>, status = 200): void {
172 globalThis.fetch = (async (input: any, init: any = {}) => {
173 captured.push({ url: String(input), body: String(init.body || '') })
174 return new Response(JSON.stringify(payload), { status })
175 }) as typeof fetch
176}
177
178describe('LinkedInDriver token exchange + refresh', () => {
179 test('exchangeCode surfaces the refresh token the base driver drops', async () => {
180 const cap: TokenCall[] = []
181 mockTokenEndpoint(cap, { access_token: 'AT1', expires_in: 5184000, refresh_token: 'RT1', refresh_token_expires_in: 31536000 })
182 const driver = new LinkedInDriver()
183
184 const token = await driver.exchangeCode({ clientId: 'c', clientSecret: 's', redirectUrl: 'https://app/cb', code: 'abc' })
185
186 expect(cap[0].url).toContain('/oauth/v2/accessToken')
187 expect(cap[0].body).toContain('grant_type=authorization_code')
188 expect(token.accessToken).toBe('AT1')
189 expect(token.refreshToken).toBe('RT1')
190 expect(token.expiresIn).toBe(5184000)
191 })
192
193 test('refreshAccessToken mints a new token from a refresh token', async () => {
194 const cap: TokenCall[] = []
195 mockTokenEndpoint(cap, { access_token: 'AT2', expires_in: 5184000, refresh_token: 'RT2' })
196 const driver = new LinkedInDriver()
197
198 const token = await driver.refreshAccessToken({ clientId: 'c', clientSecret: 's', refreshToken: 'RT1' })
199
200 expect(cap[0].body).toContain('grant_type=refresh_token')
201 expect(cap[0].body).toContain('refresh_token=RT1')
202 expect(token.accessToken).toBe('AT2')
203 expect(token.refreshToken).toBe('RT2')
204 })
205
206 test('refreshAccessToken throws when LinkedIn returns no token', async () => {
207 const cap: TokenCall[] = []
208 mockTokenEndpoint(cap, { error: 'invalid_grant' }, 400)
209 const driver = new LinkedInDriver()
210 let caught: unknown
211 await driver.refreshAccessToken({ clientId: 'c', clientSecret: 's', refreshToken: 'stale' }).catch((e) => { caught = e })
212 expect(caught).toBeInstanceOf(LinkedInApiError)
213 expect((caught as LinkedInApiError).status).toBe(400)
214 })
215})
tests/unit/threads-driver.test.tsadded+61-0
Changes to tests/unit/threads-driver.test.ts
@@ -0,0 +1,61 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { ThreadsApiError, ThreadsDriver } from '../../app/Services/Social/Drivers/ThreadsDriver'
3
4const realFetch = globalThis.fetch
5afterEach(() => { globalThis.fetch = realFetch })
6
7interface Captured { url: string, method: string }
8
9function mockThreadsTokenApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void {
10 globalThis.fetch = (async (input: any, init: any = {}) => {
11 const url = String(input)
12 const over = overrides(url)
13 if (over) { captured.push({ url, method: init.method || 'GET' }); return over }
14
15 if (url.includes('th_exchange_token')) {
16 captured.push({ url, method: init.method || 'GET' })
17 return new Response(JSON.stringify({ access_token: 'LONG_LIVED', token_type: 'bearer', expires_in: 5184000 }), { status: 200 })
18 }
19 if (url.includes('th_refresh_token')) {
20 captured.push({ url, method: init.method || 'GET' })
21 return new Response(JSON.stringify({ access_token: 'REFRESHED', token_type: 'bearer', expires_in: 5184000 }), { status: 200 })
22 }
23 captured.push({ url, method: init.method || 'GET' })
24 return new Response('{}', { status: 404 })
25 }) as typeof fetch
26}
27
28describe('ThreadsDriver token lifecycle', () => {
29 test('exchangeLongLivedToken swaps a short-lived token for a ~60-day one', async () => {
30 const cap: Captured[] = []; mockThreadsTokenApi(cap)
31 const driver = new ThreadsDriver()
32 const result = await driver.exchangeLongLivedToken('secret-xyz', 'short-token')
33
34 expect(result).toEqual({ accessToken: 'LONG_LIVED', expiresIn: 5184000 })
35 const call = cap.find(c => c.url.includes('th_exchange_token'))
36 expect(call?.url).toContain('graph.threads.net/access_token')
37 expect(call?.url).toContain('client_secret=secret-xyz')
38 expect(call?.url).toContain('access_token=short-token')
39 })
40
41 test('refreshLongLivedToken extends an existing long-lived token', async () => {
42 const cap: Captured[] = []; mockThreadsTokenApi(cap)
43 const driver = new ThreadsDriver()
44 const result = await driver.refreshLongLivedToken('long-token')
45
46 expect(result).toEqual({ accessToken: 'REFRESHED', expiresIn: 5184000 })
47 const call = cap.find(c => c.url.includes('th_refresh_token'))
48 expect(call?.url).toContain('graph.threads.net/refresh_access_token')
49 expect(call?.url).toContain('access_token=long-token')
50 })
51
52 test('surfaces a token error as ThreadsApiError with status', async () => {
53 const cap: Captured[] = []
54 mockThreadsTokenApi(cap, () => new Response(JSON.stringify({ error: { message: 'bad token' } }), { status: 400 }))
55 const driver = new ThreadsDriver()
56 let caught: unknown
57 await driver.refreshLongLivedToken('nope').catch((e) => { caught = e })
58 expect(caught).toBeInstanceOf(ThreadsApiError)
59 expect((caught as ThreadsApiError).status).toBe(400)
60 })
61})