From ef41f33e59c07f6d70ce3c7b471c163f86661082 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 8 Oct 2025 09:18:35 +0200 Subject: [PATCH 01/11] poc --- src/entity/UserIntegration.ts | 16 ++++ src/routes/gifs.ts | 135 ++++++++++++++++++++++++++++++++++ src/routes/index.ts | 2 + 3 files changed, 153 insertions(+) create mode 100644 src/routes/gifs.ts diff --git a/src/entity/UserIntegration.ts b/src/entity/UserIntegration.ts index 6250ba9cd8..69bc44ffc1 100644 --- a/src/entity/UserIntegration.ts +++ b/src/entity/UserIntegration.ts @@ -13,6 +13,7 @@ import type { User } from './user/User'; export enum UserIntegrationType { Slack = 'slack', + Gif = 'gif', } export type IntegrationMetaSlack = { @@ -25,6 +26,13 @@ export type IntegrationMetaSlack = { teamName: string; }; +export type Gif = { + id: string; + url: string; + preview: string; + title: string; +}; + @Entity() @TableInheritance({ column: { type: 'text', name: 'type' }, @@ -61,3 +69,11 @@ export class UserIntegrationSlack extends UserIntegration { @Column({ type: 'jsonb', default: {} }) meta: IntegrationMetaSlack; } + +@ChildEntity(UserIntegrationType.Gif) +export class UserIntegrationGif extends UserIntegration { + @Column({ type: 'jsonb', default: {} }) + meta: { + favorites: Gif[]; + }; +} diff --git a/src/routes/gifs.ts b/src/routes/gifs.ts new file mode 100644 index 0000000000..cb32c90f2f --- /dev/null +++ b/src/routes/gifs.ts @@ -0,0 +1,135 @@ +import type { FastifyInstance } from 'fastify'; +import createOrGetConnection from '../db'; +import { + UserIntegrationGif, + UserIntegrationType, + type Gif, +} from '../entity/UserIntegration'; + +type TenorGif = { + id: string; + media: Array<{ gif?: { url: string }; mediumgif?: { url: string } }>; + content_description?: string; +}; +export default async function (fastify: FastifyInstance): Promise { + fastify.get('/', async (req, res) => { + try { + const query = req.query as { q?: string; limit?: string; pos?: string }; + const q = query.q ?? ''; + const limit = parseInt(query.limit ?? '10', 10); + const pos = query.pos; // Pagination token from Tenor + + if (!q) { + return res.send({ gifs: [], next: undefined }); + } + + const params = new URLSearchParams({ + q: encodeURIComponent(q), + key: process.env.TENOR_API_KEY!, + limit: limit.toString(), + }); + + // Add pos parameter if it exists (for pagination) + if (pos) { + params.append('pos', pos); + } + + const tenorRes = await fetch( + `https://g.tenor.com/v1/search?${params.toString()}`, + ); + + const tenorJson = await tenorRes.json(); + + const gifs: Gif[] = (tenorJson.results ?? []).map((item: TenorGif) => ({ + id: item.id, + url: item.media[0]?.gif?.url || '', + preview: item.media[0]?.mediumgif?.url || '', + title: item.content_description || '', + })); + + return res.send({ + gifs, + next: tenorJson.next, // Pass through Tenor's pagination token + }); + } catch (error) { + console.error('Error fetching gifs:', error); + return res.status(500).send({ error: 'Failed to fetch gifs' }); + } + }); + fastify.post('/favorite', async (req, res) => { + try { + const con = await createOrGetConnection(); + console.log('--- req user id', req.userId); + const existingFavorites = await con + .getRepository(UserIntegrationGif) + .findOne({ + where: { + userId: req.userId, + type: UserIntegrationType.Gif, + }, + }); + + const newFavorite = req.body as Gif; + const gifs: Gif[] = []; + if (existingFavorites?.meta) { + gifs.push(...existingFavorites.meta.favorites); + } + + if (gifs.find((g) => g.id === newFavorite.id)) + return res.status(403).send({ error: 'Gif already favorited' }); + + gifs.push(newFavorite); + + if (existingFavorites) { + await con.getRepository(UserIntegrationGif).update( + { + userId: req.userId, + type: UserIntegrationType.Gif, + }, + { + meta: { + favorites: gifs, + }, + }, + ); + } else { + await con.getRepository(UserIntegrationGif).insert({ + userId: req.userId, + type: UserIntegrationType.Gif, + meta: { + favorites: gifs, + }, + }); + } + + return res.send({ favorites: gifs }); + } catch (e) { + console.error('*** favorite err', e); + return res.status(500).send({ error: 'Failed to favorite gif' }); + } + }); + fastify.get('/favorites', async (req, res) => { + try { + const con = await createOrGetConnection(); + const existingFavorites = await con + .getRepository(UserIntegrationGif) + .find({ + where: { + userId: req.userId, + type: UserIntegrationType.Gif, + }, + }); + + const favorites: Gif[] = []; + existingFavorites.forEach((fav) => { + if (fav.meta) { + favorites.push(...(fav.meta.favorites as Gif[])); + } + }); + + return res.send({ favorites }); + } catch (e) { + return res.status(500).send({ error: 'Failed to fetch favorite gifs' }); + } + }); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 4bb6c7850b..fd6fa61aca 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -19,6 +19,7 @@ import { UserPersonalizedDigest, UserPersonalizedDigestType } from '../entity'; import { notifyGeneratePersonalizedDigest } from '../common'; import { PersonalizedDigestFeatureConfig } from '../growthbook'; import integrations from './integrations'; +import gifs from './gifs'; export default async function (fastify: FastifyInstance): Promise { fastify.register(rss, { prefix: '/rss' }); @@ -38,6 +39,7 @@ export default async function (fastify: FastifyInstance): Promise { fastify.register(automations, { prefix: '/auto' }); fastify.register(sitemaps, { prefix: '/sitemaps' }); fastify.register(integrations, { prefix: '/integrations' }); + fastify.register(gifs, { prefix: '/gifs' }); fastify.get('/robots.txt', (req, res) => { return res.type('text/plain').send(`User-agent: * From f3ebf29153a7251d1ad83f8dd47cd5adad8434ef Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Sun, 14 Dec 2025 07:24:49 +0800 Subject: [PATCH 02/11] update to v2 api --- src/routes/gifs.ts | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/routes/gifs.ts b/src/routes/gifs.ts index cb32c90f2f..640f5f52b3 100644 --- a/src/routes/gifs.ts +++ b/src/routes/gifs.ts @@ -8,8 +8,15 @@ import { type TenorGif = { id: string; - media: Array<{ gif?: { url: string }; mediumgif?: { url: string } }>; - content_description?: string; + title: string; + media_formats: Record; + content_description: string; + url: string; +}; + +type TenorResponse = { + results: TenorGif[]; + next?: string; }; export default async function (fastify: FastifyInstance): Promise { fastify.get('/', async (req, res) => { @@ -35,31 +42,36 @@ export default async function (fastify: FastifyInstance): Promise { } const tenorRes = await fetch( - `https://g.tenor.com/v1/search?${params.toString()}`, + `https://tenor.googleapis.com/v2/search?${params.toString()}`, ); - const tenorJson = await tenorRes.json(); + const tenorJson = (await tenorRes.json()) as TenorResponse; - const gifs: Gif[] = (tenorJson.results ?? []).map((item: TenorGif) => ({ - id: item.id, - url: item.media[0]?.gif?.url || '', - preview: item.media[0]?.mediumgif?.url || '', - title: item.content_description || '', - })); + const gifs: Gif[] = tenorJson.results.map((item: TenorGif) => { + const mediaFormats = item.media_formats as Record< + string, + { url?: string } + >; + return { + id: item.id, + url: mediaFormats.gif?.url || '', + preview: mediaFormats.mediumgif?.url || mediaFormats.gif?.url || '', + title: item.content_description || item.title || '', + }; + }); return res.send({ gifs, - next: tenorJson.next, // Pass through Tenor's pagination token + next: tenorJson.next, }); } catch (error) { - console.error('Error fetching gifs:', error); return res.status(500).send({ error: 'Failed to fetch gifs' }); } }); fastify.post('/favorite', async (req, res) => { try { const con = await createOrGetConnection(); - console.log('--- req user id', req.userId); + const existingFavorites = await con .getRepository(UserIntegrationGif) .findOne({ @@ -104,7 +116,6 @@ export default async function (fastify: FastifyInstance): Promise { return res.send({ favorites: gifs }); } catch (e) { - console.error('*** favorite err', e); return res.status(500).send({ error: 'Failed to favorite gif' }); } }); From c47ef479c789bb005cf78c992d07d9809696f77d Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Sun, 14 Dec 2025 07:58:45 +0800 Subject: [PATCH 03/11] remove url encode --- src/routes/gifs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/gifs.ts b/src/routes/gifs.ts index 640f5f52b3..857e7e45d6 100644 --- a/src/routes/gifs.ts +++ b/src/routes/gifs.ts @@ -31,7 +31,7 @@ export default async function (fastify: FastifyInstance): Promise { } const params = new URLSearchParams({ - q: encodeURIComponent(q), + q: q, key: process.env.TENOR_API_KEY!, limit: limit.toString(), }); From 96f48988653476e9b253c8ab677e5b08e423c695 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Sun, 14 Dec 2025 08:53:27 +0800 Subject: [PATCH 04/11] update toggling favorites --- src/routes/gifs.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/routes/gifs.ts b/src/routes/gifs.ts index 857e7e45d6..0e760f3e83 100644 --- a/src/routes/gifs.ts +++ b/src/routes/gifs.ts @@ -24,7 +24,7 @@ export default async function (fastify: FastifyInstance): Promise { const query = req.query as { q?: string; limit?: string; pos?: string }; const q = query.q ?? ''; const limit = parseInt(query.limit ?? '10', 10); - const pos = query.pos; // Pagination token from Tenor + const pos = query.pos; if (!q) { return res.send({ gifs: [], next: undefined }); @@ -36,7 +36,6 @@ export default async function (fastify: FastifyInstance): Promise { limit: limit.toString(), }); - // Add pos parameter if it exists (for pagination) if (pos) { params.append('pos', pos); } @@ -81,16 +80,19 @@ export default async function (fastify: FastifyInstance): Promise { }, }); - const newFavorite = req.body as Gif; + const gifToToggle = req.body as Gif; const gifs: Gif[] = []; if (existingFavorites?.meta) { gifs.push(...existingFavorites.meta.favorites); } - if (gifs.find((g) => g.id === newFavorite.id)) - return res.status(403).send({ error: 'Gif already favorited' }); + const existingIndex = gifs.findIndex((g) => g.id === gifToToggle.id); - gifs.push(newFavorite); + if (existingIndex !== -1) { + gifs.splice(existingIndex, 1); + } else { + gifs.push(gifToToggle); + } if (existingFavorites) { await con.getRepository(UserIntegrationGif).update( @@ -114,9 +116,9 @@ export default async function (fastify: FastifyInstance): Promise { }); } - return res.send({ favorites: gifs }); + return res.send({ gifs }); } catch (e) { - return res.status(500).send({ error: 'Failed to favorite gif' }); + return res.status(500).send({ error: 'Failed to toggle favorite gif' }); } }); fastify.get('/favorites', async (req, res) => { @@ -138,7 +140,7 @@ export default async function (fastify: FastifyInstance): Promise { } }); - return res.send({ favorites }); + return res.send({ gifs: favorites }); } catch (e) { return res.status(500).send({ error: 'Failed to fetch favorite gifs' }); } From 75358e690c371107175d458696ffaefd14843435 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Sun, 14 Dec 2025 15:43:23 +0800 Subject: [PATCH 05/11] move to garmr and add tests --- __tests__/routes/gifs.ts | 322 ++++++++++++++++++++++++++++++ src/integrations/tenor/clients.ts | 89 +++++++++ src/integrations/tenor/index.ts | 2 + src/integrations/tenor/types.ts | 34 ++++ src/routes/gifs.ts | 63 +----- 5 files changed, 456 insertions(+), 54 deletions(-) create mode 100644 __tests__/routes/gifs.ts create mode 100644 src/integrations/tenor/clients.ts create mode 100644 src/integrations/tenor/index.ts create mode 100644 src/integrations/tenor/types.ts diff --git a/__tests__/routes/gifs.ts b/__tests__/routes/gifs.ts new file mode 100644 index 0000000000..fbda7a350c --- /dev/null +++ b/__tests__/routes/gifs.ts @@ -0,0 +1,322 @@ +import appFunc from '../../src'; +import { FastifyInstance } from 'fastify'; +import { authorizeRequest, saveFixtures } from '../helpers'; +import { User } from '../../src/entity'; +import { + UserIntegrationGif, + UserIntegrationType, +} from '../../src/entity/UserIntegration'; +import { usersFixture } from '../fixture'; +import { DataSource } from 'typeorm'; +import createOrGetConnection from '../../src/db'; +import request from 'supertest'; +import { tenorClient } from '../../src/integrations/tenor'; + +let app: FastifyInstance; +let con: DataSource; + +jest.mock('../../src/integrations/tenor', () => ({ + tenorClient: { + search: jest.fn(), + }, +})); + +const mockTenorSearch = tenorClient.search as jest.Mock; + +beforeAll(async () => { + con = await createOrGetConnection(); + app = await appFunc(); + return app.ready(); +}); + +afterAll(() => app.close()); + +beforeEach(async () => { + jest.resetAllMocks(); + await saveFixtures(con, User, usersFixture); +}); + +describe('GET /gifs', () => { + it('should return empty gifs when no query is provided', async () => { + mockTenorSearch.mockResolvedValue({ gifs: [], next: undefined }); + + const { body } = await request(app.server).get('/gifs').expect(200); + + expect(body).toEqual({ gifs: [], next: undefined }); + expect(mockTenorSearch).toHaveBeenCalledWith({ + q: '', + limit: 10, + pos: undefined, + }); + }); + + it('should return gifs from tenor search', async () => { + const mockGifs = [ + { + id: 'gif1', + url: 'https://tenor.com/gif1.gif', + preview: 'https://tenor.com/gif1-preview.gif', + title: 'Funny cat', + }, + { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }, + ]; + + mockTenorSearch.mockResolvedValue({ + gifs: mockGifs, + next: 'next-page-token', + }); + + const { body } = await request(app.server) + .get('/gifs') + .query({ q: 'funny', limit: '20' }) + .expect(200); + + expect(body).toEqual({ + gifs: mockGifs, + next: 'next-page-token', + }); + expect(mockTenorSearch).toHaveBeenCalledWith({ + q: 'funny', + limit: 20, + pos: undefined, + }); + }); + + it('should pass pagination position to tenor search', async () => { + mockTenorSearch.mockResolvedValue({ gifs: [], next: undefined }); + + await request(app.server) + .get('/gifs') + .query({ q: 'test', pos: 'page-token' }) + .expect(200); + + expect(mockTenorSearch).toHaveBeenCalledWith({ + q: 'test', + limit: 10, + pos: 'page-token', + }); + }); + + it('should return empty gifs when tenor search fails', async () => { + mockTenorSearch.mockRejectedValue(new Error('Tenor API error')); + + const { body } = await request(app.server) + .get('/gifs') + .query({ q: 'test' }) + .expect(200); + + expect(body).toEqual({ gifs: [], next: undefined }); + }); +}); + +describe('POST /gifs/favorite', () => { + const gifToFavorite = { + id: 'gif1', + url: 'https://tenor.com/gif1.gif', + preview: 'https://tenor.com/gif1-preview.gif', + title: 'Funny cat', + }; + + it('should add a gif to favorites for authenticated user', async () => { + const { body } = await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(1); + expect(body.gifs[0]).toMatchObject(gifToFavorite); + + const saved = await con.getRepository(UserIntegrationGif).findOne({ + where: { userId: '1', type: UserIntegrationType.Gif }, + }); + expect(saved?.meta.favorites).toHaveLength(1); + expect(saved?.meta.favorites[0]).toMatchObject(gifToFavorite); + }); + + it('should add multiple gifs to favorites', async () => { + const gif2 = { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }; + + await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + const { body } = await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gif2), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(2); + expect(body.gifs).toEqual( + expect.arrayContaining([ + expect.objectContaining(gifToFavorite), + expect.objectContaining(gif2), + ]), + ); + }); + + it('should remove a gif from favorites when already favorited (toggle)', async () => { + await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + const { body } = await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(0); + + const saved = await con.getRepository(UserIntegrationGif).findOne({ + where: { userId: '1', type: UserIntegrationType.Gif }, + }); + expect(saved?.meta.favorites).toHaveLength(0); + }); + + it('should keep favorites separate per user', async () => { + await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + const gif2 = { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }; + + await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gif2), + '2', + ).expect(200); + + const user1Favorites = await con.getRepository(UserIntegrationGif).findOne({ + where: { userId: '1', type: UserIntegrationType.Gif }, + }); + const user2Favorites = await con.getRepository(UserIntegrationGif).findOne({ + where: { userId: '2', type: UserIntegrationType.Gif }, + }); + + expect(user1Favorites?.meta.favorites).toHaveLength(1); + expect(user1Favorites?.meta.favorites[0].id).toBe('gif1'); + expect(user2Favorites?.meta.favorites).toHaveLength(1); + expect(user2Favorites?.meta.favorites[0].id).toBe('gif2'); + }); + + it('should return empty gifs on database error', async () => { + const repo = con.getRepository(UserIntegrationGif); + jest.spyOn(repo, 'findOne').mockRejectedValueOnce(new Error('DB error')); + + const { body } = await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + expect(body).toEqual({ gifs: [] }); + }); +}); + +describe('GET /gifs/favorites', () => { + it('should return empty array when user has no favorites', async () => { + const { body } = await authorizeRequest( + request(app.server).get('/gifs/favorites'), + '1', + ).expect(200); + + expect(body).toEqual({ gifs: [] }); + }); + + it('should return user favorites', async () => { + const gif1 = { + id: 'gif1', + url: 'https://tenor.com/gif1.gif', + preview: 'https://tenor.com/gif1-preview.gif', + title: 'Funny cat', + }; + const gif2 = { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }; + + await con.getRepository(UserIntegrationGif).insert({ + userId: '1', + type: UserIntegrationType.Gif, + meta: { favorites: [gif1, gif2] }, + }); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs/favorites'), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(2); + expect(body.gifs).toEqual( + expect.arrayContaining([ + expect.objectContaining(gif1), + expect.objectContaining(gif2), + ]), + ); + }); + + it('should only return favorites for the authenticated user', async () => { + const gif1 = { + id: 'gif1', + url: 'https://tenor.com/gif1.gif', + preview: 'https://tenor.com/gif1-preview.gif', + title: 'Funny cat', + }; + const gif2 = { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }; + + await con.getRepository(UserIntegrationGif).insert([ + { + userId: '1', + type: UserIntegrationType.Gif, + meta: { favorites: [gif1] }, + }, + { + userId: '2', + type: UserIntegrationType.Gif, + meta: { favorites: [gif2] }, + }, + ]); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs/favorites'), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(1); + expect(body.gifs[0].id).toBe('gif1'); + }); + + it('should return empty gifs on database error', async () => { + const repo = con.getRepository(UserIntegrationGif); + jest.spyOn(repo, 'find').mockRejectedValueOnce(new Error('DB error')); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs/favorites'), + '1', + ).expect(200); + + expect(body).toEqual({ gifs: [] }); + }); +}); diff --git a/src/integrations/tenor/clients.ts b/src/integrations/tenor/clients.ts new file mode 100644 index 0000000000..ffe0422215 --- /dev/null +++ b/src/integrations/tenor/clients.ts @@ -0,0 +1,89 @@ +import { GarmrService, IGarmrService, GarmrNoopService } from '../garmr'; +import type { Gif } from '../../entity/UserIntegration'; +import { + ITenorClient, + TenorSearchParams, + TenorSearchResult, + TenorSearchResponse, + TenorGif, +} from './types'; + +export class TenorClient implements ITenorClient { + private readonly apiKey: string; + public readonly garmr: IGarmrService; + + constructor( + apiKey: string, + options?: { + garmr?: IGarmrService; + }, + ) { + this.apiKey = apiKey; + this.garmr = options?.garmr || new GarmrNoopService(); + } + + async search(params: TenorSearchParams): Promise { + const { q, limit = 10, pos } = params; + + if (!q) { + return { gifs: [], next: undefined }; + } + + return this.garmr.execute(async () => { + const searchParams = new URLSearchParams({ + q, + key: this.apiKey, + limit: limit.toString(), + }); + + if (pos) { + searchParams.append('pos', pos); + } + + const response = await fetch( + `${process.env.TENOR_GIF_SEARCH_URL}?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new Error( + `Tenor API error: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as TenorSearchResponse; + + const gifs: Gif[] = data.results.map((item: TenorGif) => ({ + id: item.id, + url: item.media_formats.gif?.url || '', + preview: + item.media_formats.mediumgif?.url || + item.media_formats.gif?.url || + '', + title: item.content_description || item.title || '', + })); + + return { + gifs, + next: data.next, + }; + }); + } +} + +const garmrTenorService = new GarmrService({ + service: 'tenor', + breakerOpts: { + halfOpenAfter: 5 * 1000, + threshold: 0.1, + duration: 10 * 1000, + minimumRps: 0, + }, + retryOpts: { + maxAttempts: 2, + backoff: 100, + }, +}); + +export const tenorClient = new TenorClient(process.env.TENOR_API_KEY!, { + garmr: garmrTenorService, +}); diff --git a/src/integrations/tenor/index.ts b/src/integrations/tenor/index.ts new file mode 100644 index 0000000000..08b95f7573 --- /dev/null +++ b/src/integrations/tenor/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './clients'; diff --git a/src/integrations/tenor/types.ts b/src/integrations/tenor/types.ts new file mode 100644 index 0000000000..0a7ea6a815 --- /dev/null +++ b/src/integrations/tenor/types.ts @@ -0,0 +1,34 @@ +import { IGarmrClient } from '../garmr'; +import type { Gif } from '../../entity/UserIntegration'; + +export type TenorMediaFormat = { + url?: string; +}; + +export type TenorGif = { + id: string; + title: string; + media_formats: Record; + content_description: string; + url: string; +}; + +export type TenorSearchResponse = { + results: TenorGif[]; + next?: string; +}; + +export type TenorSearchParams = { + q: string; + limit?: number; + pos?: string; +}; + +export type TenorSearchResult = { + gifs: Gif[]; + next?: string; +}; + +export interface ITenorClient extends IGarmrClient { + search(params: TenorSearchParams): Promise; +} diff --git a/src/routes/gifs.ts b/src/routes/gifs.ts index 0e760f3e83..5797e1cbcd 100644 --- a/src/routes/gifs.ts +++ b/src/routes/gifs.ts @@ -5,19 +5,8 @@ import { UserIntegrationType, type Gif, } from '../entity/UserIntegration'; +import { tenorClient } from '../integrations/tenor'; -type TenorGif = { - id: string; - title: string; - media_formats: Record; - content_description: string; - url: string; -}; - -type TenorResponse = { - results: TenorGif[]; - next?: string; -}; export default async function (fastify: FastifyInstance): Promise { fastify.get('/', async (req, res) => { try { @@ -26,45 +15,11 @@ export default async function (fastify: FastifyInstance): Promise { const limit = parseInt(query.limit ?? '10', 10); const pos = query.pos; - if (!q) { - return res.send({ gifs: [], next: undefined }); - } - - const params = new URLSearchParams({ - q: q, - key: process.env.TENOR_API_KEY!, - limit: limit.toString(), - }); - - if (pos) { - params.append('pos', pos); - } + const result = await tenorClient.search({ q, limit, pos }); - const tenorRes = await fetch( - `https://tenor.googleapis.com/v2/search?${params.toString()}`, - ); - - const tenorJson = (await tenorRes.json()) as TenorResponse; - - const gifs: Gif[] = tenorJson.results.map((item: TenorGif) => { - const mediaFormats = item.media_formats as Record< - string, - { url?: string } - >; - return { - id: item.id, - url: mediaFormats.gif?.url || '', - preview: mediaFormats.mediumgif?.url || mediaFormats.gif?.url || '', - title: item.content_description || item.title || '', - }; - }); - - return res.send({ - gifs, - next: tenorJson.next, - }); - } catch (error) { - return res.status(500).send({ error: 'Failed to fetch gifs' }); + return res.send(result); + } catch { + return res.send({ gifs: [], next: undefined }); } }); fastify.post('/favorite', async (req, res) => { @@ -117,8 +72,8 @@ export default async function (fastify: FastifyInstance): Promise { } return res.send({ gifs }); - } catch (e) { - return res.status(500).send({ error: 'Failed to toggle favorite gif' }); + } catch { + return res.send({ gifs: [] }); } }); fastify.get('/favorites', async (req, res) => { @@ -141,8 +96,8 @@ export default async function (fastify: FastifyInstance): Promise { }); return res.send({ gifs: favorites }); - } catch (e) { - return res.status(500).send({ error: 'Failed to fetch favorite gifs' }); + } catch { + return res.send({ gifs: [] }); } }); } From 8511fcb3ba53782a461ab8a580445a94669a5da5 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 16 Dec 2025 16:17:57 +0700 Subject: [PATCH 06/11] add envs --- .infra/Pulumi.prod.yaml | 3 + __tests__/integrations/tenor/client.ts | 296 +++++++++++++++++++++++++ __tests__/routes/gifs.ts | 13 ++ src/integrations/tenor/clients.ts | 36 ++- 4 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 __tests__/integrations/tenor/client.ts diff --git a/.infra/Pulumi.prod.yaml b/.infra/Pulumi.prod.yaml index c842d17fa9..54ad8e341c 100644 --- a/.infra/Pulumi.prod.yaml +++ b/.infra/Pulumi.prod.yaml @@ -191,6 +191,9 @@ config: mapboxGeocodingUrl: https://api.mapbox.com/search/geocode/v6/forward slackBotToken: secure: AAABAH+UKbv4/Uoc9jYySYeAr7m+W7OCm/kQa9/3LCrKURh3TcPqgNPqF1ugLg31AAfsT4qVafpb0jiZm+ZCfDTYzrCfPmebxLjV0AAkHAy3kHgLK1v6YNGH + tenorApiKey: + secure: AAABAApa5GEV473b7T+WtKazqzPbyQDc7qFT3SZjqmU1LL1n24jwcpn2/bwRINsUz4vwduduqh0/0ey1JpoWfEFJ1LvxlLk= + tenorGifSearchUrl: https://tenor.googleapis.com/v2/search api:k8s: host: subs.daily.dev namespace: daily diff --git a/__tests__/integrations/tenor/client.ts b/__tests__/integrations/tenor/client.ts new file mode 100644 index 0000000000..20624ac31b --- /dev/null +++ b/__tests__/integrations/tenor/client.ts @@ -0,0 +1,296 @@ +import nock from 'nock'; +import { TenorClient } from '../../../src/integrations/tenor/clients'; +import { GarmrNoopService } from '../../../src/integrations/garmr'; +import { + deleteKeysByPattern, + getRedisObject, + getRedisObjectExpiry, +} from '../../../src/redis'; + +const TENOR_API_URL = 'https://tenor.googleapis.com'; +const TENOR_SEARCH_PATH = '/v2/search'; + +describe('TenorClient', () => { + const API_KEY = 'test-api-key'; + let client: TenorClient; + + beforeAll(() => { + process.env.TENOR_GIF_SEARCH_URL = `${TENOR_API_URL}${TENOR_SEARCH_PATH}`; + }); + + beforeEach(async () => { + nock.cleanAll(); + await deleteKeysByPattern('tenor:search:*'); + client = new TenorClient(API_KEY, { garmr: new GarmrNoopService() }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(async () => { + await deleteKeysByPattern('tenor:search:*'); + }); + + describe('search', () => { + const mockTenorResponse = { + results: [ + { + id: 'gif1', + title: 'Funny cat', + content_description: 'A funny cat', + url: 'https://tenor.com/gif1', + media_formats: { + gif: { url: 'https://media.tenor.com/gif1.gif' }, + mediumgif: { url: 'https://media.tenor.com/gif1-medium.gif' }, + }, + }, + { + id: 'gif2', + title: 'Dancing dog', + content_description: 'A dancing dog', + url: 'https://tenor.com/gif2', + media_formats: { + gif: { url: 'https://media.tenor.com/gif2.gif' }, + }, + }, + ], + next: 'next-page-token', + }; + + it('should return empty result for empty query', async () => { + const result = await client.search({ q: '' }); + + expect(result).toEqual({ gifs: [], next: undefined }); + }); + + it('should fetch from API on cache miss', async () => { + const scope = nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'cats', + key: API_KEY, + limit: '10', + }) + .reply(200, mockTenorResponse); + + const result = await client.search({ q: 'cats' }); + + expect(scope.isDone()).toBe(true); + expect(result.gifs).toHaveLength(2); + expect(result.gifs[0]).toEqual({ + id: 'gif1', + url: 'https://media.tenor.com/gif1.gif', + preview: 'https://media.tenor.com/gif1-medium.gif', + title: 'A funny cat', + }); + expect(result.next).toBe('next-page-token'); + }); + + it('should cache results after API call', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'dogs', + key: API_KEY, + limit: '10', + }) + .reply(200, mockTenorResponse); + + await client.search({ q: 'dogs' }); + + const cached = await getRedisObject('tenor:search:dogs:10'); + expect(cached).not.toBeNull(); + + const parsedCache = JSON.parse(cached!); + expect(parsedCache.gifs).toHaveLength(2); + expect(parsedCache.next).toBe('next-page-token'); + }); + + it('should return cached result on cache hit without calling API', async () => { + // First call - should hit API + const scope = nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'birds', + key: API_KEY, + limit: '10', + }) + .reply(200, mockTenorResponse); + + await client.search({ q: 'birds' }); + expect(scope.isDone()).toBe(true); + + // Second call - should use cache, not API + const secondScope = nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'birds', + key: API_KEY, + limit: '10', + }) + .reply(200, { results: [], next: undefined }); + + const result = await client.search({ q: 'birds' }); + + // API should NOT have been called + expect(secondScope.isDone()).toBe(false); + // Should return cached result + expect(result.gifs).toHaveLength(2); + expect(result.next).toBe('next-page-token'); + }); + + it('should cache with 3 hour TTL', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'fish', + key: API_KEY, + limit: '10', + }) + .reply(200, mockTenorResponse); + + await client.search({ q: 'fish' }); + + const ttl = await getRedisObjectExpiry('tenor:search:fish:10'); + const threeHoursInSeconds = 3 * 60 * 60; + + // TTL should be approximately 3 hours (allow 10 seconds tolerance) + expect(ttl).toBeLessThanOrEqual(threeHoursInSeconds); + expect(ttl).toBeGreaterThanOrEqual(threeHoursInSeconds - 10); + }); + + it('should NOT cache rate limited responses', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'ratelimited', + key: API_KEY, + limit: '10', + }) + .reply(429); + + const result = await client.search({ q: 'ratelimited' }); + + expect(result).toEqual({ gifs: [], next: undefined }); + + const cached = await getRedisObject('tenor:search:ratelimited:10'); + expect(cached).toBeNull(); + }); + + it('should preserve pagination position when rate limited', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'test', + key: API_KEY, + limit: '10', + pos: 'page-2', + }) + .reply(429); + + const result = await client.search({ q: 'test', pos: 'page-2' }); + + expect(result).toEqual({ gifs: [], next: 'page-2' }); + }); + + it('should use separate cache keys for different pagination positions', async () => { + // First page + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'animals', + key: API_KEY, + limit: '10', + }) + .reply(200, { + results: [mockTenorResponse.results[0]], + next: 'page-2', + }); + + // Second page + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'animals', + key: API_KEY, + limit: '10', + pos: 'page-2', + }) + .reply(200, { + results: [mockTenorResponse.results[1]], + next: 'page-3', + }); + + const page1 = await client.search({ q: 'animals' }); + const page2 = await client.search({ q: 'animals', pos: 'page-2' }); + + expect(page1.gifs).toHaveLength(1); + expect(page1.gifs[0].id).toBe('gif1'); + expect(page1.next).toBe('page-2'); + + expect(page2.gifs).toHaveLength(1); + expect(page2.gifs[0].id).toBe('gif2'); + expect(page2.next).toBe('page-3'); + + // Verify separate cache keys + const cachedPage1 = await getRedisObject('tenor:search:animals:10'); + const cachedPage2 = await getRedisObject( + 'tenor:search:animals:10:page-2', + ); + + expect(cachedPage1).not.toBeNull(); + expect(cachedPage2).not.toBeNull(); + expect(JSON.parse(cachedPage1!).next).toBe('page-2'); + expect(JSON.parse(cachedPage2!).next).toBe('page-3'); + }); + + it('should use separate cache keys for different limits', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'test', + key: API_KEY, + limit: '5', + }) + .reply(200, mockTenorResponse); + + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'test', + key: API_KEY, + limit: '20', + }) + .reply(200, mockTenorResponse); + + await client.search({ q: 'test', limit: 5 }); + await client.search({ q: 'test', limit: 20 }); + + const cached5 = await getRedisObject('tenor:search:test:5'); + const cached20 = await getRedisObject('tenor:search:test:20'); + + expect(cached5).not.toBeNull(); + expect(cached20).not.toBeNull(); + }); + + it('should throw error on API failure (non-429)', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'error', + key: API_KEY, + limit: '10', + }) + .reply(500, 'Internal Server Error'); + + await expect(client.search({ q: 'error' })).rejects.toThrow( + 'Tenor API error: 500 Internal Server Error', + ); + + // Should not cache error responses + const cached = await getRedisObject('tenor:search:error:10'); + expect(cached).toBeNull(); + }); + }); +}); diff --git a/__tests__/routes/gifs.ts b/__tests__/routes/gifs.ts index fbda7a350c..6afb44b907 100644 --- a/__tests__/routes/gifs.ts +++ b/__tests__/routes/gifs.ts @@ -112,6 +112,19 @@ describe('GET /gifs', () => { expect(body).toEqual({ gifs: [], next: undefined }); }); + + it('should preserve pagination position when rate limited', async () => { + // When rate limited, the client returns empty gifs but preserves the position + // so the user can retry the same page + mockTenorSearch.mockResolvedValue({ gifs: [], next: 'page-2' }); + + const { body } = await request(app.server) + .get('/gifs') + .query({ q: 'test', pos: 'page-2' }) + .expect(200); + + expect(body).toEqual({ gifs: [], next: 'page-2' }); + }); }); describe('POST /gifs/favorite', () => { diff --git a/src/integrations/tenor/clients.ts b/src/integrations/tenor/clients.ts index ffe0422215..33eb5164df 100644 --- a/src/integrations/tenor/clients.ts +++ b/src/integrations/tenor/clients.ts @@ -1,3 +1,4 @@ +import fetch from 'node-fetch'; import { GarmrService, IGarmrService, GarmrNoopService } from '../garmr'; import type { Gif } from '../../entity/UserIntegration'; import { @@ -7,6 +8,19 @@ import { TenorSearchResponse, TenorGif, } from './types'; +import { getRedisObject, setRedisObjectWithExpiry } from '../../redis'; + +const TENOR_CACHE_TTL_SECONDS = 3 * 60 * 60; // 3 hours +const TENOR_CACHE_KEY_PREFIX = 'tenor:search'; + +const generateCacheKey = (params: TenorSearchParams): string => { + const { q, limit = 10, pos } = params; + const parts = [TENOR_CACHE_KEY_PREFIX, q, limit.toString()]; + if (pos) { + parts.push(pos); + } + return parts.join(':'); +}; export class TenorClient implements ITenorClient { private readonly apiKey: string; @@ -29,6 +43,13 @@ export class TenorClient implements ITenorClient { return { gifs: [], next: undefined }; } + const cacheKey = generateCacheKey(params); + + const cached = await getRedisObject(cacheKey); + if (cached) { + return JSON.parse(cached) as TenorSearchResult; + } + return this.garmr.execute(async () => { const searchParams = new URLSearchParams({ q, @@ -44,6 +65,11 @@ export class TenorClient implements ITenorClient { `${process.env.TENOR_GIF_SEARCH_URL}?${searchParams.toString()}`, ); + if (response.status === 429) { + // if rate limited, return empty result but preserve pagination position + return { gifs: [], next: pos }; + } + if (!response.ok) { throw new Error( `Tenor API error: ${response.status} ${response.statusText}`, @@ -62,10 +88,18 @@ export class TenorClient implements ITenorClient { title: item.content_description || item.title || '', })); - return { + const result: TenorSearchResult = { gifs, next: data.next, }; + + await setRedisObjectWithExpiry( + cacheKey, + JSON.stringify(result), + TENOR_CACHE_TTL_SECONDS, + ); + + return result; }); } } From ec7a92db6652a4fcafcadbd49b9b9dc581686ef3 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 16 Dec 2025 17:24:59 +0700 Subject: [PATCH 07/11] add logging --- src/routes/gifs.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/routes/gifs.ts b/src/routes/gifs.ts index 5797e1cbcd..e5afd5a15f 100644 --- a/src/routes/gifs.ts +++ b/src/routes/gifs.ts @@ -6,6 +6,7 @@ import { type Gif, } from '../entity/UserIntegration'; import { tenorClient } from '../integrations/tenor'; +import { logger } from '../logger'; export default async function (fastify: FastifyInstance): Promise { fastify.get('/', async (req, res) => { @@ -18,7 +19,8 @@ export default async function (fastify: FastifyInstance): Promise { const result = await tenorClient.search({ q, limit, pos }); return res.send(result); - } catch { + } catch (err) { + logger.error({ err }, 'Error searching gifs'); return res.send({ gifs: [], next: undefined }); } }); @@ -72,7 +74,8 @@ export default async function (fastify: FastifyInstance): Promise { } return res.send({ gifs }); - } catch { + } catch (err) { + logger.error({ err }, 'Error toggling favorite gif'); return res.send({ gifs: [] }); } }); @@ -96,7 +99,9 @@ export default async function (fastify: FastifyInstance): Promise { }); return res.send({ gifs: favorites }); - } catch { + } catch (err) { + logger.error({ err }, 'Error getting favorited gifs'); + return res.send({ gifs: [] }); } }); From fba14174757bd4d448f4640f6bdac541314648d1 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Thu, 18 Dec 2025 01:08:22 +0700 Subject: [PATCH 08/11] check for authorization --- __tests__/routes/gifs.ts | 52 +++++++++++++++++++++++++++------------- src/routes/gifs.ts | 12 ++++++++++ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/__tests__/routes/gifs.ts b/__tests__/routes/gifs.ts index 6afb44b907..ab2187e8fe 100644 --- a/__tests__/routes/gifs.ts +++ b/__tests__/routes/gifs.ts @@ -37,10 +37,17 @@ beforeEach(async () => { }); describe('GET /gifs', () => { + it('should return 401 when user is not authenticated', async () => { + await request(app.server).get('/gifs').expect(401); + }); + it('should return empty gifs when no query is provided', async () => { mockTenorSearch.mockResolvedValue({ gifs: [], next: undefined }); - const { body } = await request(app.server).get('/gifs').expect(200); + const { body } = await authorizeRequest( + request(app.server).get('/gifs'), + '1', + ).expect(200); expect(body).toEqual({ gifs: [], next: undefined }); expect(mockTenorSearch).toHaveBeenCalledWith({ @@ -71,10 +78,10 @@ describe('GET /gifs', () => { next: 'next-page-token', }); - const { body } = await request(app.server) - .get('/gifs') - .query({ q: 'funny', limit: '20' }) - .expect(200); + const { body } = await authorizeRequest( + request(app.server).get('/gifs').query({ q: 'funny', limit: '20' }), + '1', + ).expect(200); expect(body).toEqual({ gifs: mockGifs, @@ -90,10 +97,10 @@ describe('GET /gifs', () => { it('should pass pagination position to tenor search', async () => { mockTenorSearch.mockResolvedValue({ gifs: [], next: undefined }); - await request(app.server) - .get('/gifs') - .query({ q: 'test', pos: 'page-token' }) - .expect(200); + await authorizeRequest( + request(app.server).get('/gifs').query({ q: 'test', pos: 'page-token' }), + '1', + ).expect(200); expect(mockTenorSearch).toHaveBeenCalledWith({ q: 'test', @@ -105,10 +112,10 @@ describe('GET /gifs', () => { it('should return empty gifs when tenor search fails', async () => { mockTenorSearch.mockRejectedValue(new Error('Tenor API error')); - const { body } = await request(app.server) - .get('/gifs') - .query({ q: 'test' }) - .expect(200); + const { body } = await authorizeRequest( + request(app.server).get('/gifs').query({ q: 'test' }), + '1', + ).expect(200); expect(body).toEqual({ gifs: [], next: undefined }); }); @@ -118,10 +125,10 @@ describe('GET /gifs', () => { // so the user can retry the same page mockTenorSearch.mockResolvedValue({ gifs: [], next: 'page-2' }); - const { body } = await request(app.server) - .get('/gifs') - .query({ q: 'test', pos: 'page-2' }) - .expect(200); + const { body } = await authorizeRequest( + request(app.server).get('/gifs').query({ q: 'test', pos: 'page-2' }), + '1', + ).expect(200); expect(body).toEqual({ gifs: [], next: 'page-2' }); }); @@ -135,6 +142,13 @@ describe('POST /gifs/favorite', () => { title: 'Funny cat', }; + it('should return 401 when user is not authenticated', async () => { + await request(app.server) + .post('/gifs/favorite') + .send(gifToFavorite) + .expect(401); + }); + it('should add a gif to favorites for authenticated user', async () => { const { body } = await authorizeRequest( request(app.server).post('/gifs/favorite').send(gifToFavorite), @@ -242,6 +256,10 @@ describe('POST /gifs/favorite', () => { }); describe('GET /gifs/favorites', () => { + it('should return 401 when user is not authenticated', async () => { + await request(app.server).get('/gifs/favorites').expect(401); + }); + it('should return empty array when user has no favorites', async () => { const { body } = await authorizeRequest( request(app.server).get('/gifs/favorites'), diff --git a/src/routes/gifs.ts b/src/routes/gifs.ts index e5afd5a15f..a8adfd9e19 100644 --- a/src/routes/gifs.ts +++ b/src/routes/gifs.ts @@ -10,6 +10,10 @@ import { logger } from '../logger'; export default async function (fastify: FastifyInstance): Promise { fastify.get('/', async (req, res) => { + if (!req.userId) { + return res.status(401).send(); + } + try { const query = req.query as { q?: string; limit?: string; pos?: string }; const q = query.q ?? ''; @@ -25,6 +29,10 @@ export default async function (fastify: FastifyInstance): Promise { } }); fastify.post('/favorite', async (req, res) => { + if (!req.userId) { + return res.status(401).send(); + } + try { const con = await createOrGetConnection(); @@ -80,6 +88,10 @@ export default async function (fastify: FastifyInstance): Promise { } }); fastify.get('/favorites', async (req, res) => { + if (!req.userId) { + return res.status(401).send(); + } + try { const con = await createOrGetConnection(); const existingFavorites = await con From 5cda20792e1866cca2884f96a48f1ce285cf54ad Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Thu, 18 Dec 2025 01:10:16 +0700 Subject: [PATCH 09/11] delete barrel file --- src/integrations/tenor/index.ts | 2 -- src/routes/gifs.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 src/integrations/tenor/index.ts diff --git a/src/integrations/tenor/index.ts b/src/integrations/tenor/index.ts deleted file mode 100644 index 08b95f7573..0000000000 --- a/src/integrations/tenor/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './types'; -export * from './clients'; diff --git a/src/routes/gifs.ts b/src/routes/gifs.ts index a8adfd9e19..38f2691bd7 100644 --- a/src/routes/gifs.ts +++ b/src/routes/gifs.ts @@ -5,8 +5,8 @@ import { UserIntegrationType, type Gif, } from '../entity/UserIntegration'; -import { tenorClient } from '../integrations/tenor'; import { logger } from '../logger'; +import { tenorClient } from '../integrations/tenor/clients'; export default async function (fastify: FastifyInstance): Promise { fastify.get('/', async (req, res) => { From f02ea90fd82b69f0b111f67d7071c026c2644dcc Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Thu, 18 Dec 2025 01:15:06 +0700 Subject: [PATCH 10/11] add env var --- .env | 2 ++ __tests__/integrations/tenor/client.ts | 2 +- __tests__/routes/gifs.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.env b/.env index d97109d409..40c430de82 100644 --- a/.env +++ b/.env @@ -94,3 +94,5 @@ EMPLOYMENT_AGREEMENT_BUCKET_NAME=other-bucket-name MAPBOX_GEOCODING_URL=https://api.mapbox.com/search/geocode/v6/forward MAPBOX_ACCESS_TOKEN=topsecret + +TENOR_GIF_SEARCH_URL=https://tenor.googleapis.com/v2/search diff --git a/__tests__/integrations/tenor/client.ts b/__tests__/integrations/tenor/client.ts index 20624ac31b..ccb24159e6 100644 --- a/__tests__/integrations/tenor/client.ts +++ b/__tests__/integrations/tenor/client.ts @@ -7,7 +7,7 @@ import { getRedisObjectExpiry, } from '../../../src/redis'; -const TENOR_API_URL = 'https://tenor.googleapis.com'; +const TENOR_API_URL = process.env.TENOR_GIF_SEARCH_URL!; const TENOR_SEARCH_PATH = '/v2/search'; describe('TenorClient', () => { diff --git a/__tests__/routes/gifs.ts b/__tests__/routes/gifs.ts index ab2187e8fe..ba521ad450 100644 --- a/__tests__/routes/gifs.ts +++ b/__tests__/routes/gifs.ts @@ -10,7 +10,7 @@ import { usersFixture } from '../fixture'; import { DataSource } from 'typeorm'; import createOrGetConnection from '../../src/db'; import request from 'supertest'; -import { tenorClient } from '../../src/integrations/tenor'; +import { tenorClient } from '../../src/integrations/tenor/clients'; let app: FastifyInstance; let con: DataSource; From 659f1c0727680266d70afb3dc628fbf670548892 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Thu, 18 Dec 2025 09:07:44 +0700 Subject: [PATCH 11/11] update path --- __tests__/routes/gifs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/routes/gifs.ts b/__tests__/routes/gifs.ts index ba521ad450..ec2f41d467 100644 --- a/__tests__/routes/gifs.ts +++ b/__tests__/routes/gifs.ts @@ -15,7 +15,7 @@ import { tenorClient } from '../../src/integrations/tenor/clients'; let app: FastifyInstance; let con: DataSource; -jest.mock('../../src/integrations/tenor', () => ({ +jest.mock('../../src/integrations/tenor/clients', () => ({ tenorClient: { search: jest.fn(), },