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
85 changes: 85 additions & 0 deletions src/app/api/jobs/cards/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { jobListItemSchema } from '@/features/jobs/schemas';
import { makeJobDto } from '@/features/jobs/test/job-fixtures';
import { GET } from './route';

vi.mock('@/lib/env/client', () => ({
clientEnv: { MW_URL: 'https://middleware.test' },
}));

afterEach(() => vi.unstubAllGlobals());
const request = (id: string) =>
GET(new Request('https://jobstash.xyz/api/jobs/cards/job-1'), {
params: Promise.resolve({ id }),
});

describe('GET /api/jobs/cards/[id]', () => {
it('reuses the job details endpoint and returns a valid card with the correct organization link', async () => {
const fetch = vi.fn().mockResolvedValue(Response.json(makeJobDto()));
vi.stubGlobal('fetch', fetch);
const response = await request('job-1');
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-store');
const body = jobListItemSchema.parse(await response.json());
expect(body.id).toBe('job-1');
expect(body.organization?.href).toBe('/o-acme~org-acme');
expect(fetch).toHaveBeenCalledWith(
'https://middleware.test/jobs/details/job-1',
{ cache: 'no-store' },
);
});

it.each(['../admin', 'job?token=value', '', 'a'.repeat(101)])(
'rejects invalid identifiers before calling middleware: %s',
async (id) => {
const fetch = vi.fn();
vi.stubGlobal('fetch', fetch);
const response = await request(id);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: 'Job not found' });
expect(fetch).not.toHaveBeenCalled();
},
);

it('preserves not found responses without forwarding upstream response bodies', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
Response.json({ error: 'Internal detail' }, { status: 404 }),
),
);
const response = await request('job-1');
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: 'Job not found' });
});

it.each([
'upstream failure',
'wrong job',
'malformed job',
'network failure',
])('returns a safe response for %s', async (failure) => {
const fetch = vi.fn();
if (failure === 'network failure')
fetch.mockRejectedValue(new Error('Database credential / private host'));
else
fetch.mockResolvedValue(
failure === 'upstream failure'
? Response.json(
{ error: 'Database credential / private host' },
{ status: 500 },
)
: Response.json(
failure === 'wrong job'
? makeJobDto('another-job')
: { shortUUID: 'job-1' },
),
);
vi.stubGlobal('fetch', fetch);
const response = await request('job-1');
expect(response.status).toBe(502);
expect(await response.json()).toEqual({ error: 'Job unavailable' });
});
});
31 changes: 31 additions & 0 deletions src/app/api/jobs/cards/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { clientEnv } from '@/lib/env/client';
import { jobListItemDto } from '@/features/jobs/server/dtos/job-list-item.dto';
import { dtoToJobListItem } from '@/features/jobs/server/dtos/dto-to-job-list-item';

// Reuse the public job detail lookup; stacks need only the existing card fields.
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(id))
return NextResponse.json({ error: 'Job not found' }, { status: 404 });

try {
const response = await fetch(
`${clientEnv.MW_URL}/jobs/details/${encodeURIComponent(id)}`,
{ cache: 'no-store' },
);
if (response.status === 404)
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
if (!response.ok) throw new Error('Job lookup failed');
const job = jobListItemDto.parse(await response.json());
if (job.shortUUID !== id) throw new Error('Job lookup mismatch');
return NextResponse.json(dtoToJobListItem(job), {
headers: { 'Cache-Control': 'no-store' },
});
} catch {
return NextResponse.json({ error: 'Job unavailable' }, { status: 502 });
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { JobListItem } from './job-list-item';
export { JobListItem, JobListItemFrame } from './job-list-item';
export { JobListItemBadge } from './job-list-item-badge';
export { JobListItemTechTags } from './job-list-item-tech-tags';
export { JobListItemInfoTags } from './job-list-item-info-tags';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import type { ReactNode } from 'react';
import { Fragment, type ReactNode } from 'react';

import { ArrowRightIcon, ExternalLinkIcon } from 'lucide-react';

Expand All @@ -22,9 +22,20 @@ import { AvailabilityPills } from '@/features/jobs/components/availability-pills
interface JobListItemProps {
job: JobListItemSchema;
footer?: ReactNode;
contentOnly?: boolean;
}

export const JobListItem = ({ job, footer }: JobListItemProps) => {
export const JobListItemFrame = ({ children }: { children: ReactNode }) => (
<article className='group relative overflow-hidden rounded-2xl border border-border/50 bg-card shadow-sm transition-all duration-200 hover:border-border hover:shadow-md'>
{children}
</article>
);

export const JobListItem = ({
job,
footer,
contentOnly = false,
}: JobListItemProps) => {
const { title, href, organization, infoTags, tags, badge, timestampText } =
job;

Expand All @@ -36,15 +47,9 @@ export const JobListItem = ({ job, footer }: JobListItemProps) => {
});
};

const Frame = contentOnly ? Fragment : JobListItemFrame;
return (
<article
className={cn(
'group relative overflow-hidden rounded-2xl bg-card',
'border border-border/50 shadow-sm',
'transition-all duration-200',
'hover:border-border hover:shadow-md',
)}
>
<Frame>
<div
className={cn(
'absolute inset-x-0 top-0 h-px',
Expand Down Expand Up @@ -160,6 +165,6 @@ export const JobListItem = ({ job, footer }: JobListItemProps) => {
</div>
</div>
{footer}
</article>
</Frame>
);
};
2 changes: 2 additions & 0 deletions src/features/jobs/components/job-list/job-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export const JobList = async ({
<JobStack
key={`${selectionKey}:${entry.key}`}
jobs={entry.jobs}
jobTitles={entry.jobTitles}
importRunId={entry.importRunId}
/>
) : (
<JobListItem key={entry.key} job={entry.jobs[0]} />
Expand Down
Loading
Loading