Skip to content

Commit 519ff85

Browse files
feat: gif endpoints (#3348)
1 parent d62129d commit 519ff85

9 files changed

Lines changed: 950 additions & 1 deletion

File tree

.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,5 @@ EMPLOYMENT_AGREEMENT_BUCKET_NAME=other-bucket-name
9595

9696
MAPBOX_GEOCODING_URL=https://api.mapbox.com/search/geocode/v6/forward
9797
MAPBOX_ACCESS_TOKEN=topsecret
98+
99+
TENOR_GIF_SEARCH_URL=https://tenor.googleapis.com/v2/search

.infra/Pulumi.prod.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ config:
2525
api:enablePersonalizedDigest: 'true'
2626
api:env:
2727
anthropicApiUrl: https://api.anthropic.com/v1/messages
28-
anthropicVersion: "2023-06-01"
28+
anthropicVersion: '2023-06-01'
2929
anthropicApiKey:
3030
secure: AAABAHEzcSWbWl8xVDhOLgPCopvQnihNX6MIzyG/JbaYeGA3p1qrJ3UEQhOvBg7kMoIbx9u+0CRC102IldakBAlxx0l9l9kCDSE/JqqfCfjiLT5mjdLISRE5q2dQz+MaqqVzqm0MzaIQQkWBmOxeJAxRxQ/+/dWdla8RMWKG+Q7PnrH8vS8PeNKASRQ=
3131
accessSecret:
@@ -191,6 +191,9 @@ config:
191191
mapboxGeocodingUrl: https://api.mapbox.com/search/geocode/v6/forward
192192
slackBotToken:
193193
secure: AAABAH+UKbv4/Uoc9jYySYeAr7m+W7OCm/kQa9/3LCrKURh3TcPqgNPqF1ugLg31AAfsT4qVafpb0jiZm+ZCfDTYzrCfPmebxLjV0AAkHAy3kHgLK1v6YNGH
194+
tenorApiKey:
195+
secure: AAABAApa5GEV473b7T+WtKazqzPbyQDc7qFT3SZjqmU1LL1n24jwcpn2/bwRINsUz4vwduduqh0/0ey1JpoWfEFJ1LvxlLk=
196+
tenorGifSearchUrl: https://tenor.googleapis.com/v2/search
194197
gondulOpportunityServerOrigin:
195198
secure: AAABADfUUbSK5WvKYJM6lgpfvaPChqDYdUolX6Kv6/TMy0D7hWJWPbkipmq9W8vXbkuM97XzU2RlJGFB/9eiVfEdB6jBvu2C9iu5GbJ276481jB8Q+lw1do=
196199
intercomSecretKey:
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import nock from 'nock';
2+
import { TenorClient } from '../../../src/integrations/tenor/clients';
3+
import { GarmrNoopService } from '../../../src/integrations/garmr';
4+
import {
5+
deleteKeysByPattern,
6+
getRedisObject,
7+
getRedisObjectExpiry,
8+
} from '../../../src/redis';
9+
10+
const TENOR_API_URL = process.env.TENOR_GIF_SEARCH_URL!;
11+
const TENOR_SEARCH_PATH = '/v2/search';
12+
13+
describe('TenorClient', () => {
14+
const API_KEY = 'test-api-key';
15+
let client: TenorClient;
16+
17+
beforeAll(() => {
18+
process.env.TENOR_GIF_SEARCH_URL = `${TENOR_API_URL}${TENOR_SEARCH_PATH}`;
19+
});
20+
21+
beforeEach(async () => {
22+
nock.cleanAll();
23+
await deleteKeysByPattern('tenor:search:*');
24+
client = new TenorClient(API_KEY, { garmr: new GarmrNoopService() });
25+
});
26+
27+
afterEach(() => {
28+
nock.cleanAll();
29+
});
30+
31+
afterAll(async () => {
32+
await deleteKeysByPattern('tenor:search:*');
33+
});
34+
35+
describe('search', () => {
36+
const mockTenorResponse = {
37+
results: [
38+
{
39+
id: 'gif1',
40+
title: 'Funny cat',
41+
content_description: 'A funny cat',
42+
url: 'https://tenor.com/gif1',
43+
media_formats: {
44+
gif: { url: 'https://media.tenor.com/gif1.gif' },
45+
mediumgif: { url: 'https://media.tenor.com/gif1-medium.gif' },
46+
},
47+
},
48+
{
49+
id: 'gif2',
50+
title: 'Dancing dog',
51+
content_description: 'A dancing dog',
52+
url: 'https://tenor.com/gif2',
53+
media_formats: {
54+
gif: { url: 'https://media.tenor.com/gif2.gif' },
55+
},
56+
},
57+
],
58+
next: 'next-page-token',
59+
};
60+
61+
it('should return empty result for empty query', async () => {
62+
const result = await client.search({ q: '' });
63+
64+
expect(result).toEqual({ gifs: [], next: undefined });
65+
});
66+
67+
it('should fetch from API on cache miss', async () => {
68+
const scope = nock(TENOR_API_URL)
69+
.get(TENOR_SEARCH_PATH)
70+
.query({
71+
q: 'cats',
72+
key: API_KEY,
73+
limit: '10',
74+
})
75+
.reply(200, mockTenorResponse);
76+
77+
const result = await client.search({ q: 'cats' });
78+
79+
expect(scope.isDone()).toBe(true);
80+
expect(result.gifs).toHaveLength(2);
81+
expect(result.gifs[0]).toEqual({
82+
id: 'gif1',
83+
url: 'https://media.tenor.com/gif1.gif',
84+
preview: 'https://media.tenor.com/gif1-medium.gif',
85+
title: 'A funny cat',
86+
});
87+
expect(result.next).toBe('next-page-token');
88+
});
89+
90+
it('should cache results after API call', async () => {
91+
nock(TENOR_API_URL)
92+
.get(TENOR_SEARCH_PATH)
93+
.query({
94+
q: 'dogs',
95+
key: API_KEY,
96+
limit: '10',
97+
})
98+
.reply(200, mockTenorResponse);
99+
100+
await client.search({ q: 'dogs' });
101+
102+
const cached = await getRedisObject('tenor:search:dogs:10');
103+
expect(cached).not.toBeNull();
104+
105+
const parsedCache = JSON.parse(cached!);
106+
expect(parsedCache.gifs).toHaveLength(2);
107+
expect(parsedCache.next).toBe('next-page-token');
108+
});
109+
110+
it('should return cached result on cache hit without calling API', async () => {
111+
// First call - should hit API
112+
const scope = nock(TENOR_API_URL)
113+
.get(TENOR_SEARCH_PATH)
114+
.query({
115+
q: 'birds',
116+
key: API_KEY,
117+
limit: '10',
118+
})
119+
.reply(200, mockTenorResponse);
120+
121+
await client.search({ q: 'birds' });
122+
expect(scope.isDone()).toBe(true);
123+
124+
// Second call - should use cache, not API
125+
const secondScope = nock(TENOR_API_URL)
126+
.get(TENOR_SEARCH_PATH)
127+
.query({
128+
q: 'birds',
129+
key: API_KEY,
130+
limit: '10',
131+
})
132+
.reply(200, { results: [], next: undefined });
133+
134+
const result = await client.search({ q: 'birds' });
135+
136+
// API should NOT have been called
137+
expect(secondScope.isDone()).toBe(false);
138+
// Should return cached result
139+
expect(result.gifs).toHaveLength(2);
140+
expect(result.next).toBe('next-page-token');
141+
});
142+
143+
it('should cache with 3 hour TTL', async () => {
144+
nock(TENOR_API_URL)
145+
.get(TENOR_SEARCH_PATH)
146+
.query({
147+
q: 'fish',
148+
key: API_KEY,
149+
limit: '10',
150+
})
151+
.reply(200, mockTenorResponse);
152+
153+
await client.search({ q: 'fish' });
154+
155+
const ttl = await getRedisObjectExpiry('tenor:search:fish:10');
156+
const threeHoursInSeconds = 3 * 60 * 60;
157+
158+
// TTL should be approximately 3 hours (allow 10 seconds tolerance)
159+
expect(ttl).toBeLessThanOrEqual(threeHoursInSeconds);
160+
expect(ttl).toBeGreaterThanOrEqual(threeHoursInSeconds - 10);
161+
});
162+
163+
it('should NOT cache rate limited responses', async () => {
164+
nock(TENOR_API_URL)
165+
.get(TENOR_SEARCH_PATH)
166+
.query({
167+
q: 'ratelimited',
168+
key: API_KEY,
169+
limit: '10',
170+
})
171+
.reply(429);
172+
173+
const result = await client.search({ q: 'ratelimited' });
174+
175+
expect(result).toEqual({ gifs: [], next: undefined });
176+
177+
const cached = await getRedisObject('tenor:search:ratelimited:10');
178+
expect(cached).toBeNull();
179+
});
180+
181+
it('should preserve pagination position when rate limited', async () => {
182+
nock(TENOR_API_URL)
183+
.get(TENOR_SEARCH_PATH)
184+
.query({
185+
q: 'test',
186+
key: API_KEY,
187+
limit: '10',
188+
pos: 'page-2',
189+
})
190+
.reply(429);
191+
192+
const result = await client.search({ q: 'test', pos: 'page-2' });
193+
194+
expect(result).toEqual({ gifs: [], next: 'page-2' });
195+
});
196+
197+
it('should use separate cache keys for different pagination positions', async () => {
198+
// First page
199+
nock(TENOR_API_URL)
200+
.get(TENOR_SEARCH_PATH)
201+
.query({
202+
q: 'animals',
203+
key: API_KEY,
204+
limit: '10',
205+
})
206+
.reply(200, {
207+
results: [mockTenorResponse.results[0]],
208+
next: 'page-2',
209+
});
210+
211+
// Second page
212+
nock(TENOR_API_URL)
213+
.get(TENOR_SEARCH_PATH)
214+
.query({
215+
q: 'animals',
216+
key: API_KEY,
217+
limit: '10',
218+
pos: 'page-2',
219+
})
220+
.reply(200, {
221+
results: [mockTenorResponse.results[1]],
222+
next: 'page-3',
223+
});
224+
225+
const page1 = await client.search({ q: 'animals' });
226+
const page2 = await client.search({ q: 'animals', pos: 'page-2' });
227+
228+
expect(page1.gifs).toHaveLength(1);
229+
expect(page1.gifs[0].id).toBe('gif1');
230+
expect(page1.next).toBe('page-2');
231+
232+
expect(page2.gifs).toHaveLength(1);
233+
expect(page2.gifs[0].id).toBe('gif2');
234+
expect(page2.next).toBe('page-3');
235+
236+
// Verify separate cache keys
237+
const cachedPage1 = await getRedisObject('tenor:search:animals:10');
238+
const cachedPage2 = await getRedisObject(
239+
'tenor:search:animals:10:page-2',
240+
);
241+
242+
expect(cachedPage1).not.toBeNull();
243+
expect(cachedPage2).not.toBeNull();
244+
expect(JSON.parse(cachedPage1!).next).toBe('page-2');
245+
expect(JSON.parse(cachedPage2!).next).toBe('page-3');
246+
});
247+
248+
it('should use separate cache keys for different limits', async () => {
249+
nock(TENOR_API_URL)
250+
.get(TENOR_SEARCH_PATH)
251+
.query({
252+
q: 'test',
253+
key: API_KEY,
254+
limit: '5',
255+
})
256+
.reply(200, mockTenorResponse);
257+
258+
nock(TENOR_API_URL)
259+
.get(TENOR_SEARCH_PATH)
260+
.query({
261+
q: 'test',
262+
key: API_KEY,
263+
limit: '20',
264+
})
265+
.reply(200, mockTenorResponse);
266+
267+
await client.search({ q: 'test', limit: 5 });
268+
await client.search({ q: 'test', limit: 20 });
269+
270+
const cached5 = await getRedisObject('tenor:search:test:5');
271+
const cached20 = await getRedisObject('tenor:search:test:20');
272+
273+
expect(cached5).not.toBeNull();
274+
expect(cached20).not.toBeNull();
275+
});
276+
277+
it('should throw error on API failure (non-429)', async () => {
278+
nock(TENOR_API_URL)
279+
.get(TENOR_SEARCH_PATH)
280+
.query({
281+
q: 'error',
282+
key: API_KEY,
283+
limit: '10',
284+
})
285+
.reply(500, 'Internal Server Error');
286+
287+
await expect(client.search({ q: 'error' })).rejects.toThrow(
288+
'Tenor API error: 500 Internal Server Error',
289+
);
290+
291+
// Should not cache error responses
292+
const cached = await getRedisObject('tenor:search:error:10');
293+
expect(cached).toBeNull();
294+
});
295+
});
296+
});

0 commit comments

Comments
 (0)