#!/usr/bin/env python3 """ Bugtracker, Roadmap und Fehlerstrom. Diese Gruppe geht den Weg, den ein Agent tatsaechlich nimmt: melden, Arbeit holen, uebernehmen, kommentieren, abschliessen. Einzelne Endpunkte fuer sich zu pruefen wuerde die Uebergaenge auslassen - und dort sitzen die Fehler, die weh tun (doppelte Items, zwei Agenten am selben Item, verlorene Reservierung). """ import time from harness import (test, erwarte, erwarte_status, erwarte_erfolg, erwarte_fehler, ueberspringen) PROJEKT = 'myapp' # aus sql/schema.sql - ein Projekt, das nach dem Zuruecksetzen existiert BUILD = '9.9.9-test' def _item_id(resp): """ Die Antwort nennt die ID als item_id. Ueber diesen einen Ort geholt, damit eine Umbenennung im Vertrag genau einen Testfall betrifft und nicht zehn. """ for name in ('item_id', 'id'): wert = resp.feld(name) if isinstance(wert, int) and wert > 0: return wert return resp.feld('item', 'id') def _liste(resp, *kandidaten): """Die Liste aus einer Antwort holen, ohne sich auf einen Feldnamen zu verlassen.""" for name in kandidaten: wert = resp.feld(name) if isinstance(wert, list): return wert for wert in (resp.json or {}).values(): if isinstance(wert, list): return wert return [] # --------------------------------------------------------------------------- # Melden # --------------------------------------------------------------------------- @test('bugtracker', 'Meldung legt ein Item an') def _(ctx): ref = 'e2e-%d' % int(time.time()) r = ctx.client.post('/api/bugtracker/v1/report', body={ 'project_slug': PROJEKT, 'title': 'Automatischer Testlauf: Beispielbefund', 'description': 'Angelegt von tests/e2e. Darf jederzeit geloescht werden.', 'severity': 'high', 'environment': 'testing', 'build_version': BUILD, 'error_message': 'RuntimeException: Beispiel', 'file_path': 'tests/e2e/suite_bugtracker.py', 'line_no': 42, 'client_ref': ref, }) erwarte_erfolg(r) item_id = _item_id(r) erwarte(isinstance(item_id, int) and item_id > 0, "Keine Item-ID in der Antwort: %s" % r.kurz(300)) ctx['bug_id'] = item_id ctx['bug_ref'] = ref @test('bugtracker', 'Wiederholte Meldung erzeugt kein Duplikat') def _(ctx): """ Gleiches client_ref, gleiches Item. Ein Agent, dem die Verbindung wegbricht, sendet erneut - und soll damit nicht den Tracker fluten. """ r = ctx.client.post('/api/bugtracker/v1/report', body={ 'project_slug': PROJEKT, 'title': 'Automatischer Testlauf: Beispielbefund', 'description': 'Zweite Sendung derselben Meldung.', 'severity': 'high', 'environment': 'testing', 'build_version': BUILD, 'client_ref': ctx['bug_ref'], }) erwarte_erfolg(r) erneut = _item_id(r) erwarte(erneut == ctx['bug_id'], "Zweite Sendung erzeugte Item #%s statt #%s" % (erneut, ctx['bug_id'])) erwarte(r.feld('is_new') is not True, "Die zweite Sendung gilt als neues Item: %s" % r.kurz(300)) @test('bugtracker', 'Idempotency-Key im Header wirkt wie client_ref') def _(ctx): ref = 'e2e-header-%d' % int(time.time()) body = { 'project_slug': PROJEKT, 'title': 'Automatischer Testlauf: Kopfzeilen-Idempotenz', 'description': 'Erste Sendung.', 'environment': 'testing', 'build_version': BUILD, } r1 = ctx.client.post('/api/bugtracker/v1/report', body=body, headers={'Idempotency-Key': ref}) erwarte_erfolg(r1) r2 = ctx.client.post('/api/bugtracker/v1/report', body=body, headers={'Idempotency-Key': ref}) erwarte_erfolg(r2) erwarte(_item_id(r1) == _item_id(r2), "Header-Idempotenz greift nicht: #%s dann #%s" % (_item_id(r1), _item_id(r2))) erwarte(r2.feld('idempotent_hit') is True, "Die zweite Sendung wurde nicht als Wiederholung erkannt: %s" % r2.kurz(300)) @test('bugtracker', 'Unbekannter Schweregrad faellt auf den Standard zurueck') def _(ctx): """ Ein Client mit einem Tippfehler soll seine Meldung loswerden, nicht eine Fehlermeldung. Die Anwendung normalisiert - das muss so bleiben, sonst verlieren Agenten Befunde an einer Kleinigkeit. """ r = ctx.client.post('/api/bugtracker/v1/report', body={ 'project_slug': PROJEKT, 'title': 'Automatischer Testlauf: unbekannter Schweregrad', 'severity': 'ganz-dringend-echt-jetzt', 'environment': 'testing', 'build_version': BUILD, 'client_ref': 'e2e-severity-%d' % int(time.time()), }) erwarte_erfolg(r) @test('bugtracker', 'Absender kommt aus dem Token, nicht aus dem Body') def _(ctx): """created_by darf nicht frei waehlbar sein - sonst ist die Herkunft wertlos.""" ref = 'e2e-absender-%d' % int(time.time()) r = ctx.client.post('/api/bugtracker/v1/report', body={ 'project_slug': PROJEKT, 'title': 'Automatischer Testlauf: Absender', 'environment': 'testing', 'build_version': BUILD, 'created_by': 'jemand-ganz-anderes', 'client_ref': ref, }) erwarte_erfolg(r) detail = ctx.client.get('/api/bugtracker/v1/manage', query={'action': 'get', 'id': _item_id(r)}) erwarte_erfolg(detail) item = detail.feld('item') or detail.json erstellt_von = str(item.get('created_by', '')) if isinstance(item, dict) else '' erwarte(erstellt_von != 'jemand-ganz-anderes', "created_by wurde aus dem Body uebernommen: %r" % erstellt_von) # --------------------------------------------------------------------------- # Lesen # --------------------------------------------------------------------------- @test('bugtracker', 'Projektliste enthaelt die angelegten Projekte') def _(ctx): """ Die Liste kommt aus dc_projects, nicht aus den Meldungen. Genau das ist der Unterschied, den ein Client kennen muss: In ein unbekanntes Projekt zu melden ist erlaubt, aber es taucht deshalb nicht in der Liste auf. """ r = ctx.client.get('/api/bugtracker/v1/projects') erwarte_erfolg(r) slugs = [p.get('slug') for p in _liste(r, 'projects') if isinstance(p, dict)] erwarte(PROJEKT in slugs, "Projekt %r fehlt in der Liste: %s" % (PROJEKT, slugs)) erwarte('deploymentcenter' in slugs, "Das eigene Projekt fehlt in der Liste: %s" % slugs) @test('bugtracker', 'Liste findet das angelegte Item') def _(ctx): r = ctx.client.get('/api/bugtracker/v1/manage', query={'action': 'list', 'project_slug': PROJEKT, 'limit': 50}) erwarte_erfolg(r) items = _liste(r, 'items', 'bugs', 'data') ids = [i.get('id') for i in items if isinstance(i, dict)] erwarte(ctx['bug_id'] in ids, "Item #%s nicht in der Liste (%s)" % (ctx['bug_id'], ids[:10])) @test('bugtracker', 'Einzelabruf liefert das Item') def _(ctx): r = ctx.client.get('/api/bugtracker/v1/manage', query={'action': 'get', 'id': ctx['bug_id']}) erwarte_erfolg(r) erwarte(str(ctx['bug_id']) in (r.body or ''), "Item #%s nicht enthalten" % ctx['bug_id']) @test('bugtracker', 'Einzelabruf eines fehlenden Items antwortet 404') def _(ctx): r = ctx.client.get('/api/bugtracker/v1/manage', query={'action': 'get', 'id': 999999}) erwarte_fehler(r, http=404, code='not_found') @test('bugtracker', 'Kennzahlen abrufbar') def _(ctx): r = ctx.client.get('/api/bugtracker/v1/manage', query={'action': 'stats', 'project_slug': PROJEKT}) erwarte_erfolg(r) # --------------------------------------------------------------------------- # Arbeiten # --------------------------------------------------------------------------- @test('bugtracker', 'Item laesst sich uebernehmen') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'claim', 'id': ctx['bug_id']}, body={}) erwarte_erfolg(r) @test('bugtracker', 'Ein uebernommenes Item ist fuer andere gesperrt') def _(ctx): """ Der Kern der Reservierung: Ein zweiter Agent darf dasselbe Item nicht bekommen. Ohne diese Sperre arbeiten zwei Agenten am selben Fehler und ueberschreiben sich gegenseitig. """ r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'claim', 'id': ctx['bug_id']}, token=ctx.get('zweites_token') or ctx['master_token'], body={}) if r.ok: # Dasselbe Token darf sein eigenes Item erneut uebernehmen; das ist # kein Fehler. Geprueft wird der fremde Zugriff - dafuer braucht es # ein zweites Token, das der Testlauf sich hier holt. prov = ctx.client.post('/api/tokens/v1/provision', body={ 'client_name': 'e2e-zweiter-agent', 'scopes': ['bugtracker:manage'], }) erwarte_erfolg(prov) ctx['zweites_token'] = prov.feld('sub_token') r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'claim', 'id': ctx['bug_id']}, token=ctx['zweites_token'], body={}) erwarte_fehler(r, http=409, code='already_claimed') @test('bugtracker', 'Kommentar wird angenommen') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'comment', 'id': ctx['bug_id']}, body={'comment': 'Automatischer Testlauf: Zwischenstand.', 'action_taken': 'investigated'}) erwarte_erfolg(r) @test('bugtracker', 'Leerer Kommentar wird abgewiesen') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'comment', 'id': ctx['bug_id']}, body={'comment': ' '}) erwarte_fehler(r, http=400, code='missing_comment') @test('bugtracker', 'Status laesst sich setzen') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'status', 'id': ctx['bug_id']}, body={'status': 'in_progress', 'notes': 'Automatischer Testlauf.'}) erwarte_erfolg(r) @test('bugtracker', 'Status ohne Angabe wird abgewiesen') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'status', 'id': ctx['bug_id']}, body={}) erwarte_fehler(r, http=400, code='missing_status') @test('bugtracker', 'Naechste Arbeit wird ausgegeben und reserviert') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'next'}, body={'project_slug': PROJEKT, 'limit': 1}) erwarte_erfolg(r) items = _liste(r, 'items', 'item') ctx['next_id'] = items[0].get('id') if items and isinstance(items[0], dict) else None @test('bugtracker', 'Abschluss verlangt die Build-Angabe') def _(ctx): """Ohne "behoben in" ist ein Abschluss nicht nachpruefbar.""" r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'resolve', 'id': ctx['bug_id']}, body={}) erwarte_fehler(r, http=400, code='missing_build') @test('bugtracker', 'Item laesst sich abschliessen') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'resolve', 'id': ctx['bug_id']}, body={'resolved_in_build': BUILD, 'resolution_notes': 'Automatischer Testlauf.'}) erwarte_erfolg(r) @test('bugtracker', 'Reservierung laesst sich zuruecknehmen') def _(ctx): neu = ctx.client.post('/api/bugtracker/v1/report', body={ 'project_slug': PROJEKT, 'title': 'Automatischer Testlauf: Reservierung zuruecknehmen', 'environment': 'testing', 'build_version': BUILD, 'client_ref': 'e2e-release-%d' % int(time.time()), }) erwarte_erfolg(neu) item = _item_id(neu) erwarte_erfolg(ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'claim', 'id': item}, body={})) r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'release', 'id': item}, body={'note': 'Automatischer Testlauf.'}) erwarte_erfolg(r) ctx['freies_item'] = item @test('bugtracker', 'Unbekannte Aktion wird abgewiesen') def _(ctx): r = ctx.client.get('/api/bugtracker/v1/manage', query={'action': 'alles-loeschen'}) erwarte_fehler(r, http=(400, 404)) # --------------------------------------------------------------------------- # Roadmap # --------------------------------------------------------------------------- @test('roadmap', 'Epic laesst sich anlegen') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/roadmap.php', query={'action': 'epic'}, body={'project_slug': PROJEKT, 'epic': 'e2e-epic', 'title': 'Testlauf-Epic', 'summary': 'Angelegt vom automatischen Testlauf.', 'sort_order': 10}) erwarte_erfolg(r) @test('roadmap', 'Item laesst sich einplanen') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/roadmap.php', query={'action': 'set', 'id': ctx['freies_item']}, body={'project_slug': PROJEKT, 'epic': 'e2e-epic', 'roadmap_rank': 5, 'target_build': BUILD}) erwarte_erfolg(r) @test('roadmap', 'Einplanen ohne Feld wird abgewiesen') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/roadmap.php', query={'action': 'set', 'id': ctx['freies_item']}, body={'project_slug': PROJEKT}) erwarte_fehler(r, http=400, code='nothing_to_do') @test('roadmap', 'Einplanen eines fehlenden Items antwortet 404') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/roadmap.php', query={'action': 'set', 'id': 999999}, body={'project_slug': PROJEKT, 'epic': 'e2e-epic'}) erwarte_fehler(r, http=404, code='not_found') @test('roadmap', 'Zurueckstellen ohne Ausloeser wird abgewiesen') def _(ctx): """ Die einzige echte Regel des Moduls. Ohne benannten Anlass ist ein Vorhaben nicht zurueckgestellt, sondern vergessen - und ein vergessener Punkt sieht in jeder Liste aus wie ein geplanter. Die Regel sitzt im Repository und muss deshalb auf allen Schreibwegen gleich greifen; geprueft wird hier der ueber roadmap.php. """ r = ctx.client.post('/api/bugtracker/v1/roadmap.php', query={'action': 'set', 'id': ctx['freies_item']}, body={'project_slug': PROJEKT, 'status': 'deferred'}) erwarte_fehler(r, http=400, code='invalid_update') @test('roadmap', 'Zurueckstellen mit Ausloeser wird angenommen') def _(ctx): """ Die Gegenprobe. Ohne sie liesse sich der Fall darueber auch dadurch bestehen, dass 'deferred' gar nicht mehr geht. """ r = ctx.client.post('/api/bugtracker/v1/roadmap.php', query={'action': 'set', 'id': ctx['freies_item']}, body={'project_slug': PROJEKT, 'status': 'deferred', 'defer_trigger': 'sobald der Testlauf danach fragt'}) erwarte_erfolg(r) @test('roadmap', 'Zurueckgestelltes taucht nicht in der Arbeitsschlange auf') def _(ctx): """ Der Zweck des Status. Stuende ein zurueckgestelltes Vorhaben weiterhin in ?action=next, waere es lediglich anders beschriftet. """ r = ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'next'}, body={'limit': 5}) erwarte_erfolg(r) ids = [i.get('id') for i in (r.feld('items', default=[]) or [])] # ?action=next uebernimmt, was es ausgibt. Wieder freigeben, sonst haelt # dieser Testfall fuenf Items bis zum Lease-Ende - und der naechste Lauf # oder ein Mensch an der Instanz wundert sich, wo die Arbeit hin ist. for i in ids: ctx.client.post('/api/bugtracker/v1/manage', query={'action': 'release', 'id': i}, body={'note': 'Automatischer Testlauf.'}) erwarte(ctx['freies_item'] not in ids, 'Item #%s ist zurueckgestellt, wurde aber als Arbeit ausgegeben' % ctx['freies_item']) @test('roadmap', 'Roadmap laesst sich lesen') def _(ctx): for aktion in ('list', 'epics', 'next', 'active'): r = ctx.client.get('/api/bugtracker/v1/roadmap.php', query={'action': aktion, 'project_slug': PROJEKT}) erwarte_erfolg(r) @test('roadmap', 'Roadmap laesst sich rendern') def _(ctx): r = ctx.client.get('/api/bugtracker/v1/roadmap.php', query={'action': 'render', 'project_slug': PROJEKT}) erwarte_erfolg(r) @test('roadmap', 'Werkbank-Token laesst sich anlegen') def _(ctx): """ Der Tokentyp, den MCP-Adapter und dc-testkit benutzen: dev_agent, an ein Projekt gebunden. Bis hierher benutzte ihn kein Testfall - und genau deshalb blieb der Fall darunter jahrelang ungeprueft. """ r = ctx.testctl('workbench_token', project_slug=PROJEKT, name='e2e-werkbank') erwarte_erfolg(r) erwarte(r.feld('raw_token'), 'Kein Token in der Antwort: %s' % r.kurz(300)) ctx['werkbank_token'] = r.feld('raw_token') @test('roadmap', 'Werkbank-Token rendert seine Roadmap ohne Projektangabe') def _(ctx): """ BEFUND (29.08.2026): Beim Lesen laesst projectFilter() ein Werkbank-Token bewusst durch - es soll die ganze Landschaft sehen und liefert deshalb null. Damit wurde der Projektbezug zu "all", und action=render lehnte mit "missing_project" ab. Ausgerechnet der Tokentyp, den der MCP-Adapter verlangt, konnte seine eigene Roadmap nicht rendern. Das Token ist trotzdem an ein Projekt gebunden. "Meine Roadmap" ohne Angabe heisst genau dieses Projekt. """ r = ctx.client.get('/api/bugtracker/v1/roadmap.php', query={'action': 'render'}, token=ctx['werkbank_token']) erwarte_erfolg(r) erwarte(r.feld('project_slug') == PROJEKT, 'Gerendert wurde %r statt des Heimatprojekts %r' % (r.feld('project_slug'), PROJEKT)) erwarte('dc:roadmap' in (r.feld('markdown') or ''), 'Im Ergebnis fehlt der Blockmarker: %s' % r.kurz(200)) @test('roadmap', 'Werkbank-Token bekommt die Projektliste mit Beziehungen') def _(ctx): """ BEFUND (29.08.2026): ProjectLinks::between() und forProject() benutzten denselben Platzhalter mehrfach im Statement. Bei ATTR_EMULATE_PREPARES = false ist das nicht zulaessig - PDO zaehlt Parameter, statt sie zu benennen, und wirft HY093. Die Abfrage laeuft nur, wenn ein Heimatprojekt gesetzt ist, also ausschliesslich mit einem Werkbank-Token. Fuer das Master-Token des Testlaufs wurde der Zweig uebersprungen - deshalb blieb ein sicherer 500er unbemerkt, bis der MCP-Adapter die Ressource dc://projekte abrief. Derselbe Fehler steckte schon in der Volltextsuche des Bugtrackers. Beim dritten Mal ist es kein Zufall mehr, sondern eine Falle des Frameworks - ein Suchlauf ueber alle prepare()-Aufrufe gehoert zur Durchsicht. """ r = ctx.client.get('/api/bugtracker/v1/projects', token=ctx['werkbank_token']) erwarte_erfolg(r) projekte = r.feld('projects', default=[]) or [] erwarte(projekte, 'Leere Projektliste: %s' % r.kurz(300)) eigen = [p for p in projekte if p.get('slug') == PROJEKT] erwarte(eigen, 'Das eigene Projekt %r fehlt in der Liste.' % PROJEKT) erwarte((eigen[0].get('relation') or {}).get('kind') == 'self', 'Das eigene Projekt sollte relation.kind="self" tragen, hat aber %r' % eigen[0].get('relation')) @test('roadmap', 'Werkbank-Token sieht weiterhin die ganze Landschaft') def _(ctx): """ Die Gegenprobe zum Fall darueber. Der Rueckfall aufs Heimatprojekt darf nur dort greifen, wo ein konkretes Projekt gebraucht wird - beim Lesen ueber alle Projekte hinweg waere er eine Einschraenkung, die es vorher nicht gab. """ r = ctx.client.get('/api/bugtracker/v1/roadmap.php', query={'action': 'list', 'project_slug': 'all'}, token=ctx['werkbank_token']) erwarte_erfolg(r) @test('roadmap', 'Abgleich uebersieht eine Markererwaehnung im Fliesstext') def _(ctx): """ BEFUND (29.08.2026): spliceBlock() und check() suchten den erzeugten Block mit strpos() auf den Startmarker - also die erste Fundstelle im Text. Das ging gut, bis eine Datei den Marker ERWAEHNTE. Die Einleitung einer ordentlichen Roadmap tut genau das: sie erklaert, was zwischen den dc:roadmap-Markern steht. Der Splice setzte dort an und ueberschrieb den halben Dateikopf; der Abgleich klammerte Prosa aus statt des Blocks. Bitter daran ist, dass es ausgerechnet die Dateien trifft, die das Verfahren beschreiben - also die guten. Geprueft wird mit einer Datei, die den Marker erst erwaehnt und dann wirklich fuehrt: Ein Verweis VOR der Erwaehnung muss in der Pruefung ankommen, ein Verweis INNERHALB des Blocks nicht. """ inhalt = ( '# Roadmap\n\n' 'Zwischen den ``-Markern steht eine erzeugte Kopie.\n\n' 'Siehe #%d fuer den Rest.\n\n' '\n' '| #999999 | erzeugt, wird nicht geprueft |\n' '\n' ) % (ctx['freies_item'], PROJEKT) r = ctx.client.post('/api/bugtracker/v1/roadmap.php', query={'action': 'check'}, body={'project_slug': PROJEKT, 'content': inhalt}) erwarte_erfolg(r) codes = [f['code'] for f in (r.feld('findings', default=[]) or [])] erwarte('unknown_item' not in codes, 'Der erzeugte Block wurde mitgeprueft - die Markererwaehnung in der ' 'Einleitung hat den Blockanfang bestimmt. Befunde: %s' % codes) erwarte(r.feld('checked') >= 1, 'Der Verweis vor dem Block wurde nicht geprueft (checked=%s) - es ' 'wurde zu viel ausgeklammert.' % r.feld('checked')) @test('roadmap', 'Roadmap-Schreibzugriff verlangt Verwaltungsrecht') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/roadmap.php', query={'action': 'set', 'id': ctx['freies_item']}, token=ctx['sub_token'], body={'project_slug': PROJEKT, 'epic': 'e2e-epic'}) erwarte_fehler(r, http=(401, 403)) # --------------------------------------------------------------------------- # Fehlerstrom # --------------------------------------------------------------------------- @test('fehlerstrom', 'Ausnahme laesst sich melden') def _(ctx): r = ctx.client.post('/api/errors/v1/report', body={ 'project_slug': PROJEKT, 'exception': 'System.NullReferenceException', 'message': 'Automatischer Testlauf: Beispielausnahme', 'stack_trace': 'at Test.Method() in Test.cs:line 1', 'level': 'error', 'build': BUILD, 'environment': 'testing', 'file': 'Test.cs', 'line': 1, 'client_ref': 'e2e-error-%d' % int(time.time()), }) erwarte_erfolg(r) @test('fehlerstrom', 'Meldung ohne Token abgewiesen') def _(ctx): r = ctx.client.post('/api/errors/v1/report', token=None, body={'project_slug': PROJEKT, 'message': 'ohne Token'}) erwarte_fehler(r, http=401)