Skip to content

Commit 15894f4

Browse files
feat: add timestamp to browser automations
[dev] [Marfuen] mariano/sale-45-screenshot-automation-feature-improvements
1 parent ba3718f commit 15894f4

18 files changed

Lines changed: 2229 additions & 71 deletions
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// apps/api/src/browserbase/browserbase.controller.spec.ts
2+
jest.mock('@db', () => ({
3+
db: {},
4+
Prisma: {
5+
PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error {
6+
code: string;
7+
constructor(message: string, { code }: { code: string }) {
8+
super(message);
9+
this.code = code;
10+
}
11+
},
12+
},
13+
}));
14+
15+
jest.mock('../auth/auth.server', () => ({
16+
auth: {
17+
api: {
18+
getSession: jest.fn(),
19+
},
20+
},
21+
}));
22+
23+
jest.mock('@trycompai/auth', () => ({
24+
statement: {},
25+
BUILT_IN_ROLE_PERMISSIONS: {},
26+
}));
27+
28+
import { Test } from '@nestjs/testing';
29+
import { NotFoundException } from '@nestjs/common';
30+
import type { Response } from 'express';
31+
import { BrowserbaseController } from './browserbase.controller';
32+
import { BrowserbaseService } from './browserbase.service';
33+
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
34+
import { PermissionGuard } from '../auth/permission.guard';
35+
36+
describe('BrowserbaseController.redirectToScreenshot', () => {
37+
let controller: BrowserbaseController;
38+
let service: jest.Mocked<Pick<BrowserbaseService, 'getScreenshotRedirectUrl'>>;
39+
40+
beforeEach(async () => {
41+
service = {
42+
getScreenshotRedirectUrl: jest.fn(),
43+
} as jest.Mocked<Pick<BrowserbaseService, 'getScreenshotRedirectUrl'>>;
44+
45+
const moduleRef = await Test.createTestingModule({
46+
controllers: [BrowserbaseController],
47+
providers: [{ provide: BrowserbaseService, useValue: service }],
48+
})
49+
.overrideGuard(HybridAuthGuard)
50+
.useValue({ canActivate: () => true })
51+
.overrideGuard(PermissionGuard)
52+
.useValue({ canActivate: () => true })
53+
.compile();
54+
55+
controller = moduleRef.get(BrowserbaseController);
56+
});
57+
58+
const makeRes = () => {
59+
const res: Partial<Response> = { redirect: jest.fn() };
60+
return res as Response & { redirect: jest.Mock };
61+
};
62+
63+
it('302-redirects to the freshly minted presigned URL', async () => {
64+
service.getScreenshotRedirectUrl.mockResolvedValue(
65+
'https://s3.example.com/fresh-signed',
66+
);
67+
const res = makeRes();
68+
69+
await controller.redirectToScreenshot('bar_1', 'org_1', res);
70+
71+
expect(service.getScreenshotRedirectUrl).toHaveBeenCalledWith({
72+
runId: 'bar_1',
73+
organizationId: 'org_1',
74+
download: false,
75+
});
76+
expect(res.redirect).toHaveBeenCalledWith(302, 'https://s3.example.com/fresh-signed');
77+
});
78+
79+
it('passes download=true to the service when the query param is "true"', async () => {
80+
service.getScreenshotRedirectUrl.mockResolvedValue(
81+
'https://s3.example.com/fresh-signed-attachment',
82+
);
83+
const res = makeRes();
84+
85+
await controller.redirectToScreenshot('bar_1', 'org_1', res, 'true');
86+
87+
expect(service.getScreenshotRedirectUrl).toHaveBeenCalledWith({
88+
runId: 'bar_1',
89+
organizationId: 'org_1',
90+
download: true,
91+
});
92+
});
93+
94+
it('propagates NotFoundException when the service throws', async () => {
95+
service.getScreenshotRedirectUrl.mockRejectedValue(
96+
new NotFoundException('Screenshot not found'),
97+
);
98+
const res = makeRes();
99+
100+
await expect(
101+
controller.redirectToScreenshot('bar_missing', 'org_1', res),
102+
).rejects.toBeInstanceOf(NotFoundException);
103+
expect(res.redirect).not.toHaveBeenCalled();
104+
});
105+
});

apps/api/src/browserbase/browserbase.controller.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
Param,
77
Patch,
88
Post,
9+
Query,
10+
Res,
911
UseGuards,
1012
} from '@nestjs/common';
13+
import type { Response } from 'express';
1114
import {
1215
ApiOperation,
1316
ApiParam,
@@ -371,4 +374,28 @@ export class BrowserbaseController {
371374
runId,
372375
)) as BrowserAutomationRunResponseDto | null;
373376
}
377+
378+
@Get('runs/:runId/screenshot')
379+
@RequirePermission('task', 'read')
380+
@ApiOperation({
381+
summary: 'Redirect to a freshly signed screenshot URL',
382+
description:
383+
'Issues a 302 redirect to a newly signed S3 URL so that "Open full size" links never serve an expired URL. Pass ?download=true to force an attachment download.',
384+
})
385+
@ApiParam({ name: 'runId', description: 'Run ID' })
386+
@ApiResponse({ status: 302, description: 'Redirect to signed S3 URL' })
387+
@ApiResponse({ status: 404, description: 'Run or screenshot not found' })
388+
async redirectToScreenshot(
389+
@Param('runId') runId: string,
390+
@OrganizationId() organizationId: string,
391+
@Res() res: Response,
392+
@Query('download') download?: string,
393+
): Promise<void> {
394+
const url = await this.browserbaseService.getScreenshotRedirectUrl({
395+
runId,
396+
organizationId,
397+
download: download === 'true' || download === '1',
398+
});
399+
res.redirect(302, url);
400+
}
374401
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// apps/api/src/browserbase/browserbase.service.spec.ts
2+
import { Test } from '@nestjs/testing';
3+
import { NotFoundException } from '@nestjs/common';
4+
import { BrowserbaseService } from './browserbase.service';
5+
6+
jest.mock('@db', () => ({
7+
db: {
8+
browserAutomationRun: {
9+
findUnique: jest.fn(),
10+
},
11+
},
12+
}));
13+
14+
jest.mock('@/app/s3', () => ({
15+
getSignedUrl: jest.fn().mockResolvedValue('https://s3.example.com/signed'),
16+
s3Client: { send: jest.fn() },
17+
BUCKET_NAME: 'test-bucket',
18+
}));
19+
20+
import { db } from '@db';
21+
import { getSignedUrl } from '@/app/s3';
22+
23+
describe('BrowserbaseService.getScreenshotRedirectUrl', () => {
24+
let service: BrowserbaseService;
25+
26+
beforeEach(async () => {
27+
jest.clearAllMocks();
28+
const moduleRef = await Test.createTestingModule({
29+
providers: [BrowserbaseService],
30+
}).compile();
31+
service = moduleRef.get(BrowserbaseService);
32+
});
33+
34+
it('returns a freshly minted presigned URL for an in-scope run', async () => {
35+
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
36+
id: 'bar_1',
37+
screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg',
38+
automation: { task: { organizationId: 'org_1' } },
39+
});
40+
41+
const url = await service.getScreenshotRedirectUrl({
42+
runId: 'bar_1',
43+
organizationId: 'org_1',
44+
});
45+
46+
expect(url).toBe('https://s3.example.com/signed');
47+
expect(db.browserAutomationRun.findUnique).toHaveBeenCalledWith({
48+
where: { id: 'bar_1' },
49+
include: { automation: { include: { task: true } } },
50+
});
51+
});
52+
53+
it('throws NotFoundException when the run does not exist', async () => {
54+
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue(null);
55+
56+
await expect(
57+
service.getScreenshotRedirectUrl({
58+
runId: 'bar_missing',
59+
organizationId: 'org_1',
60+
}),
61+
).rejects.toBeInstanceOf(NotFoundException);
62+
});
63+
64+
it('throws NotFoundException when the run belongs to a different org', async () => {
65+
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
66+
id: 'bar_1',
67+
screenshotUrl: 'browser-automations/org_2/bau_1/bar_1.jpg',
68+
automation: { task: { organizationId: 'org_2' } },
69+
});
70+
71+
await expect(
72+
service.getScreenshotRedirectUrl({
73+
runId: 'bar_1',
74+
organizationId: 'org_1',
75+
}),
76+
).rejects.toBeInstanceOf(NotFoundException);
77+
});
78+
79+
it('throws NotFoundException when the run has no screenshot', async () => {
80+
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
81+
id: 'bar_1',
82+
screenshotUrl: null,
83+
automation: { task: { organizationId: 'org_1' } },
84+
});
85+
86+
await expect(
87+
service.getScreenshotRedirectUrl({
88+
runId: 'bar_1',
89+
organizationId: 'org_1',
90+
}),
91+
).rejects.toBeInstanceOf(NotFoundException);
92+
});
93+
94+
it('signs the URL without Content-Disposition when download is falsy', async () => {
95+
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
96+
id: 'bar_1',
97+
screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg',
98+
automation: { task: { organizationId: 'org_1' } },
99+
});
100+
101+
await service.getScreenshotRedirectUrl({
102+
runId: 'bar_1',
103+
organizationId: 'org_1',
104+
});
105+
106+
const command = (getSignedUrl as jest.Mock).mock.calls[0][1];
107+
expect(command.input.ResponseContentDisposition).toBeUndefined();
108+
});
109+
110+
it('signs the URL with attachment Content-Disposition when download is true', async () => {
111+
(db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({
112+
id: 'bar_1',
113+
screenshotUrl: 'browser-automations/org_1/bau_1/bar_1.jpg',
114+
automation: { task: { organizationId: 'org_1' } },
115+
});
116+
117+
await service.getScreenshotRedirectUrl({
118+
runId: 'bar_1',
119+
organizationId: 'org_1',
120+
download: true,
121+
});
122+
123+
const command = (getSignedUrl as jest.Mock).mock.calls[0][1];
124+
expect(command.input.ResponseContentDisposition).toBe(
125+
'attachment; filename="screenshot-bar_1.jpg"',
126+
);
127+
});
128+
});

0 commit comments

Comments
 (0)