Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5a85ab6
feat(db): add MariaDB/MySQL support with auto-provisioning
pricyproject Jun 6, 2026
daf9de2
chore(deploy): add optional MariaDB service and env template
pricyproject Jun 6, 2026
1247b60
feat(auth): secure web-based first-run setup
pricyproject Jun 6, 2026
75c7400
feat(settings): admin-configurable default language
pricyproject Jun 6, 2026
90d17b3
docs: document MariaDB, first-run setup, and DB naming
pricyproject Jun 6, 2026
c0af117
fix(deps): update PyMySQL==1.1.1 (MariaDB/MySQL driver)
pricyproject Jun 6, 2026
19c9a5c
fix(database): add MySQL dialect-scoped compiler hook for NULLS LAST/…
pricyproject Jun 6, 2026
244ceda
feat(api): per-project sequential IDs and project-based identity
pricyproject Jun 7, 2026
184c0ab
feat(definitions): make test types, priorities and step templates per…
pricyproject Jun 7, 2026
bf0fe54
feat(reports): expose project_seq in cross-reference and doc APIs
pricyproject Jun 7, 2026
dfa6885
perf(db): add composite and partial indexes migration
pricyproject Jun 7, 2026
0a6fbd6
feat(web): project_seq foundation (resolver, helpers, types)
pricyproject Jun 7, 2026
5d11aef
feat(web): project-first URLs and badges across entities
pricyproject Jun 7, 2026
b7d4796
feat(web): dedicated /administrator settings and per-project test man…
pricyproject Jun 7, 2026
ac81224
fix(search): TQL id field is the per-project number, not the global id
pricyproject Jun 7, 2026
1b4893d
feat(web): show Administrator in the profile dropdown for admins
pricyproject Jun 7, 2026
b518069
fix(definitions): swallow concurrent seed race (MariaDB 1020) in defa…
pricyproject Jun 7, 2026
3cf9879
Remove tech stack details from README
pricyproject Jun 7, 2026
a583ba9
fix(frontend): display correct user in revision history modal
pricyproject Jun 7, 2026
fb9b0a1
fix(backend): add missing color parameter to VersionTag creation in v…
pricyproject Jun 7, 2026
31effaa
feat(test-runs): implement environment snapshot functionality for tes…
pricyproject Jun 7, 2026
0412a26
fix(execution): prevent NaN being passed to execution history API
pricyproject Jun 7, 2026
c96c941
feat: add Enter key support for saving in modal dialogs
pricyproject Jun 7, 2026
fd46221
fix: prevent view toggle from wrapping to far right on requirements t…
pricyproject Jun 7, 2026
c55a0ce
fix(defects): fix defect ID generation and validation
pricyproject Jun 7, 2026
30ef697
feat(setup): add language selector to setup page
pricyproject Jun 7, 2026
6e35707
fix(ai): prevent reasoning-model truncation and malformed JSON from f…
pricyproject Jun 7, 2026
f536b6d
fix(ai): show configured provider on failed AI calls and auto-activat…
pricyproject Jun 7, 2026
badba43
fix(execution): reset form when paging to next test case after Save &…
pricyproject Jun 7, 2026
23384c0
fix(execution): scope prev/next list to the run once its id resolves
pricyproject Jun 8, 2026
4ec89d0
feat: allow test cases in multiple suites
Sallsup77 Jun 8, 2026
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
35 changes: 35 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# TestMona environment configuration (used by docker-compose and the backend).
# Copy to ".env" and adjust. Never commit your real .env.

# ── Database ──────────────────────────────────────────────────────────────────
# Default: SQLite (zero-config, stored in the backend_data volume).
DATABASE_URL=sqlite:////app/data/test_management.db
#
# MariaDB/MySQL — run `docker compose --profile mariadb up` and use the service
# name "mariadb" as the host. The database is auto-created on first start.
# DATABASE_URL=mysql+pymysql://root:changeme@mariadb:3306/test_management?charset=utf8mb4
#
# PostgreSQL example:
# DATABASE_URL=postgresql+psycopg://postgres:changeme@postgres:5432/test_management

# Credentials for the optional bundled MariaDB service (profile: mariadb).
MARIADB_ROOT_PASSWORD=changeme
MARIADB_DATABASE=test_management

# ── Security ──────────────────────────────────────────────────────────────────
# REQUIRED in production. Generate one with:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=480
REFRESH_TOKEN_EXPIRE_DAYS=7

# ── Networking ────────────────────────────────────────────────────────────────
ALLOWED_ORIGINS=http://localhost:3000
BACKEND_PORT=8000
FRONTEND_PORT=3000
# Path/URL the built frontend uses to reach the API.
VITE_API_URL=/api

# Note: no admin credentials are seeded. On first launch the app routes you to
# the web setup wizard (/setup) to create your administrator account.
31 changes: 19 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,6 @@ Built with a React frontend and a FastAPI API.
- **Audit & Collaboration** — Project audit trails (with purge), an RTL-aware
notification system, and full internationalization (multi-language + RTL).

## Tech Stack

- Frontend: React 19, TypeScript, Vite, Tailwind CSS
- Backend: FastAPI, SQLAlchemy, Alembic, Pydantic, JWT auth
- AI: Pluggable provider manager for assistant and generation workflows
- Tooling: Docker Compose, ESLint, Postman collections

## Quick Start

Expand All @@ -66,17 +60,30 @@ npm run dev

Open the app at [http://localhost:3000](http://localhost:3000). The backend API is available at [http://localhost:8000](http://localhost:8000), and Swagger docs are available at [http://localhost:8000/api-docs](http://localhost:8000/api-docs).

## Docker
## First-run setup

No default credentials are shipped. On first launch the app sends you to a web setup wizard (`/setup`) to create your administrator account. For security, that one-time step is protected by a **setup token**: the backend prints it to the server logs on startup and writes it to `backend/.setup_token`. Paste the token into the wizard to create the admin. After that, the token is invalidated and public signup is disabled.

For automated/headless provisioning, set the token yourself with the `SETUP_TOKEN` environment variable.

## Database

SQLite is the zero-config default. To use **MariaDB/MySQL** (or PostgreSQL), set `DATABASE_URL` — the target database is auto-created on first start:

```bash
docker-compose up --build
# MariaDB / MySQL (driver: PyMySQL, already in requirements)
DATABASE_URL="mysql+pymysql://user:password@localhost:3306/test_management?charset=utf8mb4"
# PostgreSQL
DATABASE_URL="postgresql+psycopg://user:password@localhost:5432/test_management"
```

## Demo Login
**Database naming:** the database name is the last path segment of `DATABASE_URL` — you choose it. Prefer lowercase with underscores, no hyphens (e.g. `testmona`, `testmona_production`); avoid `test`-prefixed names on MySQL/MariaDB. The app auto-creates the database on first start if the user has the privilege; for production, a DBA can instead pre-create it with a least-privilege user scoped to that database — startup checks for existence first and won't require `CREATE DATABASE`.

```text
Email: demo@testmona.com
Password: demo123
## Docker

```bash
docker compose up --build # SQLite (default)
docker compose --profile mariadb up # bundled MariaDB service
```

## Repository Layout
Expand Down
4 changes: 4 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ celerybeat.pid
# SageMath parsed files
*.sage.py

# Local secrets (never commit)
.secret_key
.setup_token

# Environments
.env
.venv
Expand Down
32 changes: 30 additions & 2 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
from app.database import Base
from app.models import *
from app.models_versioning import *
from app.config import settings

config = context.config

database_url = os.getenv("DATABASE_URL")
# Honor the same database configuration the app uses: an explicit DATABASE_URL
# environment variable wins, otherwise fall back to the app settings (which read
# the .env file). This keeps `alembic` and the running app pointed at the same DB.
database_url = os.getenv("DATABASE_URL") or settings.database_url
if database_url:
config.set_main_option("sqlalchemy.url", database_url)

Expand All @@ -37,15 +41,39 @@ def run_migrations_offline() -> None:


def run_migrations_online() -> None:
# Provision the target database for server backends before connecting
# (no-op for SQLite). Mirrors the app's init_db() behavior.
from app.database import ensure_database_exists
ensure_database_exists(config.get_main_option("sqlalchemy.url"))

connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

render_as_batch = connectable.dialect.name == "sqlite"

with connectable.connect() as connection:
# This project uses descriptive revision identifiers longer than 32
# characters. Alembic's default alembic_version.version_num is
# VARCHAR(32), which SQLite ignores but length-enforcing backends
# (MySQL/MariaDB) reject. Pre-create the table with room to spare.
if connection.dialect.name != "sqlite":
from sqlalchemy import text
connection.execute(
text(
"CREATE TABLE IF NOT EXISTS alembic_version ("
"version_num VARCHAR(255) NOT NULL, "
"CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num))"
)
)
connection.commit()

context.configure(
connection=connection, target_metadata=target_metadata
connection=connection,
target_metadata=target_metadata,
render_as_batch=render_as_batch,
)

with context.begin_transaction():
Expand Down
179 changes: 179 additions & 0 deletions backend/alembic/versions/add_composite_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Add composite and partial indexes for common query patterns

Revision ID: add_composite_indexes
Revises: add_project_scoped_definitions
Create Date: 2026-06-07 12:00:00.000000

Adds composite indexes to optimize common query patterns:
- test_results(test_run_id, status) - for filtering results by run and status
- test_results(test_case_id, executed_at) - for test case execution history
- requirements(project_id, status, priority) - for requirement filtering
- defects(project_id, status, severity) - for defect triage
- audit_trails(user_id, created_at) - for user activity queries
- doc_visits(user_id, last_visited_at) - for recent docs sorting

Adds partial indexes (PostgreSQL/SQLite only) for better performance:
- test_results(status) WHERE status != 'not_started' - exclude unexecuted results
- defects(status) WHERE status != 'closed' - active defects only
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect, text

from app.services.migration_helpers import table_exists, index_exists


revision = "add_composite_indexes"
down_revision = "add_project_scoped_definitions"
branch_labels = None
depends_on = None


def upgrade() -> None:
connection = op.get_bind()
inspector = inspect(connection)

# test_results(test_run_id, status) - for filtering results by run and status
if table_exists(connection, "test_results"):
if not index_exists(connection, "test_results", "ix_test_results_test_run_id_status"):
if connection.dialect.name == "postgresql":
op.execute("CREATE INDEX CONCURRENTLY ix_test_results_test_run_id_status ON test_results (test_run_id, status)")
else:
op.create_index(
"ix_test_results_test_run_id_status",
"test_results",
["test_run_id", "status"]
)

if not index_exists(connection, "test_results", "ix_test_results_test_case_id_executed_at"):
if connection.dialect.name == "postgresql":
op.execute("CREATE INDEX CONCURRENTLY ix_test_results_test_case_id_executed_at ON test_results (test_case_id, executed_at)")
else:
op.create_index(
"ix_test_results_test_case_id_executed_at",
"test_results",
["test_case_id", "executed_at"]
)

# requirements(project_id, status, priority) - for requirement filtering
if table_exists(connection, "requirements"):
if not index_exists(connection, "requirements", "ix_requirements_project_id_status_priority"):
if connection.dialect.name == "postgresql":
op.execute("CREATE INDEX CONCURRENTLY ix_requirements_project_id_status_priority ON requirements (project_id, status, priority)")
else:
op.create_index(
"ix_requirements_project_id_status_priority",
"requirements",
["project_id", "status", "priority"]
)

# defects(project_id, status, severity) - for defect triage
if table_exists(connection, "defects"):
if not index_exists(connection, "defects", "ix_defects_project_id_status_severity"):
if connection.dialect.name == "postgresql":
op.execute("CREATE INDEX CONCURRENTLY ix_defects_project_id_status_severity ON defects (project_id, status, severity)")
else:
op.create_index(
"ix_defects_project_id_status_severity",
"defects",
["project_id", "status", "severity"]
)

# audit_trails(user_id, created_at) - for user activity queries
if table_exists(connection, "audit_trails"):
if not index_exists(connection, "audit_trails", "ix_audit_trails_user_id_created_at"):
if connection.dialect.name == "postgresql":
op.execute("CREATE INDEX CONCURRENTLY ix_audit_trails_user_id_created_at ON audit_trails (user_id, created_at)")
else:
op.create_index(
"ix_audit_trails_user_id_created_at",
"audit_trails",
["user_id", "created_at"]
)

# doc_visits(user_id, last_visited_at) - for recent docs sorting
if table_exists(connection, "doc_visits"):
if not index_exists(connection, "doc_visits", "ix_doc_visits_user_id_last_visited_at"):
# Use CONCURRENTLY for PostgreSQL to avoid table locking on large tables
if connection.dialect.name == "postgresql":
op.execute("CREATE INDEX CONCURRENTLY ix_doc_visits_user_id_last_visited_at ON doc_visits (user_id, last_visited_at)")
else:
op.create_index(
"ix_doc_visits_user_id_last_visited_at",
"doc_visits",
["user_id", "last_visited_at"]
)

# Partial indexes for better performance on large tables
# test_results(status) WHERE status != 'not_started' - exclude unexecuted results
if table_exists(connection, "test_results"):
if not index_exists(connection, "test_results", "ix_test_results_status_active"):
if connection.dialect.name == "postgresql":
op.execute("CREATE INDEX CONCURRENTLY ix_test_results_status_active ON test_results (status) WHERE status != 'not_started'")
elif connection.dialect.name == "sqlite":
op.execute("CREATE INDEX ix_test_results_status_active ON test_results (status) WHERE status != 'not_started'")
# MySQL doesn't support partial indexes until very recent versions, skip for compatibility

# defects(status) WHERE status != 'closed' - active defects only
if table_exists(connection, "defects"):
if not index_exists(connection, "defects", "ix_defects_status_active"):
if connection.dialect.name == "postgresql":
op.execute("CREATE INDEX CONCURRENTLY ix_defects_status_active ON defects (status) WHERE status != 'closed'")
elif connection.dialect.name == "sqlite":
op.execute("CREATE INDEX ix_defects_status_active ON defects (status) WHERE status != 'closed'")
# MySQL doesn't support partial indexes until very recent versions, skip for compatibility


def downgrade() -> None:
connection = op.get_bind()

# Drop composite indexes (with error handling for FK constraints)
if table_exists(connection, "test_results"):
try:
if index_exists(connection, "test_results", "ix_test_results_test_run_id_status"):
op.drop_index("ix_test_results_test_run_id_status", "test_results")
except Exception:
pass # Index may be required by FK constraint
try:
if index_exists(connection, "test_results", "ix_test_results_test_case_id_executed_at"):
op.drop_index("ix_test_results_test_case_id_executed_at", "test_results")
except Exception:
pass
try:
if index_exists(connection, "test_results", "ix_test_results_status_active"):
op.drop_index("ix_test_results_status_active", "test_results")
except Exception:
pass

if table_exists(connection, "requirements"):
try:
if index_exists(connection, "requirements", "ix_requirements_project_id_status_priority"):
op.drop_index("ix_requirements_project_id_status_priority", "requirements")
except Exception:
pass

if table_exists(connection, "defects"):
try:
if index_exists(connection, "defects", "ix_defects_project_id_status_severity"):
op.drop_index("ix_defects_project_id_status_severity", "defects")
except Exception:
pass
try:
if index_exists(connection, "defects", "ix_defects_status_active"):
op.drop_index("ix_defects_status_active", "defects")
except Exception:
pass

if table_exists(connection, "audit_trails"):
try:
if index_exists(connection, "audit_trails", "ix_audit_trails_user_id_created_at"):
op.drop_index("ix_audit_trails_user_id_created_at", "audit_trails")
except Exception:
pass

if table_exists(connection, "doc_visits"):
try:
if index_exists(connection, "doc_visits", "ix_doc_visits_user_id_last_visited_at"):
op.drop_index("ix_doc_visits_user_id_last_visited_at", "doc_visits")
except Exception:
pass
Loading