Commit Graph

295 Commits

Author SHA1 Message Date
Dries Peeters 09146fcd2b fix: resolve app version from setup.py when Docker uses dev-0
Generic images set APP_VERSION=dev-0 by default, which hid the real
semver in the UI (e.g. vdev-0). Treat that value as unset so setup.py
wins unless a real override is set; add a regression test.
2026-05-15 06:47:58 +02:00
Dries Peeters 519b3eae94 Merge pull request #637 from MacJediWizard/upstream-fix/cache-tests-app-context
test(cache): use real app fixture instead of mocking Flask's LocalProxy
2026-05-15 06:33:51 +02:00
Dries Peeters 5f28927e98 Merge pull request #636 from MacJediWizard/upstream-fix/webhook-status-after-mark-failed
test(webhook): disable retries in fixture so failure tests see 'failed' status
2026-05-15 06:33:37 +02:00
Dries Peeters 1386b303d0 Merge pull request #635 from MacJediWizard/upstream-fix/missing-test-fixtures
test(fixtures): add 6 missing fixtures referenced by refactored API v1 tests
2026-05-15 06:33:22 +02:00
Dries Peeters 0a4c75ecbc Merge pull request #634 from MacJediWizard/upstream-fix/project-costs-fixture-secret
test(project_costs): add SECRET_KEY to local fixture so create_app boots
2026-05-15 06:33:07 +02:00
Dries Peeters 5d7afe275f Merge pull request #633 from MacJediWizard/upstream-fix/permissions-and-stale-asserts
fix: three independent CI failures (perm bypass, dup Role, float assert)
2026-05-15 06:32:54 +02:00
Dries Peeters a464e64e80 Merge pull request #632 from MacJediWizard/upstream-fix/unique-sqlite-paths-refactored
fix(test): _refactored tests use FlaskClient as 'client', mileage float mismatch
2026-05-15 06:32:41 +02:00
Dries Peeters 44262fcbc2 Merge pull request #631 from MacJediWizard/upstream-fix/oidc-session-bloat-test
test(oidc): pre-create user so callback reaches token-storage code
2026-05-15 06:32:28 +02:00
Dries Peeters af2232a5b5 Merge pull request #630 from MacJediWizard/upstream-fix/unique-test-sqlite-paths
test(api-v1): use unique tempfile path per fixture, not hardcoded sqlite name
2026-05-15 06:32:12 +02:00
Dries Peeters 59c08e9032 Merge pull request #628 from MacJediWizard/upstream-fix/stale-integration-tests
fix(test): refresh stale integration tests after connector refactors
2026-05-15 06:31:44 +02:00
Dries Peeters 41121a696f Merge pull request #626 from MacJediWizard/upstream-fix/timer-edit-tests-detached-role
fix(test): re-query user inside session to avoid detached Role error
2026-05-15 06:31:15 +02:00
Dries Peeters b087e87c79 Merge pull request #625 from MacJediWizard/upstream-fix/ci-test-fixes-batch-1
fix(tests, api): four small CI failures from v5.5.6 test-fixture tightening
2026-05-15 06:31:00 +02:00
MacJediWizard 2f53325185 test(cache): use real app fixture instead of mocking Flask's LocalProxy
TestCacheIntegration::test_get_cache_with_redis_enabled and
test_get_cache_fallback_to_memory were both failing with:

    RuntimeError: Working outside of application context.

The tests patched `app.utils.cache.current_app` with a MagicMock and
set `mock_app.config = {...}`. In theory that should isolate get_cache()
from Flask's LocalProxy entirely. In practice the RuntimeError still
fires — most likely because (a) module-level `_cache` left over from a
prior test holds a reference that triggers a LocalProxy access at
return time, or (b) a code path inside the patched function still
reaches the real `current_app` past the mock boundary.

Rather than diagnose the exact mock-interaction issue, rewrite both
tests to use the real `app` fixture from conftest.py:

- Set `REDIS_ENABLED` / `REDIS_URL` / `REDIS_DEFAULT_TTL` on the real
  Flask config inside `with app.app_context():`.
- Reset `app.utils.cache._cache = None` explicitly so the global
  doesn't leak state between tests.
- Keep the `patch("app.utils.cache.RedisCache")` since we don't want
  the test to actually hit Redis; only the read of `current_app.config`
  needs to be real.

This is closer to how get_cache() runs in production (real Flask
context, mocked external dependency) and avoids the LocalProxy ↔ mock
collision entirely.

Test plan
- pytest tests/test_utils/test_cache.py::TestCacheIntegration::test_get_cache_with_redis_enabled
- pytest tests/test_utils/test_cache.py::TestCacheIntegration::test_get_cache_fallback_to_memory
- pytest tests/test_utils/test_cache.py (the other 16 tests in the file should be unaffected)
2026-05-14 17:31:32 -04:00
MacJediWizard 89f9c20a22 test(webhook): disable retries in fixture so failure tests see 'failed' status
`TestWebhookService::test_deliver_webhook_timeout` and
`test_deliver_webhook_http_error` both assert
`delivery.status == 'failed'` after a request error, but the test
fixture creates the `Webhook` with the model default `max_retries=3`.

The delivery flow at app/utils/webhook_service.py:127-141 does:

    delivery.mark_failed(error_type='timeout', ...)
    ...
    WebhookService._schedule_retry(delivery, webhook)

and `_schedule_retry` at line 244 calls `delivery.mark_retrying(...)`
when `retry_count < max_retries`. With max_retries=3, the very first
attempt gets retried, so `delivery.status` ends as 'retrying' rather
than 'failed'.

The two tests are checking *immediate* failure handling, not retry
orchestration. Setting `max_retries=0` on the fixture isolates that
concern. Retry behaviour itself is covered by
`test_retry_failed_deliveries`, which can use a separate webhook
with retries enabled if needed.

No service-code change required — the service is doing the right thing
for the production scenario (webhooks retry by default).

Test plan
- pytest tests/test_utils/test_webhook_service.py::TestWebhookService::test_deliver_webhook_timeout
- pytest tests/test_utils/test_webhook_service.py::TestWebhookService::test_deliver_webhook_http_error
- pytest tests/test_utils/test_webhook_service.py::TestWebhookService::test_deliver_webhook_success (unchanged behaviour)
2026-05-14 17:28:46 -04:00
MacJediWizard 6af9c5ba1a test(fixtures): add mileage, payment, expense, credit_note, recurring_invoice, quote
Sixteen tests in tests/test_routes/test_api_v1_*_refactored.py and
test_api_v1_recurring_invoices_credit_notes.py reference fixtures
(mileage, payment, expense, credit_note, recurring_invoice, quote) that
were never defined anywhere — not in conftest.py, not in the test files
themselves. Every affected test fails as ERROR (fixture-setup failure)
with pytest's "fixture not found" message.

Add the missing fixtures to tests/conftest.py, mirroring the existing
patterns used for the `invoice` / `project` / `test_client` fixtures:

- mileage: bound to user + project, populated with minimal valid trip
- expense: bound to user + project, "travel" category, today's date
- payment: bound to the test invoice, completed bank_transfer
- credit_note: bound to invoice + user, unique credit_number derived
  from invoice.id to avoid collisions
- recurring_invoice: monthly retainer template tied to project + client
- quote: draft quote tied to test_client and user

Each fixture commits via the function-scoped app fixture (so it ties
into the per-test sqlite isolation), refreshes the ORM instance, and
returns it. No models touched. No tests touched.

Test plan
- pytest tests/test_routes/test_api_v1_mileage_refactored.py
- pytest tests/test_routes/test_api_v1_payments_refactored.py
- pytest tests/test_routes/test_api_v1_recurring_invoices_credit_notes.py
- pytest tests/test_routes/test_api_v1_invoices_tasks_expenses_refactored.py
- pytest tests/test_routes/test_api_v1_quotes_refactored.py
- All previously-erroring tests should now reach their assertions
2026-05-14 17:27:22 -04:00
MacJediWizard 4921c7ec89 test(project_costs): add SECRET_KEY to local fixture so create_app boots
All 23 tests in `tests/test_project_costs.py` were failing as ERROR
(fixture-setup failure) with all-23-test impact from one root cause.

Root cause: the file's local `app` fixture passes
`{"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
"WTF_CSRF_ENABLED": False}` to `create_app(...)`. It does not supply
`SECRET_KEY`. The Unit Tests CI job does not export `FLASK_ENV`, so
`create_app` loads `ProductionConfig` which inherits
`SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")`.
The production-safety guard at `app/__init__.py:180-187` then raises
`ValueError: SECRET_KEY must be set explicitly in production.` The
exception fires inside the fixture, so every test in the file is
reported as ERROR before `db.create_all()` runs.

The guard was added in 531f16b5 (2026-03-06); the local fixture
predates it and was left behind. Every other test module uses the
project-wide `app` fixture in `tests/conftest.py:200` which already
sets both `FLASK_ENV: "testing"` and a non-sentinel `SECRET_KEY`.

Fix: add `"SECRET_KEY": "test-secret-key"` to the local fixture's
config dict. Minimum surgery — same `:memory:` SQLite, same TESTING
flag. Model code and the safety guard are both correct; only the test
fixture was stale.

Test plan
- pytest tests/test_project_costs.py
- All 23 tests (TestProjectCostModel, TestProjectCostConstraints,
  TestProjectCostMethods, TestProjectCostQueries, TestProjectCostIntegration)
  should reach their assertions instead of erroring at fixture setup
2026-05-14 17:20:54 -04:00
MacJediWizard 60449b92cb fix: three independent CI failures (perm bypass, dup Role, float assert)
### 1. has_permission() loses legacy-admin bypass after auto-assign (real bug)

`User.has_permission()` calls `_auto_assign_role_from_legacy()` which
side-effects: it queries the seeded "admin" Role and appends it to
`self.roles`. After the call, `self.roles` is no longer empty — so the
backward-compat bypass `if self.role == "admin" and not self.roles`
never fires for a legacy admin user. If the seeded admin Role has no
explicit permission rows assigned (as in tests), `has_permission()`
then returns False even though the user is documented to have all
permissions.

`test_legacy_admin_user_permissions` exercises exactly this and fails
with `assert admin.has_permission("any_permission") -> False`.

Fix: move the legacy-admin bypass check BEFORE the side-effecting
auto-assign call.

### 2. test_admin_role_user duplicates the seeded "admin" Role

`tests/conftest.py:334-337` seeds baseline Role rows ("admin", "user",
"manager", "subcontractor"). `test_admin_role_user` then creates
`Role(name="admin")` and commits, tripping the unique constraint on
roles.name with `IntegrityError`.

Fix: get-or-create. Re-use the existing seeded admin Role if present.

### 3. Stale assertion: expense amount returned as float, not string

`tests/test_routes/test_api_v1_expenses_complete.py:81` asserts
`data["expense"]["amount"] == "250.75"`, but `Expense.to_dict()` at
app/models/expense.py:189 returns `float(self.amount)`. The string
assertion never matches. Same pattern as the mileage `distance_km`
issue fixed in a sibling PR.

Test plan
- pytest tests/test_permissions.py::test_legacy_admin_user_permissions
- pytest tests/test_permissions.py::test_admin_role_user
- pytest tests/test_routes/test_api_v1_expenses_complete.py::TestAPIExpensesComplete::test_create_expense_all_fields
2026-05-14 17:19:03 -04:00
MacJediWizard 5c736340c4 fix(test): _refactored tests use FlaskClient as 'client', mileage float mismatch
Two narrow bugs in tests/test_routes/test_api_v1_*_refactored.py that
have been failing on every CI run.

### 1. Fixture name collision: 'client' is the Flask test client

Four test methods declared `client` as a fixture parameter:

    def test_create_project_uses_service_layer(self, app, client_with_token, client):
        ...
        json={..., "client_id": client.id, ...}

The conftest.py `client` fixture returns the Flask test client
(`FlaskClient`), not a Client model row, so `client.id` raised
`AttributeError: 'FlaskClient' object has no attribute 'id'`. The Client
model row is exposed by conftest as `test_client`. Rename the parameter
(and the `.id` references) in:

- test_create_project_uses_service_layer
- test_list_projects_with_filters
- test_create_quote_uses_service_layer
- test_create_invoice_uses_service_layer

### 2. distance_km is returned as a float, not a string

`test_create_mileage` asserts:

    assert data["mileage"]["distance_km"] == "50.5"

but `Mileage.to_dict()` returns `float(self.distance_km)` (see
app/models/mileage.py:192). The string assertion never matches. Fix the
test to compare to the float 50.5.

Test plan
- pytest tests/test_routes/test_api_v1_projects_refactored.py
- pytest tests/test_routes/test_api_v1_quotes_refactored.py
- pytest tests/test_routes/test_api_v1_invoices_tasks_expenses_refactored.py::TestAPIInvoicesRefactored::test_create_invoice_uses_service_layer
- pytest tests/test_routes/test_api_v1_mileage_refactored.py::TestAPIMileageRefactored::test_create_mileage
2026-05-14 17:12:59 -04:00
MacJediWizard 2692c0bace test(oidc): pre-create user so callback reaches token-storage code
`test_oidc_callback_does_not_store_id_token_in_cookie_session` has been
failing on every CI run with:

    AssertionError: assert 'oidc_id_token_key' in
        <SecureCookieSession {'_flashes': [('error',
        'User account does not exist and self-registration is disabled.')]}>

The OIDC callback flow needs to (a) find or create a User matching the
incoming token's identity, then (b) store the id_token server-side and
put only a short key in the session. The test exercises path (b), but
in the test environment `ALLOW_SELF_REGISTER` is false (the production-
safe default in `app/config.py:45`), so step (a) flashes the
"self-registration disabled" error and short-circuits with a redirect
to /login before the token-storage code under test even runs.

Fix: pre-create a `User` row matching the fake OIDC subject's username
+ email so step (a) finds the user and step (b) executes. The test is
about session bloat, not the user-creation path; making it independent
of the `ALLOW_SELF_REGISTER` setting keeps it focused.

Test plan
- pytest tests/test_oidc_session_cookie_bloat.py::test_oidc_callback_does_not_store_id_token_in_cookie_session
2026-05-14 17:09:05 -04:00
MacJediWizard 3e67791b87 test(api-v1): use unique tempfile path per fixture, not hardcoded sqlite name
20 test files share an anti-pattern: each declares its own local `app`
fixture with a hardcoded SQLite filename in the test runner's CWD,
e.g.

    "SQLALCHEMY_DATABASE_URI": "sqlite:///test_api_kanban.sqlite",

Two problems:

1. If a prior test run crashed before teardown, the .sqlite file
   persists. The next run starts on a non-empty database and trips
   over FK / uniqueness constraints — `test_api_tax_currency_flow`
   surfaces this as a 409 Conflict when its USD currency row already
   exists.

2. `test_api_invoice_templates.sqlite` is used by two files
   (`test_api_invoice_templates_v1.py` and
   `test_api_invoice_templates_api_v1.py`). Under pytest-xdist they
   race for the same file and corrupt each other's state.

Fix: each fixture now generates a unique per-call path using the same
pattern already established in `tests/conftest.py:204`:

    unique_db_path = os.path.join(
        tempfile.gettempdir(), f"test_<slug>_{uuid.uuid4().hex}.sqlite"
    )
    "SQLALCHEMY_DATABASE_URI": f"sqlite:///{unique_db_path}",

Adds `import os`, `import tempfile`, `import uuid` where missing.
Test lifecycle is unchanged — the existing fixture teardown
(`db.drop_all()` + engine dispose) still runs; the OS later reaps the
temp file. Black-formatted to the project's 120-char line limit.

Test plan
- pytest tests/test_api_kanban_v1.py
- pytest tests/test_api_tax_currency_v1.py (no leftover-USD 409)
- pytest -p xdist -n 2 tests/test_api_invoice_templates_v1.py tests/test_api_invoice_templates_api_v1.py (no collision)
2026-05-14 17:04:30 -04:00
MacJediWizard ed934100a0 fix(test): refresh stale integration tests after connector refactors
Four integration tests fell behind code changes in app/integrations/ and
app/models. They were asserting against the old data shapes / patching
the old mock targets and only surfaced now that v5.5.6's fixture refresh
re-enabled them.

### tests/test_integration/test_caldav_integration.py
test_sync_data_imports_events still expected the connector to write
TimeEntry rows and IntegrationExternalEventLink rows. The connector
in app/integrations/caldav_calendar.py:803 now writes CalendarEvent
rows and tracks imports via a [CalDAV: <uid>] marker embedded in the
description. Update the assertions to query CalendarEvent and look for
the marker. Add CalendarEvent to the model imports.

### tests/test_integration/test_activitywatch_integration.py
Four tests in this file patched `app.integrations.activitywatch.requests.get`.
The connector at app/integrations/activitywatch.py:78-79 was refactored
to route HTTP through `integration_session()` + `session_request()`,
so requests.get is never called and the mocks never fire. Tests that
relied on the mock then attempted real HTTP to localhost:5600 and
failed.

Repoint all four @patch decorators to
`app.integrations.activitywatch.session_request`. Update the one
positional-argument assertion in test_test_connection_success — the
URL moves from arg[0] to arg[2] because session_request's signature
is (session, method, url, **kw).

### tests/test_custom_field_definitions.py
test_delete_custom_field_preserves_other_fields called
`test_client.set_custom_field(...)` directly on the fixture instance
which may belong to a different SQLAlchemy session by the time the
test runs. The two JSON mutations weren't reliably persisted, so only
one of the two fields survived to the assertions. Re-query the client
via `Client.query.get(test_client.id)` before mutating — matches the
pattern already used in the surrounding tests in the same file.

Test plan
- pytest tests/test_integration/test_caldav_integration.py::TestCalDAVConnector::test_sync_data_imports_events
- pytest tests/test_integration/test_activitywatch_integration.py::TestActivityWatchConnector
- pytest tests/test_custom_field_definitions.py::test_delete_custom_field_preserves_other_fields
2026-05-14 16:58:52 -04:00
MacJediWizard c46fb360cb fix(test): re-query user inside session to avoid detached Role error
Three tests in `test_timer_edit_own_time_entries.py` failed with
`sqlalchemy.exc.InvalidRequestError: Object '<Role at 0x...>' is not
attached to a Session` when the suite ran after v5.5.6's fixture
refresh.

Root cause: the `user` fixture commits the user and calls
`db.session.refresh(user)`, leaving the instance bound to the
fixture's scoped session. Each test then enters a fresh
`with app.app_context():` block before calling
`_ensure_edit_own_permission(user)`. Flask-SQLAlchemy's scoped session
in the new context is a *different* session than the one that owns
`user`, so when `user.add_role(role)` tries to associate a freshly
loaded `Role` with the cross-session `user`, the resulting commit
detaches the role and the next attribute access fails.

Fix: have `_ensure_edit_own_permission` take a `user_id` instead of the
ORM instance and re-query the user from the active session
(`User.query.get(user_id)`). All three objects (user, role, permission)
now live in the same identity map. Drop the trailing
`db.session.refresh(user)` — route handlers re-load the user via their
own request-scoped sessions and see the persisted association.

Test plan
- pytest tests/test_timer_edit_own_time_entries.py
2026-05-14 16:48:43 -04:00
MacJediWizard 6cbe869fc1 fix(tests, api): four small CI failures from v5.5.6 test-fixture tightening
Bundles four narrow fixes surfaced by the comprehensive CI run after
v5.5.6 enabled SQLite FK enforcement and refreshed test fixtures. Each
fix is independent; bundled here only to keep PR overhead low.

1. tests/test_routes/test_api_v1_expenses_complete.py
   - Add missing `User` import. `test_expense_permissions` references
     `User.query.filter(...)` but `User` was never imported, causing a
     NameError at test runtime.

2. tests/test_client_single_simplification.py
   - `Client(...status="active")` failed with TypeError because the
     Client model's __init__ does not accept `status` as a keyword
     (column default already sets it to "active"). Set the attribute
     after construction.

3. tests/test_utils/test_api_auth_enhanced.py
   - `User(username=..., is_active=True)` failed because User.__init__
     does not accept `is_active`. Set the attribute after construction.
   - 9 occurrences of `app.test_request_context(remote_addr="...")`
     failed with `TypeError: EnvironBuilder.__init__() got an unexpected
     keyword argument 'remote_addr'`. Werkzeug removed the keyword;
     replace with `environ_overrides={"REMOTE_ADDR": "..."}` which is
     the supported equivalent.

4. app/routes/api_v1_time_entries.py
   - DELETE /api/v1/time-entries/<id> returned 415 Unsupported Media
     Type when called without an `application/json` Content-Type, even
     though the body is optional (only used to capture an audit reason).
     Switch to `request.get_json(silent=True)` so the endpoint accepts
     DELETE requests with no body. Same change applied to no other
     methods; POST/PUT continue to require explicit JSON.

The route file also picked up a black/isort pass from the project
auto-formatter; behaviour is identical to before, only whitespace and
import grouping differ.

Test plan
- pytest tests/test_routes/test_api_v1_expenses_complete.py::TestAPIExpensesComplete::test_expense_permissions
- pytest tests/test_client_single_simplification.py::test_manual_entry_shows_select_when_multiple_clients
- pytest tests/test_utils/test_api_auth_enhanced.py::TestAuthenticateToken
- pytest tests/test_routes/test_api_v1_time_entries_complete.py::TestAPITimeEntriesComplete::test_delete_time_entry_uses_service_layer
2026-05-14 16:44:06 -04:00
MacJediWizard f0872202f4 fix(test): add missing import pytest to test_ai_helper_gate.py
The test file uses pytestmark = [pytest.mark.integration] on line 5 but
does not import pytest, causing pytest collection to fail with
NameError: name 'pytest' is not defined. Add the missing import.
2026-05-14 14:44:49 -04:00
Dries Peeters 52c7e9f02a feat(ai): gate helper by default; add uninstall docs and release 5.5.6
- Honor AI_ENABLED across session AI, REST v1, LLM service, templates, and
  context; add regression tests for the AI helper gate.
- Docker Compose: optional Ollama behind the ai profile; align env.example
  and example compose with safe defaults.
- Add UNINSTALL.md with a dedicated AI teardown section; cross-link from
  README, INSTALLATION, Getting Started, docs index, and Docker setup guide.
- Record 5.5.6 in CHANGELOG and sync version examples in BUILD_CONFIGURATION;
  bump setup.py to 5.5.6.
2026-05-14 06:46:59 +02:00
Dries Peeters 3153f0d800 test(dashboard): assert against split stats/chart cache keys
The dashboard route now writes two cache entries per user
(dashboard:stats:<id> and dashboard💹<id>) instead of a single
dashboard:<id> blob, and today_hours moved into the nested
time_tracking payload. Update the caching tests to mirror this shape
and centralise the key tuple in a small helper.
2026-05-14 06:23:29 +02:00
Dries Peeters 973e6a285c test(project_costs): set updated_at explicitly instead of patching datetime
The ProjectCost.updated_at column default captures datetime.utcnow at
class definition time, so patching app.models.project_cost.datetime
does not change the initial value written on insert. Assign
updated_at directly on the instance to get the deterministic
"before" timestamp the test relies on.
2026-05-14 06:23:23 +02:00
Dries Peeters befb2d7b5f test(keyboard): update asset assertions for advanced shortcuts script
base.html now loads keyboard-shortcuts-advanced.js globally instead of
keyboard-shortcuts-enhanced.js, and the cheat-sheet CSS is only pulled
in on the dedicated settings page. Update the integration assertion
accordingly.
2026-05-14 06:23:18 +02:00
Dries Peeters 93dafdbc31 test(currency): detach ORM objects across app_contexts and harden Settings test
- Return lightweight SimpleNamespace snapshots from the sample_client
  and sample_project fixtures so downstream tests can access .id and
  .name without re-attaching the SQLAlchemy instance to a new session.
- In test_settings_currency_can_be_changed, persist the Settings row
  when get_settings() returns a transient instance, and re-query for
  verification instead of refresh() (which fails on transient objects).
2026-05-14 06:23:14 +02:00
Dries Peeters 92a081d37c test(webhook): use naive datetime for next_retry_at assertion
WebhookDelivery.next_retry_at is a naive DateTime column. Strip the
tzinfo before passing it to mark_retrying() so the round-trip
comparison after commit/reload does not fail with a naive/aware
mismatch.
2026-05-14 06:23:00 +02:00
Dries Peeters bda4bcec3e test(supplier): use supplier_items relationship API
Supplier.stock_items is no longer a list-like relationship; assert
against the dynamic supplier_items relationship using .count() and
.first() instead.
2026-05-14 06:22:56 +02:00
Dries Peeters f7becfa946 test(models): create Client/User rows to satisfy FK enforcement
With foreign keys enforced in tests, hard-coded client_id=1 and
user_id=1 references no longer work because no such rows exist in the
isolated test database. Create the required parent rows (Client for
inventory project tests, Client + User for the burndown/SavedFilter
smoke tests) before inserting the children.
2026-05-14 06:22:53 +02:00
Dries Peeters 8f8cb3d7b1 test(client_portal): centralise portal access setup and drop retry shims
Introduce a small set_client_portal_access helper that updates the
user row via a single UPDATE and expires the session, replacing the
scattered no_autoflush + safe_commit_with_retry + safe_get_user dance
in every test. Also auto-disable audit logging for this module to
avoid SQLite write-lock contention during portal tests, and fix a few
indentation bugs where assertions sat outside the app_context block.
2026-05-14 06:22:35 +02:00
Dries Peeters 373a21f323 test(fixtures): enable SQLite FK enforcement and seed baseline roles
- Turn on PRAGMA foreign_keys=ON for every SQLite connection so
  ondelete="CASCADE" and other FK constraints are exercised by tests.
- Disable FK enforcement only for DROP TABLE statements, since the
  schema has cyclic references (deals/leads/projects/quotes) and
  drop_all() cannot order them cleanly.
- Seed admin/user/manager/subcontractor roles in the app fixture so
  route tests that validate against the role table no longer need to
  run the full permission seed command.
- Make TimeEntryFactory.end_time deterministic relative to start_time
  so created entries always represent a valid 2h window.
2026-05-14 06:22:30 +02:00
Dries Peeters 2ed3bd6a88 feat(pdf-designer): multi-select, text alignment, guides, layers (#619)
Add multi-select with Shift/Ctrl toggles and marquee selection on the invoice
and quote PDF template editors, plus a shared transformer, group nudge with
snap-aware arrow keys, multi copy/paste/duplicate, align-to-page or
align-to-selection, horizontal/vertical distribute, and Ctrl/Cmd+G group /
Ctrl/Cmd+Shift+G ungroup.

Expose horizontal (left/center/right/justify) and vertical (top/middle/bottom)
text controls in the properties panel; export verticalAlign, group_id,
locked, and hidden in template JSON where set. Skip hidden elements in
ReportLab rendering; draw justified text on canvas via Paragraph; honor
verticalAlign when a text box height is present.

Introduce app/static/js/pdf_editor helpers (loaded with dynamic import from
the templates), pdf_editor.css, a layers panel with visibility and lock toggles,
smart-guide snapping, and optional ruler chrome. Document behaviour and manual
QA in docs/PDF_LAYOUT_EDITOR.md; add tests/test_pdf_template_schema.py for
optional template fields.

Closes #619.
2026-05-13 19:49:38 +02:00
Dries Peeters 8b24059b3d test: re-enable skipped suites and align auth/fixtures with CI
Remove obsolete skips across admin, calendar, invoices, payments, PDF,
uploads, and related tests. Use /login and passwords consistent with
conftest; fix team chat setup in silent-exception tests; assert expense
currency with locale-tolerant amount matching; align admin logo tests
with admin_authenticated_client credentials.
2026-05-13 09:12:14 +02:00
Dries Peeters 9d4be6feec fix(admin): prefer form template_json for invoice PDF preview
When the layout editor posts template_json with the preview request,
use it instead of loading only the saved database template. Preview
then matches unsaved canvas edits and avoids stale layouts.

Normalize page width/height from the selected page size when parsing
form JSON; fall back to the stored template if the body is missing
or invalid.

Add a regression test ensuring form JSON overrides DB content.
2026-04-30 06:17:21 +02:00
Dries Peeters 887c93f00c fix(quotes): align list/detail scope with quote edit permissions
Ensure quote list/detail access uses shared quote scope resolution so users with quote-management permissions can view records they can edit, including post-edit redirects in web and API flows. Add regression coverage for non-admin edit_quotes behavior and document the scope-alignment requirement in advanced permissions docs.
2026-04-29 10:27:00 +02:00
Dries Peeters eb7b1be05f feat(i18n): add Portuguese (pt) locale and translation scaffold
Register Português in LANGUAGES and normalize pt-BR/pt-PT (and similar)
to pt in _normalize_locale so Accept-Language and stored preferences resolve
to translations/pt/.

Add translations/pt/LC_MESSAGES/messages.po seeded from English msgids;
translators can fill msgstr incrementally.

Extend i18n tests for pt presence and catalog file. Update translation
docs (TRANSLATION_SYSTEM, CONTRIBUTING_TRANSLATIONS, implementation note).
2026-04-29 06:53:24 +02:00
Dries Peeters 5d4e693a2b Add LDAP setup wizard on Integrations and admin routes
Introduce a guided LDAP configuration wizard mirroring the OIDC flow:
five-step UI with server/TLS, bind, directory layout, groups and
AUTH_METHOD, then optional connection test and .env / Docker Compose
generation for copy-paste deployment.

- Refactor LDAPService.test_connection to accept an optional config
  mapping so the wizard can test draft values without merging live env
  secrets; keep POST /admin/ldap/test on current_app.config.
- Add GET /admin/ldap/setup-wizard plus POST endpoints for test,
  validate, and generate-config (manage_settings, rate limited).
- Surface an LDAP card with status badge and wizard link on the
  integrations list for admins and manage_settings users.
- Add tests for validate, generate, and wizard test delegation.
2026-04-27 20:21:34 +02:00
Dries Peeters 6c8e86cd01 fix(timer): respect Settings.single_active_timer at runtime
Timer starts always blocked a second running entry and never read the\nadmin-controlled Settings flag.\n\n- Add TimeTrackingService.can_start_timer() using Settings.get_settings()\n  and wire it into start_timer, web timer routes, kiosk start, and\n  legacy POST /api/timer/resume.\n- POST /api/v1/timer/start returns 409 with error_code\n  timer_already_running when single-active mode is on and a timer\n  is already running.\n- Deduplicate start_timer template handling in the service.\n\nTests: tests/test_single_active_timer_setting.py.\nDocs: REST_API (responses), GETTING_STARTED, REQUIREMENTS, Docker env\nnotes, TESTING_STRATEGY, env.example comment; CHANGELOG entry.
2026-04-27 19:16:25 +02:00
Dries Peeters e34a668ddc feat(auth): add LDAP directory authentication
Introduce AUTH_METHOD values ldap and all, with LDAP_* environment settings, ldap3-based LDAPService (search, optional groupOfNames checks, user bind, DB sync), and users.auth_provider (local|oidc|ldap) via migration 153_add_user_auth_provider.

Login supports LDAP-only and combined all (local then LDAP where appropriate); OIDC callback sets auth_provider. Forgot/reset/change password flows skip LDAP-managed accounts. Admin System Settings gains a read-only LDAP summary and POST /admin/ldap/test. Production env validation requires core LDAP variables when LDAP is enabled; OIDC registration and docs recognize all.

Documentation: new docs/admin/configuration/LDAP_SETUP.md; updates to OIDC_SETUP, GETTING_STARTED, Docker guides, Render deploy notes, docs README, and CHANGELOG. Tests: tests/test_ldap_auth.py; test_oidc_logout allows auth_method all.
2026-04-27 19:08:08 +02:00
Dries Peeters 8fc823c252 feat(pwa): static manifest, root-scoped worker, offline fallback
Add app/static/manifest.json (TimeTracker / Tracker, indigo theme) and PNG install icons via scripts/generate_pwa_icons.py.

Replace inline Flask service worker with app/static/js/sw.js served at /service-worker.js for full-site scope. Cache name timetracker-v1: cache-first for /static, network-first for HTML and non-v1 /api, no interception of /api/v1/* (preserves Authorization).

Add public GET /offline and offline.html for SW navigation fallback; redirect /manifest.webmanifest to the static manifest.

Wire base.html (manifest link, theme-color #4F46E5, SW registration) and pwa-enhancements.js (ready/update/push without duplicate registration). Remove legacy app/static/service-worker.js and manifest.webmanifest.

Tests: service worker and offline routes, manifest redirect, TestPWA expectations; drop duplicate test_enhanced_ui app/client fixtures in favor of conftest.

Docs: ASSETS.md, BUILD_CONFIGURATION.md, implementation notes, and incomplete-features analysis updated for new paths.
2026-04-27 18:43:14 +02:00
Dries Peeters f2e81dfaef feat(billing): invoice all unbilled time for a client from API and UI
Add POST /api/v1/clients/{client_id}/invoice-unbilled to build one draft
invoice from completed billable time not yet on any invoice line, grouped
by project with RateOverride-based rates. Supports API tokens
(write:invoices) or a logged-in user with create_invoices; enforces client
and module access.

InvoiceService gains shared preview/state helpers and
create_client_unbilled_invoice with safe_commit and invoice webhook.

Client detail page adds a confirmation flow and redirect to the new
invoice. Document read:invoices / write:invoices and the new route in
docs/api/API_TOKEN_SCOPES.md; expose the path on the v1 discovery payload.

Tests: two-project client creates two line items; second call returns
no_unbilled_entries.
2026-04-27 18:15:24 +02:00
Dries Peeters ea913c6c4b feat(ai,security): add web AI helper, secret encryption, and 2FA
Introduce a web-first AI helper with admin-configurable providers (Ollama or hosted OpenAI-compatible), server-side context building, and confirmed write actions. Expose the feature via session /api/ai/* endpoints and scoped /api/v1/ai/* endpoints.

Harden security by requiring a strong SECRET_KEY for Docker Compose, adding optional settings encryption-at-rest (Fernet), and introducing TOTP-based 2FA plus password reset flows. Update admin UI, API docs, and install documentation.
2026-04-26 07:55:47 +02:00
Dries Peeters 89623682c8 fix(security): sandbox Jinja2 for database-backed PDF and email templates
Add render_sandboxed_string() using SandboxedEnvironment so stored invoice and
quote HTML, ReportLab text templates, admin PDF previews, and invoice email
HTML are not evaluated with Flask's full template globals (mitigating SSTI).

Add regression tests for sandbox behavior and demo user permissions.
2026-04-24 21:13:29 +02:00
Dries Peeters 568933c3b9 fix(auth): scope client portal users to their assigned client
Client-portal-enabled users (main app login, typically viewer) were not
included in get_allowed_client_ids(), so ProjectService and other callers
saw scope_client_ids=None and listed every project.

- Return [client_id] for is_client_portal_user in User.get_allowed_client_ids
- Derive get_allowed_project_ids from allowed client IDs for all non-admins
- Apply client/project scope and access checks from allowed IDs, not only
  subcontractor is_scope_restricted (fixes user_can_access_* for portal)

Fixes DRYTRIX/TimeTracker#592.

Tests: extend test_scope_filter with client_portal_scoped_user and API
isolation for GET /api/v1/projects.
2026-04-24 16:15:24 +02:00
Dries Peeters eb2f5c6afa fix(apps): harden desktop and mobile server connectivity
Desktop (Electron):
- Add two-step first-run wizard: test TimeTracker via GET /api/v1/info, then log in with API token
- Replace bogus token check with validateSession (users/me, fallback to timer/status for narrow scopes)
- Normalize base URLs; classify TLS/DNS/timeout errors; periodic 401 forces re-login
- Settings save/test use public + authenticated checks; prebuild/prestart and npm test

Server:
- Exempt /api/v1/info, /api/v1/health, and POST /api/v1/auth/login from HTML setup redirect
- Include setup_required on GET /api/v1/info for unfinished installs

Mobile (Flutter):
- Validate saved token against new server URL before persisting settings change
- Remove unused lib/core/config.dart; point BUILD_CONFIGURATION at app_config.dart

Docs: DESKTOP_SETTINGS, desktop README, mobile-desktop-apps README, REST_API /info
2026-04-16 19:59:20 +02:00
Dries Peeters 999f5c6319 feat(api): clarify /api vs /api/v1 and reduce duplication
Document the dual HTTP surface everywhere integrators look: OpenAPI intro and servers, ARCHITECTURE, REST_API, API_VERSIONING (deprecated vs internal routes, shared modules), and CONTRIBUTING (v1-first rule).

Session JSON routes in app/routes/api.py that overlap REST v1 now return X-API-Deprecated and a Link header with rel successor-version, implemented via app/utils/api_deprecation.py.

Extract shared global search into app/services/global_search_service.py for both GET /api/search and GET /api/v1/search while preserving legacy short-query 200 empty responses and v1 400 validation.

Refactor legacy POST /api/timer/start, /api/timer/stop, and the start path of /api/timer/resume to use TimeTrackingService; keep existing socketio emits for the web UI.

Add tests/test_api_deprecation_headers.py and adjust search partial-failure tests to patch Project.query on the service module.
2026-04-16 15:36:01 +02:00
Dries Peeters 7aeef629db docs(api): document value dashboard; fix project tasks API test
Document session-auth GET /api/stats/value-dashboard in REST_API.md
(response shape, last_7_days, estimated value fields, 10-minute Redis
cache). Link dashboard session JSON routes from docs/API.md and note
that /api/v1 scopes do not apply to those legacy paths.

Update the comprehensive API test so GET /api/projects/<id>/tasks is
expected to return every task status, including done and cancelled,
which matches the time-entry UI.

Changelog: record the documentation update and the test correction.
2026-04-16 15:09:03 +02:00