"""Fase C tests: dossier-module — migratie, generatie (gemockte ai_complete), kennisbank-context in de prompt, statusflow, eindredactie, admin-guard, AI-fout → pending, nieuwsbrief-seed, social-export en agenda-koppeling.""" import os import sys from datetime import date, timedelta import pytest from flask import Flask sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from agenda_module import init_agenda_db, register_agenda_routes from dossier_module import (init_dossier_db, register_dossier_routes, get_dossier, get_dossier_voor_item) from conftest import FakeDb, page_stub, login MOCK_DOSSIER = """## Kernkaart | Wat | Wanneer | |---|---| | Testvergadering | binnenkort | ## Context Context over het agendapunt. ## Checklist voorbereiding - Voorbereiding af - Uitnodigingen versturen ## Talking points - TP-MARKER — samen verder bouwen aan een beter district - Nog een talking point ## Open vragen aan de DG - Wie is de contactpersoon?""" class AIMock: """Stand-in voor ai_complete(messages) uit app.py.""" def __init__(self, response=MOCK_DOSSIER, error=None): self.response = response self.error = error self.calls = [] # elke call: de [system, user]-messages def __call__(self, messages): self.calls.append(messages) if self.error: raise self.error return self.response @pytest.fixture def db(tmp_path): fake = FakeDb(str(tmp_path / 'dossier_test.db')) init_agenda_db(fake) init_dossier_db(fake) init_dossier_db(fake) # tweede keer mag geen fout geven (idempotent) conn = fake.get_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS knowledge_docs ( id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, original_name TEXT NOT NULL, tier TEXT NOT NULL, file_size INTEGER DEFAULT 0, content_text TEXT DEFAULT '', uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS newsletter_content_pool ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT '', content TEXT DEFAULT '', content_type TEXT DEFAULT 'text', source_file TEXT, category TEXT DEFAULT 'algemeen', tags TEXT DEFAULT '', is_used INTEGER DEFAULT 0, used_in_newsletter_id INTEGER, uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); """) conn.commit() conn.close() return fake @pytest.fixture def make_client(db): def _make(ai_mock): app = Flask(__name__) app.secret_key = 'test-secret' register_agenda_routes(app, page_stub, '', ai_mock, db) register_dossier_routes(app, page_stub, '', ai_mock, db) return app.test_client() return _make def insert_item(db, titel='Kabinetsvergadering GMT', typ='kabinetsvergadering', datum=None, beschrijving=''): conn = db.get_db() cur = conn.execute( "INSERT INTO agenda_items (datum, titel, type, locatie, beschrijving) VALUES (?,?,?,?,?)", (datum or (date.today() + timedelta(days=5)).isoformat(), titel, typ, 'Zwolle', beschrijving)) conn.commit() item_id = cur.lastrowid conn.close() return item_id def insert_kennisbank_doc(db, naam, tekst): conn = db.get_db() conn.execute("INSERT INTO knowledge_docs (filename, original_name, tier, content_text) " "VALUES (?,?,?,?)", (naam, naam, 'algemeen', tekst)) conn.commit() conn.close() def genereer_dossier_via_http(client, item_id): login(client) resp = client.post(f'/agenda/dossier/{item_id}/genereer') assert resp.status_code == 302 return resp # -- 1. Migratie idempotent ---------------------------------------------------- def test_migratie_idempotent(db): init_dossier_db(db) # derde keer (fixtures deden er al 2) conn = db.get_db() n = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' " "AND name='agenda_dossiers'").fetchone()[0] idx = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='index' " "AND name='idx_agenda_dossiers_item'").fetchone()[0] cols = [r['name'] for r in conn.execute("PRAGMA table_info(agenda_dossiers)").fetchall()] conn.close() assert n == 1 and idx == 1 assert cols == ['id', 'agenda_item_id', 'gegenereerd_op', 'type', 'status', 'inhoud_md', 'ai_model'] # -- 2. Genereren met gemockte ai_complete -------------------------------------- def test_genereer_dossier_ok(make_client, db): ai = AIMock() client = make_client(ai) item_id = insert_item(db) genereer_dossier_via_http(client, item_id) d = get_dossier_voor_item(db, item_id) assert d is not None assert d['status'] == 'concept' assert d['inhoud_md'] == MOCK_DOSSIER assert d['ai_model'] == 'ai_complete' assert d['type'] == 'kabinetsvergadering' assert len(ai.calls) == 1 # precies een AI-call html = client.get(f"/agenda/dossier/{d['id']}").get_data(as_text=True) assert 'Voorbereidingsdossier' in html assert 'Markeer definitief' in html assert 'Regenereren' in html assert 'Eindredactie' in html # -- 3. Kennisbank-context meegevraagd in de prompt ----------------------------- def test_prompt_bevat_kennisbank_context(make_client, db): ai = AIMock() client = make_client(ai) insert_kennisbank_doc(db, 'Zonehandleiding Noord', 'UNIEKE-KENNIS-MARKER-42 —richtlijnen voor de zonebijeenkomst ' 'in het noorden van district 110AN, inclusief opkomstschema.') item_id = insert_item(db, titel='Zonebijeenkomst Noord', typ='Zonebijeenkomst') genereer_dossier_via_http(client, item_id) assert len(ai.calls) == 1 system, user = ai.calls[0][0], ai.calls[0][1] assert 'UNIEKE-KENNIS-MARKER-42' in user['content'] # fragment in de prompt assert 'Zonehandleiding Noord' in user['content'] # brontitel in de prompt assert 'Samen Verder Bouwen' in system['content'] # jaarthema-stijl assert 'Kernkaart' in system['content'] # vaste koppen assert 'Open vragen aan de DG' in system['content'] assert 'zonebijeenkomst' in user['content'].lower() # type-specifieke focus assert 'Zonebijeenkomst Noord' in user['content'] # item zelf in de prompt def test_prompt_zonder_kennisbank_expliciet(make_client, db): ai = AIMock() client = make_client(ai) item_id = insert_item(db, titel='Conventie Antwerpen', typ='conventie') genereer_dossier_via_http(client, item_id) user = ai.calls[0][1] assert 'kennisbank-fragmenten: geen gevonden' in user['content'].lower() assert 'conventie' in user['content'].lower() # -- 4. AI-fout -> status pending, nooit fake-content --------------------------- def test_ai_fout_wordt_pending(make_client, db): client = make_client(AIMock(error=RuntimeError('provider 429'))) item_id = insert_item(db) login(client) resp = client.post(f'/agenda/dossier/{item_id}/genereer') assert resp.status_code == 302 # nette pending-afhandeling d = get_dossier_voor_item(db, item_id) assert d['status'] == 'pending' assert (d['inhoud_md'] or '') == '' # GEen fake-content html = client.get(f"/agenda/dossier/{d['id']}").get_data(as_text=True) assert 'pending' in html.lower() assert 'Probeer opnieuw' in html def test_ai_fout_bij_regeneratie_behoudt_oude_inhoud(make_client, db): ai = AIMock() client = make_client(ai) item_id = insert_item(db) genereer_dossier_via_http(client, item_id) ai.error = RuntimeError('time-out') # regeneratie faalt nu client.post(f'/agenda/dossier/{item_id}/genereer') d = get_dossier_voor_item(db, item_id) assert d['status'] == 'pending' assert d['inhoud_md'] == MOCK_DOSSIER # vorige inhoud behouden ai.error = None # retry slaagt client.post(f'/agenda/dossier/{item_id}/genereer') assert get_dossier_voor_item(db, item_id)['status'] == 'concept' def test_regeneratie_upsert_geen_duplicaat(make_client, db): ai = AIMock() client = make_client(ai) item_id = insert_item(db) genereer_dossier_via_http(client, item_id) ai.response = MOCK_DOSSIER + '\n\n## Extra\nnog een sectie' client.post(f'/agenda/dossier/{item_id}/genereer') conn = db.get_db() n = conn.execute("SELECT COUNT(*) FROM agenda_dossiers WHERE agenda_item_id=?", (item_id,)).fetchone()[0] conn.close() assert n == 1 # een dossier per item (upsert) assert 'Extra' in get_dossier_voor_item(db, item_id)['inhoud_md'] # -- 5. Statusflow concept -> definitief ----------------------------------------- def test_statusflow_concept_definitief(make_client, db): client = make_client(AIMock()) item_id = insert_item(db) genereer_dossier_via_http(client, item_id) dossier_id = get_dossier_voor_item(db, item_id)['id'] resp = client.post(f'/agenda/dossier/{dossier_id}/status', data={'status': 'definitief'}) assert resp.status_code == 302 assert get_dossier(db, dossier_id)['status'] == 'definitief' # ongeldige overgangen geweigerd assert client.post(f'/agenda/dossier/{dossier_id}/status', data={'status': 'definitief'}).status_code == 400 assert client.post(f'/agenda/dossier/{dossier_id}/status', data={'status': 'onzin'}).status_code == 400 assert client.post(f'/agenda/dossier/{dossier_id}/status', data={'status': 'pending'}).status_code == 400 # alleen systeem assert get_dossier(db, dossier_id)['status'] == 'definitief' # terug naar concept mag assert client.post(f'/agenda/dossier/{dossier_id}/status', data={'status': 'concept'}).status_code == 302 assert get_dossier(db, dossier_id)['status'] == 'concept' # -- 6. Eindredactie slaat bewerkte inhoud op als inhoud_md ---------------------- def test_eindredactie_opslaat(make_client, db): client = make_client(AIMock()) item_id = insert_item(db) genereer_dossier_via_http(client, item_id) dossier_id = get_dossier_voor_item(db, item_id)['id'] bewerkt = MOCK_DOSSIER.replace('Context over het agendapunt.', 'EINDREDACTIE-MARKER: aangepast door de DG.') resp = client.post(f'/agenda/dossier/{dossier_id}/bewerken', data={'inhoud_md': bewerkt}) assert resp.status_code == 302 assert get_dossier(db, dossier_id)['inhoud_md'] == bewerkt # lege edit wordt geweigerd (geen dataverlies) client.post(f'/agenda/dossier/{dossier_id}/bewerken', data={'inhoud_md': ' '}) assert get_dossier(db, dossier_id)['inhoud_md'] == bewerkt # -- 7. Admin-guard op schrijfacties --------------------------------------------- def test_admin_guard(make_client, db): client = make_client(AIMock()) # NIET ingelogd item_id = insert_item(db) assert client.post(f'/agenda/dossier/{item_id}/genereer').status_code == 403 # na genereren (ingelogd) ook de andere schrijfacties guarden genereer_dossier_via_http(client, item_id) dossier_id = get_dossier_voor_item(db, item_id)['id'] anon = make_client(AIMock()) # aparte anonieme client assert anon.post(f'/agenda/dossier/{dossier_id}/status', data={'status': 'definitief'}).status_code == 403 assert anon.post(f'/agenda/dossier/{dossier_id}/bewerken', data={'inhoud_md': 'x'}).status_code == 403 assert anon.post(f'/agenda/dossier/{dossier_id}/naar-nieuwsbrief').status_code == 403 # detailpagina en social-export zijn leesbaar (achter de globale login in productie) assert anon.get(f'/agenda/dossier/{dossier_id}').status_code == 200 # -- 8. Gebruik in nieuwsbrief: seed in de bestaande content pool ---------------- def test_naar_nieuwsbrief_seed(make_client, db): client = make_client(AIMock()) item_id = insert_item(db, titel='Kabinetsvergadering GMT') genereer_dossier_via_http(client, item_id) dossier_id = get_dossier_voor_item(db, item_id)['id'] resp = client.post(f'/agenda/dossier/{dossier_id}/naar-nieuwsbrief') assert resp.status_code == 302 conn = db.get_db() rows = conn.execute("SELECT title, content, category, tags, is_used " "FROM newsletter_content_pool").fetchall() conn.close() assert len(rows) == 1 assert 'Kabinetsvergadering GMT' in rows[0]['title'] # herleidbaar seed-item assert rows[0]['content'] == MOCK_DOSSIER # dossierinhoud als content assert rows[0]['category'] == 'agenda' assert rows[0]['is_used'] == 0 # klaar voor de generator-flow # -- 9. Social-ready export ------------------------------------------------------- def test_social_export_json(make_client, db): client = make_client(AIMock()) item_id = insert_item(db, titel='Conventie Antwerpen', typ='conventie') genereer_dossier_via_http(client, item_id) dossier_id = get_dossier_voor_item(db, item_id)['id'] resp = client.get(f'/agenda/dossier/{dossier_id}/social-export') assert resp.status_code == 200 assert 'attachment' in resp.headers.get('Content-Disposition', '') data = resp.get_json() assert data['titel'] == 'Conventie Antwerpen' assert 'TP-MARKER' in data['talking_points'] # alleen de talking points assert 'Open vragen' not in data['talking_points'] sg = data['social_generator'] assert sg['endpoint'] == '/api/social/generate' # bestaande generator assert [p['platform'] for p in sg['payloads']] == ['instagram', 'linkedin', 'facebook'] assert all(p['description'] for p in sg['payloads']) # description = talking points # -- 10. Detailpagina 404 + agenda-koppeling vanaf T-14 --------------------------- def test_detail_onbekend_404(make_client, db): client = make_client(AIMock()) resp = client.get('/agenda/dossier/9999') assert resp.status_code == 404 def test_agenda_pagina_toont_dossierknop_vanaf_t14(make_client, db): client = make_client(AIMock()) nabij = insert_item(db, titel='Nabij gelegen punt', datum=(date.today() + timedelta(days=5)).isoformat()) ver = insert_item(db, titel='Ver weg punt', datum=(date.today() + timedelta(days=60)).isoformat()) login(client) html = client.get('/agenda').get_data(as_text=True) # binnen T-14: genereer-knop zichtbaar assert f'/agenda/dossier/{nabij}/genereer' in html # verder weg: GEEN knop (lean, geen ruis) assert f'/agenda/dossier/{ver}/genereer' not in html # na genereren: link naar dossier-detail met status client.post(f'/agenda/dossier/{nabij}/genereer') dossier_id = get_dossier_voor_item(db, nabij)['id'] html = client.get('/agenda').get_data(as_text=True) assert f'/agenda/dossier/{dossier_id}' in html assert 'concept' in html