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
10 changes: 8 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"dev": "next dev --port 3000",
"lint": "eslint .",
"test": "tsx --test src/**/*.test.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"build": "next build",
"clean": "node -e \"fs.rmSync('.next', { recursive: true, force: true }); fs.rmSync('dist', { recursive: true, force: true })\""
Expand All @@ -24,13 +24,19 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^9",
"eslint-config-next": "^15.4.2",
"jsdom": "^29.1.1",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.11"
},
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "^4.3.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createElement, type ChangeEvent, type ComponentProps } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ApiError } from '@/lib/api/errors';
import type { Contribution, Proposal } from '@/lib/api/contributions';
import { ContributionEditorForm } from './contribution-editor-form';

const mocks = vi.hoisted(() => ({
authSession: vi.fn(),
createContribution: vi.fn(),
listContributions: vi.fn(),
push: vi.fn(),
submitContribution: vi.fn(),
updateContribution: vi.fn(),
}));

vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mocks.push,
}),
}));

vi.mock('@/providers/auth-session-provider', () => ({
useAuthSession: () => mocks.authSession(),
}));

vi.mock('@/lib/api/contributions', async () => {
const actual =
await vi.importActual<typeof import('@/lib/api/contributions')>('@/lib/api/contributions');

return {
...actual,
createContribution: mocks.createContribution,
listContributions: mocks.listContributions,
submitContribution: mocks.submitContribution,
updateContribution: mocks.updateContribution,
};
});

vi.mock('@/components/editor/rich-text-editor', () => ({
RichTextEditor: ({
disabled,
label,
onJsonChange,
placeholder,
}: {
disabled?: boolean;
label?: string;
onJsonChange?: (content: Record<string, unknown>) => void;
placeholder?: string;
}) =>
createElement(
'label',
null,
label,
createElement('textarea', {
'aria-label': label,
disabled,
onChange: (event: ChangeEvent<HTMLTextAreaElement>) =>
onJsonChange?.({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: event.currentTarget.value }],
},
],
}),
placeholder,
}),
),
}));

function contribution(overrides: Partial<Contribution> = {}): Contribution {
return {
authorId: 'user-1',
content: {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Draft body' }] }],
},
createdAt: '2026-08-30T20:00:00.000Z',
id: 'draft-1',
kind: 'LORE',
proposal: null,
proposalId: null,
status: 'DRAFT',
submittedAt: null,
summary: null,
targetLoreEntry: null,
targetLoreEntryId: null,
title: 'Draft title',
updatedAt: '2026-08-30T20:00:00.000Z',
worldId: 'world-1',
...overrides,
};
}

function proposal(overrides: Partial<Proposal> = {}): Proposal {
return {
id: 'proposal-1',
proposalType: 'ADD_LORE',
status: 'VOTING',
submittedAt: '2026-08-30T20:01:00.000Z',
title: 'Updated version',
worldId: 'world-1',
...overrides,
};
}

function apiError(status: number, error: string) {
return new ApiError({
body: { error },
status,
statusText: error,
});
}

function renderEditor(props: Partial<ComponentProps<typeof ContributionEditorForm>> = {}) {
return render(
createElement(ContributionEditorForm, {
initialKind: 'LORE',
worldId: 'world-1',
...props,
}),
);
}

async function fillRequiredFields() {
const user = userEvent.setup();

renderEditor();

await screen.findByRole('button', { name: 'Save Draft' });
await user.type(screen.getByLabelText('Title'), 'A better ending');
await user.type(screen.getByLabelText('Contribution body'), 'The gate opens at dawn.');

return user;
}

describe('ContributionEditorForm', () => {
beforeEach(() => {
mocks.push.mockReset();
mocks.listContributions.mockReset();
mocks.createContribution.mockReset();
mocks.updateContribution.mockReset();
mocks.submitContribution.mockReset();
mocks.authSession.mockReset();
mocks.authSession.mockReturnValue({
accessToken: 'access-token-1',
isSessionLoading: false,
refreshSession: vi.fn(),
user: { hiveUsername: 'kareem' },
});
mocks.listContributions.mockResolvedValue({
contributions: [],
pagination: { page: 1, pageSize: 1, total: 0 },
});
});

it('saves a draft with the current structured content', async () => {
mocks.createContribution.mockResolvedValue({ contribution: contribution() });

const user = await fillRequiredFields();
await user.click(screen.getByRole('button', { name: 'Save Draft' }));

await waitFor(() => expect(mocks.createContribution).toHaveBeenCalledOnce());
expect(mocks.createContribution).toHaveBeenCalledWith(
'world-1',
expect.objectContaining({
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'The gate opens at dawn.' }],
},
],
},
kind: 'LORE',
title: 'A better ending',
}),
'access-token-1',
);
});

it('refreshes the token after a 401 mid-save and retries with the rotated token', async () => {
const refreshSession = vi.fn().mockResolvedValue({
accessToken: 'access-token-2',
user: { hiveUsername: 'kareem' },
});

mocks.authSession.mockReturnValue({
accessToken: 'access-token-1',
isSessionLoading: false,
refreshSession,
user: { hiveUsername: 'kareem' },
});
mocks.createContribution
.mockRejectedValueOnce(apiError(401, 'token expired'))
.mockResolvedValueOnce({ contribution: contribution() });

const user = await fillRequiredFields();
await user.click(screen.getByRole('button', { name: 'Save Draft' }));

await waitFor(() => expect(mocks.createContribution).toHaveBeenCalledTimes(2));
expect(refreshSession).toHaveBeenCalledOnce();
expect(mocks.createContribution).toHaveBeenNthCalledWith(
2,
'world-1',
expect.any(Object),
'access-token-2',
);
});

it('hides the editor when the permission preflight returns 403', async () => {
mocks.listContributions.mockRejectedValue(apiError(403, 'forbidden'));

renderEditor();

expect(
await screen.findByText('You do not have permission to draft contributions in this world.'),
).toBeInTheDocument();
expect(screen.queryByLabelText('Title')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save Draft' })).not.toBeInTheDocument();
});

it('submits the latest unsaved edits before creating the proposal', async () => {
mocks.createContribution.mockResolvedValueOnce({
contribution: contribution({ id: 'draft-1', title: 'Initial version' }),
});
mocks.updateContribution.mockResolvedValueOnce({
contribution: contribution({ id: 'draft-1', title: 'Updated version' }),
});
mocks.submitContribution.mockResolvedValueOnce({
alreadySubmitted: false,
contribution: contribution({ id: 'draft-1', status: 'SUBMITTED' }),
proposal: proposal(),
});

const user = await fillRequiredFields();
await user.click(screen.getByRole('button', { name: 'Save Draft' }));

await waitFor(() => expect(mocks.createContribution).toHaveBeenCalledOnce());
const titleField = screen.getByLabelText('Title');
await user.clear(titleField);
await user.type(titleField, 'Updated version');
await user.click(screen.getByRole('button', { name: 'Submit Proposal' }));

await waitFor(() => expect(mocks.updateContribution).toHaveBeenCalledOnce());
expect(mocks.updateContribution).toHaveBeenCalledWith(
'world-1',
'draft-1',
expect.objectContaining({ title: 'Updated version' }),
'access-token-1',
);
expect(mocks.submitContribution).toHaveBeenCalledWith('world-1', 'draft-1', 'access-token-1');
expect(mocks.push).toHaveBeenCalledWith('/worlds/world-1/proposals/proposal-1');
});
});
7 changes: 3 additions & 4 deletions apps/web/src/lib/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { describe, expect, test } from 'vitest';

import { parsePublicApiUrl } from './env';

describe('web public environment', () => {
test('normalizes the API base URL to an origin', () => {
assert.equal(parsePublicApiUrl('http://localhost:3001/api'), 'http://localhost:3001');
expect(parsePublicApiUrl('http://localhost:3001/api')).toBe('http://localhost:3001');
});

test('rejects non-http API URLs', () => {
assert.throws(() => parsePublicApiUrl('file:///tmp/hivelore'), /http or https/);
expect(() => parsePublicApiUrl('file:///tmp/hivelore')).toThrow(/http or https/);
});
});
1 change: 1 addition & 0 deletions apps/web/src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
21 changes: 21 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';

const appDir = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(appDir, 'src'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});
Loading
Loading