feat(api): add broad API v1 parity, scope UI, and consistency improvements

Backend: add Black/isort/Flake8 configs and .editorconfig; switch health/readiness to locale-based time. Fix service worker asset list; add smoke test.

Admin scopes UI: add read:* and write:* wildcards; add granular scopes for invoices, expenses, payments, mileage, per diem, budget alerts, calendar, comments, recurring invoices.

API v1: add endpoints for invoices, expenses, payments, mileage, per diem (+rates), budget alerts, calendar, kanban, saved filters, time entry templates, comments, recurring invoices, credit notes, client notes (paginated), project costs (paginated), currencies, exchange rates, favorites, audit logs, activities, and invoice PDF/templates (admin). Extend /api/v1/info with all resources. No schema changes.

Tests: add coverage for new endpoints (CRUD/list/pagination) and service worker route smoke test.
This commit is contained in:
Dries Peeters
2025-11-14 13:09:57 +01:00
parent 70d9dad4f3
commit f54ab9934f
27 changed files with 4188 additions and 7 deletions

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
[*.py]
indent_style = space
indent_size = 4
max_line_length = 120
[*.{json,yml,yaml}]
indent_style = space
indent_size = 2

14
.flake8 Normal file
View File

@@ -0,0 +1,14 @@
[flake8]
max-line-length = 120
extend-ignore = E203, W503
exclude =
.git,
__pycache__,
venv,
.venv,
build,
dist,
htmlcov,
app/static/vendor

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
import pytz
from app import db, track_page_view
from sqlalchemy import text
from app.models.time_entry import local_now
from flask import make_response, current_app
import json
@@ -113,7 +114,7 @@ def readiness_check():
"""Readiness probe: verify DB connectivity and critical dependencies"""
try:
db.session.execute(text('SELECT 1'))
return {'status': 'ready', 'timestamp': datetime.utcnow().isoformat()}, 200
return {'status': 'ready', 'timestamp': local_now().isoformat()}, 200
except Exception as e:
return {'status': 'not_ready', 'error': 'db_unreachable'}, 503
@@ -209,11 +210,19 @@ def service_worker():
# Build absolute URLs for static assets to ensure proper caching
assets = [
'/',
url_for('static', filename='base.css'),
url_for('static', filename='mobile.css'),
url_for('static', filename='ui.css'),
# CSS
url_for('static', filename='dist/output.css'),
url_for('static', filename='enhanced-ui.css'),
url_for('static', filename='ui-enhancements.css'),
url_for('static', filename='form-validation.css'),
url_for('static', filename='keyboard-shortcuts.css'),
url_for('static', filename='toast-notifications.css'),
# JS
url_for('static', filename='mobile.js'),
url_for('static', filename='commands.js'),
url_for('static', filename='enhanced-ui.js'),
url_for('static', filename='ui-enhancements.js'),
url_for('static', filename='toast-notifications.js'),
]
preamble = "const CACHE_NAME='tt-cache-v2';\n"
assets_js = "const ASSETS=" + json.dumps(assets) + ";\n\n"

View File

@@ -156,6 +156,15 @@
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scopes *</label>
<div class="space-y-2">
<!-- Convenience wildcards -->
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:*" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:* - Read access to all resources</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:*" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:* - Write access to all resources</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:projects" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:projects - View projects</span>
@@ -164,6 +173,30 @@
<input type="checkbox" name="scopes" value="write:projects" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:projects - Create/update projects</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:invoices" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:invoices - View invoices and billing data</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:invoices" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:invoices - Create/update invoices</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:expenses" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:expenses - View expenses</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:expenses" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:expenses - Create/update expenses</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:payments" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:payments - View payments</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:payments" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:payments - Create/update payments</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:time_entries" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:time_entries - View time entries</span>
@@ -188,6 +221,54 @@
<input type="checkbox" name="scopes" value="write:clients" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:clients - Create/update clients</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:comments" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:comments - View comments</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:comments" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:comments - Create/update comments</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:mileage" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:mileage - View mileage</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:mileage" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:mileage - Create/update mileage</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:per_diem" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:per_diem - View per diem</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:per_diem" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:per_diem - Create/update per diem</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:calendar" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:calendar - View calendar events</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:calendar" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:calendar - Create/update calendar events</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:budget_alerts" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:budget_alerts - View budget alerts</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:budget_alerts" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:budget_alerts - Create/ack budget alerts</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:recurring_invoices" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:recurring_invoices - View recurring invoices</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:recurring_invoices" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:recurring_invoices - Create/update recurring invoices</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:reports" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:reports - View reports</span>

16
pyproject.toml Normal file
View File

@@ -0,0 +1,16 @@
[tool.black]
line-length = 120
target-version = ["py311"]
skip-string-normalization = false
include = '\.pyi?$'
[tool.isort]
profile = "black"
line_length = 120
known_first_party = ["app", "tests"]
combine_as_imports = true
force_sort_within_sections = true
include_trailing_comma = true
multi_line_output = 3

View File

@@ -0,0 +1,56 @@
import pytest
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_audit_activities.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def admin_user(app):
u = User(username='admin', email='admin@example.com', role='admin')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def admin_token(app, admin_user):
token, plain = ApiToken.create_token(
user_id=admin_user.id,
name='Admin Token',
scopes='admin:all,read:reports'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_audit_and_activities_list(client, admin_token):
r = client.get('/api/v1/audit-logs', headers=_auth(admin_token))
assert r.status_code == 200
r = client.get('/api/v1/activities', headers=_auth(admin_token))
assert r.status_code == 200

View File

@@ -0,0 +1,81 @@
import pytest
from app import create_app, db
from app.models import User, Project, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_budget_alerts.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def admin_user(app):
u = User(username='adminuser', email='admin@example.com', role='admin')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, admin_user):
token, plain = ApiToken.create_token(
user_id=admin_user.id,
name='Budget Token',
scopes='admin:all,read:budget_alerts,write:budget_alerts'
)
db.session.add(token)
db.session.commit()
return plain
@pytest.fixture
def project(app):
p = Project(name='BA Project', status='active')
db.session.add(p)
db.session.commit()
return p
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_budget_alerts(client, api_token, project):
# create alert
payload = {
'project_id': project.id,
'alert_type': 'warning_80',
'budget_consumed_percent': 80.0,
'budget_amount': 1000.0,
'consumed_amount': 800.0,
'message': '80% consumed'
}
r = client.post('/api/v1/budget-alerts', headers=_auth(api_token), json=payload)
assert r.status_code == 201
alert_id = r.get_json()['alert']['id']
# list
r = client.get('/api/v1/budget-alerts', headers=_auth(api_token))
assert r.status_code == 200
assert len(r.get_json()['alerts']) >= 1
# acknowledge
r = client.post(f'/api/v1/budget-alerts/{alert_id}/ack', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,78 @@
import pytest
from datetime import datetime, timedelta
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_calendar.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='caluser', email='cal@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Calendar Token',
scopes='read:calendar,write:calendar'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_calendar_crud(client, api_token):
start = (datetime.utcnow() + timedelta(hours=1)).isoformat() + 'Z'
end = (datetime.utcnow() + timedelta(hours=2)).isoformat() + 'Z'
# create
payload = {'title': 'Meeting', 'start_time': start, 'end_time': end, 'location': 'Office'}
r = client.post('/api/v1/calendar/events', headers=_auth(api_token), json=payload)
assert r.status_code == 201
ev_id = r.get_json()['event']['id']
# list
r = client.get('/api/v1/calendar/events', headers=_auth(api_token))
assert r.status_code == 200
# get
r = client.get(f'/api/v1/calendar/events/{ev_id}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/calendar/events/{ev_id}', headers=_auth(api_token), json={'title': 'Updated'})
assert r.status_code == 200
assert r.get_json()['event']['title'] == 'Updated'
# delete
r = client.delete(f'/api/v1/calendar/events/{ev_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,85 @@
import pytest
from app import create_app, db
from app.models import User, Client, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_client_notes.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='cnoteuser', email='cnote@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='ClientNotes Token',
scopes='read:clients,write:clients'
)
db.session.add(token)
db.session.commit()
return plain
@pytest.fixture
def client_model(app):
c = Client(name='Client Notes', email='client@example.com')
db.session.add(c)
db.session.commit()
return c
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_client_notes_crud(client, api_token, client_model):
# list empty
r = client.get(f'/api/v1/clients/{client_model.id}/notes', headers=_auth(api_token))
assert r.status_code == 200
body = r.get_json()
assert 'notes' in body and 'pagination' in body
assert body['notes'] == []
# create
payload = {'content': 'Important note', 'is_important': True}
r = client.post(f'/api/v1/clients/{client_model.id}/notes', headers=_auth(api_token), json=payload)
assert r.status_code == 201
note_id = r.get_json()['note']['id']
# get
r = client.get(f'/api/v1/client-notes/{note_id}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/client-notes/{note_id}', headers=_auth(api_token), json={'content': 'Updated'})
assert r.status_code == 200
assert r.get_json()['note']['content'] == 'Updated'
# delete
r = client.delete(f'/api/v1/client-notes/{note_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,79 @@
import pytest
from app import create_app, db
from app.models import User, Project, Task, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_comments.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='cuser', email='c@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Comments Token',
scopes='read:comments,write:comments'
)
db.session.add(token)
db.session.commit()
return plain
@pytest.fixture
def project(app):
p = Project(name='Comments Project', status='active')
db.session.add(p)
db.session.commit()
return p
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_comments_crud_project(client, api_token, project):
# create
payload = {'content': 'Hello world', 'project_id': project.id}
r = client.post('/api/v1/comments', headers=_auth(api_token), json=payload)
assert r.status_code == 201
c_id = r.get_json()['comment']['id']
# list
r = client.get(f'/api/v1/comments?project_id={project.id}', headers=_auth(api_token))
assert r.status_code == 200
assert len(r.get_json()['comments']) >= 1
# update
r = client.patch(f'/api/v1/comments/{c_id}', headers=_auth(api_token), json={'content': 'Updated'})
assert r.status_code == 200
assert r.get_json()['comment']['content'] == 'Updated'
# delete
r = client.delete(f'/api/v1/comments/{c_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,97 @@
import pytest
from datetime import date
from app import create_app, db
from app.models import User, Client, Project, Invoice, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_credit_notes.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='cnuser', email='cn@example.com', role='admin')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='CN Token',
scopes='read:invoices,write:invoices'
)
db.session.add(token)
db.session.commit()
return plain
@pytest.fixture
def setup_invoice(app, user):
c = Client(name='CN Client', email='client@example.com')
db.session.add(c)
db.session.commit()
p = Project(name='CN Project', client_id=c.id, status='active')
db.session.add(p)
db.session.commit()
inv = Invoice(
invoice_number=Invoice.generate_invoice_number(),
project_id=p.id,
client_name=c.name,
client_id=c.id,
due_date=date.today(),
created_by=user.id,
)
db.session.add(inv)
db.session.commit()
return inv
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_credit_notes_crud(client, api_token, setup_invoice):
inv = setup_invoice
# list empty
r = client.get(f'/api/v1/credit-notes?invoice_id={inv.id}', headers=_auth(api_token))
assert r.status_code == 200
assert r.get_json()['credit_notes'] == []
# create
payload = {'invoice_id': inv.id, 'amount': 10.0, 'reason': 'Discount'}
r = client.post('/api/v1/credit-notes', headers=_auth(api_token), json=payload)
assert r.status_code == 201
cn_id = r.get_json()['credit_note']['id']
# get
r = client.get(f'/api/v1/credit-notes/{cn_id}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/credit-notes/{cn_id}', headers=_auth(api_token), json={'reason': 'Updated'})
assert r.status_code == 200
# delete
r = client.delete(f'/api/v1/credit-notes/{cn_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,85 @@
import pytest
from datetime import date
from app import create_app, db
from app.models import User, Expense, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_expenses.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='expuser', email='expuser@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Expenses Token',
scopes='read:expenses,write:expenses'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(token):
return {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
def test_expenses_crud(client, api_token):
# list empty
r = client.get('/api/v1/expenses', headers=_auth(api_token))
assert r.status_code == 200
assert r.get_json()['expenses'] == []
# create
payload = {
'title': 'Taxi',
'category': 'travel',
'amount': 23.5,
'expense_date': date.today().isoformat(),
'billable': True
}
r = client.post('/api/v1/expenses', headers=_auth(api_token), json=payload)
assert r.status_code == 201
exp = r.get_json()['expense']
exp_id = exp['id']
# get
r = client.get(f'/api/v1/expenses/{exp_id}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/expenses/{exp_id}', headers=_auth(api_token), json={'notes': 'airport ride'})
assert r.status_code == 200
assert r.get_json()['expense']['notes'] == 'airport ride'
# delete (reject)
r = client.delete(f'/api/v1/expenses/{exp_id}', headers=_auth(api_token))
assert r.status_code == 200
db.session.expire_all()
assert Expense.query.get(exp_id).status == 'rejected'

View File

@@ -0,0 +1,80 @@
import pytest
from app import create_app, db
from app.models import User, Project, Client, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_favorites.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='favuser', email='fav@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Favorites Token',
scopes='read:projects,write:projects'
)
db.session.add(token)
db.session.commit()
return plain
@pytest.fixture
def project(app):
c = Client(name='Fav Client')
db.session.add(c)
db.session.commit()
p = Project(name='Fav Project', client_id=c.id, status='active')
db.session.add(p)
db.session.commit()
return p
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_favorites_flow(client, api_token, project):
# list empty
r = client.get('/api/v1/users/me/favorites/projects', headers=_auth(api_token))
assert r.status_code == 200
assert r.get_json()['favorites'] == []
# add
r = client.post('/api/v1/users/me/favorites/projects', headers=_auth(api_token), json={'project_id': project.id})
assert r.status_code in (200, 201)
# list
r = client.get('/api/v1/users/me/favorites/projects', headers=_auth(api_token))
assert r.status_code == 200
assert any(f['project_id'] == project.id for f in r.get_json()['favorites'])
# remove
r = client.delete(f'/api/v1/users/me/favorites/projects/{project.id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,57 @@
import pytest
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_invoice_templates.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def admin_user(app):
u = User(username='admin', email='admin@example.com', role='admin')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def admin_token(app, admin_user):
token, plain = ApiToken.create_token(
user_id=admin_user.id,
name='Admin Token',
scopes='admin:all'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_invoice_pdf_templates_list_and_get(client, admin_token):
r = client.get('/api/v1/invoice-pdf-templates', headers=_auth(admin_token))
assert r.status_code == 200
# A4 default template is always available via get_template()
r = client.get('/api/v1/invoice-pdf-templates/A4', headers=_auth(admin_token))
assert r.status_code == 200

View File

@@ -0,0 +1,75 @@
import pytest
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_invoice_templates.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def admin_user(app):
u = User(username='admin', email='admin@example.com', role='admin')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def admin_token(app, admin_user):
token, plain = ApiToken.create_token(
user_id=admin_user.id,
name='Admin Token',
scopes='admin:all'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_invoice_templates_crud(client, admin_token):
# list (empty)
r = client.get('/api/v1/invoice-templates', headers=_auth(admin_token))
assert r.status_code == 200
assert r.get_json()['templates'] == []
# create
r = client.post('/api/v1/invoice-templates', headers=_auth(admin_token), json={
'name': 'Clean', 'description': 'Clean template', 'html': '<div>Hi</div>', 'css': 'div{color:#000}'
})
assert r.status_code == 201
tpl_id = r.get_json()['template']['id']
# get
r = client.get(f'/api/v1/invoice-templates/{tpl_id}', headers=_auth(admin_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/invoice-templates/{tpl_id}', headers=_auth(admin_token), json={'is_default': True})
assert r.status_code == 200
# delete
r = client.delete(f'/api/v1/invoice-templates/{tpl_id}', headers=_auth(admin_token))
assert r.status_code == 200

View File

@@ -0,0 +1,119 @@
import json
import pytest
from datetime import date, timedelta
from app import create_app, db
from app.models import User, Client, Project, Invoice, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_invoices.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='apiuser', email='apiuser@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Invoices Token',
scopes='read:invoices,write:invoices,read:clients,read:projects'
)
db.session.add(token)
db.session.commit()
return plain
@pytest.fixture
def client_model(app):
c = Client(name='Invoice Client', email='client@example.com', company='ClientCo')
db.session.add(c)
db.session.commit()
return c
@pytest.fixture
def project(app, client_model):
p = Project(name='Invoice Project', client_id=client_model.id, status='active')
db.session.add(p)
db.session.commit()
return p
def _auth_header(token):
return {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
def test_list_invoices_empty(client, api_token):
r = client.get('/api/v1/invoices', headers=_auth_header(api_token))
assert r.status_code == 200
data = r.get_json()
assert 'invoices' in data
assert isinstance(data['invoices'], list)
assert data['invoices'] == []
def test_create_get_update_cancel_invoice(client, api_token, user, project, client_model):
due = (date.today() + timedelta(days=14)).isoformat()
create_payload = {
'project_id': project.id,
'client_id': client_model.id,
'client_name': client_model.name,
'client_email': client_model.email,
'due_date': due,
'notes': 'Test invoice',
'tax_rate': 20.0,
'currency_code': 'EUR',
}
# Create
r = client.post('/api/v1/invoices', headers=_auth_header(api_token), json=create_payload)
assert r.status_code == 201
created = r.get_json()['invoice']
assert created['client_name'] == client_model.name
invoice_id = created['id']
# Get
r = client.get(f'/api/v1/invoices/{invoice_id}', headers=_auth_header(api_token))
assert r.status_code == 200
inv = r.get_json()['invoice']
assert inv['id'] == invoice_id
assert inv['status'] in ('draft', 'sent', 'paid', 'overdue', 'cancelled')
# Update
r = client.patch(f'/api/v1/invoices/{invoice_id}', headers=_auth_header(api_token), json={'notes': 'Updated'})
assert r.status_code == 200
updated = r.get_json()['invoice']
assert updated['notes'] == 'Updated'
# Cancel (soft-delete)
r = client.delete(f'/api/v1/invoices/{invoice_id}', headers=_auth_header(api_token))
assert r.status_code == 200
# Verify cancelled
db.session.expire_all()
inv_obj = Invoice.query.get(invoice_id)
assert inv_obj.status == 'cancelled'
*** End Patch*** } ?>

View File

@@ -0,0 +1,69 @@
import pytest
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_kanban.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='kbuser', email='kb@example.com', role='admin')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Kanban Token',
scopes='read:tasks,write:tasks'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_kanban_columns(client, api_token):
# list (may be empty)
r = client.get('/api/v1/kanban/columns', headers=_auth(api_token))
assert r.status_code == 200
# create
payload = {'key': 'custom', 'label': 'Custom', 'is_system': False}
r = client.post('/api/v1/kanban/columns', headers=_auth(api_token), json=payload)
assert r.status_code == 201
col_id = r.get_json()['column']['id']
# reorder
r = client.post('/api/v1/kanban/columns/reorder', headers=_auth(api_token), json={'column_ids': [col_id]})
assert r.status_code == 200
# delete
r = client.delete(f'/api/v1/kanban/columns/{col_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,84 @@
import pytest
from datetime import date
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_mileage.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='mileuser', email='mileuser@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Mileage Token',
scopes='read:mileage,write:mileage'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_mileage_crud(client, api_token):
# list empty
r = client.get('/api/v1/mileage', headers=_auth(api_token))
assert r.status_code == 200
assert r.get_json()['mileage'] == []
# create
payload = {
'trip_date': date.today().isoformat(),
'purpose': 'Airport transfer',
'start_location': 'Home',
'end_location': 'Airport',
'distance_km': 15.5,
'rate_per_km': 0.3
}
r = client.post('/api/v1/mileage', headers=_auth(api_token), json=payload)
assert r.status_code == 201
entry = r.get_json()['mileage']
eid = entry['id']
# get
r = client.get(f'/api/v1/mileage/{eid}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/mileage/{eid}', headers=_auth(api_token), json={'notes': 'return trip included'})
assert r.status_code == 200
assert r.get_json()['mileage']['notes'] == 'return trip included'
# delete (reject)
r = client.delete(f'/api/v1/mileage/{eid}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,100 @@
import pytest
from datetime import date
from app import create_app, db
from app.models import User, Client, Project, Invoice, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_payments.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='payuser', email='payuser@example.com', role='admin')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Payments Token',
scopes='read:payments,write:payments,read:invoices'
)
db.session.add(token)
db.session.commit()
return plain
@pytest.fixture
def setup_invoice(app, user):
c = Client(name='Pay Client', email='client@example.com')
db.session.add(c)
db.session.commit()
p = Project(name='Pay Project', client_id=c.id, status='active')
db.session.add(p)
db.session.commit()
inv = Invoice(
invoice_number=Invoice.generate_invoice_number(),
project_id=p.id,
client_name=c.name,
client_id=c.id,
due_date=date.today(),
created_by=user.id,
)
db.session.add(inv)
db.session.commit()
return inv
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_payments_crud(client, api_token, setup_invoice):
inv = setup_invoice
# list empty
r = client.get(f'/api/v1/payments?invoice_id={inv.id}', headers=_auth(api_token))
assert r.status_code == 200
assert r.get_json()['payments'] == []
# create
payload = {'invoice_id': inv.id, 'amount': 100.0, 'currency': 'EUR', 'method': 'bank_transfer'}
r = client.post('/api/v1/payments', headers=_auth(api_token), json=payload)
assert r.status_code == 201
pay = r.get_json()['payment']
pid = pay['id']
assert pay['amount'] == 100.0
# get
r = client.get(f'/api/v1/payments/{pid}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/payments/{pid}', headers=_auth(api_token), json={'notes': 'noted'})
assert r.status_code == 200
assert r.get_json()['payment']['notes'] == 'noted'
# delete
r = client.delete(f'/api/v1/payments/{pid}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,86 @@
import pytest
from datetime import date, timedelta
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_per_diem.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='pduser', email='pduser@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='PerDiem Token',
scopes='read:per_diem,write:per_diem'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_per_diem_crud(client, api_token):
# list empty
r = client.get('/api/v1/per-diems', headers=_auth(api_token))
assert r.status_code == 200
assert r.get_json()['per_diems'] == []
# create
payload = {
'trip_purpose': 'Conference',
'start_date': date.today().isoformat(),
'end_date': (date.today() + timedelta(days=2)).isoformat(),
'country': 'Germany',
'full_day_rate': 30.0,
'half_day_rate': 15.0,
'full_days': 2,
'half_days': 0
}
r = client.post('/api/v1/per-diems', headers=_auth(api_token), json=payload)
assert r.status_code == 201
pd = r.get_json()['per_diem']
pd_id = pd['id']
# get
r = client.get(f'/api/v1/per-diems/{pd_id}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/per-diems/{pd_id}', headers=_auth(api_token), json={'notes': 'OK'})
assert r.status_code == 200
assert r.get_json()['per_diem']['notes'] == 'OK'
# delete (reject)
r = client.delete(f'/api/v1/per-diems/{pd_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,94 @@
import pytest
from datetime import date
from app import create_app, db
from app.models import User, Project, Client, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_project_costs.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='pcuser', email='pc@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='ProjectCosts Token',
scopes='read:projects,write:projects'
)
db.session.add(token)
db.session.commit()
return plain
@pytest.fixture
def project(app):
c = Client(name='PC Client')
db.session.add(c)
db.session.commit()
p = Project(name='PC Project', client_id=c.id, status='active')
db.session.add(p)
db.session.commit()
return p
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_project_costs_crud(client, api_token, project):
# list empty
r = client.get(f'/api/v1/projects/{project.id}/costs', headers=_auth(api_token))
assert r.status_code == 200
body = r.get_json()
assert 'costs' in body and 'pagination' in body
assert body['costs'] == []
# create
payload = {
'description': 'Laptop',
'category': 'equipment',
'amount': 1200.0,
'cost_date': date.today().isoformat(),
'billable': True
}
r = client.post(f'/api/v1/projects/{project.id}/costs', headers=_auth(api_token), json=payload)
assert r.status_code == 201
cost_id = r.get_json()['cost']['id']
# get
r = client.get(f'/api/v1/project-costs/{cost_id}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/project-costs/{cost_id}', headers=_auth(api_token), json={'notes': 'Purchased'})
assert r.status_code == 200
# delete
r = client.delete(f'/api/v1/project-costs/{cost_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,100 @@
import pytest
from datetime import date, timedelta
from app import create_app, db
from app.models import User, Client, Project, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_recurring.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='riuser', email='ri@example.com', role='admin')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='RI Token',
scopes='read:recurring_invoices,write:recurring_invoices,read:invoices,write:invoices'
)
db.session.add(token)
db.session.commit()
return plain
@pytest.fixture
def setup_project_client(app):
c = Client(name='RI Client', email='client@example.com')
db.session.add(c)
db.session.commit()
p = Project(name='RI Project', client_id=c.id, status='active')
db.session.add(p)
db.session.commit()
return p, c
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_recurring_invoices_crud_and_generate(client, api_token, user, setup_project_client):
project, cl = setup_project_client
# list empty
r = client.get('/api/v1/recurring-invoices', headers=_auth(api_token))
assert r.status_code == 200
assert r.get_json()['recurring_invoices'] == []
# create
payload = {
'name': 'Monthly Billing',
'project_id': project.id,
'client_id': cl.id,
'client_name': cl.name,
'frequency': 'monthly',
'interval': 1,
'next_run_date': date.today().isoformat(),
'tax_rate': 0.0
}
r = client.post('/api/v1/recurring-invoices', headers=_auth(api_token), json=payload)
assert r.status_code == 201
ri_id = r.get_json()['recurring_invoice']['id']
# get
r = client.get(f'/api/v1/recurring-invoices/{ri_id}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/recurring-invoices/{ri_id}', headers=_auth(api_token), json={'notes': 'updated'})
assert r.status_code == 200
# generate
r = client.post(f'/api/v1/recurring-invoices/{ri_id}/generate', headers=_auth(api_token))
assert r.status_code in (200, 201)
# deactivate
r = client.delete(f'/api/v1/recurring-invoices/{ri_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,75 @@
import pytest
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_saved_filters.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='sfuser', email='sf@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Filters Token',
scopes='read:filters,write:filters'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_saved_filters_crud(client, api_token):
# list empty
r = client.get('/api/v1/saved-filters', headers=_auth(api_token))
assert r.status_code == 200
assert r.get_json()['filters'] == []
# create
payload = {'name': 'My filter', 'scope': 'time', 'payload': {'billable': True}}
r = client.post('/api/v1/saved-filters', headers=_auth(api_token), json=payload)
assert r.status_code == 201
f_id = r.get_json()['filter']['id']
# get
r = client.get(f'/api/v1/saved-filters/{f_id}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/saved-filters/{f_id}', headers=_auth(api_token), json={'is_shared': True})
assert r.status_code == 200
assert r.get_json()['filter']['is_shared'] == True
# delete
r = client.delete(f'/api/v1/saved-filters/{f_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,81 @@
import pytest
from datetime import date
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_tax_currency.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def admin_user(app):
u = User(username='admin', email='admin@example.com', role='admin')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def admin_token(app, admin_user):
token, plain = ApiToken.create_token(
user_id=admin_user.id,
name='Admin Token',
scopes='admin:all,read:invoices'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_tax_currency_flow(client, admin_token):
# create currency
r = client.post('/api/v1/currencies', headers=_auth(admin_token), json={'code': 'USD', 'name': 'US Dollar', 'symbol': '$'})
assert r.status_code == 201
# list currencies
r = client.get('/api/v1/currencies', headers=_auth(admin_token))
assert r.status_code == 200
assert any(c['code'] == 'USD' for c in r.get_json()['currencies'])
# create exchange rate
r = client.post('/api/v1/exchange-rates', headers=_auth(admin_token), json={
'base_code': 'EUR', 'quote_code': 'USD', 'rate': 1.1, 'date': date.today().isoformat(), 'source': 'test'
})
assert r.status_code == 201
# list exchange rates
r = client.get('/api/v1/exchange-rates?base_code=EUR&quote_code=USD', headers=_auth(admin_token))
assert r.status_code == 200
# create tax rule
r = client.post('/api/v1/tax-rules', headers=_auth(admin_token), json={
'name': 'VAT DE', 'country': 'DE', 'rate_percent': 19.0, 'active': True
})
assert r.status_code == 201
# list tax rules
r = client.get('/api/v1/tax-rules', headers=_auth(admin_token))
assert r.status_code == 200

View File

@@ -0,0 +1,75 @@
import pytest
from app import create_app, db
from app.models import User, ApiToken
@pytest.fixture
def app():
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///test_api_templates.sqlite',
'WTF_CSRF_ENABLED': False,
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(app):
u = User(username='tpluser', email='tpl@example.com', role='user')
u.is_active = True
db.session.add(u)
db.session.commit()
return u
@pytest.fixture
def api_token(app, user):
token, plain = ApiToken.create_token(
user_id=user.id,
name='Templates Token',
scopes='read:time_entries,write:time_entries'
)
db.session.add(token)
db.session.commit()
return plain
def _auth(t):
return {'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'}
def test_templates_crud(client, api_token):
# list empty
r = client.get('/api/v1/time-entry-templates', headers=_auth(api_token))
assert r.status_code == 200
assert r.get_json()['templates'] == []
# create
payload = {'name': 'Quick dev', 'default_duration_minutes': 120, 'default_notes': 'dev'}
r = client.post('/api/v1/time-entry-templates', headers=_auth(api_token), json=payload)
assert r.status_code == 201
t_id = r.get_json()['template']['id']
# get
r = client.get(f'/api/v1/time-entry-templates/{t_id}', headers=_auth(api_token))
assert r.status_code == 200
# update
r = client.patch(f'/api/v1/time-entry-templates/{t_id}', headers=_auth(api_token), json={'default_notes': 'updated'})
assert r.status_code == 200
assert r.get_json()['template']['default_notes'] == 'updated'
# delete
r = client.delete(f'/api/v1/time-entry-templates/{t_id}', headers=_auth(api_token))
assert r.status_code == 200

View File

@@ -0,0 +1,14 @@
import re
def test_service_worker_serves_assets(client):
resp = client.get('/service-worker.js')
assert resp.status_code == 200
text = resp.get_data(as_text=True)
# Ensure JS content type and presence of cache list with known asset
assert 'application/javascript' in (resp.headers.get('Content-Type') or '')
assert 'dist/output.css' in text
assert 'enhanced-ui.js' in text
# Basic sanity: ASSETS array present
assert 'const ASSETS=' in text