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
53 changes: 53 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: e2e tests

on:
deployment_status:
workflow_dispatch:
inputs:
base_url:
description: 'Base URL for e2e tests (e.g. https://smpl-app.vercel.app)'
required: true
type: string

jobs:
e2e:
name: e2e tests
runs-on: ubuntu-latest
timeout-minutes: 15
if: >
(github.event_name == 'deployment_status' &&
github.event.deployment_status.state == 'success' &&
github.event.deployment.environment == 'Preview') ||
github.event_name == 'workflow_dispatch'
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"

- name: Install dependencies
run: pnpm i --frozen-lockfile

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Run e2e tests
run: pnpm test:e2e
env:
BASE_URL: ${{ github.event_name == 'deployment_status' && github.event.deployment_status.target_url || inputs.base_url }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 14
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ package-lock.json

# testing
/coverage
/playwright-report/
/blob-report/
/test-results/

# next.js
/.next/
Expand Down
24 changes: 23 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,19 @@ Enum `TaskType` в Prisma генерирует TypeScript тип в `@/generated

### Тесты

Для каждой функции (кроме совсем тривиальных) должны быть написаны unit-тесты. Они пишутся ишутся на vitest. Файлы тестов рядом с кодом, название *.spec.ts. Названия тестов всегда на английском, комментарии не нужны кроме неочевидных случаев. Для каждой функции свой describe, внутри несколько it с различными тесткейсами. Они должны тестировать разные вещи: не пиши несколько повторяющихся тестов.
**Unit-тесты** — для каждой функции (кроме совсем тривиальных) должны быть написаны unit-тесты. Они пишутся на vitest. Файлы тестов рядом с кодом, название *.spec.ts. Названия тестов всегда на английском, комментарии не нужны кроме неочевидных случаев. Для каждой функции свой describe, внутри несколько it с различными тесткейсами. Они должны тестировать разные вещи: не пиши несколько повторяющихся тестов.

**E2E-тесты** — написаны на Playwright, лежат в `e2e/`. Покрывают:
- `pages.spec.ts` — загрузка страниц, отсутствие console errors
- `api.spec.ts` — формат ответов API (tasks_info, tasks_count, progress и тд)
- `performance.spec.ts` — таймауты API (< 3s), время загрузки страниц (< 5s), CLS (< 0.25)

Запуск:
- `pnpm test:e2e` — против локального сервера (нужен запущенный `pnpm dev`)
- `BASE_URL=https://preview.vercel.app pnpm test:e2e` — против Vercel preview deployment
- `pnpm test:e2e:ui` — с визуальным интерфейсом Playwright

`MOCK_TELEGRAM_ID` в тестах должен совпадать с `USER_ID_MOCK` из `src/shared/constants/index.ts`.

### Работа с git

Expand Down Expand Up @@ -293,3 +305,13 @@ const user = await withDbTiming('user.findByTelegramId', () =>
**Instrumentation** (`src/instrumentation.ts`) — серверный мониторинг. Функция `register()` выполняется при старте сервера, `onRequestError()` ловит необработанные ошибки рендеринга и роутинга.

**Важно:** `@vercel/analytics`, `@vercel/speed-insights` и `web-vitals` должны быть в `dependencies` (не `devDependencies`), иначе они не попадут в production build.

### Оптимизация производительности БД

**DB connection warmup** — в `instrumentation.ts` при старте сервера выполняется `SELECT 1`, чтобы прогреть TCP-соединение с БД. Без этого первый запрос пользователя тратит ~1000ms на установление соединения.

**Индексы на FK** — в `schema.prisma` добавлены `@@index` на все foreign key поля (`owner_id`, `workspace_id`, `project_id`, `type`). PostgreSQL не создаёт индексы на FK автоматически. После изменения индексов нужно запустить `pnpm migrate`.

**Лёгкие запросы к воркспейсам** — методы `findUnique`, `findMany`, `findByUser`, `findMyWorkspace` в `prismaClient.ts` загружают только `{ type: true }` из задач вместо полных объектов. Этого достаточно для подсчёта completed/total. Если нужны полные задачи — использовать `db.task.findByWorkspace` или `db.task.findByUser`.

**`findMyWorkspaceId`** — лёгкий метод, возвращающий только `{ id }` воркспейса. Используется в `/api/task?project=my`, где нужен только ID воркспейса, а не все его задачи и тиммейты.
23 changes: 23 additions & 0 deletions MONITORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,33 @@ Both are mounted in `src/app/layout.tsx`:

## Tests

### Unit tests

Logger utilities are covered by unit tests:

| File | Tests | Coverage |
|------|-------|----------|
| `src/shared/utils/logger/logger.spec.ts` | 9 | JSON format, log levels, context serialization |
| `src/shared/utils/logger/withLogging.spec.ts` | 5 | Response passthrough, log levels by status, error handling, context forwarding |
| `src/shared/utils/logger/withDbTiming.spec.ts` | 5 | Return values, timing measurement, slow query threshold, error propagation |

### E2E tests (Playwright)

Located in `e2e/`. Can run against local dev server or Vercel preview deployments.

| File | Tests | Coverage |
|------|-------|----------|
| `e2e/pages.spec.ts` | 5 | Page loading (home, my-workspace, profile, workspace), console errors |
| `e2e/api.spec.ts` | 7 | API response shapes (workspace tasks_info, project tasks_count/progress, task grouping, teammate list, error handling) |
| `e2e/performance.spec.ts` | 5 | API response times (<3s), page load time (<5s), CLS (<0.25) |

```bash
# Run against local server (requires pnpm dev)
pnpm test:e2e

# Run against Vercel preview deployment
BASE_URL=https://your-preview.vercel.app pnpm test:e2e

# Interactive UI mode
pnpm test:e2e:ui
```
159 changes: 159 additions & 0 deletions e2e/api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { test, expect } from '@playwright/test';


const MOCK_TELEGRAM_ID = '862381667';

test.describe('API: /api/workspace', () => {
test('GET /api/workspace?userId returns workspaces with tasks_info', async ({ request }) => {
const response = await request.get(`/api/workspace?userId=${MOCK_TELEGRAM_ID}`);

expect(response.status()).toBe(200);

const data = await response.json();

expect(data).toHaveProperty('workspaces');
expect(Array.isArray(data.workspaces)).toBe(true);

if (data.workspaces.length > 0) {
const ws = data.workspaces[0];

expect(ws).toHaveProperty('id');
expect(ws).toHaveProperty('title');
expect(ws).toHaveProperty('tasks_info');
expect(ws.tasks_info).toHaveProperty('total');
expect(ws.tasks_info).toHaveProperty('completed');
expect(typeof ws.tasks_info.total).toBe('number');
expect(typeof ws.tasks_info.completed).toBe('number');
expect(ws.tasks_info.completed).toBeLessThanOrEqual(ws.tasks_info.total);
}
});

test('GET /api/workspace/my returns workspace with tasks_info', async ({ request }) => {
const response = await request.get(`/api/workspace/my?userId=${MOCK_TELEGRAM_ID}`);

expect(response.status()).toBe(200);

const data = await response.json();

expect(data).toHaveProperty('id');
expect(data).toHaveProperty('tasks_info');
expect(data.tasks_info).toHaveProperty('total');
expect(data.tasks_info).toHaveProperty('completed');
expect(typeof data.tasks_info.total).toBe('number');
expect(typeof data.tasks_info.completed).toBe('number');
});
});

test.describe('API: /api/project', () => {
test('GET /api/project?workspace returns projects with tasks_count and progress', async ({ request }) => {
const wsResponse = await request.get(`/api/workspace/my?userId=${MOCK_TELEGRAM_ID}`);
const ws = await wsResponse.json();

const response = await request.get(`/api/project?workspace=${ws.id}`);

expect(response.status()).toBe(200);

const data = await response.json();

expect(Array.isArray(data)).toBe(true);

if (data.length > 0) {
const project = data[0];

expect(project).toHaveProperty('id');
expect(project).toHaveProperty('title');
expect(project).toHaveProperty('tasks_count');
expect(project).toHaveProperty('progress');
expect(typeof project.tasks_count).toBe('number');
expect(typeof project.progress).toBe('number');
expect(project.progress).toBeGreaterThanOrEqual(0);
expect(project.progress).toBeLessThanOrEqual(100);
}
});
});

test.describe('API: /api/task', () => {
test('GET /api/task?project=my returns grouped tasks', async ({ request }) => {
const response = await request.get(
`/api/task?project=my&userId=${MOCK_TELEGRAM_ID}`
);

expect(response.status()).toBe(200);

const data = await response.json();

expect(data).toHaveProperty('workspace_id');
expect(data).toHaveProperty('todo');
expect(data).toHaveProperty('progress');
expect(data).toHaveProperty('review');
expect(data).toHaveProperty('done');
expect(Array.isArray(data.todo)).toBe(true);
expect(Array.isArray(data.progress)).toBe(true);
expect(Array.isArray(data.review)).toBe(true);
expect(Array.isArray(data.done)).toBe(true);
});

test('task objects have required fields', async ({ request }) => {
const response = await request.get(
`/api/task?project=my&userId=${MOCK_TELEGRAM_ID}`
);
const data = await response.json();

const allTasks = [
...data.todo,
...data.progress,
...data.review,
...data.done,
];

if (allTasks.length > 0) {
const task = allTasks[0];

expect(task).toHaveProperty('id');
expect(task).toHaveProperty('title');
expect(task).toHaveProperty('type');
expect(task).toHaveProperty('date');
expect(task).toHaveProperty('serial');
}
});
});

test.describe('API: /api/teammate', () => {
test('GET /api/teammate/:workspaceId returns teammates', async ({ request }) => {
const wsResponse = await request.get(`/api/workspace?userId=${MOCK_TELEGRAM_ID}`);
const wsData = await wsResponse.json();

if (wsData.workspaces?.length > 0) {
const workspaceId = wsData.workspaces[0].id;
const response = await request.get(`/api/teammate/${workspaceId}`);

expect(response.status()).toBe(200);

const data = await response.json();

expect(data).toHaveProperty('workspaceId');
expect(data).toHaveProperty('teammates');
expect(Array.isArray(data.teammates)).toBe(true);
}
});
});

test.describe('API: error handling', () => {
test('GET /api/workspace/my without userId returns 400', async ({ request }) => {
const response = await request.get('/api/workspace/my');

expect(response.status()).toBe(400);
});

test('GET /api/workspace/999999 returns 404', async ({ request }) => {
const response = await request.get('/api/workspace/999999');

expect(response.status()).toBe(404);
});

test('GET /api/task?project=my without userId returns valid response', async ({ request }) => {
const response = await request.get('/api/task?project=my');

expect(response.ok()).toBe(true);
});
});
53 changes: 53 additions & 0 deletions e2e/pages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';


test.describe('Page loading', () => {
test('homepage loads and redirects', async ({ page }) => {
const response = await page.goto('/');

expect(response?.status()).toBeLessThan(400);
});

test('my-workspace page loads', async ({ page }) => {
const response = await page.goto('/my-workspace');

expect(response?.status()).toBeLessThan(400);
await expect(page).toHaveURL(/my-workspace/);
});

test('profile page loads', async ({ page }) => {
const response = await page.goto('/profile');

expect(response?.status()).toBeLessThan(400);
});

test('workspace page loads', async ({ page }) => {
const workspaces = await page.request.get('/api/workspace');
const data = await workspaces.json();

if (Array.isArray(data) && data.length > 0) {
const response = await page.goto(`/workspace/${data[0].id}`);

expect(response?.status()).toBeLessThan(400);
}
});

test('no console errors on my-workspace', async ({ page }) => {
const errors: string[] = [];

page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});

await page.goto('/my-workspace');
await page.waitForTimeout(2000);

const criticalErrors = errors.filter(
(e) => !e.includes('favicon') && !e.includes('hydration')
);

expect(criticalErrors).toHaveLength(0);
});
});
Loading