S16 postgresql opslag added
All checks were successful
Build, test and publish / verify (push) Successful in 7m31s

This commit is contained in:
2026-11-04 17:10:25 +01:00
parent f59cad115a
commit 694bf3fa86
89 changed files with 3087 additions and 252 deletions

View File

@@ -0,0 +1,209 @@
#!/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()