#!/usr/bin/env python3
"""Messagerie privée (CDC, lot 2) — socle : conversations directes, messages, demandes de message, plafonds, lu / non lu, suppression et purge, accès."""
import glob, json, os, re, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib import ROOT, call, mk_user, ok, reset_rate_limits, sql, summary

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 unset(k): sql(f"DELETE FROM messaging_settings WHERE setting_key = '{k}'")
def follow(a, b): sql(f"INSERT IGNORE INTO member_follows (follower_id, followed_id) VALUES ('{a}', '{b}')")        # a suit b
def section(t): print('\n■ ' + t); reset_rate_limits()
def start(who, to, body='Bonjour', **kw): return call('POST', who, '/conversations', {'user_id': to, 'body': body, **kw})
def send(who, cid, body='Salut', **kw): return call('POST', who, f'/conversations/{cid}/messages', {'body': body, **kw})
def lst(who, f='inbox', qs=''): st, d, _ = call('GET', who, f'/conversations?filter={f}{qs}'); assert st == 200, (st, d); return d['items']
def msgs(who, cid, qs=''): return call('GET', who, f'/conversations/{cid}/messages{qs}')
def ids(items): return [i['id'] for i in items]
def notifs(who, typ='message'): return [n for n in call('GET', who, '/notifications')[1]['items'] if n['type'] == typ]
def cnt(q): return int(sql(q))
def age_messages(uid, seconds=120): sql(f"UPDATE messages SET created_at = created_at - INTERVAL {seconds} SECOND WHERE user_id = '{uid}'")
def wipe():
    p = "'c\\_%'"
    sql(f"DELETE FROM messages WHERE user_id LIKE {p}; DELETE FROM message_requests WHERE sender_id LIKE {p} OR recipient_id LIKE {p}; DELETE FROM conversation_members WHERE user_id LIKE {p}; DELETE FROM conversations WHERE created_by LIKE {p}")
    sql(f"DELETE FROM notifications WHERE user_id LIKE {p} OR actor_id LIKE {p}; DELETE FROM notification_prefs WHERE user_id LIKE {p}; DELETE FROM member_follows WHERE follower_id LIKE {p} OR followed_id LIKE {p}")
def php(code): return subprocess.run(['php', '-d', 'display_errors=0', '-r', '$_SERVER["REQUEST_METHOD"]="GET"; require "api/_conversations.php"; ' + code], capture_output=True, text=True, cwd=ROOT).stdout.strip()

wipe(); sql("DELETE FROM users WHERE id LIKE 'c\\_%'")
for uid, first, last in [('c_a', 'Anne', 'Adam'), ('c_b', 'Bruno', 'Blanc'), ('c_c', 'Chloé', 'Carré'), ('c_d', 'Dan', 'Dupont'), ('c_i', 'Ines', 'Inactive'), ('c_p', 'Paul', 'Pending'), ('c_x', 'Xavier', 'Xu')]: mk_user(uid, 'client_essentiel', first, last)
sql("UPDATE users SET active = 0 WHERE id = 'c_i'; UPDATE users SET account_status = 'pending' WHERE id = 'c_p'; UPDATE users SET created_at = NOW() - INTERVAL 60 DAY WHERE id LIKE 'c\\_%'")
for k in ('message_max_chars', 'messages_per_minute', 'message_requests_per_day', 'new_account_days', 'conversation_purge_days'): unset(k)

section("Tables, réglage, et UN SEUL code qui touche aux messages")
ok(sql("SHOW TABLES LIKE 'conversations'") == 'conversations' and cnt("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME IN ('cipher', 'key_version')") == 2, 'les tables existent ; messages porte cipher et key_version (le chiffrement pourra être activé plus tard sans refonte)')
bad = [os.path.basename(f) for f in glob.glob(ROOT + '/api/*.php') if not f.endswith('_message_store.php') and re.search(r'\b(FROM|INTO|UPDATE|JOIN)\s+messages\b', open(f, encoding='utf-8').read(), re.I)]
ok(not bad, 'AUCUN fichier hors MessageStore ne lit ni n\'écrit la table messages (prérequis du chiffrement futur)' + (f' — fautifs : {bad}' if bad else ''))
logs = [l for f in ('_conversations.php', 'conversations.php', '_message_store.php') for l in open(os.path.join(ROOT, 'api', f), encoding='utf-8').read().split('\n') if 'error_log(' in l]
ok(all('body' not in l and '$text' not in l and "['body']" not in l for l in logs), 'le texte d\'un message n\'est écrit dans aucun journal d\'erreur (' + str(len(logs)) + ' appel(s) à error_log, aucun ne contient de texte)')
mk_user('c_admin', 'admin', 'Adm', 'In'); S = {s['key']: s for s in call('GET', 'c_admin', '/admin-messaging-settings')[1]['settings']}
ok(S['message_max_chars']['default'] == 2000 and (S['message_max_chars']['min'], S['message_max_chars']['max']) == (100, 10000) and call('GET', 'c_a', '/messaging-config')[1]['config'].get('message_max_chars') == 2000, 'réglage /control « message_max_chars » : 100 à 10 000, 2 000 par défaut, affiché aux membres')

section("Accès")
ok(call('GET', None, '/conversations')[0] == 401 and call('POST', None, '/conversations', {})[0] == 401 and call('GET', None, '/conversations/unread')[0] == 401, 'sans connexion : 401')
st, d, _ = call('GET', 'c_a', '/conversations?filter=nimporte'); ok(st == 422 and 'filter' in d['errors'], 'filtre inconnu : 422')
ok(call('GET', 'c_a', '/conversations/abc')[0] == 404 and call('GET', 'c_a', '/conversations/0')[0] == 404 and call('GET', 'c_a', '/conversations/999999')[0] == 404 and call('DELETE', 'c_a', '/conversations')[0] == 404, 'identifiant invalide, nul ou inexistant : 404')
ok(lst('c_a') == [] and call('GET', 'c_a', '/conversations/unread')[1] == {'conversations': 0, 'requests': 0, 'last_message_id': 0, 'suspended_until': None}, 'au départ : aucune conversation, compteurs à zéro')

section("Démarrer une conversation")
for body, why in ((None, 'sans texte'), ('', 'texte vide'), ('   \n ', 'espaces seulement'), (5, 'pas du texte'), ({'a': 1}, 'un objet'), ('x' * 2001, '2 001 caractères (maximum 2 000)')):
    st, d, _ = call('POST', 'c_a', '/conversations', {'user_id': 'c_b', 'body': body}); ok(st == 422 and 'body' in d['errors'], f'texte refusé (422) : {why}')
ok(cnt("SELECT COUNT(*) FROM conversations WHERE created_by = 'c_a'") == 0, 'les refus ne créent AUCUNE conversation')
for to, why in (('c_inconnu', 'identifiant inconnu'), ('c_a', 'soi-même'), ('c_i', 'compte inactif'), ('c_p', 'compte en attente de validation')):
    st, d, _ = start('c_a', to); ok(st == 404 and d == {'error': 'Membre introuvable'}, f'{why} : « Membre introuvable » (404), aucune information en plus')
for body in ({'body': 'x'}, {'user_id': '', 'body': 'x'}, {'user_id': 7, 'body': 'x'}):
    st, d, _ = call('POST', 'c_a', '/conversations', body); ok(st == 422 and 'user_id' in d['errors'], 'destinataire absent, vide ou pas du texte : 422')
follow('c_b', 'c_a')                                                          # Bruno SUIT Anne : son premier message arrive en direct
st, d, _ = start('c_a', 'c_b', '  Bonjour Bruno !  \r\n\r\n\r\nÀ bientôt'); C1 = d.get('conversation_id')
ok(st == 201 and d['ok'] and d['request'] is False and d['message']['body'] == 'Bonjour Bruno !  \n\n\nÀ bientôt' and d['message']['mine'] is True and d['message']['author'] == {'id': 'c_a', 'name': 'Anne A.'}, 'destinataire qui SUIT l\'expéditeur : conversation directe, 201, texte nettoyé (retours à la ligne normalisés, espaces de début et de fin retirés), auteur « Anne A. »')
ok(set(d['message']) == {'id', 'conversation_id', 'author', 'mine', 'body', 'deleted', 'edited', 'created_at', 'quoted'} and d['message']['deleted'] is False and d['message']['edited'] is False and d['message']['quoted'] is None and d['message']['created_at'].endswith('Z'), 'forme du message : id, conversation, auteur, mine, texte, supprimé, modifié, date UTC, citation')
ok(ids(lst('c_a')) == [C1] and ids(lst('c_b')) == [C1] and lst('c_b', 'requests') == [], 'la conversation est dans la liste des DEUX membres, et pas dans « Demandes »')
st, d, _ = start('c_a', 'c_b', 'Deuxième message'); ok(st == 200 and d['conversation_id'] == C1 and d['created'] is False and cnt(f"SELECT COUNT(*) FROM conversations WHERE direct_key = 'c_a|c_b'") == 1, 'redémarrer avec le même membre : la MÊME conversation (200), jamais un doublon')
st, d, _ = start('c_b', 'c_a', 'Réponse de Bruno'); ok(st == 200 and d['conversation_id'] == C1 and cnt("SELECT COUNT(*) FROM conversations WHERE created_by LIKE 'c\\_%'") == 1, 'depuis l\'autre côté : la même conversation (la paire est unique, quel que soit l\'ordre)')
L = lst('c_a')[0]; ok(L['type'] == 'direct' and L['other']['id'] == 'c_b' and L['other']['name'] == 'Bruno B.' and L['last_message']['snippet'] == 'Réponse de Bruno' and L['last_message']['mine'] is False and L['unread'] == 1 and L['request'] is None and L['pinned'] is False and L['muted'] is False, 'dans la liste : l\'AUTRE membre (« Bruno B. »), l\'aperçu du dernier message, 1 non lu')
ok({'badge', 'badge_kind', 'badge_level', 'active'} <= set(L['other']), 'l\'autre membre porte son badge et son indicateur « actif » (comme dans le fil)')

section("Demandes de message : un inconnu écrit pour la première fois")
st, d, _ = start('c_a', 'c_c', 'Bonjour, je suis Anne'); C2 = d['conversation_id']
ok(st == 201 and d['request'] is True, 'destinataire qui ne suit PAS l\'expéditeur : c\'est une DEMANDE')
ok(C2 not in ids(lst('c_c')) and ids(lst('c_c', 'requests')) == [C2] and lst('c_c', 'requests')[0]['request'] == 'incoming' and lst('c_c', 'requests')[0]['last_message']['snippet'] == 'Bonjour, je suis Anne', 'chez le destinataire : dans « Demandes » seulement (pas dans la boîte principale), avec le message à lire')
ok(lst('c_a')[0]['id'] == C2 and lst('c_a')[0]['request'] == 'outgoing', 'chez l\'expéditeur : la conversation est dans sa boîte, marquée « demande envoyée »')
ok(call('GET', 'c_a', '/conversations/unread')[1]['requests'] == 0 and call('GET', 'c_c', '/conversations/unread')[1] == {'conversations': 0, 'requests': 1, 'last_message_id': int(sql(f"SELECT last_message_id FROM conversations WHERE id = {C2}")), 'suspended_until': None}, 'compteur : une demande en attente chez Chloé (et aucune conversation non lue)')
N = notifs('c_c'); ok(len(N) == 1 and N[0]['kind'] == 'request' and N[0]['actor']['id'] == 'c_a' and N[0]['subject_type'] == 'conversation' and N[0]['subject_id'] == str(C2) and 'Bonjour, je suis Anne' not in json.dumps(N), 'notification « message » de sorte « request » : l\'auteur et la conversation, JAMAIS le texte du message')
st, d, _ = send('c_a', C2, 'Je relance'); ok(st == 409 and d == {'error': 'Demande de message en attente'}, 'un SECOND message avant acceptation : refusé (409), un seul message par demande')
ok(cnt(f"SELECT COUNT(*) FROM messages WHERE conversation_id = {C2}") == 1, 'et rien n\'est écrit')
ok(msgs('c_c', C2)[0] == 200 and len(msgs('c_c', C2)[1]['items']) == 1, 'le destinataire peut lire la demande avant de décider')
ok(send('c_d', C2)[0] == 404 and msgs('c_d', C2)[0] == 404 and call('POST', 'c_d', f'/conversations/{C2}/request', {'action': 'accept'})[0] == 404, 'un tiers : tout est « introuvable » (lecture, écriture, acceptation)')
st, d, _ = call('POST', 'c_a', f'/conversations/{C2}/request', {'action': 'accept'}); ok(st == 409, 'l\'EXPÉDITEUR ne peut pas accepter sa propre demande (409)')
st, d, _ = call('POST', 'c_c', f'/conversations/{C2}/request', {'action': 'nimporte'}); ok(st == 422 and 'action' in d['errors'], 'action inconnue : 422')
st, d, _ = call('POST', 'c_c', f'/conversations/{C2}/request', {'action': 'accept'}); ok(st == 200 and C2 in ids(lst('c_c')) and lst('c_c', 'requests') == [] and lst('c_c')[0]['request'] is None, 'accepter : la conversation passe dans la boîte principale, plus dans « Demandes »')
ok(call('POST', 'c_c', f'/conversations/{C2}/request', {'action': 'accept'})[0] == 409, 'accepter deux fois : 409')
st, d, _ = send('c_a', C2, 'Merci d\'avoir accepté'); ok(st == 201, 'après acceptation : l\'expéditeur peut écrire librement'); ok(lst('c_a')[0]['request'] is None, 'et la mention « demande envoyée » disparaît')
st, d, _ = start('c_d', 'c_b', 'Hello Bruno'); C3 = d['conversation_id']; ok(d['request'] is True, 'autre demande (Dan → Bruno)')
st, d, _ = send('c_b', C3, 'Bonjour Dan, ravi de te lire'); ok(st == 201 and C3 in ids(lst('c_b')) and lst('c_b', 'requests') == [], 'RÉPONDRE à une demande l\'accepte (sans bouton) : elle quitte « Demandes »'); ok(send('c_d', C3, 'Merci')[0] == 201, 'et l\'expéditeur peut alors continuer')

section("Refuser une demande : l'expéditeur n'en sait RIEN")
st, d, _ = start('c_c', 'c_d', 'Salut Dan'); C4 = d['conversation_id']; pending_answer = send('c_c', C4, 'Encore')
ok(pending_answer[0] == 409, 'départ : demande en attente, second message refusé')
st, d, _ = call('POST', 'c_d', f'/conversations/{C4}/request', {'action': 'decline'}); ok(st == 200 and lst('c_d', 'requests') == [] and C4 not in ids(lst('c_d')), 'refuser : elle disparaît chez le destinataire')
ok(msgs('c_d', C4)[0] == 404 and send('c_d', C4, 'x')[0] == 404, 'le destinataire n\'y voit plus rien (lecture et écriture « introuvables »)')
declined_answer = send('c_c', C4, 'Encore')
ok(declined_answer[0] == pending_answer[0] and declined_answer[1] == pending_answer[1], 'l\'expéditeur qui réécrit reçoit EXACTEMENT la même réponse que pendant l\'attente (409 « Demande de message en attente ») : rien ne révèle le refus')
ok(C4 in ids(lst('c_c')) and lst('c_c')[0]['request'] == 'outgoing' and msgs('c_c', C4)[0] == 200, 'et pour lui la conversation est inchangée : toujours « demande envoyée », son message toujours lisible')
ok(cnt("SELECT COUNT(*) FROM notifications WHERE user_id = 'c_c' AND type = 'message' AND actor_id = 'c_d'") == 0, 'aucune notification de refus (Dan n\'a rien envoyé à Chloé)')
st, d, _ = start('c_c', 'c_d', 'Je réessaie'); ok(st == 409, 'redémarrer vers le même membre : même réponse (la conversation existe, la demande reste sans suite)')
st, d, _ = start('c_d', 'c_c', 'Finalement, bonjour Chloé !'); ok(st == 200 and d['conversation_id'] == C4 and C4 in ids(lst('c_d')) and C4 in ids(lst('c_c')), 'si le destinataire écrit LUI-MÊME ensuite : la conversation réapparaît chez lui, et la demande est acceptée')
ok(send('c_c', C4, 'Avec plaisir')[0] == 201, 'l\'expéditeur initial peut alors écrire')
st, d, _ = start('c_x', 'c_d', 'Hey'); C5 = d['conversation_id']; st, d, _ = call('POST', 'c_d', f'/conversations/{C5}/request', {'action': 'block'})
ok(st == 200 and lst('c_d', 'requests') == [] and start('c_x', 'c_d', 'Encore')[0] == 404, 'BLOQUER l\'auteur d\'une demande : elle disparaît ; ses messages suivants et ses nouvelles demandes sont « introuvables » (404)')
ok(start('c_d', 'c_x', 'Bonjour ?')[0] == 404 and send('c_x', C5)[0] == 404 and msgs('c_x', C5)[0] == 404, 'le blocage vaut dans les DEUX sens (liste noire de collaboration, une seule source) : lecture et écriture « introuvables » pour les deux')
ok(C5 not in ids(lst('c_x')) and C5 not in ids(lst('c_d')), 'la conversation est masquée des deux listes')
B = call('GET', 'c_d', '/blocks')[1]; ok(any(x['id'] == 'c_x' for x in B['blocked']), 'le membre bloqué figure dans la liste des membres bloqués du fil (même liste)')
call('POST', 'c_d', '/blocks', {'user_id': 'c_x', 'block': False}); ok(start('c_x', 'c_d', 'De nouveau')[0] in (200, 201, 409), 'débloqué : on peut de nouveau lui écrire (réversible)')

section("Notifications")
sql("DELETE FROM notifications WHERE user_id LIKE 'c\\_%'"); st, d, _ = send('c_a', C1, 'Un message')
n = notifs('c_b'); ok(len(n) == 1 and n[0]['kind'] == 'message' and n[0]['actor']['id'] == 'c_a' and n[0]['subject_id'] == str(C1) and 'Un message' not in json.dumps(n), 'un message dans une conversation acceptée : notification « message », sans le texte')
send('c_a', C1, 'Et un autre'); ok(len(notifs('c_b')) == 1, 'plusieurs messages non lus : UNE seule notification non lue (pas de rafale)')
ok(notifs('c_a') == [], 'l\'auteur n\'est jamais notifié de son propre message')
call('PUT', 'c_b', f'/conversations/{C1}', {'muted': True}); sql("DELETE FROM notifications WHERE user_id = 'c_b'"); send('c_a', C1, 'Pendant la sourdine'); ok(notifs('c_b') == [], 'conversation en SOURDINE : aucune notification'); call('PUT', 'c_b', f'/conversations/{C1}', {'muted': False})
call('PUT', 'c_b', '/notification-prefs', {'types': {'message': {'in_app': False}}}); sql("DELETE FROM notifications WHERE user_id = 'c_b'"); send('c_a', C1, 'Cloche coupée'); ok(notifs('c_b') == [], 'type « message » désactivé dans les préférences : aucune notification'); call('PUT', 'c_b', '/notification-prefs', {'types': {'message': {'in_app': True}}})
send('c_a', C1, 'Visible'); ok(len(notifs('c_b')) == 1 and call('GET', 'c_b', '/notifications')[1]['unread'] >= 1, 'préférence rétablie : la notification revient')
call('POST', 'c_b', f'/conversations/{C1}/read', {}); ok(all(n['read'] for n in notifs('c_b')), 'lire la conversation marque ses notifications comme lues')

section("Plafonds d'envoi")
setting('messages_per_minute', 3); age_messages('c_a', 3600); reset_rate_limits()
st1 = [send('c_a', C1, f'Rafale {i}')[0] for i in range(4)]; ok(st1 == [201, 201, 201, 429], 'messages_per_minute = 3 : le 4e message en une minute est refusé (429)')
st, d, _ = send('c_a', C1, 'Encore'); ok(st == 429 and 'patientez' in d['error'], 'et le message dit de patienter')
st, d, _ = start('c_a', 'c_d', 'Autre conversation'); ok(st == 429, 'le plafond vaut pour TOUTES les conversations du membre, pas une seule')
age_messages('c_a', 120); ok(send('c_a', C1, 'Après une minute')[0] == 201, 'une minute plus tard : on peut écrire de nouveau'); unset('messages_per_minute'); age_messages('c_a', 3600)
setting('message_requests_per_day', 2); sql("UPDATE users SET created_at = NOW() WHERE id = 'c_x'"); sql("DELETE FROM messages WHERE user_id = 'c_x'; DELETE FROM message_requests WHERE sender_id = 'c_x'; DELETE FROM conversation_members WHERE conversation_id IN (SELECT id FROM (SELECT id FROM conversations WHERE created_by = 'c_x') t); DELETE FROM conversations WHERE created_by = 'c_x'"); reset_rate_limits()
r = [start('c_x', t, 'Demande')[0] for t in ('c_a', 'c_b', 'c_c')]; ok(r == [201, 201, 429], 'compte RÉCENT, 2 demandes par jour : la 3e demande est refusée (429)')
st, d, _ = start('c_x', 'c_d', 'Demande 4'); ok(st == 429 and 'demandes' in d['error'], 'et le message parle des demandes')
ok(cnt("SELECT COUNT(*) FROM conversations WHERE created_by = 'c_x'") == 2, 'la demande refusée ne crée AUCUNE conversation')
follow('c_d', 'c_x'); st, d, _ = start('c_x', 'c_d', 'Dan me suit'); ok(st == 201 and d['request'] is False, 'écrire à quelqu\'un qui VOUS SUIT n\'est pas une demande : le plafond ne s\'applique pas')
setting('new_account_days', 0); reset_rate_limits(); st, d, _ = start('c_x', 'c_c', 'Sans plafond'); ok(st == 201 and d['request'] is True, 'new_account_days = 0 : plus aucun compte n\'est « récent », donc plus de plafond de demandes (la demande est créée malgré les 2 déjà faites aujourd\'hui)')
unset('new_account_days'); unset('message_requests_per_day'); sql("UPDATE users SET created_at = NOW() - INTERVAL 60 DAY WHERE id = 'c_x'")
setting('message_max_chars', 100); ok(start('c_a', 'c_b', 'y' * 101)[0] == 422 and send('c_a', C1, 'y' * 101)[0] == 422 and send('c_a', C1, 'y' * 100)[0] == 201, 'message_max_chars = 100 : appliqué TOUT DE SUITE, à la création comme à l\'envoi (101 refusé, 100 accepté)'); unset('message_max_chars'); age_messages('c_a', 3600)

section("Lire les messages : ordre, pages, lecture incrémentale")
st, d, _ = start('c_a', 'c_d', 'M1'); C6 = d['conversation_id']; sql(f"UPDATE conversation_members SET accepted = 1 WHERE conversation_id = {C6}"); sql(f"UPDATE message_requests SET status = 'accepted' WHERE conversation_id = {C6}")
mid = [d['message']['id']] + [send('c_a', C6, f'M{i}')[1]['message']['id'] for i in range(2, 8)]
st, d, _ = msgs('c_a', C6); ok(st == 200 and [m['body'] for m in d['items']] == [f'M{i}' for i in range(1, 8)] and d['has_more'] is False, 'sans paramètre : les messages du plus ANCIEN au plus récent')
st, d, _ = msgs('c_a', C6, '?limit=3'); ok([m['body'] for m in d['items']] == ['M5', 'M6', 'M7'] and d['has_more'] is True, 'limit=3 : les 3 plus récents, dans l\'ordre, avec has_more')
st, d, _ = msgs('c_a', C6, f"?limit=3&before={mid[4]}"); ok([m['body'] for m in d['items']] == ['M2', 'M3', 'M4'] and d['has_more'] is True and 'first_unread_id' not in d, 'before=… : la page PRÉCÉDENTE, toujours dans l\'ordre')
st, d, _ = msgs('c_a', C6, f"?limit=3&before={mid[3]}"); ok([m['body'] for m in d['items']] == ['M1', 'M2', 'M3'] and d['has_more'] is False, 'jusqu\'au début : has_more faux')
st, d, _ = msgs('c_a', C6, f"?since={mid[4]}"); ok([m['body'] for m in d['items']] == ['M6', 'M7'] and d['has_more'] is False and 'last_read_message_id' not in d, 'since=… : SEULEMENT les messages plus récents (lecture incrémentale), du plus ancien au plus récent')
st, d, _ = msgs('c_a', C6, f"?since={mid[6]}"); ok(st == 200 and d['items'] == [], 'since = dernier message : liste vide, sans erreur (coût : une lecture d\'index)')
ok(msgs('c_a', C6, '?since=abc')[1]['items'] and len(msgs('c_a', C6, '?limit=500')[1]['items']) == 7 and len(msgs('c_a', C6, '?limit=0')[1]['items']) == 1, 'since invalide ignoré ; limit borné entre 1 et 100')
st, d, _ = msgs('c_d', C6); ok(d['last_read_message_id'] == 0 and d['first_unread_id'] == mid[0], 'pour le destinataire : last_read 0 et le premier non lu (repère « Nouveaux messages »)')
call('POST', 'c_d', f'/conversations/{C6}/read', {'message_id': mid[2]}); st, d, _ = msgs('c_d', C6); ok(d['last_read_message_id'] == mid[2] and d['first_unread_id'] == mid[3], 'après lecture jusqu\'à M3 : le premier non lu est M4')
ok(call('GET', 'c_a', f'/conversations/{C6}/messages/{mid[0]}')[0] == 404 and 'M1' not in json.dumps(call('GET', 'c_a', f'/conversations/{C6}/messages/{mid[0]}')[1]), 'l\'adresse d\'UN message n\'a pas de lecture : 404, jamais son texte')

section("Modifier, supprimer, citer")
st, d, _ = call('PUT', 'c_a', f'/conversations/{C6}/messages/{mid[6]}', {'body': '  M7 corrigé  '}); ok(st == 200 and d['message']['body'] == 'M7 corrigé' and d['message']['edited'] is True, 'modifier son message : texte nettoyé, « modifié »')
st, d, _ = call('PUT', 'c_d', f'/conversations/{C6}/messages/{mid[6]}', {'body': 'piraté'}); ok(st == 403 and msgs('c_a', C6)[1]['items'][-1]['body'] == 'M7 corrigé', 'modifier le message d\'AUTRUI : 403, rien ne change')
ok(call('DELETE', 'c_d', f'/conversations/{C6}/messages/{mid[6]}')[0] == 403, 'supprimer le message d\'autrui : 403')
st, d, _ = call('PUT', 'c_a', f'/conversations/{C6}/messages/{mid[6]}', {'body': ''}); ok(st == 422 and 'body' in d['errors'], 'modifier avec un texte vide : 422')
ok(call('PUT', 'c_a', f'/conversations/{C6}/messages/99999', {'body': 'x'})[0] == 404 and call('PUT', 'c_a', f'/conversations/{C6}/messages/abc', {'body': 'x'})[0] == 404, 'message inexistant ou identifiant invalide : 404')
st, d, _ = start('c_b', 'c_a', 'Autre conversation'); other_mid = d['message']['id']; ok(call('PUT', 'c_a', f'/conversations/{C6}/messages/{other_mid}', {'body': 'x'})[0] == 404, 'un message d\'une AUTRE conversation, adressé via celle-ci : 404')
st, d, _ = send('c_d', C6, 'Je cite M2', quoted_id=mid[1]); Q = d['message']
ok(st == 201 and Q['quoted'] == {'id': mid[1], 'author': 'Anne A.', 'excerpt': 'M2', 'deleted': False}, 'citer un message : l\'auteur et un extrait de l\'original')
st, d, _ = send('c_d', C6, 'Je cite un message d\'ailleurs', quoted_id=other_mid); ok(st == 422 and 'quoted_id' in d['errors'], 'citer un message d\'une AUTRE conversation : refusé (422)')
ok(send('c_d', C6, 'x', quoted_id=0)[0] == 422 and send('c_d', C6, 'x', quoted_id='5')[0] == 422 and send('c_d', C6, 'x', quoted_id=999999)[0] == 422, 'citation absente, mal typée ou inexistante : 422')
st, d, _ = send('c_d', C6, 'Sans citation', quoted_id=None); ok(st == 201 and d['message']['quoted'] is None, 'quoted_id nul : pas de citation')
st, d, _ = call('DELETE', 'c_a', f'/conversations/{C6}/messages/{mid[1]}'); ok(st == 200, 'supprimer son message (le M2 déjà cité)')
row = sql(f"SELECT CONCAT(IFNULL(body, 'NULL'), '|', IFNULL(cipher, 'NULL'), '|', deleted_at IS NOT NULL) FROM messages WHERE id = {mid[1]}"); ok(row == 'NULL|NULL|1', 'le contenu est PURGÉ de la base tout de suite (texte vide, pas seulement masqué)')
it = [m for m in msgs('c_a', C6)[1]['items'] if m['id'] == mid[1]][0]; ok(it['deleted'] is True and it['body'] is None and it['edited'] is False, 'la bulle devient « Message supprimé » : texte absent de l\'API')
q = [m for m in msgs('c_d', C6)[1]['items'] if m['id'] == Q['id']][0]['quoted']; ok(q == {'id': mid[1], 'author': 'Anne A.', 'excerpt': None, 'deleted': True}, 'la citation du message supprimé devient « Message supprimé » (jamais l\'ancien extrait)')
ok(call('DELETE', 'c_a', f'/conversations/{C6}/messages/{mid[1]}')[0] == 404 and call('PUT', 'c_a', f'/conversations/{C6}/messages/{mid[1]}', {'body': 'x'})[0] == 404, 'supprimer ou modifier un message déjà supprimé : 404')
LAST = send('c_d', C6, 'Tout dernier message')[1]['message']['id']; ok([c for c in lst('c_a') if c['id'] == C6][0]['last_message']['snippet'] == 'Tout dernier message', 'l\'aperçu de la liste montre le dernier message'); call('DELETE', 'c_d', f'/conversations/{C6}/messages/{LAST}'); L6 = [c for c in lst('c_a') if c['id'] == C6][0]['last_message']; ok(L6['id'] == LAST and L6['deleted'] is True and L6['snippet'] is None, 'dernier message SUPPRIMÉ : l\'aperçu n\'affiche plus son texte (« Message supprimé »)')

section("Écrire à… : connaître le membre et la conversation déjà ouverte")
st, d, _ = call('GET', 'c_a', '/conversations/with/c_c'); ok(st == 200 and d['user']['id'] == 'c_c' and d['user']['name'] == 'Chloé C.' and d['conversation_id'] == C2 and {'badge', 'badge_kind', 'badge_level', 'active'} <= set(d['user']), 'un membre avec qui une conversation existe : son nom « Chloé C. », son badge, et l\'identifiant de la conversation')
st, d, _ = call('GET', 'c_a', '/conversations/with/c_x'); ok(st == 200 and d['user']['name'] == 'Xavier X.' and (d['conversation_id'] is None or isinstance(d['conversation_id'], int)), 'un membre sans conversation en cours : le nom et conversation_id nul (ou celle qui existe)')
mk_user('c_w', 'client_essentiel', 'Wanda', 'Wu'); st, d, _ = call('GET', 'c_a', '/conversations/with/c_w'); ok(st == 200 and d['conversation_id'] is None and d['user']['name'] == 'Wanda W.', 'jamais contacté : conversation_id nul')
st, d, _ = start('c_a', 'c_w', 'Pour tester'); cw = d['conversation_id']; ok(call('GET', 'c_a', '/conversations/with/c_w')[1]['conversation_id'] == cw, 'après un premier message : l\'identifiant apparaît')
call('DELETE', 'c_a', f'/conversations/{cw}'); ok(call('GET', 'c_a', '/conversations/with/c_w')[1]['conversation_id'] is None, 'conversation supprimée pour moi : de nouveau nulle (on repart de zéro)')
for who, to, why in (('c_a', 'c_a', 'soi-même'), ('c_a', 'c_i', 'compte inactif'), ('c_a', 'c_p', 'compte en attente'), ('c_a', 'inconnu', 'identifiant inconnu')): st, d, _ = call('GET', who, f'/conversations/with/{to}'); ok(st == 404 and d == {'error': 'Membre introuvable'}, f'{why} : « Membre introuvable » (404)')
call('POST', 'c_w', '/blocks', {'user_id': 'c_a', 'block': True}); st, d, _ = call('GET', 'c_a', '/conversations/with/c_w'); ok(st == 404 and d == {'error': 'Membre introuvable'}, 'un membre qui m\'a bloqué : « introuvable », comme un inconnu (aucune fuite)'); call('POST', 'c_w', '/blocks', {'user_id': 'c_a', 'block': False})
ok(call('GET', None, '/conversations/with/c_c')[0] == 401 and call('POST', 'c_a', '/conversations/with/c_c', {})[0] == 404, 'sans connexion : 401 ; autre méthode : 404')

section("Accès : un membre ne lit ni n'écrit dans la conversation d'autrui")
for who in ('c_c',):
    ok(msgs(who, C6)[0] == 404 and send(who, C6)[0] == 404 and call('PUT', who, f'/conversations/{C6}/messages/{mid[0]}', {'body': 'x'})[0] == 404 and call('DELETE', who, f'/conversations/{C6}/messages/{mid[0]}')[0] == 404, 'un tiers : lire, écrire, modifier, supprimer → « introuvable » (404)')
    ok(call('POST', who, f'/conversations/{C6}/read', {})[0] == 404 and call('PUT', who, f'/conversations/{C6}', {'pinned': True})[0] == 404 and call('DELETE', who, f'/conversations/{C6}')[0] == 404 and call('GET', who, f'/conversations/{C6}')[0] == 404, 'marquer lu, régler, supprimer, consulter : 404')
    ok(call('POST', who, f'/conversations/{C6}/request', {'action': 'accept'})[0] == 404, 'traiter une demande qui n\'est pas la sienne : 404')
ok(len(msgs('c_c', C6)[1].get('items', [])) == 0 and 'M1' not in json.dumps(msgs('c_c', C6)[1]), 'et aucune donnée n\'est jamais renvoyée avec ce 404')
ok(all(c['id'] != C6 for c in lst('c_c')), 'la conversation d\'autrui n\'apparaît dans aucune de MES listes')

section("Lu / non lu")
st, d, _ = start('c_a', 'c_b', 'Pour le test des non lus'); sql(f"UPDATE conversation_members SET last_read_message_id = 0 WHERE user_id = 'c_b' AND conversation_id = {C1}")
u0 = [c for c in lst('c_b') if c['id'] == C1][0]['unread']; ok(u0 >= 3, 'plusieurs messages non lus : le compteur de la liste (' + str(u0) + ')')
r = call('POST', 'c_b', f'/conversations/{C1}/read', {'message_id': 1})[1]; ok(r['last_read_message_id'] == 1 and r['unread'] == u0 - cnt(f"SELECT COUNT(*) FROM messages WHERE conversation_id = {C1} AND id <= 1 AND user_id = 'c_a'"), 'lire « jusqu\'à ce message » : le compteur diminue en conséquence')
last = int(sql(f"SELECT last_message_id FROM conversations WHERE id = {C1}")); r = call('POST', 'c_b', f'/conversations/{C1}/read', {})[1]; ok(r['last_read_message_id'] == last and r['unread'] == 0, 'sans identifiant : lu jusqu\'au dernier message, 0 non lu')
r = call('POST', 'c_b', f'/conversations/{C1}/read', {'message_id': 1})[1]; ok(r['last_read_message_id'] == last, 'on ne peut JAMAIS « dé-lire » en lisant un message plus ancien (le repère ne recule pas)')
r = call('POST', 'c_b', f'/conversations/{C1}/read', {'message_id': 99999999})[1]; ok(r['last_read_message_id'] == last, 'ni lire au-delà du dernier message (le repère ne dépasse pas)')
ok(call('POST', 'c_b', f'/conversations/{C1}/read', {'message_id': 'x'})[0] == 422 and call('POST', 'c_b', f'/conversations/{C1}/read', {'message_id': -5})[0] == 422, 'identifiant invalide : 422')
send('c_a', C1, 'Nouveau'); U = call('GET', 'c_b', '/conversations/unread')[1]; ok(U['conversations'] >= 1 and U['last_message_id'] == int(sql(f"SELECT MAX(id) FROM messages WHERE conversation_id IN (SELECT conversation_id FROM conversation_members WHERE user_id = 'c_b')")), 'compteur léger : au moins une conversation non lue, et le dernier identifiant (pour savoir s\'il y a du nouveau)')
call('PUT', 'c_b', f'/conversations/{C1}', {'muted': True}); U2 = call('GET', 'c_b', '/conversations/unread')[1]; ok(U2['conversations'] == U['conversations'] - 1, 'une conversation en sourdine ne compte pas dans le compteur'); call('PUT', 'c_b', f'/conversations/{C1}', {'muted': False})
call('POST', 'c_b', f'/conversations/{C1}/read', {}); call('PUT', 'c_b', f'/conversations/{C1}', {'marked_unread': True}); ok([c for c in lst('c_b') if c['id'] == C1][0]['marked_unread'] is True and call('GET', 'c_b', '/conversations/unread')[1]['conversations'] >= 1, '« Marquer non lu » : la conversation compte comme non lue')
call('POST', 'c_b', f'/conversations/{C1}/read', {}); ok([c for c in lst('c_b') if c['id'] == C1][0]['marked_unread'] is False, 'la lire efface ce marquage')
ok([c for c in lst('c_a') if c['id'] == C1][0]['unread'] == 0, 'les messages qu\'on a écrits soi-même ne comptent jamais comme non lus')
ok(cnt("SELECT last_active_at IS NOT NULL FROM users WHERE id = 'c_a'") == 1, 'envoyer un message est une action significative (pastille « actif »)')

section("Réglages d'une conversation : épingler, archiver, sourdine")
target = C2; age_messages('c_a', 3600)          # la conversation Anne – Chloé, plus ancienne que celle d'Anne et Dan
send('c_a', C6, 'Pour faire passer l\'autre conversation devant'); ok(ids(lst('c_a'))[0] != target, 'départ : cette conversation n\'est pas la première (une autre a un message plus récent)')
call('PUT', 'c_a', f'/conversations/{target}', {'pinned': True}); ok(ids(lst('c_a'))[0] == target and lst('c_a')[0]['pinned'] is True, 'épingler : la conversation passe EN TÊTE, quelle que soit l\'ancienneté de son dernier message')
call('PUT', 'c_a', f'/conversations/{target}', {'archived': True}); ok(target not in ids(lst('c_a')) and target in ids(lst('c_a', 'archived')) and lst('c_a', 'archived')[0]['archived'] is True, 'archiver : elle quitte la boîte principale et apparaît dans le filtre « archived »')
ok(msgs('c_a', target)[0] == 200, 'une conversation archivée reste accessible'); call('PUT', 'c_a', f'/conversations/{target}', {'archived': False, 'pinned': False}); ok(target in ids(lst('c_a')), 'désarchiver : elle revient')
st, d, _ = call('PUT', 'c_a', f'/conversations/{target}', {'muted': 'oui'}); ok(st == 422 and 'muted' in d['errors'], 'valeur qui n\'est pas oui / non : 422'); ok(call('PUT', 'c_a', f'/conversations/{target}', {})[0] == 422, 'aucun réglage : 422'); ok(call('PUT', 'c_a', f'/conversations/{target}', {'inconnu': True})[0] == 422, 'réglage inconnu seul : 422 (rien à modifier)')
ok([c for c in lst('c_c') if c['id'] == target][0]['pinned'] is False and [c for c in lst('c_c') if c['id'] == target][0]['archived'] is False, 'les réglages sont personnels : l\'autre membre (Chloé) ne voit aucun changement')

section("Lecture incrémentale de la liste")
base = max(c['last_message']['id'] for c in lst('c_a')); ok(lst('c_a', 'inbox', f'&updated_since={base}') == [], 'updated_since = le dernier message : aucune conversation (liste vide)')
send('c_a', C1, 'Une nouveauté'); new = lst('c_a', 'inbox', f'&updated_since={base}'); ok(ids(new) == [C1], 'après un nouveau message : SEULE cette conversation revient')

section("Supprimer une conversation pour soi, puis la purge définitive")
st, d, _ = start('c_a', 'c_b', 'Avant la suppression'); hist = msgs('c_b', C1)[1]['items']; n_before = len(hist)
ok(call('DELETE', 'c_b', f'/conversations/{C1}')[0] == 200 and C1 not in ids(lst('c_b')) and msgs('c_b', C1)[0] == 404 and C1 in ids(lst('c_a')), '« supprimer pour moi » : elle disparaît de MA liste (et devient introuvable), pas de celle de l\'autre')
ok(len(msgs('c_a', C1)[1]['items']) >= n_before, 'l\'autre membre garde tout son historique')
send('c_a', C1, 'Après la suppression'); ok(C1 in ids(lst('c_b')) and [m['body'] for m in msgs('c_b', C1)[1]['items']] == ['Après la suppression'], 'un NOUVEAU message la fait réapparaître, mais l\'historique d\'avant ne revient PAS (seulement ce qui suit)')
ok(lst('c_b')[0]['unread'] == 1, 'et il est non lu')
call('DELETE', 'c_a', f'/conversations/{C1}'); call('DELETE', 'c_b', f'/conversations/{C1}'); setting('conversation_purge_days', 30)
sql(f"UPDATE conversation_members SET deleted_at = UTC_TIMESTAMP() - INTERVAL 10 DAY WHERE conversation_id = {C1}"); ok(php('echo conversations_purge(db());') == '0' and cnt(f"SELECT COUNT(*) FROM messages WHERE conversation_id = {C1}") > 0, 'supprimée par les DEUX depuis 10 jours (délai : 30) : pas encore purgée')
sql(f"UPDATE conversation_members SET deleted_at = UTC_TIMESTAMP() - INTERVAL 31 DAY WHERE conversation_id = {C1}"); sql(f"UPDATE conversation_members SET deleted_at = NULL WHERE conversation_id = {C1} AND user_id = 'c_a'")
ok(php('echo conversations_purge(db());') == '0' and cnt(f"SELECT COUNT(*) FROM conversations WHERE id = {C1}") == 1, 'supprimée par UN SEUL membre : jamais purgée, même après 31 jours (l\'autre en a besoin)')
sql(f"UPDATE conversation_members SET deleted_at = UTC_TIMESTAMP() - INTERVAL 31 DAY WHERE conversation_id = {C1}"); sql(f"INSERT INTO notifications (user_id, type, actor_id, subject_type, subject_id, payload, created_at) VALUES ('c_a', 'message', 'c_b', 'conversation', '{C1}', '{{}}', UTC_TIMESTAMP())")
ok(php('echo conversations_purge(db());') == '1', 'supprimée par TOUS depuis 31 jours : purgée (1)')
ok(cnt(f"SELECT COUNT(*) FROM messages WHERE conversation_id = {C1}") == 0 and cnt(f"SELECT COUNT(*) FROM conversation_members WHERE conversation_id = {C1}") == 0 and cnt(f"SELECT COUNT(*) FROM conversations WHERE id = {C1}") == 0 and cnt(f"SELECT COUNT(*) FROM notifications WHERE subject_type = 'conversation' AND subject_id = '{C1}'") == 0, 'messages, membres, conversation et notifications : tout est effacé définitivement'); unset('conversation_purge_days')
ok(cnt(f"SELECT COUNT(*) FROM messages WHERE conversation_id = {C6}") > 0, 'les autres conversations ne sont pas touchées')

section("Liste noire : dans tous les sens, sans rien révéler")
st, d, _ = start('c_a', 'c_b', 'Pour le blocage'); C7 = d['conversation_id']
call('POST', 'c_b', '/blocks', {'user_id': 'c_a', 'block': True})
ok(send('c_a', C7)[0] == 404 and msgs('c_a', C7)[0] == 404 and start('c_a', 'c_b')[0] == 404, 'Bruno a bloqué Anne : elle ne peut ni écrire ni lire ni redémarrer — toujours « introuvable » (404), jamais « bloquée »')
ok(send('c_b', C7)[0] == 404 and C7 not in ids(lst('c_a')) and C7 not in ids(lst('c_b')), 'le blocage vaut aussi pour Bruno (même liste noire), et masque la conversation des deux listes')
ok(call('GET', 'c_a', '/conversations/unread')[1]['last_message_id'] == 0 or C7 not in [x for x in ids(lst('c_a'))], 'le compteur et les listes n\'en tiennent plus compte')
call('POST', 'c_b', '/blocks', {'user_id': 'c_a', 'block': False}); ok(send('c_a', C7, 'De nouveau')[0] == 201 and C7 in ids(lst('c_a')), 'débloqué : tout revient, historique compris')

section("Compte supprimé : « Ancien membre »")
mk_user('c_y', 'client_essentiel', 'Yann', 'Yeti'); sql("UPDATE users SET created_at = NOW() - INTERVAL 60 DAY WHERE id = 'c_y'"); follow('c_y', 'c_a'); st, d, _ = start('c_a', 'c_y', 'Avant ton départ'); C8 = d['conversation_id']; send('c_y', C8, 'Salut Anne, je pars')
sql("DELETE FROM users WHERE id = 'c_y'")
ok(C8 in ids(lst('c_a')) and [c for c in lst('c_a') if c['id'] == C8][0]['other']['name'] == 'Ancien membre', 'compte supprimé : la conversation reste dans la liste, sous « Ancien membre »')
m8 = msgs('c_a', C8)[1]['items']; ok([m['body'] for m in m8] == ['Avant ton départ', 'Salut Anne, je pars'] and m8[1]['author'] == {'id': 'c_y', 'name': 'Ancien membre'}, 'ses messages restent LISIBLES, signés « Ancien membre » (ni nom ni photo)')
ok(send('c_a', C8, 'Tu es là ?')[0] == 404 and start('c_a', 'c_y', 'Tu es là ?')[0] == 404, 'mais on ne peut plus lui écrire (404)')
ok(call('DELETE', 'c_a', f'/conversations/{C8}')[0] == 200 and C8 not in ids(lst('c_a')), 'et la conversation peut être supprimée normalement')

wipe(); sql("DELETE FROM users WHERE id LIKE 'c\\_%'"); [unset(k) for k in ('message_max_chars', 'messages_per_minute', 'message_requests_per_day', 'new_account_days', 'conversation_purge_days')]; summary()
