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
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ jobs:
test:
runs-on: ubuntu-latest

services:
# `yarn build` connects to MongoDB at build time: generateStaticParams
# for the article page calls article.getAllSlugs, and session-aware pages
# call getSession(). An empty DB is enough — build just needs it reachable.
mongodb:
image: mongo:7.0
ports:
- 27017:27017
options: >-
--health-cmd "mongosh --quiet --eval 'db.runCommand({ ping: 1 })'"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v4

Expand All @@ -21,4 +35,20 @@ jobs:

- run: yarn install --frozen-lockfile
- run: yarn run lint
- run: yarn run type-check
- run: yarn test
- run: yarn build
env:
# The build connects to the mongodb service above (for static params
# and getSession), but storage is never exercised — only validated.
# `next build` runs with NODE_ENV=production, so getServerConfig()
# demands the full storage env set even though it is never called.
MONGODB_URI: mongodb://localhost:27017/articlify-ci
BETTER_AUTH_SECRET: ci-build-only-not-used-at-runtime
Comment thread
basedest marked this conversation as resolved.
STORAGE_PROVIDER: minio
S3_ENDPOINT: http://localhost:9000
S3_REGION: us-east-1
S3_ACCESS_KEY: ci-build-only
S3_SECRET_KEY: ci-build-only
S3_BUCKET: articlify-ci
S3_PUBLIC_URL: http://localhost:9000/articlify-ci
2 changes: 1 addition & 1 deletion app/[locale]/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
const tError = useTranslations('error');

useEffect(() => {
reportError({
void reportError({
message: error.message || tError('unexpected'),
digest: error.digest,
stack: error.stack,
Expand Down
10 changes: 6 additions & 4 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { Button } from '~/shared/ui/button';
import { Alert, AlertDescription, AlertTitle } from '~/shared/ui/alert';
import '~/app/styles/globals.css';

// next-intl context is unavailable above the [locale] segment, so we render
// EN + RU side-by-side rather than picking a single language for the user.
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
reportError({
void reportError({
message: error.message || 'A critical error occurred.',
digest: error.digest,
stack: error.stack,
Expand All @@ -22,12 +24,12 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
<div className="container mx-auto flex min-h-screen items-center justify-center px-4">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Something went wrong</AlertTitle>
<AlertTitle>Something went wrong / Что-то пошло не так</AlertTitle>
<AlertDescription className="mt-2">
{error.message || 'A critical error occurred.'}
{error.message || 'A critical error occurred. / Произошла критическая ошибка.'}
</AlertDescription>
<Button onClick={reset} className="mt-4" variant="outline">
Try again
Try again / Попробовать снова
</Button>
</Alert>
</div>
Expand Down
10 changes: 7 additions & 3 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import Link from 'next/link';
import { Button } from '~/shared/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card';

// This file only renders when the request is outside the [locale] segment
// (e.g. a static asset miss). The localized variant in app/[locale]/not-found.tsx
// handles in-app 404s. Bilingual fallback to avoid an English-only leak.
export default function RootNotFound() {
return (
<div className="container mx-auto flex min-h-screen items-center justify-center px-4">
<Card className="max-w-md">
<CardHeader>
<CardTitle className="text-4xl">404</CardTitle>
<CardDescription className="text-lg">Page Not Found</CardDescription>
<CardDescription className="text-lg">Page Not Found / Страница не найдена</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
The page you are looking for doesn&apos;t exist or has been moved.
The page you are looking for doesn&apos;t exist or has been moved. / Запрашиваемая страница не
существует или была перемещена.
</p>
<Button asChild>
<Link href="/">Go Home</Link>
<Link href="/">Go Home / На главную</Link>
</Button>
</CardContent>
</Card>
Expand Down
19 changes: 19 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import eslintConfigPrettier from 'eslint-config-prettier';
import prettier from 'eslint-plugin-prettier';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';

const eslintConfig = defineConfig([
...nextVitals,
Expand All @@ -20,6 +22,23 @@ const eslintConfig = defineConfig([
'react/display-name': 'off',
},
},
// Async-safety: catch floating promises and misused async handlers.
// Type-aware linting is required for these rules.
{
files: ['src/**/*.{ts,tsx}', 'server/**/*.ts', 'app/**/*.{ts,tsx}'],
plugins: { '@typescript-eslint': tseslint },
languageOptions: {
parser: tsparser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: { attributes: false } }],
},
},
// FSD layer boundaries: shared cannot import from higher layers
{
files: ['src/shared/**/*.ts', 'src/shared/**/*.tsx'],
Expand Down
2 changes: 1 addition & 1 deletion server/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const router = t.router;
export const publicProcedure = t.procedure.use(loggingMiddleware);

// Protected procedure - requires authentication
export const protectedProcedure = t.procedure.use(async (opts) => {
export const protectedProcedure = t.procedure.use(loggingMiddleware).use(async (opts) => {
const { ctx } = opts;
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
Expand Down
70 changes: 44 additions & 26 deletions src/entities/article/api/__tests__/article.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,43 +85,57 @@ describe('ArticleService', () => {
);
});

it('update allows when user is author', async () => {
const article = { ...baseArticle(), author: 'user1' };
it('update allows when user is author by authorId', async () => {
const article = { ...baseArticle(), author: 'user1', authorId: 'id-1' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.updateBySlug).mockResolvedValue({ ...article, title: 'Updated' } as Article);

const result = await service.update('test-slug', { title: 'Updated' }, 'user1');
const result = await service.update('test-slug', { title: 'Updated' }, { id: 'id-1', name: 'user1' });

expect(articleRepository.updateBySlug).toHaveBeenCalledWith('test-slug', { title: 'Updated' });
expect(result).toEqual(expect.objectContaining({ title: 'Updated' }));
});

it('update falls back to author name for legacy docs without authorId', async () => {
const article = { ...baseArticle(), author: 'user1' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.updateBySlug).mockResolvedValue(article as Article);

await service.update('test-slug', { title: 'X' }, { id: 'some-id', name: 'user1' });

expect(articleRepository.updateBySlug).toHaveBeenCalled();
});

it('update allows when user is admin even if not author', async () => {
const article = { ...baseArticle(), author: 'other' };
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.updateBySlug).mockResolvedValue(article as Article);

await service.update('test-slug', { title: 'Updated' }, 'admin-user', 'admin');
await service.update(
'test-slug',
{ title: 'Updated' },
{ id: 'id-admin', name: 'admin-user', role: 'admin' },
);

expect(articleRepository.updateBySlug).toHaveBeenCalledWith('test-slug', { title: 'Updated' });
});

it('delete allows when user is author', async () => {
const article = { ...baseArticle(), author: 'user1' };
it('delete allows when user is author by authorId', async () => {
const article = { ...baseArticle(), author: 'user1', authorId: 'id-1' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.deleteBySlug).mockResolvedValue(true);

const result = await service.delete('test-slug', 'user1');
const result = await service.delete('test-slug', { id: 'id-1', name: 'user1' });

expect(result).toEqual({ success: true });
});

it('delete allows when user is admin even if not author', async () => {
const article = { ...baseArticle(), author: 'other' };
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.deleteBySlug).mockResolvedValue(true);

const result = await service.delete('test-slug', 'admin-user', 'admin');
const result = await service.delete('test-slug', { id: 'id-admin', name: 'admin-user', role: 'admin' });

expect(result).toEqual({ success: true });
});
Expand All @@ -138,52 +152,56 @@ describe('ArticleService', () => {
expect(articleRepository.findBySlug).toHaveBeenCalledWith('missing');
});

it('create throws CONFLICT when slug already exists', async () => {
vi.mocked(articleRepository.findBySlug).mockResolvedValue(baseArticle() as Article);
it('create throws CONFLICT when slug already exists (duplicate key error)', async () => {
const dupErr = Object.assign(new Error('E11000'), { code: 11000 });
vi.mocked(articleRepository.create).mockRejectedValue(dupErr);

await expect(service.create(baseArticle())).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Article with this slug already exists',
});
expect(articleRepository.create).not.toHaveBeenCalled();
});
});

describe('invalid input / permission', () => {
it('update throws FORBIDDEN when user is not author and not admin', async () => {
const article = { ...baseArticle(), author: 'other' };
it('update throws FORBIDDEN when authorId does not match and not admin', async () => {
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);

await expect(service.update('test-slug', { title: 'Updated' }, 'random-user')).rejects.toMatchObject({
await expect(
service.update('test-slug', { title: 'X' }, { id: 'random-id', name: 'random-user' }),
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: 'You do not have permission to edit this article',
});
expect(articleRepository.updateBySlug).not.toHaveBeenCalled();
});

it('update throws FORBIDDEN when user is not author and userRole is not admin', async () => {
const article = { ...baseArticle(), author: 'other' };
it('update throws FORBIDDEN when user role is not admin and not the author', async () => {
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);

await expect(service.update('test-slug', {}, 'random-user', 'user')).rejects.toMatchObject({
await expect(
service.update('test-slug', {}, { id: 'random-id', name: 'random-user', role: 'user' }),
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});

it('update throws NOT_FOUND when article does not exist', async () => {
vi.mocked(articleRepository.findBySlug).mockResolvedValue(null);

await expect(service.update('missing', {}, 'user1')).rejects.toMatchObject({
await expect(service.update('missing', {}, { id: 'id-1', name: 'user1' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'Article not found',
});
});

it('delete throws FORBIDDEN when user is not author and not admin', async () => {
const article = { ...baseArticle(), author: 'other' };
it('delete throws FORBIDDEN when authorId does not match and not admin', async () => {
const article = { ...baseArticle(), author: 'other', authorId: 'id-other' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);

await expect(service.delete('test-slug', 'random-user')).rejects.toMatchObject({
await expect(service.delete('test-slug', { id: 'random-id', name: 'random-user' })).rejects.toMatchObject({
code: 'FORBIDDEN',
message: 'You do not have permission to delete this article',
});
Expand All @@ -193,18 +211,18 @@ describe('ArticleService', () => {
it('delete throws NOT_FOUND when article does not exist', async () => {
vi.mocked(articleRepository.findBySlug).mockResolvedValue(null);

await expect(service.delete('missing', 'user1')).rejects.toMatchObject({
await expect(service.delete('missing', { id: 'id-1', name: 'user1' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'Article not found',
});
});

it('delete throws INTERNAL_SERVER_ERROR when deleteBySlug returns false', async () => {
const article = { ...baseArticle(), author: 'user1' };
const article = { ...baseArticle(), author: 'user1', authorId: 'id-1' };
vi.mocked(articleRepository.findBySlug).mockResolvedValue(article as Article);
vi.mocked(articleRepository.deleteBySlug).mockResolvedValue(false);

await expect(service.delete('test-slug', 'user1')).rejects.toMatchObject({
await expect(service.delete('test-slug', { id: 'id-1', name: 'user1' })).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to delete article',
});
Expand Down
3 changes: 2 additions & 1 deletion src/entities/article/api/article.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ArticleModel, type Article } from '~/entities/article/model/types';
import { connectDB } from '~/shared/lib/server/connection';
import { escapeRegex } from '~/shared/lib/escape-regex';
import { Types } from 'mongoose';

export interface ArticleQuery {
Expand Down Expand Up @@ -28,7 +29,7 @@ export class ArticleRepository {
}

if (title) {
filter.title = { $regex: title, $options: 'i' };
filter.title = { $regex: escapeRegex(title), $options: 'i' };
}

if (author) {
Expand Down
Loading
Loading