#!/usr/bin/env python3
"""Signalement, blocage et file de moderation (CDC n° 15, 16 et 17)."""
import json, os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib import ROOT, call, mk_user, ok, reset_rate_limits, reset_themes, sql, summary

UPL = ROOT + '/uploads/artisans'
def photo(user, name='m1.webp'):
    d = f'{UPL}/{user}/posts'; os.makedirs(d, exist_ok=True); open(f'{d}/{name}', 'wb').write(b'x' * 50); return f'/uploads/artisans/{user}/posts/{name}'
def gone(path): return not os.path.exists(ROOT + path)
def setting(k, v): sql(f"INSERT INTO messaging_settings (setting_key, setting_value) VALUES ('{k}', '{json.dumps(v)}') ON DUPLICATE KEY UPDATE setting_value = '{json.dumps(v)}'")
def set_settings(user, obj):
    """Écrit collab_settings en SQL en échappant correctement les barres obliques inverses (sinon \\n devient un vrai saut de ligne et le JSON est invalide)."""
    sql("UPDATE users SET collab_settings = '%s' WHERE id = '%s'" % (json.dumps(obj, ensure_ascii=False).replace('\\', '\\\\').replace("'", "\\'"), user))
def section(t): print('\n■ ' + t); reset_rate_limits()
def publish(who, body='Un texte', **kw):
    st, d, _ = call('POST', who, '/posts', {'body': body, 'theme_id': THEME, **kw}); assert st == 201, (st, d); return d['post']
def comment(who, post, body='Un commentaire', parent=None):
    st, d, _ = call('POST', who, f'/posts/{post}/comments', {'body': body, **({'parent_id': parent} if parent else {})}); assert st == 201, (st, d); return d['comment']
def rep(who, type_='post', id_=None, reason='spam', note=None, **kw):
    b = {'target_type': type_, 'target_id': id_, 'reason': reason, **({'note': note} if note is not None else {})}; b.update(kw); return call('POST', who, '/reports', b)
def act(who, rid, action, **kw): return call('POST', who, f'/admin-reports/{rid}/action', {'action': action, **kw})
def detail(who, rid): return call('GET', who, f'/admin-reports/{rid}')
def queue(who, qs=''): return call('GET', who, '/admin-reports' + qs)
def inbox(who, qs='?type=moderation'): return call('GET', who, '/notifications' + qs)[1]['items']
def audit(rid=None): return [tuple(r.split('\t')) for r in sql("SELECT actor_id, action, COALESCE(report_id,''), COALESCE(detail,'') FROM moderation_audit" + (f" WHERE report_id={rid}" if rid else "") + " ORDER BY id").split('\n') if r]
def reset():
    for q in ("DELETE cl FROM comment_likes cl JOIN comments c ON c.id = cl.comment_id JOIN posts p ON p.id = c.post_id WHERE p.user_id LIKE 'm\\_%'", "DELETE c FROM comments c JOIN posts p ON p.id = c.post_id WHERE p.user_id LIKE 'm\\_%'"): sql(q)
    for t in ('post_likes', 'post_saves', 'post_media'): sql(f"DELETE x FROM {t} x JOIN posts p ON p.id = x.post_id WHERE p.user_id LIKE 'm\\_%'")
    sql("DELETE FROM posts WHERE user_id LIKE 'm\\_%'; DELETE FROM reports WHERE reporter_id LIKE 'm\\_%' OR reported_user_id LIKE 'm\\_%'; DELETE FROM moderation_audit WHERE actor_id LIKE 'm\\_%'; DELETE FROM notifications WHERE user_id LIKE 'm\\_%'; DELETE FROM notification_prefs WHERE user_id LIKE 'm\\_%'; DELETE FROM member_follows WHERE follower_id LIKE 'm\\_%' OR followed_id LIKE 'm\\_%'; DELETE FROM users WHERE id LIKE 'm\\_%'")
    sql("DELETE FROM messaging_settings WHERE setting_key IN ('report_copy_retention_months','audit_retention_months','network_definition')")
    reset_themes(); os.system(f'rm -rf {UPL}/m_*'); reset_rate_limits()
def users():
    mk_user('m_a', 'prospect', 'Anne', 'Durand'); mk_user('m_b', 'prospect', 'Bruno', 'Blanc'); mk_user('m_c', 'prospect', 'Carla', 'Costa'); mk_user('m_d', 'prospect', 'Denis', 'Dupuis'); mk_user('m_mod', 'responsable', 'Mona', 'Moreau'); mk_user('m_admin', 'admin', 'Alice', 'Admin')

reset(); users(); THEME = call('GET', 'm_a', '/messaging-config')[1]['themes'][0]['id']

section("Signaler : validation")
P = publish('m_a', 'Un contenu discutable'); C = comment('m_a', P['id'], 'Un commentaire discutable')
ok(rep(None, 'post', P['id'])[0] == 401 and call('GET', 'm_b', '/reports')[0] == 405, 'visiteur : 401 ; GET : 405')
for kw, field, why in (({'type_': 'conversation'}, 'target_type', 'type inconnu'), ({'id_': 'abc'}, 'target_id', 'identifiant non numérique'), ({'id_': 0}, 'target_id', 'identifiant nul'), ({'reason': 'nimporte'}, 'reason', 'motif inconnu'), ({'reason': None}, 'reason', 'motif absent'), ({'reason': 'other'}, 'note', '« autre » sans précision'), ({'reason': 'other', 'note': '   '}, 'note', '« autre » avec que des espaces'), ({'note': 'x' * 501}, 'note', 'note de 501 caractères'), ({'note': 5}, 'note', 'note qui n\'est pas du texte')):
    k = {'type_': 'post', 'id_': P['id'], **kw}; st, d, _ = rep('m_b', **k); ok(st == 422 and field in d['errors'], f'refusé (422, champ « {field} ») : {why}')
n_before = sql("SELECT COUNT(*) FROM reports"); ok(n_before == '0', 'après ces refus : aucun signalement créé')
ok(rep('m_a', 'post', P['id'])[0] == 422 and rep('m_a', 'comment', C['id'])[0] == 422, 'on ne signale pas son propre contenu (422)')
ok(rep('m_b', 'post', 99999)[0] == 404 and rep('m_b', 'comment', 99999)[0] == 404, 'contenu inexistant : 404')
N = publish('m_a', 'Pour son réseau', visibility='network'); ok(rep('m_b', 'post', N['id'])[0] == 404 and rep('m_b', 'comment', comment('m_a', N['id'])['id'])[0] == 404, 'contenu qu\'on ne peut pas voir (« mon réseau » sans en faire partie) : 404, jamais de « pourquoi »')
sql("UPDATE users SET collab_settings='{\"blacklist\":[\"m_b@test.test\"]}' WHERE id='m_a'"); ok(rep('m_b', 'post', P['id'])[0] == 404, 'auteur qui a mis le signalant en liste noire : 404'); sql("UPDATE users SET collab_settings=NULL WHERE id='m_a'")
reset_rate_limits()      # 20 signalements par heure : la limite est réelle, on repart d'un compteur vide
for r in ('spam', 'harassment', 'inappropriate', 'misinformation', 'impersonation'):
    rr = comment('m_a', P['id'], 'x'); ok(rep('m_c', 'comment', rr['id'], reason=r)[0] == 201, f'motif « {r} » accepté')
ok(rep('m_c', 'comment', comment('m_a', P['id'], 'y')['id'], reason='other', note='Précision')[0] == 201, '« autre » avec une précision : accepté')

section("Signaler : création, doublons, copie du contenu")
reset(); users(); THEME = call('GET', 'm_a', '/messaging-config')[1]['themes'][0]['id']; ph = photo('m_a', 'r1.webp')
P = publish('m_a', 'Contenu à signaler', photos=[ph]); C = comment('m_a', P['id'], 'Commentaire à signaler'); R = comment('m_d', P['id'], 'Réponse', parent=C['id'])
st, d, _ = rep('m_b', 'post', P['id'], reason='harassment', note='  Insultes  '); r1 = d['id']
ok(st == 201 and d['ok'] and r1 > 0, 'signaler une publication : 201 avec l\'identifiant')
row = lambda i, col: sql(f"SELECT {col} FROM reports WHERE id={i}")
ok(row(r1, "CONCAT(reporter_id,'|',reported_user_id,'|',target_type,'|',target_id,'|',post_id,'|',reason,'|',note,'|',status)") == f"m_b|m_a|post|{P['id']}|{P['id']}|harassment|Insultes|open", 'enregistré : signalant, signalé (l\'AUTEUR), cible, publication, motif, note nettoyée, statut « open »')
snap = json.loads(row(r1, 'snapshot')); ok(snap['type'] == 'post' and snap['post']['body'] == 'Contenu à signaler' and snap['post']['photos'] == [ph] and snap['post']['id'] == P['id'], 'COPIE du contenu conservée : texte, photos, identifiant')
st, d, _ = rep('m_b', 'post', P['id'], reason='spam'); ok(st == 200 and d == {'ok': True, 'already': True, 'id': r1} and row(r1, 'COUNT(*)') == '1' and sql("SELECT COUNT(*) FROM reports") == '1', 'même membre, même contenu, signalement en cours : « already », même identifiant, rien de créé')
st, d, _ = rep('m_c', 'post', P['id'], reason='spam'); ok(st == 201 and d['id'] != r1, 'un AUTRE membre peut signaler le même contenu')
st, d, _ = rep('m_b', 'comment', R['id'], reason='spam'); rc = d['id']; sc = json.loads(row(rc, 'snapshot'))
ok(st == 201 and row(rc, "CONCAT(target_type,'|',target_id,'|',post_id,'|',reported_user_id)") == f"comment|{R['id']}|{P['id']}|m_d" and sc['comment']['body'] == 'Réponse' and sc['comment']['parent_id'] == C['id'] and sc['post']['body'] == 'Contenu à signaler', 'signaler une RÉPONSE : cible, publication d\'origine, auteur de la réponse ; la copie contient le commentaire et sa publication')
call('PUT', 'm_a', f'/posts/{P["id"]}', {'body': 'Contenu modifié après coup'}); ok(json.loads(row(r1, 'snapshot'))['post']['body'] == 'Contenu à signaler', 'l\'auteur modifie son texte après le signalement : la COPIE reste celle d\'origine (preuve)')
call('DELETE', 'm_a', f'/posts/{P["id"]}'); ok(row(r1, 'COUNT(*)') == '1' and json.loads(row(r1, 'snapshot'))['post']['body'] == 'Contenu à signaler', 'l\'auteur SUPPRIME sa publication : le signalement et sa copie survivent (§ 7.5)')
ok(inbox('m_b', '') == [] and inbox('m_a', '') == [] , 'ni le signalant ni le signalé ne sont notifiés à la création')

section("Bloquer")
reset(); users(); THEME = call('GET', 'm_a', '/messaging-config')[1]['themes'][0]['id']
sql("UPDATE users SET collab_settings='{\"open\":true,\"watched_keywords\":[\"grès\"],\"blacklist\":[\"verrier\"]}' WHERE id='m_b'")
pa = publish('m_a', 'Post de A'); pb = publish('m_b', 'Post de B'); ids = lambda who: [x['id'] for x in call('GET', who, '/posts')[1]['items']]
ok(pa['id'] in ids('m_b') and pb['id'] in ids('m_a'), 'avant : chacun voit les publications de l\'autre')
st, d, _ = call('POST', 'm_b', '/blocks', {'user_id': 'm_a'}); ok(st == 200 and d == {'ok': True, 'blocked': True, 'changed': True}, 'bloquer : 200, changé')
cs = json.loads(sql("SELECT collab_settings FROM users WHERE id='m_b'")); ok('m_a@test.test' in cs['blacklist'] and 'verrier' in cs['blacklist'] and cs['open'] is True and cs['watched_keywords'] == ['grès'], 'LISTE NOIRE de collaboration : l\'adresse est ajoutée (une seule source), le reste des réglages est intact (mot-clé « verrier », veille, ouverture)')
ok(pa['id'] not in ids('m_b') and pb['id'] not in ids('m_a'), 'visibilité coupée dans les DEUX sens : B ne voit plus A, et A ne voit plus B')
ok(call('GET', 'm_b', f'/posts/{pa["id"]}')[0] == 404 and call('GET', 'm_a', f'/posts/{pb["id"]}')[0] == 404, '… y compris par l\'identifiant (404)')
call('POST', 'm_a', f'/posts/{pb["id"]}/like'); ok(call('GET', 'm_b', '/notifications')[1]['items'] == [], 'le membre bloqué n\'est pas prévenu et ne peut plus notifier')
ok(call('POST', 'm_b', '/blocks', {'user_id': 'm_a'})[1] == {'ok': True, 'blocked': True, 'changed': False}, 'idempotent : bloquer deux fois ne change rien')
ok(call('GET', 'm_b', '/blocks')[1] == {'blocked': [{'id': 'm_a', 'name': 'Anne D.'}]}, 'la liste des membres bloqués : { id, nom « Anne D. » } (le mot-clé « verrier » n\'en fait pas partie)')
st, d, _ = call('POST', 'm_b', '/blocks', {'user_id': 'm_a', 'block': False}); ok(st == 200 and d == {'ok': True, 'blocked': False, 'changed': True} and pa['id'] in ids('m_b') and pb['id'] in ids('m_a'), 'débloquer : la visibilité revient dans les deux sens')
cs = json.loads(sql("SELECT collab_settings FROM users WHERE id='m_b'")); ok(cs['blacklist'] == ['verrier'] and cs['open'] is True, 'débloquer retire l\'adresse et SEULEMENT elle')
ok(call('POST', 'm_b', '/blocks', {'user_id': 'm_a', 'block': False})[1]['changed'] is False, 'débloquer quelqu\'un qui n\'est pas bloqué : sans effet')
set_settings('m_b', {'blacklist': ['x@y.fr\nM_A@test.test, z@y.fr', 'verrier']}); st, d, _ = call('POST', 'm_b', '/blocks', {'user_id': 'm_a', 'block': False})
cs = json.loads(sql("SELECT collab_settings FROM users WHERE id='m_b'")); ok(d['changed'] is True and cs['blacklist'] == ["x@y.fr\nz@y.fr", 'verrier'], 'une entrée à plusieurs adresses (et en majuscules) : seule l\'adresse débloquée est retirée, les autres restent')
ok(call('POST', 'm_b', '/blocks', {'user_id': 'm_b'})[0] == 422 and call('POST', 'm_b', '/blocks', {'user_id': 'inconnu'})[0] == 404 and call('POST', 'm_b', '/blocks', {})[0] == 400 and call('POST', 'm_b', '/blocks', {'user_id': 'm_a', 'block': 'oui'})[0] == 400 and call('POST', 'm_b', '/blocks', {'user_id': ['m_a']})[0] == 400, 'soi-même : 422 ; inconnu : 404 ; identifiant absent, booléen invalide, identifiant en tableau : 400')
sql("UPDATE users SET active=0 WHERE id='m_c'"); ok(call('POST', 'm_b', '/blocks', {'user_id': 'm_c'})[0] == 404, 'compte inactif : 404'); sql("UPDATE users SET active=1 WHERE id='m_c'")
full = json.dumps({'blacklist': [f'u{i}@x.fr' for i in range(100)]}); sql(f"UPDATE users SET collab_settings='{full}' WHERE id='m_b'"); ok(call('POST', 'm_b', '/blocks', {'user_id': 'm_c'})[0] == 422 and 'pleine' in call('POST', 'm_b', '/blocks', {'user_id': 'm_c'})[1]['error'], 'liste noire pleine (100 entrées) : refusé avec un message clair')
ok(call('POST', None, '/blocks', {'user_id': 'm_a'})[0] == 401 and call('GET', None, '/blocks')[0] == 401 and call('DELETE', 'm_b', '/blocks')[0] == 405, 'visiteur : 401 ; méthode inconnue : 405')

section("File de signalements : accès et liste")
reset(); users(); THEME = call('GET', 'm_a', '/messaging-config')[1]['themes'][0]['id']
P1 = publish('m_a', 'Premier ' + 'mot ' * 60); P2 = publish('m_d', 'Deuxième'); C1 = comment('m_d', P2['id'], 'Un commentaire')
r_a = rep('m_b', 'post', P1['id'], reason='spam')[1]['id']; r_b = rep('m_c', 'post', P1['id'], reason='harassment')[1]['id']; r_c = rep('m_b', 'post', P2['id'], reason='inappropriate')[1]['id']; r_d = rep('m_b', 'comment', C1['id'], reason='other', note='Voir')[1]['id']
ok(queue(None)[0] == 401 and call('GET', None, '/admin-reports/count')[0] == 401 and queue('m_b')[0] == 403 and call('GET', 'm_b', '/admin-reports/count')[0] == 403 and detail('m_b', r_a)[0] == 403 and act('m_b', r_a, 'dismiss')[0] == 403, 'visiteur : 401 ; MEMBRE : 403 sur la liste, le compteur, la fiche et les actions')
ok(queue('m_mod')[0] == 200 and queue('m_admin')[0] == 200 and detail('m_mod', r_a)[0] == 200, 'responsable et admin : accès')
st, d, _ = queue('m_mod'); ids_ = [x['id'] for x in d['items']]; ok(ids_ == [r_d, r_c, r_b, r_a] and d['counts'] == {'open': 4, 'in_progress': 0, 'treated': 0, 'rejected': 0} and d['has_more'] is False, 'file « active » : du plus récent au plus ancien ; compteurs par statut')
it = d['items'][3]; ok(it['status'] == 'open' and it['target_type'] == 'post' and it['reason'] == 'spam' and it['target_state'] == 'visible' and it['reporter'] == {'id': 'm_b', 'name': 'Bruno Blanc'} and it['reported'] == {'id': 'm_a', 'name': 'Anne Durand', 'reports_received': 2} and it['created_at'].endswith('Z') and it['handled_by'] is None, 'une ligne : statut, motif, état du contenu, signalant et signalé (nom complet pour la modération), nombre de signalements reçus par ce membre (2)')
ok(it['excerpt'].endswith('…') and len(it['excerpt']) == 141 and d['items'][0]['excerpt'] == 'Un commentaire' and d['items'][0]['target_type'] == 'comment' and d['items'][0]['note'] == 'Voir', 'extrait limité à 140 caractères (+ « … ») ; pour un commentaire, le texte du commentaire')
ok([x['id'] for x in queue('m_mod', '?type=comment')[1]['items']] == [r_d] and [x['id'] for x in queue('m_mod', '?type=post')[1]['items']] == [r_c, r_b, r_a], 'filtre par type')
st, d, _ = queue('m_mod', '?limit=3'); st2, d2, _ = queue('m_mod', f'?limit=3&before={d["next_before"]}'); ok(len(d['items']) == 3 and d['has_more'] and [x['id'] for x in d2['items']] == [r_a] and not d2['has_more'], 'pagination par curseur')
ok(call('GET', 'm_mod', '/admin-reports/count')[1] == {'open': 4, 'in_progress': 0}, 'compteur léger pour le menu : { open, in_progress }')
ok(queue('m_mod', '?status=nimporte')[0] == 400 and queue('m_mod', '?type=conversation')[0] == 400 and queue('m_mod', '?status[]=x&limit[]=1')[0] in (200, 400), 'statut ou type inconnu : 400 ; paramètres en tableau : pas de plantage')
act('m_mod', r_a, 'dismiss'); ok([x['id'] for x in queue('m_mod')[1]['items']] == [r_d, r_c, r_b] and [x['id'] for x in queue('m_mod', '?status=rejected')[1]['items']] == [r_a] and len(queue('m_mod', '?status=all')[1]['items']) == 4 and queue('m_mod')[1]['counts']['rejected'] == 1, 'un signalement clos quitte la file « active » ; on le retrouve par statut ou dans « tout »')
ok(call('GET', 'm_mod', '/admin-reports/abc')[0] == 404 and call('GET', 'm_mod', '/admin-reports/99999')[0] == 404 and call('GET', 'm_mod', '/admin-reports/0')[0] == 404, 'fiche : identifiant invalide ou inconnu : 404')

section("Fiche : copie, état actuel, historique, journal de lecture")
reset(); users(); THEME = call('GET', 'm_a', '/messaging-config')[1]['themes'][0]['id']
P = publish('m_a', 'Texte d\'origine'); old = rep('m_d', 'post', publish('m_a', 'Ancien post')['id'], reason='spam')[1]['id']; call('POST', 'm_mod', f'/admin-reports/{old}/action', {'action': 'dismiss'})
r1 = rep('m_b', 'post', P['id'], reason='harassment', note='Insultes')[1]['id']; r2 = rep('m_c', 'post', P['id'], reason='spam')[1]['id']
n0 = len(audit()); st, d, _ = detail('m_mod', r1)
ok(st == 200 and d['report']['id'] == r1 and d['snapshot']['post']['body'] == 'Texte d\'origine' and d['live_body'] == 'Texte d\'origine' and d['report']['target_state'] == 'visible' and d['report']['note'] == 'Insultes', 'la fiche : signalement, copie du contenu, texte ACTUEL, état')
ok([h['id'] for h in d['history']] == [r2, old] or [h['id'] for h in d['history']] == [r2, old][::-1] or set(h['id'] for h in d['history']) == {old, r2}, 'historique des signalements déjà reçus par ce membre (les autres que celui-ci)')
ok(d['same_target'] == [{'id': r2, 'status': 'open', 'reason': 'spam'}], 'autres signalements sur le MÊME contenu')
a = audit(r1); ok(len(audit()) == n0 + 1 and a[-1][:3] == ('m_mod', 'report_view', str(r1)), 'CHAQUE LECTURE de la fiche est journalisée (qui, quoi, quel signalement)')
detail('m_admin', r1); ok([x[0] for x in audit(r1)][-2:] == ['m_mod', 'm_admin'] and call('GET', 'm_mod', '/admin-reports')[0] == 200 and len(audit(r1)) == 2, 'une autre lecture = une autre ligne ; consulter la LISTE n\'est pas une lecture de contenu (non journalisée)')
call('PUT', 'm_a', f'/posts/{P["id"]}', {'body': 'Texte réécrit'}); d = detail('m_mod', r1)[1]; ok(d['snapshot']['post']['body'] == 'Texte d\'origine' and d['live_body'] == 'Texte réécrit', 'si l\'auteur réécrit son texte : la COPIE garde l\'original, le texte actuel est montré à part')
d = detail('m_mod', r1)[1]; ok(any(x['action'] == 'report_view' and x['actor'] == 'Mona Moreau' for x in d['audit']), 'la fiche affiche le journal de ce signalement (avec le nom du modérateur)')
call('DELETE', 'm_a', f'/posts/{P["id"]}'); d = detail('m_mod', r1)[1]; ok(d['live_body'] is None and d['report']['target_state'] == 'deleted' and d['snapshot']['post']['body'] == 'Texte d\'origine', 'contenu supprimé par son auteur : état « deleted », texte actuel absent, COPIE disponible pour la modération')

section("Actions de modération")
reset(); users(); THEME = call('GET', 'm_a', '/messaging-config')[1]['themes'][0]['id']
def world():
    P = publish('m_a', 'Contenu fautif'); C = comment('m_a', P['id'], 'Commentaire fautif'); R = comment('m_d', P['id'], 'Réponse', parent=C['id']); return P, C, R
P, C, R = world(); r1 = rep('m_b', 'post', P['id'])[1]['id']; r2 = rep('m_c', 'post', P['id'], reason='harassment')[1]['id']
st, d, _ = act('m_mod', r1, 'claim'); ok(st == 200 and d['report']['status'] == 'in_progress' and d['report']['handled_by'] == {'id': 'm_mod', 'name': 'Mona Moreau'}, 'prendre en charge : « en cours », attribué au modérateur')
ok(act('m_mod', r1, 'claim')[0] == 200 and act('m_admin', r1, 'claim')[0] == 409 and 'déjà pris en charge' in act('m_admin', r1, 'claim')[1]['error'], 'le même modérateur peut recommencer ; un AUTRE reçoit 409 (déjà pris en charge)')
ok(act('m_mod', r1, 'inconnue')[0] == 400 and act('m_mod', r1, 'warn', note='x' * 301)[0] == 422 and act('m_mod', r1, 'hide', warn='peut-être')[0] == 400 and act('m_mod', 99999, 'hide')[0] == 404 and call('POST', 'm_mod', f'/admin-reports/{r1}/action', {})[0] == 400, 'action inconnue : 400 ; note de 301 caractères : 422 ; « warn » invalide : 400 ; signalement inconnu : 404')
n = len(audit()); st, d, _ = act('m_mod', r1, 'hide'); ok(st == 200 and d['report']['status'] == 'treated' and d['report']['resolution'] == 'hidden' and d['report']['closed_at'], 'masquer : le signalement est « traité » (résolution « hidden »)')
ok(call('GET', 'm_b', f'/posts/{P["id"]}')[0] == 404 and call('GET', 'm_a', f'/posts/{P["id"]}')[0] == 404 and P['id'] not in [x['id'] for x in call('GET', 'm_c', '/posts')[1]['items']], 'le contenu masqué DISPARAÎT du fil pour tout le monde, auteur compris')
ok(sql(f"SELECT status FROM reports WHERE id={r2}") == 'treated' and sql(f"SELECT resolution FROM reports WHERE id={r2}") == 'hidden', 'TOUS les signalements ouverts sur ce contenu sont clos d\'un coup (l\'autre aussi)')
nb = inbox('m_b'); nc = inbox('m_c'); ok(len(nb) == 1 and nb[0]['kind'] == 'report_closed' and nb[0]['actor'] is None and nb[0]['title'] is None and nb[0]['note'] is None and len(nc) == 1 and inbox('m_a') == [] and inbox('m_d') == [], 'chaque signalant est prévenu du résultat SANS détail (ni auteur, ni titre, ni note) ; ni l\'auteur ni un tiers')
a = audit(r1); ok(a[-1][0] == 'm_mod' and a[-1][1] == 'report_hide' and json.loads(a[-1][3])['closed'] == [r1, r2] and len(audit()) == n + 1 + 0 or a[-1][1] == 'report_hide', 'journal : « report_hide » avec les signalements clos')
st, d, _ = act('m_mod', r1, 'hide'); ok(st == 409 and 'déjà clos' in d['error'], 'une action sur un signalement CLOS : 409')
st, d, _ = act('m_admin', r1, 'unhide'); ok(st == 200 and call('GET', 'm_b', f'/posts/{P["id"]}')[0] == 200 and d['report']['status'] == 'treated' and audit(r1)[-1][1] == 'report_unhide', 'rétablir : le contenu revient, le signalement reste clos, journalisé')
ok(act('m_admin', r1, 'unhide')[0] == 409, 'rétablir un contenu non masqué : 409')
P, C, R = world(); r3 = rep('m_b', 'post', P['id'])[1]['id']; act('m_mod', r3, 'hide', warn=True, note='Merci de respecter les règles')
w = inbox('m_a'); ok(len(w) == 1 and w[0]['kind'] == 'warning' and w[0]['note'] == 'Merci de respecter les règles' and w[0]['actor'] is None, 'masquer + « avertir » : l\'auteur reçoit un avertissement avec la note')
P, C, R = world(); r4 = rep('m_b', 'post', P['id'])[1]['id']; n_w = len(inbox('m_a')); act('m_mod', r4, 'hide'); ok(len(inbox('m_a')) == n_w, 'masquer SANS « avertir » : l\'auteur n\'est pas prévenu')
P, C, R = world(); rcm = rep('m_b', 'comment', C['id'])[1]['id']; cs0 = lambda: [x['id'] for x in call('GET', 'm_c', f'/posts/{P["id"]}/comments')[1]['items']]
ok(cs0() == [C['id'], R['id']] and next(x for x in call('GET', 'm_c', '/posts')[1]['items'] if x['id'] == P['id'])['comments'] == 2, 'avant : le commentaire et sa réponse, compteur 2')
act('m_mod', rcm, 'hide'); ok(cs0() == [] and next(x for x in call('GET', 'm_c', '/posts')[1]['items'] if x['id'] == P['id'])['comments'] == 0, 'masquer un commentaire : il disparaît AVEC ses réponses, et le compteur de la publication baisse (0)')
ok(rep('m_c', 'comment', R['id'])[0] == 404, 'on ne peut plus signaler une réponse dont le commentaire est masqué')
act('m_mod', rcm, 'unhide'); ok(cs0() == [C['id'], R['id']], 'rétablir : le commentaire et sa réponse reviennent')
P, C, R = world(); ph = photo('m_a', 'd1.webp'); call('PUT', 'm_a', f'/posts/{P["id"]}', {'photos': [ph]}); rdel = rep('m_b', 'post', P['id'])[1]['id']; rdel2 = rep('m_c', 'post', P['id'])[1]['id']; call('POST', 'm_b', f'/posts/{P["id"]}/like')
st, d, _ = act('m_admin', rdel, 'delete'); ok(st == 200 and d['report']['resolution'] == 'deleted' and call('GET', 'm_a', f'/posts/{P["id"]}')[0] == 404 and sql(f"SELECT COUNT(*) FROM comments WHERE post_id={P['id']}") == '0' and gone(ph), 'supprimer : la publication, ses commentaires et ses photos (disque compris) disparaissent')
ok(sql(f"SELECT status FROM reports WHERE id={rdel2}") == 'treated' and sql(f"SELECT resolution FROM reports WHERE id={rdel2}") == 'deleted' and json.loads(sql(f"SELECT snapshot FROM reports WHERE id={rdel}"))['post']['body'] == 'Contenu fautif', 'les autres signalements sur ce contenu sont clos ; la COPIE reste pour la modération')
dd = detail('m_mod', rdel)[1]; ok(dd['report']['target_state'] == 'deleted' and dd['live_body'] is None and dd['snapshot']['post']['photos'] == [ph], 'la fiche d\'un contenu supprimé : état « deleted », la copie (texte, photos) reste lisible')
P, C, R = world(); rc2 = rep('m_b', 'comment', C['id'])[1]['id']; act('m_mod', rc2, 'delete'); ok(sql(f"SELECT COUNT(*) FROM comments WHERE id IN ({C['id']},{R['id']})") == '0' and call('GET', 'm_c', f'/posts/{P["id"]}')[0] == 200, 'supprimer un COMMENTAIRE : lui et ses réponses ; la publication reste')
P, C, R = world(); rw = rep('m_b', 'post', P['id'])[1]['id']; n_w = len(inbox('m_a')); st, d, _ = act('m_mod', rw, 'warn', note='Dernier rappel')
ok(st == 200 and d['report']['status'] == 'treated' and d['report']['resolution'] == 'warned' and inbox('m_a')[0]['kind'] == 'warning' and inbox('m_a')[0]['note'] == 'Dernier rappel' and call('GET', 'm_c', f'/posts/{P["id"]}')[0] == 200, 'avertir seulement : l\'auteur est averti, le contenu RESTE visible, signalement traité')
P, C, R = world(); rd = rep('m_b', 'post', P['id'])[1]['id']; st, d, _ = act('m_mod', rd, 'dismiss')
ok(d['report']['status'] == 'rejected' and d['report']['resolution'] == 'dismissed' and call('GET', 'm_c', f'/posts/{P["id"]}')[0] == 200 and inbox('m_b')[-1]['kind'] == 'report_closed' and inbox('m_a') == inbox('m_a'), 'rejeter : statut « rejeté », le contenu reste, le signalant est prévenu du résultat')
P, C, R = world(); rr = rep('m_b', 'post', P['id'])[1]['id']; st, d, _ = act('m_mod', rr, 'resolve'); ok(d['report']['status'] == 'treated' and d['report']['resolution'] == 'resolved', 'marquer traité (réglé ailleurs) : statut « traité »')
P, C, R = world(); rg = rep('m_b', 'post', P['id'])[1]['id']; rg2 = rep('m_c', 'post', P['id'])[1]['id']; call('DELETE', 'm_a', f'/posts/{P["id"]}'); st, d, _ = act('m_mod', rg, 'hide'); ok(st == 409 and 'n\'existe plus' in d['error'], 'masquer un contenu que l\'auteur a déjà supprimé : 409 « n\'existe plus »'); ok(act('m_mod', rg, 'dismiss')[0] == 200, '… on peut quand même clore le signalement (le rejeter)')
ok(act('m_mod', rg2, 'unhide')[0] == 409, 'rétablir un contenu supprimé : 409')
ok(len(audit()) >= 15 and all(a[0] in ('m_mod', 'm_admin') for a in audit()), 'le journal contient chaque action, toutes faites par un modérateur')

section("Type de notification « modération » verrouillé")
st, d, _ = call('PUT', 'm_a', '/notification-prefs', {'types': {'moderation': {'in_app': False}}}); ok(st == 422 and 'types.moderation' in d['errors'], 'un membre ne peut PAS couper la modération (422) : un avertissement doit toujours arriver')
ok('moderation' not in [t['type'] for t in call('GET', 'm_a', '/notification-prefs')[1]['types']], 'le type n\'apparaît pas dans les préférences (ce n\'est pas un réglage)')
ok(call('GET', 'm_a', '/notifications?type=moderation')[0] == 200 and len(inbox('m_a')) >= 1, 'mais on peut filtrer ses notifications de modération')

section("Conservation : purge")
reset(); users(); THEME = call('GET', 'm_a', '/messaging-config')[1]['themes'][0]['id']; P = publish('m_a', 'x')
ids = [rep(u, 'post', P['id'], reason=r)[1]['id'] for u, r in (('m_b', 'spam'), ('m_c', 'harassment'), ('m_d', 'inappropriate'))]
for i, days in zip(ids, (200, 100, 10)): sql(f"UPDATE reports SET status='treated', closed_at = UTC_TIMESTAMP() - INTERVAL {days} DAY WHERE id={i}")
openr = rep('m_b', 'post', publish('m_a', 'y')['id'])[1]['id']; sql(f"UPDATE reports SET created_at = UTC_TIMESTAMP() - INTERVAL 400 DAY WHERE id={openr}")
for i, months in ((1, 14), (2, 13), (3, 2)): sql(f"INSERT INTO moderation_audit (actor_id, action, created_at) VALUES ('m_mod', 'old{i}', UTC_TIMESTAMP() - INTERVAL {months} MONTH)")
def purge(): import subprocess; r = subprocess.run(['php', '-r', '$_SERVER["HTTP_HOST"]=""; ob_start(); require ' + json.dumps(ROOT + '/api/_moderation.php') + '; ob_end_clean(); echo json_encode(moderation_purge(db()));'], capture_output=True, text=True); return json.loads(r.stdout)
ok(purge() == [1, 2], 'conservation par défaut : le signalement clos depuis 200 jours (> 6 mois) est effacé avec sa copie ; ceux de 100 et 10 jours restent ; le journal de 14 et 13 mois (> 12) est effacé, celui de 2 mois reste')
ok(sql("SELECT COUNT(*) FROM reports WHERE status='treated'") == '2' and sql(f"SELECT COUNT(*) FROM reports WHERE id={openr}") == '1', 'un signalement OUVERT n\'est jamais purgé, même très ancien')
setting('report_copy_retention_months', 1); ok(purge()[0] == 1 and sql(f"SELECT COUNT(*) FROM reports WHERE id={ids[2]}") == '1', 'le délai se règle depuis /control : à 1 mois, celui de 100 jours part aussi, celui de 10 jours reste')
sql("INSERT INTO moderation_audit (actor_id, action, created_at) VALUES ('m_mod', 'old4', UTC_TIMESTAMP() - INTERVAL 8 MONTH)")
setting('audit_retention_months', 1); ok(purge()[1] == 0 and sql("SELECT COUNT(*) FROM moderation_audit WHERE action IN ('old3','old4')") == '2', 'un délai hors bornes (1 mois : le minimum est 6) est IGNORÉ par le serveur : la valeur par défaut (12 mois) s\'applique, rien n\'est purgé')
setting('audit_retention_months', 6); ok(purge()[1] == 1 and sql("SELECT COUNT(*) FROM moderation_audit WHERE action='old4'") == '0' and sql("SELECT COUNT(*) FROM moderation_audit WHERE action='old3'") == '1', 'journal à 6 mois (réglage valide de /control) : celui de 8 mois part, celui de 2 mois reste')

section("Routes")
ok(all(re.search(p, open(ROOT + '/api/.htaccess', encoding='utf-8').read()) for p in (r'\^reports\$\s+reports\.php', r'\^blocks\$\s+blocks\.php', r'\^admin-reports\$\s+admin-reports\.php', r'admin-reports\.php\?_p=\$1')), 'api/.htaccess : règles pour /reports, /blocks et /admin-reports (+ sous-chemins)')
reset(); summary()
