#!/usr/bin/env python3 """Exercise packaged-backend durability against an isolated, already running PostgreSQL container. Build with mvn verify first. Never point this test at production: --database-container is deliberately stopped and restarted to exercise failed mutation acknowledgements. """ import argparse import json import os from pathlib import Path import signal import subprocess import tempfile import time import urllib.error import urllib.request import uuid ROOT = Path(__file__).resolve().parents[2] JAVA = str(Path(os.environ['JAVA_HOME']) / 'bin/java') if os.environ.get('JAVA_HOME') else 'java' class Backend: def __init__(self, args): self.args = args self.base = f'http://127.0.0.1:{args.port}' self.process = None self.log = None self.paths = [] def start(self, overrides=None, ready=True): env = dict(os.environ, DATABASE_URL=self.args.url, DATABASE_USER=self.args.user, DATABASE_PASSWORD=self.args.password, QUARKUS_HTTP_PORT=str(self.args.port)) env.update(overrides or {}) self.log = tempfile.NamedTemporaryFile(prefix='s16-backend-', suffix='.log', delete=False) self.paths.append(self.log.name) self.process = subprocess.Popen([JAVA, '-jar', str(ROOT / 'agiledancers-be/target/quarkus-app/quarkus-run.jar')], cwd=ROOT, env=env, stdout=self.log, stderr=subprocess.STDOUT) if ready: deadline = time.monotonic() + 45 while time.monotonic() < deadline: if self.process.poll() is not None: raise AssertionError(f'Backend exited: {Path(self.log.name).read_text()}') try: if self.request('/q/health/ready')[0] == 200: return except OSError: pass time.sleep(.2) raise AssertionError(f'Backend readiness timeout: {self.log.name}') def stop(self, abrupt=False): if self.process and self.process.poll() is None: self.process.send_signal(signal.SIGKILL if abrupt else signal.SIGTERM) self.process.wait(timeout=30) if self.log: self.log.close() def request(self, path, method='GET', body=None, revision=None): headers = {'Content-Type': 'application/json'} if revision is not None: headers['X-School-Revision'] = str(revision) request = urllib.request.Request(self.base + path, data=None if body is None else json.dumps(body).encode(), headers=headers, method=method) try: response = urllib.request.urlopen(request, timeout=12) except urllib.error.HTTPError as failure: response = failure with response: data = response.read() return response.status, json.loads(data) if data else None def mutate(self, school, suffix, method='POST', body=None): path = f'/api/dance-schools/{school}' status, current = self.request(path) assert status == 200, current return self.request(path + suffix, method, body, current['revision']) def imported(backend, size): document = dict(formatVersion=3, key=str(uuid.uuid4()), name=f'S16 restart {size}', dancers=[dict(id=str(index), name=f'Dancer {index}', birthDate='2000-01-01') for index in range(1, size + 1)], coaches=[], groups=[dict(id=str(index), category=f'Group {index}', type='REGULAR', startAge=0, endAge=99, styles=['JAZZ'], dancerIds=[str(index)], coachId=None) for index in range(1, size + 1)], shows=[dict(id='1', name='Saved show', preferredDayParts=1, appearances={'REGULAR': 1}, finalized=False, dayParts=[dict(date='2027-01-01', type='MORNING', groupIds=['1'])])]) status, school = backend.request('/api/dance-schools/import', 'POST', document) assert status == 200, school status, shows = backend.request(f'/api/dance-schools/{school["id"]}/shows') assert status == 200 return school['id'], shows[0]['id'] def run_path(pair): return f'/api/dance-schools/{pair[0]}/shows/{pair[1]}/schedule' def assert_completed(backend, pair, original): status, run = backend.request(run_path(pair)) assert status == 200 and run == original, run status, shows = backend.request(f'/api/dance-schools/{pair[0]}/shows') assert status == 200 and len(shows[0]['dayParts'][0]['danceGroupIds']) == 1 def assert_interrupted(backend, pair, run, assignments): status, current = backend.request(run_path(pair)) assert status == 200 and current['state'] == 'INTERRUPTED', current assert current['runId'] == run['runId'] and current['finishedAt'] assert 'behouden' in current['message'] status, shows = backend.request(f'/api/dance-schools/{pair[0]}/shows') assert status == 200 and shows[0]['dayParts'] == assignments # Reconnected SSE snapshots carry stable recovered identities. with urllib.request.urlopen(backend.base + '/api/scheduling/events', timeout=10) as stream: while True: line = stream.readline().decode().strip() if line.startswith('data:'): snapshot = json.loads(line[5:]) if snapshot['runs'] is not None: assert any(item['runId'] == run['runId'] and item['state'] == 'INTERRUPTED' for item in snapshot['runs']) break def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--url', default='jdbc:postgresql://127.0.0.1:54329/agiledancers') parser.add_argument('--user', default='agiledancers') parser.add_argument('--password', default='s16-test-only') parser.add_argument('--port', type=int, default=18086) parser.add_argument('--database-container', required=True) args = parser.parse_args() backend = Backend(args) schools = [] try: backend.start() small = imported(backend, 1) schools.append(small[0]) status, started = backend.mutate(small[0], f'/shows/{small[1]}/schedule') assert status == 202, started deadline = time.monotonic() + 20 while time.monotonic() < deadline: _, completed = backend.request(run_path(small)) if completed['state'] != 'RUNNING': break time.sleep(.1) assert completed['state'] == 'OPTIMAL' and completed['summary'] is not None, completed backend.stop() backend.start() assert_completed(backend, small, completed) large = imported(backend, 70) schools.append(large[0]) _, shows = backend.request(f'/api/dance-schools/{large[0]}/shows') assignments = shows[0]['dayParts'] for abrupt in (False, True): status, run = backend.mutate(large[0], f'/shows/{large[1]}/schedule') assert status == 202 and run['state'] == 'RUNNING', run _, current = backend.request(run_path(large)) assert current['state'] == 'RUNNING', 'Fixture completed too quickly to test interruption' backend.stop(abrupt=abrupt) backend.start() assert_interrupted(backend, large, run, assignments) assert_completed(backend, small, completed) _, before = backend.request(f'/api/dance-schools/{small[0]}') subprocess.run(['docker', 'stop', args.database_container], check=True, stdout=subprocess.DEVNULL) try: status, failure = backend.request(f'/api/dance-schools/{small[0]}', 'PUT', {'name': 'Must not persist'}, before['revision']) assert status == 503 and 'niet opgeslagen' in failure['message'], (status, failure) assert backend.request('/q/health/ready')[0] == 503 finally: subprocess.run(['docker', 'start', args.database_container], check=True, stdout=subprocess.DEVNULL) backend.stop() backend.start() assert_completed(backend, small, completed) assert backend.request(f'/api/dance-schools/{small[0]}')[1] == before for school in schools: assert backend.mutate(school, '', 'DELETE')[0] == 204 schools.clear() backend.stop() backend.start({'DATABASE_URL': 'jdbc:postgresql://127.0.0.1:1/unavailable'}, ready=False) backend.process.wait(timeout=35) assert backend.process.returncode != 0 assert 'Connection' in Path(backend.log.name).read_text() backend.stop() schema = 's16_invalid_' + uuid.uuid4().hex def sql(statement): subprocess.run(['docker', 'exec', args.database_container, 'psql', '-v', 'ON_ERROR_STOP=1', '-U', args.user, '-d', args.url.rsplit('/', 1)[-1].split('?')[0], '-c', statement], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) sql(f'CREATE SCHEMA {schema}; CREATE TABLE {schema}.school (blocker integer);') try: # Baseline version zero lets V1 run, then fail against the deliberately conflicting table. backend.start({'QUARKUS_FLYWAY_SCHEMAS': schema, 'QUARKUS_FLYWAY_BASELINE_ON_MIGRATE': 'true', 'QUARKUS_FLYWAY_BASELINE_VERSION': '0'}, ready=False) backend.process.wait(timeout=35) assert backend.process.returncode != 0 assert 'Migration' in Path(backend.log.name).read_text() finally: backend.stop() sql(f'DROP SCHEMA {schema} CASCADE;') print('PASS: completed results, graceful interruption, SIGKILL recovery, stable SSE identity, database outage and startup/migration failure') finally: backend.stop() print('Backend logs: ' + ', '.join(backend.paths)) if schools: print('Test school IDs retained for diagnostics: ' + ', '.join(map(str, schools))) if __name__ == '__main__': main()