diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 7304c3d8..c9bef58d 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -3,9 +3,10 @@ name: backend on: push: branches: [main] - paths: ["backend/**", ".github/workflows/backend.yml"] + # The tests build their database from the role scripts in infra/compose/postgres/initdb (tests/conftest.py) + paths: ["backend/**", "infra/compose/postgres/initdb/**", ".github/workflows/backend.yml"] pull_request: - paths: ["backend/**", ".github/workflows/backend.yml"] + paths: ["backend/**", "infra/compose/postgres/initdb/**", ".github/workflows/backend.yml"] concurrency: group: backend-${{ github.ref }} @@ -17,7 +18,8 @@ permissions: jobs: checks: runs-on: ubuntu-24.04 - timeout-minutes: 15 + # 4,120 tests on 4 workers take about 7.5 minutes on a 4-CPU machine; this leaves room for a slower runner + timeout-minutes: 30 defaults: run: working-directory: backend diff --git a/apps/web/src/features/auth/invite-screen.test.tsx b/apps/web/src/features/auth/invite-screen.test.tsx index 3e8d9375..885159c7 100644 --- a/apps/web/src/features/auth/invite-screen.test.tsx +++ b/apps/web/src/features/auth/invite-screen.test.tsx @@ -77,8 +77,8 @@ describe('InviteScreen', () => { it('puts the reason the API refuses a password on the password field, keeping the name', async () => { const rendered = await openInvitation(); - await register(rendered, 'lena-2026-password'); - expect(await screen.findByText('Do not use your email address in the password.')).toBeInTheDocument(); + await register(rendered, 'short'); + expect(await screen.findByText(/^Use at least \d+ characters\.$/)).toBeInTheDocument(); expect(screen.getByLabelText('Your name')).toHaveValue('Lena Vogel'); }); diff --git a/apps/web/src/features/rankings/ranking-pages.tsx b/apps/web/src/features/rankings/ranking-pages.tsx index d74d2050..f179de81 100644 --- a/apps/web/src/features/rankings/ranking-pages.tsx +++ b/apps/web/src/features/rankings/ranking-pages.tsx @@ -62,15 +62,16 @@ export function RankingPagesCard({ trigger, pages, checkedAt, timeZone }: Rankin const t = useTranslations('rankings.pages'); const rank = useTranslations('rankings.rank'); return ( - +

{t('title', { count: pages.length })}

- + {/* A fixed layout keeps a long path inside the card: the page column takes what the rank leaves and truncates */} +
- @@ -78,11 +79,12 @@ export function RankingPagesCard({ trigger, pages, checkedAt, timeZone }: Rankin {pages.map((page, index) => ( -
{t('page')} + {rank('rank')}
+ {page.url.path} diff --git a/apps/web/src/features/rankings/rankings.test.tsx b/apps/web/src/features/rankings/rankings.test.tsx index 1af6a233..61a8c30d 100644 --- a/apps/web/src/features/rankings/rankings.test.tsx +++ b/apps/web/src/features/rankings/rankings.test.tsx @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import en from '../../../messages/en.json'; -import type { TrackedKeywordState } from '@/lib/api/types'; +import type { TrackedKeywordRow, TrackedKeywordState } from '@/lib/api/types'; import { MOCK_ENTITY_IDS } from '@/mocks/entity-fixtures'; import { MOCK_KEYWORDS, MOCK_TRACKED_KEYWORDS } from '@/mocks/keyword-fixtures'; import { KEYWORD_TABLE } from '@/mocks/keyword-table'; @@ -509,10 +509,11 @@ describe('Rankings', () => { }); /** The fixture keywords as the mocks state them now: what the API will answer for each active row. */ -function fixtureStates(): { text: string; state: TrackedKeywordState }[] { +function fixtureStates(): { text: string; device: TrackedKeywordRow['device']; state: TrackedKeywordState }[] { const texts = new Map(MOCK_KEYWORDS.map((keyword) => [keyword.keyword_id, keyword.text])); return MOCK_TRACKED_KEYWORDS.filter((row) => row.is_active).map((row) => ({ text: texts.get(row.keyword_id) ?? '', + device: row.device, state: rankStateOf( row.tracked_keyword_id, row.created_at, @@ -967,8 +968,10 @@ describe('Rankings rank filters', () => { asked.some((query) => query.get('filter[rank_gte]') === '4' && query.get('filter[rank_lte]') === '10'), ).toBe(true), ); + // The table shows one device, desktop unless mobile is chosen const within4to10 = fixtureStates().filter( - ({ state }) => state.current.rank !== null && state.current.rank >= 4 && state.current.rank <= 10, + ({ device, state }) => + device === 'desktop' && state.current.rank !== null && state.current.rank >= 4 && state.current.rank <= 10, ); expect(within4to10.length).toBeGreaterThan(0); await waitFor(() => expect(keywordsIn(grid)).toHaveLength(within4to10.length)); diff --git a/apps/web/src/features/rankings/strip/rank-strip.test.tsx b/apps/web/src/features/rankings/strip/rank-strip.test.tsx index 24563d1b..9e97a96b 100644 --- a/apps/web/src/features/rankings/strip/rank-strip.test.tsx +++ b/apps/web/src/features/rankings/strip/rank-strip.test.tsx @@ -35,11 +35,11 @@ describe('RankStrip', () => { expect(await screen.findByText(String(activeCount))).toBeInTheDocument(); expect(await screen.findByText('55 checked')).toBeInTheDocument(); expect(screen.getByText('Top 3')).toBeInTheDocument(); - expect(screen.getByText('6')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); expect(screen.getByText('Top 10')).toBeInTheDocument(); expect(screen.getByText('12')).toBeInTheDocument(); expect(screen.getByText('Top 20')).toBeInTheDocument(); - expect(screen.getByText('28')).toBeInTheDocument(); + expect(screen.getByText('23')).toBeInTheDocument(); expect(screen.getAllByText('of 55 checked')).toHaveLength(3); }); @@ -85,7 +85,7 @@ describe('RankStrip', () => { expect(screen.getByText('Top 10')).toBeInTheDocument(); expect(screen.getByText('12')).toBeInTheDocument(); expect(screen.getByText('Top 20')).toBeInTheDocument(); - expect(screen.getByText('28')).toBeInTheDocument(); + expect(screen.getByText('23')).toBeInTheDocument(); expect(screen.getByText('The summary did not load.')).toBeInTheDocument(); }); diff --git a/apps/web/src/features/rankings/strip/rank-strip.tsx b/apps/web/src/features/rankings/strip/rank-strip.tsx index 342fff5d..5c319a83 100644 --- a/apps/web/src/features/rankings/strip/rank-strip.tsx +++ b/apps/web/src/features/rankings/strip/rank-strip.tsx @@ -20,8 +20,8 @@ export interface RankStripProps { /** * Summary metric strip of the filtered keywords (24-page-templates.md section 9.3). - * Four counts of tracked keywords (a keyword on each device is two): Keywords (with checked count), Top 3, Top 10 - * and Top 20 (each of checked count). The device chosen narrows all four. + * Four counts of tracked keywords: Keywords (with checked count), Top 3, Top 10 and Top 20 (each of checked + * count). The filters carry the device shown, so each keyword counts once and Keywords equals the table's count. */ export function RankStrip({ scope, filters, total, enabled = true }: RankStripProps): ReactElement { const t = useTranslations('rankings.strip'); @@ -54,8 +54,7 @@ export function RankStrip({ scope, filters, total, enabled = true }: RankStripPr }; // The strip ignores rows without a check date for rank counts (24-page-templates.md section 9.3). - // The count of keywords is of tracked keywords, one for each device: both devices chosen, a keyword counts - // twice, as its Top 3, Top 10 and Top 20 do, and one device chosen counts only its own. + // The count of keywords is of the device shown, like the table's: a keyword tracked on both devices is one. const items: MetricStripItem[] = [ { id: 'tracked', diff --git a/apps/web/src/mocks/handlers/costs.ts b/apps/web/src/mocks/handlers/costs.ts index 1711e2d5..8e87228f 100644 --- a/apps/web/src/mocks/handlers/costs.ts +++ b/apps/web/src/mocks/handlers/costs.ts @@ -90,7 +90,7 @@ const keyOf = (event: CostEvent, key: GroupKey): string => { }; /** Today in UTC, as the API's default dates are. */ -const today = (): string => new Date().toISOString().slice(0, 10); +const today = (): string => new Date(Date.now()).toISOString().slice(0, 10); export const costHandlers: HttpHandler[] = [ mock('get', `${W}/costs`, ({ request, cookies, params }) => { diff --git a/backend/check.sh b/backend/check.sh index ff8a639a..246ecfde 100755 --- a/backend/check.sh +++ b/backend/check.sh @@ -6,4 +6,7 @@ uv run ruff check . uv run ruff format --check . uv run mypy uv run lint-imports -uv run pytest "$@" +# Four workers, each with its own PostgreSQL, Redis and object store containers (the session fixtures run +# once per worker). The suite waits on the databases far more than on the CPU, so this fits the 4-vCPU CI +# runner and a 4-CPU Colima. Pass -n 0 to run serially. +uv run pytest -n 4 "$@" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b20d391f..921e0955 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -162,6 +162,7 @@ dev = [ "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", + "pytest-xdist>=3.8", "ruff>=0.16.9", "testcontainers[postgres,redis]>=4.15.0", "types-aiobotocore[s3]>=3.9.1", diff --git a/backend/uv.lock b/backend/uv.lock index 51b56b11..3128e698 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -459,6 +459,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastapi" version = "0.142.1" @@ -692,6 +701,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] +[[package]] +name = "idraft" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiobotocore" }, + { name = "aiohttp" }, + { name = "alembic" }, + { name = "argon2-cffi" }, + { name = "asyncpg" }, + { name = "cryptography" }, + { name = "fastapi" }, + { name = "google-re2" }, + { name = "httpx" }, + { name = "idna" }, + { name = "publicsuffixlist" }, + { name = "pyahocorasick" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyotp" }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "sentry-sdk", extra = ["fastapi"] }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "structlog" }, + { name = "taskiq" }, + { name = "taskiq-redis" }, + { name = "tzdata" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "zstandard" }, +] + +[package.dev-dependencies] +dev = [ + { name = "asgi-lifespan" }, + { name = "asyncpg-stubs" }, + { name = "hypothesis" }, + { name = "import-linter" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "testcontainers", extra = ["redis"] }, + { name = "types-aiobotocore", extra = ["s3"] }, +] + +[package.metadata] +requires-dist = [ + { name = "aiobotocore", specifier = ">=3.9.1" }, + { name = "aiohttp", specifier = ">=3.14.3" }, + { name = "alembic", specifier = ">=1.20.0" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "cryptography", specifier = ">=50.0.1" }, + { name = "fastapi", specifier = ">=0.142.1" }, + { name = "google-re2", specifier = "==1.1.20251105" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "idna", specifier = "==3.20" }, + { name = "publicsuffixlist", specifier = "==1.0.2.20260925" }, + { name = "pyahocorasick", specifier = "==2.3.1" }, + { name = "pydantic", specifier = ">=2.13.5" }, + { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "pyotp", specifier = ">=2.10.0" }, + { name = "python-multipart", specifier = ">=0.0.32" }, + { name = "redis", specifier = ">=8.1.0" }, + { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.71.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.1.1" }, + { name = "structlog", specifier = ">=26.1.0" }, + { name = "taskiq", specifier = ">=0.13.0" }, + { name = "taskiq-redis", specifier = ">=1.2.4" }, + { name = "tzdata", specifier = ">=2026.4" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" }, + { name = "zstandard", specifier = ">=0.25.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "asgi-lifespan", specifier = ">=2.1.0" }, + { name = "asyncpg-stubs", specifier = ">=0.31.3" }, + { name = "hypothesis", specifier = ">=6.168.3" }, + { name = "import-linter", specifier = ">=2.15" }, + { name = "mypy", specifier = ">=2.3.1" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-xdist", specifier = ">=3.8" }, + { name = "ruff", specifier = ">=0.16.9" }, + { name = "testcontainers", extras = ["postgres", "redis"], specifier = ">=4.15.0" }, + { name = "types-aiobotocore", extras = ["s3"], specifier = ">=3.9.1" }, +] + [[package]] name = "import-linter" version = "2.15" @@ -1097,6 +1199,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1235,97 +1350,6 @@ fastapi = [ { name = "fastapi" }, ] -[[package]] -name = "idraft" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "aiobotocore" }, - { name = "aiohttp" }, - { name = "alembic" }, - { name = "argon2-cffi" }, - { name = "asyncpg" }, - { name = "cryptography" }, - { name = "fastapi" }, - { name = "google-re2" }, - { name = "httpx" }, - { name = "idna" }, - { name = "publicsuffixlist" }, - { name = "pyahocorasick" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyotp" }, - { name = "python-multipart" }, - { name = "redis" }, - { name = "sentry-sdk", extra = ["fastapi"] }, - { name = "sqlalchemy", extra = ["asyncio"] }, - { name = "structlog" }, - { name = "taskiq" }, - { name = "taskiq-redis" }, - { name = "tzdata" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "zstandard" }, -] - -[package.dev-dependencies] -dev = [ - { name = "asgi-lifespan" }, - { name = "asyncpg-stubs" }, - { name = "hypothesis" }, - { name = "import-linter" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "ruff" }, - { name = "testcontainers", extra = ["redis"] }, - { name = "types-aiobotocore", extra = ["s3"] }, -] - -[package.metadata] -requires-dist = [ - { name = "aiobotocore", specifier = ">=3.9.1" }, - { name = "aiohttp", specifier = ">=3.14.3" }, - { name = "alembic", specifier = ">=1.20.0" }, - { name = "argon2-cffi", specifier = ">=25.1.0" }, - { name = "asyncpg", specifier = ">=0.31.0" }, - { name = "cryptography", specifier = ">=50.0.1" }, - { name = "fastapi", specifier = ">=0.142.1" }, - { name = "google-re2", specifier = "==1.1.20251105" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "idna", specifier = "==3.20" }, - { name = "publicsuffixlist", specifier = "==1.0.2.20260925" }, - { name = "pyahocorasick", specifier = "==2.3.1" }, - { name = "pydantic", specifier = ">=2.13.5" }, - { name = "pydantic-settings", specifier = ">=2.15.0" }, - { name = "pyotp", specifier = ">=2.10.0" }, - { name = "python-multipart", specifier = ">=0.0.32" }, - { name = "redis", specifier = ">=8.1.0" }, - { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.71.0" }, - { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.1.1" }, - { name = "structlog", specifier = ">=26.1.0" }, - { name = "taskiq", specifier = ">=0.13.0" }, - { name = "taskiq-redis", specifier = ">=1.2.4" }, - { name = "tzdata", specifier = ">=2026.4" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" }, - { name = "zstandard", specifier = ">=0.25.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "asgi-lifespan", specifier = ">=2.1.0" }, - { name = "asyncpg-stubs", specifier = ">=0.31.3" }, - { name = "hypothesis", specifier = ">=6.168.3" }, - { name = "import-linter", specifier = ">=2.15" }, - { name = "mypy", specifier = ">=2.3.1" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "pytest-asyncio", specifier = ">=1.4.0" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "ruff", specifier = ">=0.16.9" }, - { name = "testcontainers", extras = ["postgres", "redis"], specifier = ">=4.15.0" }, - { name = "types-aiobotocore", extras = ["s3"], specifier = ">=3.9.1" }, -] - [[package]] name = "six" version = "1.17.0"