All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
210 lines
9.8 KiB
Python
210 lines
9.8 KiB
Python
#!/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()
|