Skip to content
Open
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
43 changes: 43 additions & 0 deletions apps/admin/src/app/(authenticated)/jobs/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* /jobs smoke tests. Pure server-rendered grid — the assertions
* confirm one card per known queue and that each card links to the
* filtered DLQ. Issue #507.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';

import JobsPage from './page';
import { KNOWN_QUEUES } from './dlq/types';

describe('JobsPage', () => {
it('renders without crashing', () => {
render(<JobsPage />);
expect(screen.getByTestId('jobs-page')).toBeInTheDocument();
});

it('renders the italic-accent headline ("Background jobs.")', () => {
render(<JobsPage />);
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1.textContent).toMatch(/Background\s+jobs\./);
expect(h1.querySelector('em')?.textContent).toBe('jobs');
});

it('renders one card per known queue', () => {
render(<JobsPage />);
const list = screen.getByRole('list', { name: /Queues/i });
const items = list.querySelectorAll('li');
expect(items.length).toBe(KNOWN_QUEUES.length);
});

it('cards link to /jobs/dlq filtered by queue', () => {
render(<JobsPage />);
for (const queue of KNOWN_QUEUES) {
const card = screen.getByTestId(`jobs-queue-card-${queue}`);
// Next's <Link> serialises `{pathname, query}` into the rendered
// href, so we just look for the encoded query param.
expect(card.getAttribute('href')).toMatch(
new RegExp(`/jobs/dlq\\?queue=${queue}`),
);
}
});
});
119 changes: 119 additions & 0 deletions apps/admin/src/app/(authenticated)/jobs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Jobs — queue landing page.
*
* The DLQ subsurface (/jobs/dlq) links here via a "Back to jobs"
* affordance, but the parent route had no `page.tsx` and 404'd
* (issue #507). This file fills the gap with a static card grid: one
* card per known queue, each routing to the DLQ filtered by that
* queue.
*
* No data fetching today. The queue topology is loaded from the same
* `KNOWN_QUEUES` constant the DLQ chip filter reads — that list is the
* canonical chassis queue set (docs/05-admin-api.md §4.3). Live
* queue-depth / failed-count instrumentation lands when the
* `/api/v1/admin/jobs/stats` endpoint ships; for now the cards exist so
* the surface stops looking broken and operators have a discoverable
* path into each queue's DLQ.
*
* Brand: italic accent on the noun ("Background *jobs*."), card grid
* on paper-2, queue-tone matching the DLQ chips so the cross-link feels
* cohesive.
*/
import type { ReactElement } from 'react';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';

import { Headline } from '@/components/ui/headline';
import { Badge } from '@/components/ui/badge';
import { KNOWN_QUEUES } from './dlq/types';

/**
* Short, human description for each known queue. Keeps the landing
* page readable instead of just listing nouns — operators can pick
* the right queue without cross-referencing the chassis docs.
*
* Unknown queues (e.g. plugin-defined) render with a generic blurb
* via `fallbackDescription`; the URL is the queue name so the DLQ
* still works for them.
*/
const QUEUE_DESCRIPTIONS: Readonly<Record<string, string>> = {
critical: 'High-priority work — auth flows, billing webhooks, anything that blocks a user.',
default: 'Catch-all queue. If a job didn’t pick a queue, it lands here.',
webhooks: 'Outgoing webhook deliveries and the retry budget that comes with them.',
media: 'Image processing, thumbnail derivatives, large-file moves.',
search: 'Search index re-builds and incremental document updates.',
reports: 'Long-running analytics rollups, exports, scheduled summaries.',
low: 'Background cleanup — orphan sweeps, archive compaction, telemetry rolls.',
};

const fallbackDescription =
'Background tasks routed onto this queue. Open the DLQ to inspect anything that failed.';

/**
* Surface tone for the queue badge. Mirrors `queueTone()` in the DLQ
* client so a "critical" chip looks the same here as it does on the
* DLQ list — the cross-link is a continuation of the same view.
*/
function queueTone(
queue: string,
): 'emerald' | 'lavender' | 'outline' | 'default' {
if (queue === 'critical') return 'emerald';
if (queue === 'webhooks' || queue === 'important') return 'lavender';
if (queue === 'low') return 'outline';
return 'default';
}

export default function JobsPage(): ReactElement {
return (
<section data-testid="jobs-page" className="flex flex-col gap-6">
{/* Page head — instrument-panel feel, matches the DLQ sibling. */}
<div className="flex flex-col gap-3 border-b border-border pb-6">
<span className="font-sans text-2xs font-medium uppercase tracking-[0.12em] text-emerald-deep">
Operations
</span>
<Headline as="h1" size="page">
Background <em>jobs</em>.
</Headline>
<p className="max-w-[540px] font-sans text-sm text-fg-muted">
Every async task runs on one of the chassis queues. Pick a queue
to inspect its dead-letter contents and replay or discard
failures.
</p>
</div>

<ul
role="list"
aria-label="Queues"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{KNOWN_QUEUES.map((queue) => (
<li key={queue}>
<Link
href={{ pathname: '/jobs/dlq', query: { queue } }}
className="group flex h-full flex-col gap-3 rounded-lg border border-border bg-paper-2 p-5 shadow-xs transition-all hover:-translate-y-[2px] hover:border-emerald hover:shadow-md focus-visible:border-emerald focus-visible:shadow-focus focus-visible:outline-none"
data-testid={`jobs-queue-card-${queue}`}
>
<div className="flex items-start justify-between gap-3">
<Badge variant={queueTone(queue)} dot>
{queue}
</Badge>
<ArrowRight
aria-hidden="true"
width={14}
height={14}
className="mt-1 text-fg-subtle transition-transform group-hover:translate-x-[2px] group-hover:text-emerald-deep"
/>
</div>
<p className="text-sm text-fg-muted">
{QUEUE_DESCRIPTIONS[queue] ?? fallbackDescription}
</p>
<span className="mt-auto inline-flex items-center gap-1 text-xs font-medium text-fg-subtle group-hover:text-emerald-deep">
Open dead-letter queue
</span>
</Link>
</li>
))}
</ul>
</section>
);
}
90 changes: 90 additions & 0 deletions apps/admin/src/app/(authenticated)/pages/new/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* /pages/new smoke tests. Mirrors the posts/new shape — issue #507.
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';

const pushMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: pushMock, replace: vi.fn(), prefetch: vi.fn() }),
}));

const apiPostMock = vi.fn();
vi.mock('@/lib/api-client', async () => {
const actual =
await vi.importActual<typeof import('@/lib/api-client')>(
'@/lib/api-client',
);
return {
...actual,
api: { ...actual.api, post: (...args: unknown[]) => apiPostMock(...args) },
};
});

import NewPagePage, { slugifyPage } from './page';
import { ApiError } from '@/lib/api-client';

beforeEach(() => {
pushMock.mockReset();
apiPostMock.mockReset();
});

describe('slugifyPage', () => {
it('emits a leading-slash slug', () => {
expect(slugifyPage('About Us')).toBe('/about-us');
});
it('returns empty for an alphanumeric-less input', () => {
expect(slugifyPage(' ')).toBe('');
});
});

describe('NewPagePage', () => {
it('renders without crashing', () => {
render(<NewPagePage />);
expect(screen.getByTestId('new-page-page')).toBeInTheDocument();
});

it('renders the italic-accent headline ("Create a new page.")', () => {
render(<NewPagePage />);
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1.textContent).toMatch(/Create\s+a\s+new\s+page\./);
expect(h1.querySelector('em')?.textContent).toBe('page');
});

it('POSTs to /api/v1/posts with post_type:"page" and redirects on success', async () => {
apiPostMock.mockResolvedValueOnce({ id: 'about' });
render(<NewPagePage />);

fireEvent.change(screen.getByLabelText(/Title/i), {
target: { value: 'About Us' },
});

await act(async () => {
fireEvent.click(screen.getByTestId('new-page-submit'));
});

expect(apiPostMock).toHaveBeenCalledWith('/api/v1/posts', {
title: 'About Us',
slug: '/about-us',
status: 'draft',
post_type: 'page',
content_blocks: [],
});
expect(pushMock).toHaveBeenCalledWith('/pages/about');
});

it('renders an inline error when the API rejects the create', async () => {
apiPostMock.mockRejectedValueOnce(
new ApiError(422, 'Unprocessable Entity', null),
);
render(<NewPagePage />);
fireEvent.change(screen.getByLabelText(/Title/i), {
target: { value: 'Bad' },
});
await act(async () => {
fireEvent.click(screen.getByTestId('new-page-submit'));
});
expect(screen.getByTestId('new-page-error')).toHaveTextContent(/HTTP 422/);
expect(pushMock).not.toHaveBeenCalled();
});
});
Loading