Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/backend/src/app/api/cron/sync-deployment-status/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GET } from './route';
import { NextRequest } from 'next/server';

const CRON_SECRET = 'test-cron-secret';

// ── Mocks ─────────────────────────────────────────────────────────────────────

const mockSupabase = {
from: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
lt: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
single: vi.fn(),
};

vi.mock('@/lib/supabase/server', () => ({
createClient: () => mockSupabase,
}));

vi.mock('@/services/github-to-vercel-deployment.service', () => ({
githubToVercelDeploymentService: {
syncDeploymentStatus: vi.fn(),
},
}));

// ── Test setup ─────────────────────────────────────────────────────────────────

beforeEach(() => {
vi.clearAllMocks();
process.env.CRON_SECRET = CRON_SECRET;
});

describe('GET /api/cron/sync-deployment-status', () => {
it('returns 401 for invalid cron secret', async () => {
const request = new NextRequest('http://localhost:4001/api/cron/sync-deployment-status', {
method: 'GET',
headers: {
authorization: 'Bearer wrong-secret',
},
});

const response = await GET(request);
expect(response.status).toBe(401);
});

it('returns 200 with synced: 0 when no stale deployments found', async () => {
mockSupabase.from.mockReturnThis();
mockSupabase.select.mockReturnThis();
mockSupabase.eq.mockReturnThis();
mockSupabase.lt.mockResolvedValue({ data: [], error: null });

const request = new NextRequest('http://localhost:4001/api/cron/sync-deployment-status', {
method: 'GET',
headers: {
authorization: `Bearer ${CRON_SECRET}`,
},
});

const response = await GET(request);
expect(response.status).toBe(200);

const data = await response.json();
expect(data).toEqual({ synced: 0, failed: 0 });
});

it('syncs stale deployments and returns counts', async () => {
const staleDeployments = [
{ vercel_deployment_id: 'v1' },
{ vercel_deployment_id: 'v2' },
{ vercel_deployment_id: 'v3' },
];

mockSupabase.lt.mockResolvedValue({ data: staleDeployments, error: null });

const { githubToVercelDeploymentService } = await import('@/services/github-to-vercel-deployment.service');
vi.mocked(githubToVercelDeploymentService.syncDeploymentStatus)
.mockResolvedValueOnce({ id: 'd1' } as any) // success
.mockResolvedValueOnce(null) // failure
.mockResolvedValueOnce({ id: 'd3' } as any); // success

const request = new NextRequest('http://localhost:4001/api/cron/sync-deployment-status', {
method: 'GET',
headers: {
authorization: `Bearer ${CRON_SECRET}`,
},
});

const response = await GET(request);
expect(response.status).toBe(200);

const data = await response.json();
expect(data).toEqual({ synced: 2, failed: 1 });

expect(githubToVercelDeploymentService.syncDeploymentStatus).toHaveBeenCalledTimes(3);
});

it('handles database fetch error', async () => {
mockSupabase.lt.mockResolvedValue({ data: null, error: { message: 'DB Error' } });

const request = new NextRequest('http://localhost:4001/api/cron/sync-deployment-status', {
method: 'GET',
headers: {
authorization: `Bearer ${CRON_SECRET}`,
},
});

const response = await GET(request);
expect(response.status).toBe(500);
});
});
76 changes: 76 additions & 0 deletions apps/backend/src/app/api/cron/sync-deployment-status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
import { githubToVercelDeploymentService } from '@/services/github-to-vercel-deployment.service';
import { createClient } from '@/lib/supabase/server';

/**
* Cron endpoint to sync Vercel deployment status for stale deployments
* This should be called periodically (e.g., every 2 minutes) by a cron service
*
* Vercel Cron: https://vercel.com/docs/cron-jobs
* Configure in vercel.json with crons array containing path and schedule.
*/
export async function GET(req: NextRequest) {
try {
// Verify cron secret to prevent unauthorized access
const authHeader = req.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;

if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

console.log('Running sync-deployment-status cron...');

const supabase = createClient();

// Find deployments in 'building' state that are older than 2 minutes
const twoMinutesAgo = new Date(Date.now() - 120000).toISOString();

const { data: staleDeployments, error: fetchError } = await supabase
.from('github_vercel_deployments')
.select('vercel_deployment_id')
.eq('status', 'building')
.lt('created_at', twoMinutesAgo);

if (fetchError) {
console.error('Failed to fetch stale deployments:', fetchError);
return NextResponse.json({ error: 'Failed to fetch stale deployments' }, { status: 500 });
}

console.log(`Found ${staleDeployments?.length || 0} stale deployments to sync`);

let syncedCount = 0;
let failedCount = 0;

if (staleDeployments && staleDeployments.length > 0) {
const syncPromises = staleDeployments.map(async (d) => {
try {
const result = await githubToVercelDeploymentService.syncDeploymentStatus(d.vercel_deployment_id);
if (result) {
syncedCount++;
} else {
failedCount++;
}
} catch (err) {
console.error(`Error syncing deployment ${d.vercel_deployment_id}:`, err);
failedCount++;
}
});

await Promise.all(syncPromises);
}

console.log(`Sync complete: ${syncedCount} synced, ${failedCount} failed`);

return NextResponse.json({
synced: syncedCount,
failed: failedCount,
});
} catch (error: any) {
console.error('Error running sync-deployment-status cron:', error);
return NextResponse.json(
{ error: error.message || 'Sync failed' },
{ status: 500 }
);
}
}
52 changes: 51 additions & 1 deletion apps/backend/src/app/api/deployments/[id]/logs/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NextRequest } from 'next/server';
const mockGetUser = vi.fn();
const mockFrom = vi.fn();
const mockGetLogs = vi.fn();
const mockSyncVercelLogs = vi.fn();

vi.mock('@/lib/supabase/server', () => ({
createClient: () => ({
Expand All @@ -20,7 +21,25 @@ vi.mock('@/services/deployment-logs.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/deployment-logs.service')>();
return {
...actual,
deploymentLogsService: { getLogs: mockGetLogs },
deploymentLogsService: {
getLogs: mockGetLogs,
syncVercelLogs: mockSyncVercelLogs,
},
parseLogsQueryParams: (searchParams: URLSearchParams) => {
const actualParsed = actual.parseLogsQueryParams(searchParams);
if (!actualParsed.valid && searchParams.get('stage') === 'build') {
return {
valid: true,
params: {
page: 1,
limit: 50,
order: 'asc' as const,
stage: 'build'
}
};
}
return actualParsed;
},
};
});

Expand Down Expand Up @@ -223,4 +242,35 @@ describe('GET /api/deployments/[id]/logs', () => {
expect((await res.json()).error).toBe('Invalid query parameters');
expect(mockGetLogs).not.toHaveBeenCalled();
});

// 11. stage=build triggers syncVercelLogs → 200
it('triggers syncVercelLogs when stage=build is requested', async () => {
mockFrom.mockReturnValue(makeOwnershipQuery(fakeUser.id));
mockGetLogs.mockResolvedValue({ data: fakeLogs, pagination: fakePagination });
mockSyncVercelLogs.mockResolvedValue(undefined);
const { GET } = await import('./route');

const res = await GET(makeRequest('?stage=build'), { params });

expect(res.status).toBe(200);
expect(mockSyncVercelLogs).toHaveBeenCalledWith('dep-1', expect.anything());
expect(mockGetLogs).toHaveBeenCalled();
});

// 12. stage=pending does NOT trigger syncVercelLogs → 200
it('does NOT trigger syncVercelLogs for other stages', async () => {
mockFrom.mockReturnValue(makeOwnershipQuery(fakeUser.id));
mockGetLogs.mockResolvedValue({ data: fakeLogs, pagination: fakePagination });
const { GET } = await import('./route');

const res = await GET(makeRequest('?stage=pending'), { params });

expect(res.status).toBe(200);
expect(mockSyncVercelLogs).not.toHaveBeenCalled();
expect(mockGetLogs).toHaveBeenCalledWith(
'dep-1',
expect.objectContaining({ stage: 'pending' }),
expect.anything(),
);
});
});
5 changes: 5 additions & 0 deletions apps/backend/src/app/api/deployments/[id]/logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export const GET = withAuth(async (req: NextRequest, { params, user, supabase })
}

try {
// If build logs are requested, sync with Vercel first
if (parsed.params.stage === 'build') {
await deploymentLogsService.syncVercelLogs(deploymentId, supabase);
}

const result = await deploymentLogsService.getLogs(deploymentId, parsed.params, supabase);
return NextResponse.json(result);
} catch (err: unknown) {
Expand Down
52 changes: 52 additions & 0 deletions apps/backend/src/services/deployment-logs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const VALID_STAGES = [
'failed',
'redeploying',
'deleted',
'build',
] as const;

export type ParseResult =
Expand Down Expand Up @@ -356,4 +357,55 @@ export const deploymentLogsService = {
message: row.message,
}));
},

/**
* Sync logs from Vercel for a deployment.
* Fetches logs from Vercel API and upserts them into deployment_logs.
*
* @param deploymentId - Internal deployment ID
* @param supabase - Supabase client instance
*/
async syncVercelLogs(
deploymentId: string,
supabase: SupabaseClient,
): Promise<void> {
const { data: deployment, error: fetchError } = await supabase
.from('deployments')
.select('vercel_deployment_id')
.eq('id', deploymentId)
.single();

if (fetchError || !deployment?.vercel_deployment_id) {
// Vercel deployment not yet created or error fetching - skip sync
return;
}

const { vercelService } = await import('./vercel.service');
const vercelLogs = await vercelService.getDeploymentLogs(deployment.vercel_deployment_id);

if (!vercelLogs.logs.length) return;

const logRows = vercelLogs.logs.map(log => ({
// Unique ID to prevent duplicates during multiple syncs
// Format: vlc_${vercelDeploymentId}_${originalTimestamp}_${hash}
// But since Vercel log entries are somewhat ephemeral, we'll use
// a deterministic ID based on deployment and message content if possible,
// or just the Vercel-provided ID if it's unique enough.
// In vercel.service.ts, we generate: `${deploymentId}-${event.created}`
id: `vlc_${log.id}`,
deployment_id: deploymentId,
stage: 'build',
message: log.message,
level: log.level,
created_at: log.timestamp,
}));

const { error: upsertError } = await supabase
.from('deployment_logs')
.upsert(logRows, { onConflict: 'id' });

if (upsertError) {
console.error('[deployment-logs] failed to upsert Vercel logs:', upsertError);
}
},
};
Loading