#!/usr/bin/env python3
"""Centre de notifications (CDC n° 12 et 13) : notify(), endpoints notifications et notification-prefs, préférences, heures calmes,
visibilité, déduplication, purge, et branchement des réponses / invitations / alertes de collaboration."""
import 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

TYPES = ['message', 'mention', 'like', 'comment', 'collaboration']
MAILS = ROOT + '/tests/mails.jsonl'
def mails(): return [json.loads(l) for l in open(MAILS)] if os.path.exists(MAILS) else []
def php(code):
    r = subprocess.run(['php', '-r', '$_SERVER["HTTP_HOST"]=""; ob_start(); require ' + json.dumps(ROOT + '/api/_notifications.php') + '; ob_end_clean(); ' + code], capture_output=True, text=True)
    return r.stdout.strip() if r.returncode == 0 else 'ERREUR ' + (r.stdout + r.stderr).strip()[-400:]       # la fin : c'est là qu'est le message d'exception
def notify(user, type_, actor, subject=7, payload=None, subject_type='project'):
    out = php(f'echo json_encode(notify(db(), {json.dumps(user)}, {json.dumps(type_)}, {json.dumps(actor)}, {json.dumps(subject_type)}, {json.dumps(subject)}, json_decode({json.dumps(json.dumps(payload or {}))}, true)));')
    return json.loads(out) if out.startswith('{') else out
def lst(who, qs=''): st, d, _ = call('GET', who, '/notifications' + qs); return st, d
def unread(who): return call('GET', who, '/notifications/unread')[1]
def prefs(who): return call('GET', who, '/notification-prefs')[1]
def reset():
    sql("DELETE FROM notifications; DELETE FROM notification_prefs; DELETE FROM notification_settings; DELETE FROM collab_alerts_sent; DELETE FROM collaboration_invites; DELETE FROM collaboration_replies; DELETE FROM collaboration_requests WHERE user_id LIKE 'm\\_%'; DELETE FROM users WHERE id LIKE 'm\\_%'; DELETE FROM messaging_settings WHERE setting_key = 'notifications_retention_days'")
    open(MAILS, 'w').close()
    reset_rate_limits()

reset()
for u in ('m_a', 'm_b', 'm_c'): mk_user(u, 'prospect', u.upper(), 'Durand')
mk_user('m_admin', 'admin', 'Alice', 'Admin')
sql("UPDATE users SET firstname='Anne', lastname='Durand' WHERE id='m_a'")

print("\n■ Types et préférences par défaut")
st, p, _ = call('GET', 'm_a', '/notification-prefs')
ok(st == 200 and [t['type'] for t in p['types']] == TYPES, 'types dans l\'ordre du cahier des charges : messages, mentions, j\'aime, commentaires, collaborations')
ch = {t['type']: [c['channel'] for c in t['channels']] for t in p['types']}
ok(ch['message'] == ['in_app', 'push', 'email'] and all(ch[t] == ['in_app', 'push'] for t in TYPES[1:4]) and ch['collaboration'] == ['in_app', 'email', 'push'], 'seuls les canaux RÉELLEMENT branchés sont proposés : « in_app » et « push » partout, « email » pour les messages (non lus) et les collaborations (pas de réglage sans effet)')
ok(all(c['enabled'] for t in p['types'] for c in t['channels'] if not (t['type'] == 'like' and c['channel'] == 'push')) and [c['enabled'] for t in p['types'] if t['type'] == 'like' for c in t['channels'] if c['channel'] == 'push'] == [False], 'par défaut : tout est activé, sauf le push des « j\'aime » (trop fréquents)')
ok(p['quiet'] == {'enabled': False, 'start': '22:00', 'end': '08:00', 'timezone': 'Europe/Paris'}, 'heures calmes : désactivées par défaut (22:00 → 08:00, Europe/Paris)')
ok(call('GET', None, '/notification-prefs')[0] == 401 and call('GET', None, '/notifications')[0] == 401 and call('GET', None, '/notifications/unread')[0] == 401, 'visiteur : 401 partout')
ok(call('GET', 'm_admin', '/notifications')[0] == 200, 'tous les rôles ont un centre de notifications (admin compris)')

print("\n■ notify()")
r = notify('m_b', 'collaboration', 'm_a', 12, {'kind': 'reply', 'title': 'Vases en céramique', 'secret': 'ne doit pas être gardé', 'keywords': ['a', 'b']})
ok(isinstance(r, dict) and r['status'] == 'created' and r['id'] > 0, 'une notification est créée')
st, d = lst('m_b'); it = d['items'][0]
ok(st == 200 and len(d['items']) == 1 and it['type'] == 'collaboration' and it['kind'] == 'reply' and it['title'] == 'Vases en céramique' and it['subject_type'] == 'project' and it['subject_id'] == '12' and it['read'] is False, 'listée avec son type, son sujet, son titre, non lue')
ok(it['actor'] == {'id': 'm_a', 'name': 'Anne D.'}, 'l\'auteur apparaît comme « Anne D. » (jamais le nom complet)')
ok(re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', it['created_at']) and 'secret' not in json.dumps(it), 'date en UTC (ISO, suffixe Z) ; seuls les champs connus du texte sont conservés')
ok('secret' not in sql("SELECT payload FROM notifications ORDER BY id DESC LIMIT 1"), '… et le champ inconnu n\'est jamais écrit en base')
ok(unread('m_b') == {'unread': 1, 'last_id': it['id']} and unread('m_c')['unread'] == 0, 'compteur et dernier identifiant ; l\'autre membre n\'a rien')
ok(notify('m_a', 'collaboration', 'm_a')['status'] == 'skipped', 'on ne se notifie pas soi-même')
ok(notify('inconnu', 'collaboration', 'm_a')['status'] == 'skipped', 'destinataire inexistant : rien')
sql("UPDATE users SET account_status='pending' WHERE id='m_c'"); ok(notify('m_c', 'collaboration', 'm_a', 1)['status'] == 'skipped', 'compte en attente de validation : rien'); sql("UPDATE users SET account_status='active' WHERE id='m_c'")
sql("UPDATE users SET active=0 WHERE id='m_c'"); ok(notify('m_c', 'collaboration', 'm_a', 2)['status'] == 'skipped', 'compte inactif : rien'); sql("UPDATE users SET active=1 WHERE id='m_c'")
ok('inconnu' in notify('m_b', 'inconnu', 'm_a') and 'ERREUR' not in str(notify('m_b', 'like', 'm_a', 99, subject_type='post')), 'un type inconnu est une erreur de programmation (exception) ; un type connu fonctionne')
d2 = notify('m_b', 'collaboration', 'm_a', 12, {'kind': 'reply'}); ok(d2['status'] == 'duplicate' and d2['id'] == r['id'], 'même notification NON LUE : pas de doublon (« duplicate », même identifiant)')
call('POST', 'm_b', '/notifications', {'action': 'read', 'ids': [r['id']]}); d3 = notify('m_b', 'collaboration', 'm_a', 12, {'kind': 'reply'})
ok(d3['status'] == 'created' and d3['id'] != r['id'], 'une fois lue, la suivante est créée')
sql("DELETE FROM notifications")

print("\n■ Liste, pagination, filtres")
for i in range(1, 8): notify('m_b', 'like' if i % 2 else 'comment', 'm_a', i, subject_type='post')
ids = [x['id'] for x in lst('m_b')[1]['items']]
ok(ids == sorted(ids, reverse=True) and len(ids) == 7, '7 notifications, la plus récente d\'abord')
st, d = lst('m_b', '?limit=3'); ok(len(d['items']) == 3 and d['has_more'] is True and d['next_before'] == d['items'][-1]['id'], 'limit=3 : 3 éléments, has_more, next_before = identifiant du dernier')
st, d2 = lst('m_b', f'?limit=3&before={d["next_before"]}'); ok([x['id'] for x in d['items'] + d2['items']] == ids[:6] and d2['has_more'] is True, 'page suivante avec before : la suite exacte, sans trou ni doublon')
st, d3 = lst('m_b', f'?limit=3&before={d2["next_before"]}'); ok(len(d3['items']) == 1 and d3['has_more'] is False, 'dernière page : 1 élément, plus de suite')
st, d = lst('m_b', f'?since={ids[3]}'); ok([x['id'] for x in d['items']] == ids[:3], 'since=N : seulement ce qui est plus récent (API incrémentale)')
st, d = lst('m_b', f'?since={ids[0]}'); ok(d['items'] == [] and d['last_id'] == ids[0] and d['unread'] == 7, 'rien de nouveau : liste vide, mais compteur et last_id toujours donnés')
st, d = lst('m_b', '?type=like'); ok(all(x['type'] == 'like' for x in d['items']) and len(d['items']) == 4, 'filtre par type')
ok(lst('m_b', '?type=nimporte')[0] == 400 and lst('m_b', '?limit=9999')[0] == 200 and len(lst('m_b', '?limit=9999')[1]['items']) == 7 and len(lst('m_b', '?limit=0')[1]['items']) == 7 and lst('m_b', '?limit[]=1&type[]=x')[0] == 200, 'type inconnu : 400 ; limite bornée (50 au plus) ; paramètres en tableau : aucune erreur')
ok(lst('m_c')[1]['items'] == [] and lst('m_a')[1]['items'] == [], 'un membre ne voit JAMAIS les notifications d\'un autre')

print("\n■ Marquer comme lu")
ids = [x['id'] for x in lst('m_b')[1]['items']]
st, r = call('POST', 'm_b', '/notifications', {'action': 'read', 'ids': ids[:2]})[:2]; ok(st == 200 and r['updated'] == 2 and r['unread'] == 5, '2 marquées lues : « updated 2 », compteur 5')
ok(call('POST', 'm_b', '/notifications', {'action': 'read', 'ids': ids[:2]})[1]['updated'] == 0, 'idempotent : les relire ne change rien')
other = notify('m_c', 'like', 'm_a', 1, subject_type='post')['id']
r = call('POST', 'm_b', '/notifications', {'action': 'read', 'ids': [other]})[1]; ok(r['updated'] == 0 and unread('m_c')['unread'] == 1, 'on ne peut pas marquer lue la notification d\'un AUTRE membre')
ok(call('POST', 'm_b', '/notifications', {'action': 'read', 'ids': [str(ids[2])]})[1]['updated'] == 1, 'un identifiant donné en chaîne de chiffres est accepté')
for body, why in (({'action': 'read'}, 'sans ids'), ({'action': 'read', 'ids': []}, 'liste vide'), ({'action': 'read', 'ids': ['a']}, 'identifiant non numérique'), ({'action': 'read', 'ids': [1.5]}, 'décimal'), ({'action': 'read', 'ids': list(range(1, 102))}, '101 identifiants'), ({'action': 'nimporte'}, 'action inconnue'), ({'action': 'read_all', 'type': 'x'}, 'type inconnu'), ({}, 'corps vide')):
    ok(call('POST', 'm_b', '/notifications', body)[0] == 400, f'refusé (400) : {why}')
r = call('POST', 'm_b', '/notifications', {'action': 'read_all', 'type': 'like'})[1]; ok(r['updated'] == 2 and r['unread'] == 2, 'read_all d\'un type : seulement les « j\'aime » restantes (2) ; les 2 commentaires restent non lus')
r = call('POST', 'm_b', '/notifications', {'action': 'read_all'})[1]; ok(r['unread'] == 0 and unread('m_b')['unread'] == 0, 'read_all : tout est lu, compteur à 0')
ok(call('POST', 'm_b', '/notifications', {'action': 'read_all'})[1]['updated'] == 0 and unread('m_c')['unread'] == 1, 'rien n\'est touché chez les autres membres')
ok(call('DELETE', 'm_b', '/notifications')[0] == 404 and call('GET', 'm_b', '/notifications/inconnu')[0] == 404, 'méthode ou route inconnue : 404')
ok(re.search(r'RewriteRule \^notifications\$\s+notifications\.php', open(ROOT + '/api/.htaccess', encoding='utf-8').read()) and 'notification-prefs.php' in open(ROOT + '/api/.htaccess', encoding='utf-8').read(), 'api/.htaccess : une règle par endpoint')

print("\n■ Visibilité : liste noire de collaboration (dans les deux sens)")
sql("DELETE FROM notifications"); a1 = notify('m_b', 'collaboration', 'm_a', 1)['id']; notify('m_b', 'like', 'm_c', 2, subject_type='post')
ok(unread('m_b')['unread'] == 2, 'avant : 2 non lues')
sql("UPDATE users SET collab_settings='{\"blacklist\":[\"m_b@test.test\"]}' WHERE id='m_a'")
d = lst('m_b')[1]; ok([x['actor']['id'] for x in d['items']] == ['m_c'] and d['unread'] == 1 and unread('m_b')['unread'] == 1, 'm_a a mis m_b en liste noire : ses notifications disparaissent de la liste ET du compteur, la liste et le compteur restent cohérents')
ok(notify('m_b', 'collaboration', 'm_a', 3)['status'] == 'blocked', 'et une nouvelle notification de m_a vers m_b est refusée (« blocked »)')
sql("UPDATE users SET collab_settings=NULL WHERE id='m_a'; UPDATE users SET collab_settings='{\"blacklist\":[\"m_c@test.test\"]}' WHERE id='m_b'")
ok(notify('m_b', 'like', 'm_c', 9, subject_type='post')['status'] == 'blocked' and [x['actor']['id'] for x in lst('m_b')[1]['items']] == ['m_a'], 'sens inverse : m_b a mis m_c en liste noire, il ne reçoit plus rien de lui')
sql("UPDATE users SET collab_settings=NULL WHERE id IN ('m_a','m_b')"); ok(len(lst('m_b')[1]['items']) == 2 and unread('m_b')['unread'] == 2, 'liste noire retirée : tout réapparaît')

print("\n■ Préférences")
st, r, _ = call('PUT', 'm_b', '/notification-prefs', {'types': {'collaboration': {'email': False}, 'like': {'in_app': False}}})
S = {t['type']: {c['channel']: c['enabled'] for c in t['channels']} for t in r['types']}
ok(st == 200 and S['collaboration'] == {'in_app': True, 'email': False, 'push': True} and S['like'] == {'in_app': False, 'push': False} and S['comment'] == {'in_app': True, 'push': True}, 'modifier un canal d\'un type : seul celui-là change (les autres canaux, push compris, gardent leur valeur)')
ok(notify('m_b', 'like', 'm_a', 55, subject_type='post')['status'] == 'disabled', 'type coupé pour le canal « application » : notify() renvoie « disabled », rien n\'est créé')
ok(php('echo json_encode([notification_channel_allowed(db(), "m_b", "collaboration", "email"), notification_channel_allowed(db(), "m_b", "collaboration", "in_app"), notification_channel_allowed(db(), "m_c", "collaboration", "email")]);') == '[false,true,true]', 'canal email coupé : refusé ; application : permise ; un autre membre garde les valeurs par défaut')
n0 = sql("SELECT COUNT(*) FROM notification_prefs"); bad = {'types': {'nimporte': {'in_app': True}}}
for body, why in ((bad, 'type inconnu'), ({'types': {'like': {'email': True}}}, 'canal indisponible pour ce type'), ({'types': {'like': {'sms': True}}}, 'canal inexistant'), ({'types': {'collaboration': {'email': 'oui'}}}, 'pas un booléen'), ({'types': 'x'}, 'types pas un objet'), ({'quiet': {'enabled': True, 'start': '25:00'}}, 'heure invalide'), ({'quiet': {'enabled': True, 'start': '8:00'}}, 'format HH:MM exigé'), ({'quiet': {'start': '09:00', 'end': '09:00', 'enabled': True}}, 'début = fin'), ({'quiet': {'timezone': 'Mars/Olympus'}}, 'fuseau inconnu'), ({'quiet': {'enabled': 'peut-être'}}, 'bascule invalide'), ({'quiet': 5}, 'quiet pas un objet')):
    st, r, _ = call('PUT', 'm_b', '/notification-prefs', body); ok(st == 422 and r.get('errors'), f'refusé (422) : {why}')
ok(sql("SELECT COUNT(*) FROM notification_prefs") == n0, 'après les refus : rien n\'a été écrit')
st, r, _ = call('PUT', 'm_b', '/notification-prefs', {'types': {'comment': {'in_app': False}}, 'quiet': {'enabled': True, 'start': '25:00'}})
ok(st == 422 and S['comment'] == {'in_app': True, 'push': True} and {t['type']: {c['channel']: c['enabled'] for c in t['channels']} for t in prefs('m_b')['types']}['comment'] == {'in_app': True, 'push': True}, 'ATOMIQUE : une valeur valide + une invalide → rien n\'est enregistré')
ok(call('PUT', 'm_b', '/notification-prefs', {})[0] == 400 and call('PUT', 'm_b', '/notification-prefs', raw=b'{pas du json')[0] == 400 and call('DELETE', 'm_b', '/notification-prefs')[0] == 405, 'corps vide ou invalide : 400 ; méthode inconnue : 405')

print("\n■ Heures calmes")
st, r, _ = call('PUT', 'm_c', '/notification-prefs', {'quiet': {'enabled': True, 'start': '22:00', 'end': '08:00', 'timezone': 'Europe/Paris'}})
ok(st == 200 and r['quiet'] == {'enabled': True, 'start': '22:00', 'end': '08:00', 'timezone': 'Europe/Paris'}, 'activées : 22:00 → 08:00')
def quiet(user, when, tz='Europe/Paris'): return php(f'echo json_encode(notification_in_quiet_hours(db(), {json.dumps(user)}, new DateTimeImmutable({json.dumps(when)}, new DateTimeZone({json.dumps(tz)}))));') == 'true'
ok(quiet('m_c', '2026-10-14 23:30:00') and quiet('m_c', '2026-10-14 03:00:00') and quiet('m_c', '2026-10-14 22:00:00') and quiet('m_c', '2026-10-14 07:59:00'), 'la plage passe minuit : 23:30, 03:00, 22:00 (début inclus) et 07:59 sont « calmes »')
ok(not quiet('m_c', '2026-10-14 08:00:00') and not quiet('m_c', '2026-10-14 12:00:00') and not quiet('m_c', '2026-10-14 21:59:00'), 'hors plage : 08:00 (fin exclue), midi, 21:59')
ok(not quiet('m_a', '2026-10-14 23:30:00'), 'un membre qui ne l\'a pas activée n\'a pas d\'heures calmes')
call('PUT', 'm_c', '/notification-prefs', {'quiet': {'start': '09:00', 'end': '17:00'}}); ok(quiet('m_c', '2026-10-14 12:00:00') and not quiet('m_c', '2026-10-14 17:00:00') and not quiet('m_c', '2026-10-14 08:59:00'), 'plage dans la journée (09:00 → 17:00) : fin exclue')
call('PUT', 'm_c', '/notification-prefs', {'quiet': {'start': '22:00', 'end': '08:00', 'timezone': 'Asia/Tokyo'}})
ok(quiet('m_c', '2026-10-14 15:00:00', 'Europe/Paris') and not quiet('m_c', '2026-10-14 15:00:00', 'Asia/Tokyo'), 'le fuseau du MEMBRE compte : 15:00 à Paris = 22:00 à Tokyo → calme pour un membre réglé sur Tokyo')
call('PUT', 'm_c', '/notification-prefs', {'quiet': {'timezone': 'Europe/Paris', 'start': '22:00', 'end': '08:00'}})
ok(php('echo json_encode([notification_channel_allowed(db(), "m_c", "collaboration", "email", new DateTimeImmutable("2026-10-14 23:30:00", new DateTimeZone("Europe/Paris"))), notification_channel_allowed(db(), "m_c", "collaboration", "in_app", new DateTimeImmutable("2026-10-14 23:30:00", new DateTimeZone("Europe/Paris"))), notification_channel_allowed(db(), "m_c", "collaboration", "email", new DateTimeImmutable("2026-10-14 12:00:00", new DateTimeZone("Europe/Paris")))]);') == '[false,true,true]', 'pendant les heures calmes : PAS d\'email, mais la notification dans l\'application reste permise ; hors plage : email permis')

print("\n■ Branchement : réponse à un projet")
reset(); mk_user('m_auth', 'client_premium', 'Marie', 'Martin'); mk_user('m_rep', 'prospect', 'Paul', 'Petit'); mk_user('m_rep2', 'prospect', 'Zoé', 'Zola')
st, d, _ = call('POST', 'm_auth', '/collaboration-requests', {'title': 'Vases en céramique', 'description': 'x', 'tags': ['Céramique'], 'project_type': 'creation_unique'}); pid = d['id']
st, r, _ = call('POST', 'm_rep', '/collaboration-replies', {'request_id': pid, 'message': 'Bonjour, je suis intéressé'})
ok(st == 200 and r['ok'] and isinstance(r['id'], (int, str)) and int(r['id']) > 0, 'la réponse est enregistrée, et son identifiant renvoyé est bien CELUI DE LA RÉPONSE (pas de la notification créée ensuite)')
ok(int(r['id']) == int(sql("SELECT MAX(id) FROM collaboration_replies")), '… identifiant vérifié en base')
n = lst('m_auth')[1]['items']
ok(len(n) == 1 and n[0]['type'] == 'collaboration' and n[0]['kind'] == 'reply' and n[0]['title'] == 'Vases en céramique' and n[0]['subject_id'] == str(pid) and n[0]['actor'] == {'id': 'm_rep', 'name': 'Paul P.'}, 'l\'AUTEUR du projet reçoit « Paul P. a répondu à votre projet » (avant : aucune alerte)')
call('POST', 'm_rep', '/collaboration-replies', {'request_id': pid, 'message': 'Un second message'}); ok(len(lst('m_auth')[1]['items']) == 1, 'une seconde réponse du même membre tant que la première n\'est pas lue : pas de doublon')
call('POST', 'm_rep2', '/collaboration-replies', {'request_id': pid, 'message': 'Moi aussi'}); ok(len(lst('m_auth')[1]['items']) == 2, 'un AUTRE membre qui répond : nouvelle notification')
call('POST', 'm_auth', '/collaboration-replies', {'request_id': pid, 'message': 'Je me réponds'}); ok(len(lst('m_auth')[1]['items']) == 2, 'répondre à son propre projet : aucune notification')
call('PUT', 'm_auth', '/notification-prefs', {'types': {'collaboration': {'in_app': False}}}); call('POST', 'm_auth', '/notifications', {'action': 'read_all'})
sql("DELETE FROM notifications"); call('POST', 'm_rep', '/collaboration-replies', {'request_id': pid, 'message': 'Encore'}); ok(len(lst('m_auth')[1]['items']) == 0, 'l\'auteur a coupé « collaborations » dans l\'application : pas de notification (la réponse est quand même enregistrée)')
ok(int(sql("SELECT COUNT(*) FROM collaboration_replies")) == 5, 'les 5 réponses sont bien enregistrées quoi qu\'il arrive côté notifications')
call('PUT', 'm_auth', '/notification-prefs', {'types': {'collaboration': {'in_app': True}}}); sql("UPDATE users SET collab_settings='{\"blacklist\":[\"m_rep@test.test\"]}' WHERE id='m_auth'")
st, r, _ = call('POST', 'm_rep', '/collaboration-replies', {'request_id': pid, 'message': 'Liste noire'}); ok(st == 404 and len(lst('m_auth')[1]['items']) == 0, 'membre en liste noire de l\'auteur : réponse refusée (404) comme avant, et aucune notification'); sql("UPDATE users SET collab_settings=NULL WHERE id='m_auth'")

print("\n■ Branchement : alertes de mots-clés surveillés")
reset(); mk_user('m_auth', 'client_premium', 'Marie', 'Martin')
sql("UPDATE users SET collab_settings = NULL WHERE id NOT LIKE 'm\\_%' AND collab_settings LIKE '%watched_keywords%'")      # d'anciens comptes d'essais surveillent aussi « Céramique » : on les met de côté (rien n'est supprimé)
OPEN = '{"open":true,"watched_keywords":["Céramique"]}'
for u in ('m_w1', 'm_w2', 'm_w3', 'm_w4'): mk_user(u, 'prospect', u.upper(), 'Veille'); sql(f"UPDATE users SET craft='Verrier', collab_settings='{OPEN}' WHERE id='{u}'")
call('PUT', 'm_w2', '/notification-prefs', {'types': {'collaboration': {'email': False}}})
call('PUT', 'm_w3', '/notification-prefs', {'types': {'collaboration': {'email': False, 'in_app': False}}})
call('PUT', 'm_w4', '/notification-prefs', {'quiet': {'enabled': True, 'start': '00:00', 'end': '23:59', 'timezone': 'UTC'}})
open(MAILS, 'w').close()
st, d, _ = call('POST', 'm_auth', '/collaboration-requests', {'title': 'Service en grès', 'description': 'Un service de table', 'tags': ['Céramique'], 'project_type': 'creation_unique'}); pid = d['id']
to = sorted(m['to'] for m in mails())
ok(to == ['m_w1@test.test'], 'EMAIL : seul m_w1 le reçoit (w2 l\'a coupé, w3 a tout coupé, w4 est en heures calmes) : ' + str(to))
who = lambda u: [(x['kind'], x['title'], x['keywords']) for x in lst(u)[1]['items']]
ok(who('m_w1') == [('alert', 'Service en grès', ['Céramique'])] and who('m_w2') == who('m_w1') and who('m_w4') == who('m_w1'), 'APPLICATION : m_w1, m_w2 et m_w4 ont la notification (type alerte, titre, mot-clé qui a déclenché) ; le canal email coupé ou les heures calmes n\'empêchent pas l\'application')
ok(who('m_w3') == [] and who('m_auth') == [], 'm_w3 (tout coupé) et l\'auteur : rien')
rows = sql("SELECT GROUP_CONCAT(member_id ORDER BY member_id) FROM collab_alerts_sent WHERE request_id = " + str(pid))
ok(rows == 'm_w1,m_w2,m_w4', 'ne sont comptés « alertés » que ceux qui ont reçu quelque chose (m_w3 a tout coupé : il n\'est pas marqué, il pourra l\'être si il change d\'avis)')
ok(any('Service en grès' in m['subject'] for m in mails()), 'le contenu de l\'email est inchangé (« Nouveau projet pour … »)')

print("\n■ Branchement : invitations aux membres correspondants")
reset(); mk_user('m_auth', 'client_premium', 'Marie', 'Martin')
for u in ('m_i1', 'm_i2', 'm_i3'): mk_user(u, 'prospect', u.upper(), 'Potier'); sql(f"UPDATE users SET craft='Potier', collab_settings='{{\"open\":true}}' WHERE id='{u}'")
st, d, _ = call('POST', 'm_auth', '/collaboration-requests', {'title': 'Vases en céramique', 'description': 'x', 'tags': ['Céramique'], 'project_type': 'creation_unique'}); pid = d['id']
call('PUT', 'm_i2', '/notification-prefs', {'types': {'collaboration': {'email': False}}}); call('PUT', 'm_i3', '/notification-prefs', {'types': {'collaboration': {'email': False, 'in_app': False}}}); open(MAILS, 'w').close()
st, r, _ = call('POST', 'm_auth', '/collaboration-requests', {'action': 'invite', 'id': pid, 'recipient_ids': ['m_i1', 'm_i2', 'm_i3']})
ok(st == 200 and r['sent'] == 2 and r['skipped'] >= 1 and r['failed'] == 0, f'invitation : 2 envoyées (i1, i2), i3 (tout coupé) n\'est PAS un échec mais « ignoré » : {r}')
ok([m['to'] for m in mails()] == ['m_i1@test.test'], 'email : seulement i1 (i2 l\'a coupé, i3 a tout coupé)')
ok([x['kind'] for x in lst('m_i1')[1]['items']] == ['invite'] and [x['kind'] for x in lst('m_i2')[1]['items']] == ['invite'] and lst('m_i3')[1]['items'] == [] and lst('m_i1')[1]['items'][0]['actor']['name'] == 'Marie M.', 'application : i1 et i2 ont « Marie M. vous invite sur un projet » ; i3 rien')
ok(int(sql("SELECT COUNT(*) FROM collaboration_invites WHERE request_id = " + str(pid))) == 2, 'seuls ceux qui ont été joints sont enregistrés comme invités')

print("\n■ Conservation (purge)")
reset(); mk_user('m_p', 'prospect')
for days in (1, 10, 100, 200): sql(f"INSERT INTO notifications (user_id, type, subject_type, subject_id, created_at) VALUES ('m_p', 'like', 'post', '{days}', UTC_TIMESTAMP() - INTERVAL {days} DAY)")
ok(php('echo notifications_purge(db());') == '2', 'conservation par défaut (90 jours) : les 2 notifications de 100 et 200 jours sont supprimées, celles de 1 et 10 jours restent')
ok(sorted(r.split('\t')[0] for r in sql("SELECT subject_id FROM notifications").split('\n')) == ['1', '10'], '… vérifié en base')
sql("INSERT INTO messaging_settings (setting_key, setting_value) VALUES ('notifications_retention_days', '7')")
ok(php('echo notifications_purge(db());') == '1' and sql("SELECT subject_id FROM notifications") == '1', 'le délai se règle depuis /control (paramètre) : à 7 jours, celle de 10 jours part aussi')
sql("DELETE FROM notifications"); [sql(f"INSERT INTO notifications (user_id, type, subject_type, subject_id, created_at) VALUES ('m_p', 'like', 'post', '{i}', UTC_TIMESTAMP() - INTERVAL 400 DAY)") for i in range(3)]
ok(php('echo notifications_purge(db());') == '3', 'purge par lots : tout ce qui est périmé (ici 3) est supprimé en un passage')

sql("DELETE FROM notifications; DELETE FROM notification_prefs; DELETE FROM notification_settings; DELETE FROM collab_alerts_sent; DELETE FROM collaboration_invites; DELETE FROM collaboration_replies; DELETE FROM collaboration_requests WHERE user_id LIKE 'm\\_%'; DELETE FROM users WHERE id LIKE 'm\\_%'; DELETE FROM messaging_settings WHERE setting_key = 'notifications_retention_days'")
summary()
