mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 08:07:37 +09:00
249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""Proving tests for PR Preview Environments.
|
|
|
|
Two testable cores are pure (no DB): domain templating and reconciliation.
|
|
The orchestration parts (settings upsert, create/destroy row writes, stale
|
|
expiry) use the ``app`` fixture for a clean DB and call service methods
|
|
directly — the job system is a no-op under ENV=testing.
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
|
|
# Import the model at module load so its tables are registered on db.Model's
|
|
# metadata before the `app` fixture runs db.create_all(). In production the host
|
|
# imports it from models/__init__.py; here we import it directly.
|
|
from app.models.application_preview import ( # noqa: F401
|
|
ApplicationPreview, ApplicationPreviewSettings)
|
|
from app.services.preview_service import PreviewService
|
|
|
|
|
|
# ── render_domain (pure) ─────────────────────────────────────────────────────
|
|
|
|
def test_render_domain_pr_number_and_app_domain():
|
|
out = PreviewService.render_domain(
|
|
'pr-{pr_number}.{app_domain}',
|
|
{'pr_number': 42, 'app_domain': 'acme.com'})
|
|
assert out == 'pr-42.acme.com'
|
|
|
|
|
|
def test_render_domain_slugifies_branch():
|
|
out = PreviewService.render_domain(
|
|
'{branch}.{base_domain}',
|
|
{'branch': 'feature/Login-Page', 'base_domain': 'Acme.COM'})
|
|
# branch slugified, whole host lowercased
|
|
assert out == 'feature-login-page.acme.com'
|
|
|
|
|
|
def test_render_domain_base_domain_alias():
|
|
# {app_domain} falls back to base_domain when app_domain is absent.
|
|
out = PreviewService.render_domain(
|
|
'pr-{pr_number}.{app_domain}',
|
|
{'pr_number': 7, 'base_domain': 'example.org'})
|
|
assert out == 'pr-7.example.org'
|
|
|
|
|
|
def test_render_domain_empty_template_and_unknown_placeholder():
|
|
assert PreviewService.render_domain('', {'pr_number': 1}) == ''
|
|
# Unknown placeholders are left intact rather than raising.
|
|
out = PreviewService.render_domain('{nope}-{pr_number}', {'pr_number': 3})
|
|
assert out == '{nope}-3'
|
|
|
|
|
|
# ── reconcile (pure) ─────────────────────────────────────────────────────────
|
|
|
|
def test_reconcile_creates_for_open_pr_without_preview():
|
|
plan = PreviewService.reconcile(
|
|
open_prs=[{'pr_number': 1, 'commit_sha': 'aaa'}],
|
|
active_previews=[])
|
|
assert [p['pr_number'] for p in plan['to_create']] == [1]
|
|
assert plan['to_update'] == [] and plan['to_destroy'] == []
|
|
|
|
|
|
def test_reconcile_destroys_preview_for_closed_pr():
|
|
preview = {'pr_number': 9, 'commit_sha': 'aaa', 'status': 'running'}
|
|
plan = PreviewService.reconcile(open_prs=[], active_previews=[preview])
|
|
assert plan['to_destroy'] == [preview]
|
|
assert plan['to_create'] == [] and plan['to_update'] == []
|
|
|
|
|
|
def test_reconcile_updates_on_new_commit():
|
|
plan = PreviewService.reconcile(
|
|
open_prs=[{'pr_number': 5, 'commit_sha': 'newsha'}],
|
|
active_previews=[{'pr_number': 5, 'commit_sha': 'oldsha', 'status': 'running'}])
|
|
assert [p['pr_number'] for p in plan['to_update']] == [5]
|
|
assert plan['to_create'] == [] and plan['to_destroy'] == []
|
|
|
|
|
|
def test_reconcile_noop_when_commit_unchanged():
|
|
plan = PreviewService.reconcile(
|
|
open_prs=[{'pr_number': 5, 'commit_sha': 'same'}],
|
|
active_previews=[{'pr_number': 5, 'commit_sha': 'same', 'status': 'running'}])
|
|
assert plan['to_create'] == [] and plan['to_update'] == [] and plan['to_destroy'] == []
|
|
|
|
|
|
def test_reconcile_ignores_already_destroyed_previews():
|
|
# A destroyed preview is not "live": its closed PR should NOT be re-destroyed.
|
|
plan = PreviewService.reconcile(
|
|
open_prs=[],
|
|
active_previews=[{'pr_number': 2, 'commit_sha': 'x', 'status': 'destroyed'}])
|
|
assert plan['to_destroy'] == []
|
|
|
|
|
|
def test_reconcile_mixed_matrix():
|
|
open_prs = [
|
|
{'pr_number': 1, 'commit_sha': 'a'}, # new -> create
|
|
{'pr_number': 2, 'commit_sha': 'b2'}, # moved -> update
|
|
{'pr_number': 3, 'commit_sha': 'c'}, # unchanged -> noop
|
|
]
|
|
active = [
|
|
{'pr_number': 2, 'commit_sha': 'b1', 'status': 'running'},
|
|
{'pr_number': 3, 'commit_sha': 'c', 'status': 'running'},
|
|
{'pr_number': 4, 'commit_sha': 'd', 'status': 'running'}, # closed -> destroy
|
|
]
|
|
plan = PreviewService.reconcile(open_prs, active)
|
|
assert [p['pr_number'] for p in plan['to_create']] == [1]
|
|
assert [p['pr_number'] for p in plan['to_update']] == [2]
|
|
assert [p['pr_number'] for p in plan['to_destroy']] == [4]
|
|
|
|
|
|
# ── DB-backed orchestration ──────────────────────────────────────────────────
|
|
|
|
def _mk_app(name='preview-app', app_type='docker', port=8400):
|
|
from app import db
|
|
from app.models import User, Application
|
|
from werkzeug.security import generate_password_hash
|
|
u = User(email=f'{name}@prev.local', username=f'prev-{name}',
|
|
password_hash=generate_password_hash('x'),
|
|
role=User.ROLE_ADMIN, is_active=True)
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
a = Application(name=name, app_type=app_type, user_id=u.id,
|
|
root_path=f'/srv/{name}', port=port)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
return a
|
|
|
|
|
|
def test_enable_and_get_settings(app):
|
|
with app.app_context():
|
|
a = _mk_app()
|
|
# Defaults before any save.
|
|
s = PreviewService.get_settings(a.id)
|
|
assert s.enabled is False
|
|
assert s.domain_template == 'pr-{pr_number}.{app_domain}'
|
|
|
|
result = PreviewService.enable_previews(a.id, {
|
|
'enabled': True,
|
|
'domain_template': 'preview-{pr_number}.{app_domain}',
|
|
'ttl_days': 3,
|
|
})
|
|
assert result['enabled'] is True
|
|
assert result['domain_template'] == 'preview-{pr_number}.{app_domain}'
|
|
assert result['ttl_days'] == 3
|
|
|
|
# Re-fetch is persisted.
|
|
again = PreviewService.get_settings(a.id)
|
|
assert again.enabled is True
|
|
assert again.ttl_days == 3
|
|
|
|
|
|
def test_create_and_destroy_writes_rows_and_flips_status(app):
|
|
from app.models.application_preview import ApplicationPreview
|
|
with app.app_context():
|
|
a = _mk_app(name='create-destroy')
|
|
PreviewService.enable_previews(a.id, {'enabled': True})
|
|
|
|
created = PreviewService.create_preview(a, {
|
|
'pr_number': 12, 'branch': 'feature/x', 'commit_sha': 'deadbeef',
|
|
'pr_title': 'Add X',
|
|
})
|
|
assert created.get('pr_number') == 12
|
|
assert created.get('status') == ApplicationPreview.STATUS_FAILED
|
|
assert created['url'] is None
|
|
assert created.get('domain') # a domain was rendered
|
|
|
|
row = ApplicationPreview.query.filter_by(application_id=a.id, pr_number=12).first()
|
|
assert row is not None and row.status == ApplicationPreview.STATUS_FAILED
|
|
|
|
destroyed = PreviewService.destroy_preview(row.id)
|
|
assert destroyed.get('status') == ApplicationPreview.STATUS_DESTROYED
|
|
assert row.deleted_at is not None
|
|
|
|
|
|
def test_create_preview_skips_when_disabled(app):
|
|
with app.app_context():
|
|
a = _mk_app(name='disabled-app')
|
|
# No enable_previews call -> previews disabled.
|
|
res = PreviewService.create_preview(a, {'pr_number': 1})
|
|
assert res.get('skipped') is True
|
|
assert res.get('reason') == 'previews_disabled'
|
|
|
|
|
|
def test_expire_stale_destroys_past_due(app):
|
|
from app import db
|
|
from app.models.application_preview import ApplicationPreview
|
|
with app.app_context():
|
|
a = _mk_app(name='stale-app')
|
|
# Stale preview (expired yesterday).
|
|
stale = ApplicationPreview(
|
|
application_id=a.id, pr_number=99, status=ApplicationPreview.STATUS_RUNNING,
|
|
expires_at=datetime.utcnow() - timedelta(days=1))
|
|
# Fresh preview (expires tomorrow) — must survive.
|
|
fresh = ApplicationPreview(
|
|
application_id=a.id, pr_number=100, status=ApplicationPreview.STATUS_RUNNING,
|
|
expires_at=datetime.utcnow() + timedelta(days=1))
|
|
db.session.add_all([stale, fresh])
|
|
db.session.commit()
|
|
|
|
out = PreviewService.expire_stale()
|
|
assert out['expired'] == 1
|
|
|
|
db.session.refresh(stale)
|
|
db.session.refresh(fresh)
|
|
assert stale.status == ApplicationPreview.STATUS_DESTROYED
|
|
assert fresh.status == ApplicationPreview.STATUS_RUNNING
|
|
|
|
|
|
def test_reconcile_against_db_rows(app):
|
|
"""reconcile accepts ORM rows (attribute access), not just dicts."""
|
|
from app import db
|
|
from app.models.application_preview import ApplicationPreview
|
|
with app.app_context():
|
|
a = _mk_app(name='orm-reconcile')
|
|
live = ApplicationPreview(
|
|
application_id=a.id, pr_number=7, commit_sha='old',
|
|
status=ApplicationPreview.STATUS_RUNNING)
|
|
db.session.add(live)
|
|
db.session.commit()
|
|
|
|
plan = PreviewService.reconcile(
|
|
open_prs=[{'pr_number': 7, 'commit_sha': 'new'}],
|
|
active_previews=[live])
|
|
assert [p['pr_number'] for p in plan['to_update']] == [7]
|
|
|
|
|
|
def test_preview_requires_success_and_runtime_ids(app, monkeypatch):
|
|
with app.app_context():
|
|
a = _mk_app(name='runtime-proof')
|
|
PreviewService.enable_previews(a.id, {'enabled': True})
|
|
for number, result, expected in [
|
|
(1, {'ok': True, 'container_ids': ['runtime']}, 'running'),
|
|
(2, {'ok': True, 'container_ids': []}, 'failed'),
|
|
(3, {'ok': False, 'container_ids': ['runtime']}, 'failed'),
|
|
(4, {'container_ids': ['runtime']}, 'failed'),
|
|
]:
|
|
monkeypatch.setattr(PreviewService, '_provision', lambda *a, **kw: result)
|
|
row = PreviewService.create_preview(a, {'pr_number': number})
|
|
assert row['status'] == expected
|
|
assert bool(row['url']) == (expected == 'running')
|
|
|
|
|
|
def test_legacy_preview_without_runtime_is_not_advertised():
|
|
row = ApplicationPreview(status='running', domain='pr.example.test')
|
|
assert row.to_dict()['status'] == 'unavailable'
|
|
assert row.to_dict()['url'] is None
|
|
assert row.status == 'running' # reads do not mutate persistence
|
|
row.set_container_ids(['runtime'])
|
|
assert row.to_dict()['url'] == 'https://pr.example.test'
|
|
for status in ('failed', 'stopped', 'destroyed', 'building', 'queued'):
|
|
row.status = status
|
|
assert row.to_dict()['url'] is None
|