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
1 change: 1 addition & 0 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test:ci": "vitest run --coverage"
},
"dependencies": {
"@gonext/api-types": "workspace:*",
"@gonext/blocks-editor": "workspace:*",
"@gonext/blocks-sdk": "workspace:*",
"@radix-ui/react-avatar": "^1.1.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,53 @@ describe('CommentListClient', () => {
expect(href).toContain('/posts/p1');
});

// ── Regression locks for PR #523 ──────────────────────────────────
//
// Before #523 the comments table's "On post" column built its href
// via `${postId}/edit`, which 404'd because no nested editor route
// exists — the [id] segment IS the editor. The fix re-pointed all
// call sites to `postEditHref()` (which returns the bare /posts/{id}
// path despite the legacy name). These tests pin the contract so a
// future rename or copy-paste can't silently re-introduce /edit.

it('TestCommentList_PostLinkOmitsEditSuffix_Issue523: every comment-row post link omits /edit', () => {
// SAMPLE has rows on two distinct post ids (p1, p2). All four
// rows must use bare /posts/{id}.
render(<CommentListClient initialData={makeInitial(SAMPLE)} />);

const postLinks = [
...screen.getAllByRole('link', { name: /hello world/i }),
...screen.getAllByRole('link', { name: /^second$/i }),
];
// 4 comment rows total, but we may have one link per row, so
// check we found at least the two distinct post titles.
expect(postLinks.length).toBeGreaterThanOrEqual(2);
for (const link of postLinks) {
const href = link.getAttribute('href') ?? '';
expect(href).not.toMatch(/\/edit($|[?#])/);
expect(href).toMatch(/^\/posts\/[^/]+$/);
}
});

it('TestCommentList_RowPostLinkPointsToBarePostId_Issue523: link href matches /posts/{postId} exactly', () => {
// Pinned constants: row "c1" is on post "p1" → expect /posts/p1.
// Locks down the literal route shape so a refactor to e.g.
// /admin/posts/{id} wouldn't pass either.
render(<CommentListClient initialData={makeInitial(SAMPLE)} />);
const link = screen.getAllByRole('link', { name: /hello world/i })[0];
expect(link?.getAttribute('href')).toBe('/posts/p1');
});

it('TestCommentList_PostLinkUsesPostIdNotCommentId_Issue523: the column links to the post, not the comment', () => {
// Sanity: if someone wires the link to comment.id by accident
// (c1, c2, …) the regression test fails loudly.
render(<CommentListClient initialData={makeInitial(SAMPLE)} />);
const link = screen.getAllByRole('link', { name: /hello world/i })[0];
const href = link?.getAttribute('href') ?? '';
expect(href).not.toMatch(/\/posts\/c\d+/);
expect(href).toContain('p1');
});

it('all-rows checkbox selects every row', () => {
render(<CommentListClient initialData={makeInitial(SAMPLE)} />);
const selectAll = screen.getByLabelText(/select all comments/i);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,31 @@ describe('FolderTree', () => {
await waitFor(() => expect(onMediaMoved).toHaveBeenCalled());
});

// ── Regression locks for PR #523 (data:null tolerance) ────────────
//
// listCollections used to return `{ data: null }` for an empty
// folder set (Postgres NULL → Go nil-slice → JSON null). The
// FolderTree wrapped the result with `Array.isArray(res.data) ?
// res.data : []` so the empty-state path doesn't blow up. Lock the
// band-aid here in case the API regresses on a future build.

it('TestFolderTree_TolerateListCollectionsDataNull_Issue523: renders empty tree without crash when API returns data:null', async () => {
mocks.listCollections.mockReset().mockResolvedValue({
data: null as unknown as MediaCollection[],
});

expect(() =>
render(<FolderTree selectedId={ALL_NODE_ID} onSelect={vi.fn()} />),
).not.toThrow();

await waitFor(() => expect(mocks.listCollections).toHaveBeenCalled());
// The synthetic leaves ("All", "Unfiled") still render — they
// come from the component itself, not the API. Their presence
// proves the tree mounted and didn't crash on the null payload.
expect(screen.getByTestId('folder-leaf-all')).toBeInTheDocument();
expect(screen.getByTestId('folder-leaf-unfiled')).toBeInTheDocument();
});

it('drops onto Unfiled with collection_id null', async () => {
mocks.listCollections.mockResolvedValueOnce({ data: [] });
mocks.moveMediaToCollection.mockResolvedValueOnce({ moved: 1 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,62 @@ describe('MediaGrid', () => {
expect(screen.getByTestId('tile-edit-a')).toBeInTheDocument();
expect(screen.getByTestId('tile-delete-a')).toBeInTheDocument();
});

// ── Regression locks for PR #523 (data:null tolerance) ────────────
//
// Postgres' nil-slice + Go's omitempty quirk meant the admin list
// endpoints would emit `data: null` instead of `data: []` for an
// empty result set. PR #523 added a router-level coerce, but the
// client-side defensive band-aid (Array.isArray(...) ? ... : [])
// must stay so the grid keeps rendering against older API builds.

it('TestMediaGrid_TolerateInitialDataNull_Issue523: renders without crash when initialData.data is null', async () => {
// Cast through unknown to bypass the static `data: MediaAsset[]`
// type — at runtime the API used to actually emit null, and the
// band-aid in MediaGrid is what saves us when it does.
const initialWithNullData = {
data: null as unknown as never,
pagination: { next_cursor: '' },
} as unknown as MediaListResponse;

mocks.listMedia.mockResolvedValue({
data: [] as MediaAsset[],
pagination: { next_cursor: '' },
});

expect(() =>
render(<MediaGrid initialData={initialWithNullData} />),
).not.toThrow();

// After the post-mount refetch settles, the empty-state surface
// appears — proves the grid recovered without crashing.
await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
});

it('TestMediaGrid_TolerateFetchDataNull_Issue523: refetch returning data:null does not crash', async () => {
// The first refetch (issued on mount when filter changes /
// hydrated flag flips) used to throw "Cannot read properties of
// null (reading 'length')" if the API emitted null. The
// band-aid coerces null → [].
mocks.listMedia.mockResolvedValue({
data: null as unknown as MediaAsset[],
pagination: { next_cursor: '' },
});

render(
<MediaGrid initialData={{ data: [], pagination: { next_cursor: '' } }} />,
);

fireEvent.click(screen.getByTestId('filter-chip-image'));

// The grid is still mounted, no crash banner appears, and the
// empty-state survives the null payload.
await waitFor(() => {
const lastCall = mocks.listMedia.mock.calls.at(-1);
expect(lastCall?.[0]).toMatchObject({ type: 'image' });
});
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
});
146 changes: 146 additions & 0 deletions apps/admin/src/app/(authenticated)/media/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Media page — server-component coercion regression tests (issue #523).
*
* The admin media list endpoint historically returned `data: null` for
* empty libraries (Postgres NULL → Go nil-slice → JSON null). The page
* coerces null → [] before handing the result to the <MediaGrid>
* client island, because the client assumes `data` is always an array
* and calling `.length` on null throws.
*
* PR #523 added a router-level fix on the API side (router.Page[T]
* coerces nil Data to []), but the page-side band-aid stays in place
* so older API builds keep working. These tests lock the coercion
* logic.
*
* Note: media/page.tsx is a Server Component and exports only the
* default async function. We cannot import `fetchInitial` directly,
* so we test by mocking `@/lib/server-api`, awaiting the page's
* default export, and verifying the resulting JSX prop tree carries a
* safe array shape into the MediaGrid client.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// MediaGrid is a complex client component with hooks; we don't need
// to render it for these tests. Replace it with a transparent shim
// so we can read the props the server component passed in.
const mediaGridProps: { value: unknown } = { value: undefined };
vi.mock('./components/MediaGrid', () => ({
MediaGrid: (props: unknown) => {
mediaGridProps.value = props;
// Render nothing — props introspection is what we care about.
return null;
},
}));

const serverApiFetchMock = vi.fn();
vi.mock('@/lib/server-api', () => ({
serverApiFetch: (...args: unknown[]) => serverApiFetchMock(...args),
}));

import MediaPage from './page';

beforeEach(() => {
serverApiFetchMock.mockReset();
mediaGridProps.value = undefined;
});

afterEach(() => {
vi.useRealTimers();
});

/** Build a Response shim for serverApiFetch to return. */
function jsonRes(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}

describe('Media page server coercion (issue #523)', () => {
it('TestMediaPage_CoercesDataNullToEmptyArray_Issue523: API data:null is normalised to []', async () => {
// Reproduce the failure mode: the API returned `data: null` and
// the client crashed reading `.length`. The page-level coerce in
// page.tsx is what saves it.
serverApiFetchMock.mockResolvedValueOnce(
jsonRes({ data: null, pagination: { next_cursor: '' } }),
);

// Awaiting an async server component is the only way to drive
// its rendering in vitest without a full React server runtime.
const tree = await MediaPage();
// Render the resulting JSX through React's virtual tree just to
// trigger the MediaGrid shim's prop-capture.
const { render } = await import('@testing-library/react');
render(tree);

const props = mediaGridProps.value as {
initialData: { data: unknown[] };
};
expect(props.initialData.data).toEqual([]);
expect(Array.isArray(props.initialData.data)).toBe(true);
});

it('TestMediaPage_PassesThroughArrayData_Issue523: real arrays are not mutated', async () => {
// Sanity: when the API returns the canonical shape the page
// does not corrupt the data on its way to the client.
const data = [
{
id: 'a',
filename: 'a.png',
mime_type: 'image/png',
byte_size: 100,
alt_text: '',
caption: '',
storage_key: 'k/a',
uploader_id: 'u',
created_at: '2026-05-17T00:00:00Z',
updated_at: '2026-05-17T00:00:00Z',
tags: [],
},
];
serverApiFetchMock.mockResolvedValueOnce(
jsonRes({ data, pagination: { next_cursor: '' } }),
);

const tree = await MediaPage();
const { render } = await import('@testing-library/react');
render(tree);

const props = mediaGridProps.value as {
initialData: { data: unknown[] };
};
expect(props.initialData.data).toHaveLength(1);
});

it('TestMediaPage_FallbackOnFetchFailure_Issue523: non-2xx surfaces an empty list (no crash)', async () => {
// Server-side fetch failure path. The page returns null from
// fetchInitial and falls through to a safe `{data:[]}` default.
serverApiFetchMock.mockResolvedValueOnce(
new Response('forbidden', { status: 403 }),
);

const tree = await MediaPage();
const { render } = await import('@testing-library/react');
render(tree);

const props = mediaGridProps.value as {
initialData: { data: unknown[] };
};
expect(props.initialData.data).toEqual([]);
});

it('TestMediaPage_FallbackOnFetchThrow_Issue523: thrown network error surfaces an empty list', async () => {
// serverApiFetch can also throw (DNS, abort, etc.). The page's
// try/catch is the last line of defence.
serverApiFetchMock.mockRejectedValueOnce(new Error('ECONNRESET'));

const tree = await MediaPage();
const { render } = await import('@testing-library/react');
render(tree);

const props = mediaGridProps.value as {
initialData: { data: unknown[] };
};
expect(props.initialData.data).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { Post, PostListResponse } from './columns';
import { postEditHref, type Post, type PostListResponse } from './columns';

// Hoisted router stubs so we can inspect calls from inside the
// vi.mock factory and from each test body.
Expand Down Expand Up @@ -279,6 +279,40 @@ describe('PostListClient', () => {
).toBeInTheDocument();
});

// ── Regression locks for PR #523 ──────────────────────────────────
//
// The post edit href was renamed in PR #523 from
// `/posts/{id}/edit` → bare `/posts/{id}` because the single-id
// route IS the editor (no nested /edit segment exists). The change
// touched both the posts list and the comments table. These tests
// pin the contract on the producer side so any call site that
// re-adopts the `/edit` suffix fails loud.

it('TestPostEditHref_OmitsEditSuffix_Issue523: returns bare /posts/{id} (no /edit segment)', () => {
// Locks the literal shape postEditHref returns. A regression that
// re-appends /edit (e.g. via a copy-paste from older code) fails
// here, in CommentListClient.test.tsx, AND in the row-render
// tests below.
expect(postEditHref('p1')).toBe('/posts/p1');
expect(postEditHref('p1')).not.toMatch(/\/edit$/);
});

it('TestPostEditHref_PercentEncodesSpecials_Issue523: id is URL-encoded', () => {
// Defensive: post ids today are UUIDs but the API contract
// tolerates arbitrary strings, so the helper must encode them.
expect(postEditHref('a/b')).toBe('/posts/a%2Fb');
});

it('TestPostListRow_TitleLinkOmitsEditSuffix_Issue523: rendered row href matches postEditHref output', () => {
// Sanity: the row in PostListClient must consume postEditHref, not
// build its own URL. If the row goes back to template-string
// concatenation a future refactor could re-introduce /edit.
render(<PostListClient initialData={makeInitialData(SAMPLE_POSTS)} />);
const link = screen.getByRole('link', { name: /hello world/i });
expect(link.getAttribute('href')).toBe(postEditHref('p1'));
expect(link.getAttribute('href')).not.toMatch(/\/edit$/);
});

it('shows an inline error when "Load more" fetcher rejects', async () => {
const fetcher = vi.fn(async () => {
throw new Error('boom');
Expand Down
Loading