mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-03 18:19:46 -06:00
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:
17
.editorconfig
Normal file
17
.editorconfig
Normal 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
14
.flake8
Normal 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
|
||||
|
||||
|
||||
2380
app/routes/api_v1.py
2380
app/routes/api_v1.py
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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
16
pyproject.toml
Normal 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
|
||||
|
||||
|
||||
56
tests/test_api_audit_activities_v1.py
Normal file
56
tests/test_api_audit_activities_v1.py
Normal 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
|
||||
|
||||
81
tests/test_api_budget_alerts_v1.py
Normal file
81
tests/test_api_budget_alerts_v1.py
Normal 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
|
||||
|
||||
78
tests/test_api_calendar_v1.py
Normal file
78
tests/test_api_calendar_v1.py
Normal 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
|
||||
|
||||
85
tests/test_api_client_notes_v1.py
Normal file
85
tests/test_api_client_notes_v1.py
Normal 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
|
||||
|
||||
79
tests/test_api_comments_v1.py
Normal file
79
tests/test_api_comments_v1.py
Normal 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
|
||||
|
||||
97
tests/test_api_credit_notes_v1.py
Normal file
97
tests/test_api_credit_notes_v1.py
Normal 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
|
||||
|
||||
85
tests/test_api_expenses_v1.py
Normal file
85
tests/test_api_expenses_v1.py
Normal 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'
|
||||
|
||||
80
tests/test_api_favorites_v1.py
Normal file
80
tests/test_api_favorites_v1.py
Normal 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
|
||||
|
||||
57
tests/test_api_invoice_templates_api_v1.py
Normal file
57
tests/test_api_invoice_templates_api_v1.py
Normal 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
|
||||
|
||||
75
tests/test_api_invoice_templates_v1.py
Normal file
75
tests/test_api_invoice_templates_v1.py
Normal 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
|
||||
|
||||
119
tests/test_api_invoices_v1.py
Normal file
119
tests/test_api_invoices_v1.py
Normal 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*** } ਜੋ ?>
|
||||
|
||||
69
tests/test_api_kanban_v1.py
Normal file
69
tests/test_api_kanban_v1.py
Normal 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
|
||||
|
||||
84
tests/test_api_mileage_v1.py
Normal file
84
tests/test_api_mileage_v1.py
Normal 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
|
||||
|
||||
100
tests/test_api_payments_v1.py
Normal file
100
tests/test_api_payments_v1.py
Normal 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
|
||||
|
||||
86
tests/test_api_per_diem_v1.py
Normal file
86
tests/test_api_per_diem_v1.py
Normal 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
|
||||
|
||||
94
tests/test_api_project_costs_v1.py
Normal file
94
tests/test_api_project_costs_v1.py
Normal 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
|
||||
|
||||
100
tests/test_api_recurring_invoices_v1.py
Normal file
100
tests/test_api_recurring_invoices_v1.py
Normal 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
|
||||
|
||||
75
tests/test_api_saved_filters_v1.py
Normal file
75
tests/test_api_saved_filters_v1.py
Normal 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
|
||||
|
||||
81
tests/test_api_tax_currency_v1.py
Normal file
81
tests/test_api_tax_currency_v1.py
Normal 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"e_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
|
||||
|
||||
75
tests/test_api_time_entry_templates_v1.py
Normal file
75
tests/test_api_time_entry_templates_v1.py
Normal 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
|
||||
|
||||
14
tests/test_service_worker.py
Normal file
14
tests/test_service_worker.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user