#!/usr/bin/env python3
"""Publications automatiques d'avancées dans le Fil (CDC du 17/10/2026) : accord du membre, publication, « démarré → terminé », plafond et regroupement, parcours, réglages /control,
visibilité, « Encourager », protections, résilience, suppression de compte, réglages de type texte."""
import json, os, 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 cnt(q): return int(sql(q))
def setting(who, k, v): return call('PUT', who, '/admin-messaging-settings', {'changes': {k: v}})
def raw_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 take(who, card, status='en_cours'): return call('POST', who, '/my-actions', {'card_id': card, 'status': status})
def finish(who, card): return call('PUT', who, '/my-actions', {'card_id': card, 'status': 'validee'})
def optin(who, share=True, **kw): st, d, _ = call('PUT', who, '/auto-posts/preference', {'share': share, **kw}); assert st == 200, (st, d); return d
def autos(uid, extra=''): return [r.split('\t') for r in sql(f"SELECT id, auto_event, visibility, auto_day FROM posts WHERE user_id = '{uid}' AND kind = 'auto' {extra} ORDER BY id").split('\n') if r.strip()]
def post(who, pid): st, d, _ = call('GET', who, f'/posts/{pid}'); assert st == 200, (st, d); return d['post']
def notifs(who, typ='comment'): return [n for n in call('GET', who, '/notifications')[1]['items'] if n['type'] == typ]
def section(t): print('\n■ ' + t); reset_rate_limits()
def wipe():
    p = "'ap\\_%'"
    sql(f"DELETE cl FROM comment_likes cl JOIN comments c ON c.id = cl.comment_id WHERE c.user_id LIKE {p} OR c.post_id IN (SELECT id FROM posts WHERE user_id LIKE {p})")
    sql(f"DELETE FROM mentions WHERE member_id LIKE {p}; DELETE FROM comments WHERE user_id LIKE {p} OR post_id IN (SELECT id FROM posts WHERE user_id LIKE {p})")
    for t in ('post_likes', 'post_saves', 'post_media', 'auto_post_refs'): sql(f"DELETE FROM {t} WHERE post_id IN (SELECT id FROM posts WHERE user_id LIKE {p})")
    sql(f"DELETE FROM post_likes WHERE user_id LIKE {p}; DELETE FROM posts WHERE user_id LIKE {p}; DELETE FROM auto_post_refs WHERE user_id LIKE {p}; DELETE FROM notifications WHERE user_id LIKE {p} OR actor_id LIKE {p}")
    sql(f"DELETE FROM actions WHERE user_id LIKE {p}; DELETE FROM user_parcours WHERE user_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}; DELETE FROM notification_settings WHERE user_id LIKE {p}")
    sql("DELETE FROM cards WHERE id LIKE 'TZ-%'")
def php(code): return subprocess.run(['php', '-d', 'display_errors=0', '-r', '$_SERVER["REQUEST_METHOD"]="GET"; require "api/_auto_posts.php"; ' + code], capture_output=True, text=True, cwd=ROOT).stdout.strip()

wipe(); sql("DELETE FROM users WHERE id LIKE 'ap\\_%'")
for k in ('auto_posts_enabled', 'auto_posts_action_started', 'auto_posts_action_finished', 'auto_posts_parcours_started', 'auto_posts_parcours_finished', 'auto_posts_daily_cap'): unset(k)
U = {}
for uid, first, last in [('ap_a', 'Marie', 'Martin'), ('ap_b', 'Bruno', 'Blanc'), ('ap_c', 'Chloé', 'Carré'), ('ap_d', 'Dan', 'Dupont'), ('ap_e', 'Eva', 'Estève'), ('ap_f', 'Félix', 'Faure'), ('ap_g', 'Gaëlle', 'Gros'), ('ap_h', 'Hugo', 'Henry'), ('ap_i', 'Inès', 'Iribar')]: mk_user(uid, 'client_essentiel', first, last)
mk_user('ap_admin', 'admin', 'Adm', 'In'); sql("UPDATE users SET created_at = NOW() - INTERVAL 60 DAY WHERE id LIKE 'ap\\_%'")
sql("INSERT IGNORE INTO categories (id, slug, label, sort_order) VALUES ('tzcat', 'tzcat', 'Catégorie de test', 1)")
for i, (t, d) in enumerate([('Premier devis', 3), ('Deuxième action', 2), ('Troisième action', 1), ('Quatrième action', 4), ('Cinquième action', 5), ('Sixième action', 2), ('Septième action', 3), ('Huitième action', 1)], 1):
    sql(f"INSERT INTO cards (id, category_id, title, slug, objective, description, difficulty, is_derived) VALUES ('TZ-A{i}', 'tzcat', '{t}', 'tz-a{i}', '', '', {d}, 0)")
THEME_AUTO = int(sql("SELECT id FROM post_themes WHERE system_key = 'progress'")); THEME = int(sql("SELECT id FROM post_themes WHERE active = 1 AND system_key IS NULL ORDER BY id LIMIT 1"))

section("Structure et choix du membre")
ok(THEME_AUTO > 0 and cnt("SELECT COUNT(*) FROM post_themes WHERE system_key = 'progress'") == 1 and sql(f"SELECT label_fr FROM post_themes WHERE id = {THEME_AUTO}") == 'Avancées', 'le thème réservé « Avancées » existe, une seule fois')
T = {t['id']: t for t in call('GET', 'ap_a', '/messaging-config')[1]['themes']}; ok(T[THEME_AUTO]['system'] is True and T[THEME]['system'] is False, 'la configuration publique marque le thème réservé (system) : l\'interface peut ne pas le proposer pour écrire')
ok(call('GET', None, '/auto-posts/preference')[0] == 401 and call('POST', None, '/auto-posts/first-answer', {})[0] == 401 and call('GET', 'ap_a', '/auto-posts/inconnu')[0] == 404 and call('DELETE', 'ap_a', '/auto-posts/preference')[0] == 404, 'sans connexion : 401 ; route ou méthode inconnue : 404')
st, d, _ = call('GET', 'ap_a', '/auto-posts/preference'); ok(st == 200 and d['share'] is None and d['visibility'] == 'all' and d['decided_at'] is None and d['hide_auto'] is False and d['enabled'] is True and d['events'] == {'action_started': True, 'action_finished': True, 'parcours_started': True, 'parcours_finished': True}, 'au départ : pas répondu (null), visibilité « tous », aucune date, fonction et 4 événements activés')
for body, key in (({'share': 'peut-être'}, 'share'), ({'visibility': 'secret'}, 'visibility'), ({'visibility': 5}, 'visibility')): st, d, _ = call('PUT', 'ap_a', '/auto-posts/preference', body); ok(st == 422 and key in d['errors'], f'choix invalide refusé (422, « {key} »)')
ok(call('PUT', 'ap_a', '/auto-posts/preference', {})[0] == 422 and call('GET', 'ap_a', '/auto-posts/preference')[1]['share'] is None, 'aucun champ : 422, et rien n\'a changé')
st, d, _ = call('PUT', 'ap_a', '/auto-posts/preference', {'hide_auto': 'peut-être'}); ok(st == 422 and 'hide_auto' in d['errors'] and call('GET', 'ap_a', '/auto-posts/preference')[1]['hide_auto'] is False, 'hide_auto invalide : 422, rien ne change')
st, d, _ = call('PUT', 'ap_a', '/auto-posts/preference', {'hide_auto': True}); ok(st == 200 and d['hide_auto'] is True and d['share'] is None and d['decided_at'] is None and d['visibility'] == 'all', 'masquer les avancées dans mon Fil : enregistré SEUL, sans toucher à l\'accord de publier (share reste « pas répondu », aucune date)')
st, d, _ = call('PUT', 'ap_a', '/auto-posts/preference', {'hide_auto': False}); ok(st == 200 and d['hide_auto'] is False, 'et dans l\'autre sens')
d1 = optin('ap_e', True); ok(d1['share'] is True and d1['decided_at'] and d1['decided_at'].endswith('Z'), 'dire oui : mémorisé avec sa DATE (pour pouvoir justifier l\'accord)')
sql("UPDATE users SET auto_posts_optin_at = '2020-01-01 00:00:00' WHERE id = 'ap_e'"); optin('ap_e', True); ok(call('GET', 'ap_e', '/auto-posts/preference')[1]['decided_at'].startswith('2020-01-01'), 'répéter le même oui ne change pas la date du choix')
d2 = optin('ap_e', False); ok(d2['share'] is False and not d2['decided_at'].startswith('2020'), 'repasser à non : mémorisé, la date du choix est mise à jour'); optin('ap_e', True)
st, d, _ = call('PUT', 'ap_e', '/auto-posts/preference', {'visibility': 'network'}); ok(st == 200 and d['visibility'] == 'network' and d['share'] is True, 'la visibilité se règle seule, sans toucher au choix')

section("Sans accord, rien ne se publie ; la question n'est posée qu'une fois")
st, d, _ = take('ap_a', 'TZ-A1'); ok(st == 201 and d.get('card_id') == 'TZ-A1' and d.get('share_prompt') == {'ref_type': 'action', 'ref_id': 'TZ-A1', 'event': 'started'} and not autos('ap_a'), 'membre qui n\'a pas répondu : l\'action est prise, AUCUNE publication, et la réponse demande de poser la question une fois')
st, d, _ = call('POST', 'ap_a', '/auto-posts/first-answer', {'share': False, 'ref_type': 'action', 'ref_id': 'TZ-A1', 'event': 'started'}); ok(st == 201 and d['result'] == 'declined' and d['share'] is False and not autos('ap_a'), 'réponse « non » à la question : mémorisée, rien n\'est publié')
st, d, _ = take('ap_a', 'TZ-A2'); ok(st == 201 and 'share_prompt' not in d and not autos('ap_a'), 'après un non : plus de question, plus de publication')
st, d, _ = call('POST', 'ap_a', '/auto-posts/first-answer', {'share': True, 'ref_type': 'action', 'ref_id': 'TZ-A2', 'event': 'started'}); ok(st == 409 and not autos('ap_a'), 'on ne répond pas deux fois à la première question (409) : le choix se modifie dans les paramètres du compte')
optin('ap_a', True); st, d, _ = take('ap_a', 'TZ-A3'); ok(st == 201 and 'share_prompt' not in d and len(autos('ap_a')) == 1, 'le membre change d\'avis dans son compte : les événements SUIVANTS publient')
ok(cnt("SELECT COUNT(*) FROM actions WHERE user_id = 'ap_a'") == 3, 'et les actions elles-mêmes n\'ont jamais été gênées')
call('PUT', 'ap_a', '/auto-posts/preference', {'share': False}); ok(len(autos('ap_a')) == 1, 'repasser à non : la publication déjà créée est CONSERVÉE'); st, d, _ = take('ap_a', 'TZ-A4'); ok(len(autos('ap_a')) == 1, 'et rien de plus ne se publie')
call('PUT', 'ap_a', '/auto-posts/preference', {'share': True}); finish('ap_a', 'TZ-A1'); ok(len(autos('ap_a')) == 2 and autos('ap_a')[1][1] == 'action_finished', 'réactivé : terminer une action qui n\'avait pas été publiée au départ crée une publication « terminé »')
st, d, _ = call('POST', 'ap_b', '/auto-posts/first-answer', {'share': True, 'ref_type': 'action', 'ref_id': 'TZ-A5', 'event': 'finished'}); ok(st == 422 and call('GET', 'ap_b', '/auto-posts/preference')[1]['share'] is None, 'un événement qui ne s\'est PAS produit : refusé (422) et le choix n\'est pas enregistré')
for body, key in (({'share': 'x', 'ref_type': 'action', 'ref_id': 'TZ-A1', 'event': 'started'}, 'share'), ({'share': True, 'ref_type': 'carte', 'ref_id': 'TZ-A1', 'event': 'started'}, 'ref_type'), ({'share': True, 'ref_type': 'action', 'ref_id': '', 'event': 'started'}, 'ref_id'), ({'share': True, 'ref_type': 'action', 'ref_id': 'TZ-A1', 'event': 'gagné'}, 'event')):
    st, d, _ = call('POST', 'ap_b', '/auto-posts/first-answer', body); ok(st == 422 and key in d['errors'], f'réponse mal formée refusée (422, « {key} »)')
take('ap_b', 'TZ-A1'); st, d, _ = call('POST', 'ap_b', '/auto-posts/first-answer', {'share': True, 'ref_type': 'action', 'ref_id': 'TZ-A1', 'event': 'started'}); ok(st == 201 and d['result'] == 'published' and d['share'] is True and len(autos('ap_b')) == 1, 'un « oui » à la première question publie CET événement (vérifié côté serveur)')

section("Publier une avancée")
st, d, _ = take('ap_b', 'TZ-A2'); a = autos('ap_b'); ok(st == 201 and 'share_prompt' not in d and len(a) == 2, 'prendre une action : une publication, sans nouvelle question')
P = post('ap_b', int(a[1][0]))
ok(P['kind'] == 'auto' and P['body'] == '' and P['theme']['label_fr'] == 'Avancées' and P['visibility'] == 'all' and P['mine'] is True and P['author']['name'] == 'Bruno B.' and P['photos'] == [] and P['mentions'] == [], 'publication automatique : type « auto », texte vide (la phrase est construite par l\'interface), thème « Avancées », visible par tous, auteur « Bruno B. »')
ok(P['auto']['event'] == 'action_started' and P['auto']['count'] == 1 and P['auto']['items'] == [{'ref_type': 'action', 'ref_id': 'TZ-A2', 'title': 'Deuxième action', 'difficulty': 2, 'slug': None, 'unavailable': False}] and P['auto']['types'] == {'action': 1}, 'elle désigne l\'action : titre et difficulté (le titre n\'est PAS dans une phrase stockée), et le type de ses éléments')
q = post('ap_a', int(a[1][0])); ok(q['mine'] is False and q['kind'] == 'auto' and q['author']['name'] == 'Bruno B.', 'un autre membre la voit dans le Fil, comme les autres publications')
take('ap_b', 'TZ-A2'); take('ap_b', 'TZ-A2', 'en_cours'); ok(len(autos('ap_b')) == 2, 'reprendre la même action : AUCUNE nouvelle publication')
c0 = call('POST', 'ap_a', f'/posts/{a[1][0]}/like', {}); cm = call('POST', 'ap_a', f'/posts/{a[1][0]}/comments', {'body': 'Bravo à toi'}); created0 = sql(f"SELECT created_at FROM posts WHERE id = {a[1][0]}")
sql(f"UPDATE posts SET published_at = published_at - INTERVAL 2 HOUR WHERE id = {a[1][0]}"); old_date = sql(f"SELECT published_at FROM posts WHERE id = {a[1][0]}")
st, d, _ = finish('ap_b', 'TZ-A2'); a2 = autos('ap_b'); P = post('ap_b', int(a[1][0]))
ok(st == 200 and len(a2) == 2 and a2[1][0] == a[1][0] and a2[1][1] == 'action_finished' and P['auto']['event'] == 'action_finished' and P['auto']['count'] == 1, 'terminer l\'action : la MÊME publication passe à « terminé » (pas de seconde publication)')
ok(P['likes'] == 1 and P['comments'] == 1 and sql(f"SELECT published_at FROM posts WHERE id = {a[1][0]}") > old_date and sql(f"SELECT created_at FROM posts WHERE id = {a[1][0]}") == created0, 'les j\'aime et les encouragements reçus sont conservés ; la date de PUBLICATION devient celle de la fin, la date de CRÉATION ne bouge pas')
finish('ap_b', 'TZ-A2'); ok(len(autos('ap_b')) == 2 and autos('ap_b')[1][1] == 'action_finished', 'terminer une seconde fois : rien ne change')
st, d, _ = take('ap_b', 'TZ-A6', 'validee'); ok(st == 201 and len(autos('ap_b')) == 3 and autos('ap_b')[2][1] == 'action_finished', 'une action créée directement « terminée » : une publication « terminé »')
before = len(autos('ap_b')); call('POST', 'ap_b', '/my-actions', [{'card_id': 'TZ-A7', 'status': 'en_cours'}, {'card_id': 'TZ-A8', 'status': 'validee'}]); ok(len(autos('ap_b')) == before and cnt("SELECT COUNT(*) FROM actions WHERE user_id = 'ap_b' AND card_id IN ('TZ-A7', 'TZ-A8')") == 2, 'la prise EN MASSE (attribution après le quiz) ne publie JAMAIS, même en « en cours » ou « terminée »')
take('ap_c', 'TZ-A1', 'selected'); optin('ap_c', True); take('ap_c', 'TZ-A2', 'selected'); ok(not autos('ap_c'), 'une action seulement « proposée » (selected) ne publie pas')
st, d, _ = call('PUT', 'ap_c', '/my-actions', {'card_id': 'TZ-A1', 'status': 'irrelevant'}); st2, d2, _ = call('PUT', 'ap_c', '/my-actions', {'card_id': 'TZ-A2', 'status': 'abandonnee'}); ok(not autos('ap_c'), 'passer une action à « non pertinente » ou « abandonnée » ne publie pas')
st, d, _ = call('PUT', 'ap_c', '/my-actions', {'card_id': 'TZ-A1', 'status': 'en_cours'}); ok(st == 200 and len(autos('ap_c')) == 1 and autos('ap_c')[0][1] == 'action_started', 'la prendre ENSUITE : la publication part (c\'est le vrai choix du membre)')
call('PUT', 'ap_c', '/my-actions', {'card_id': 'TZ-A1', 'status': 'abandonnee'}); call('PUT', 'ap_c', '/my-actions', {'card_id': 'TZ-A1', 'status': 'en_cours'}); ok(len(autos('ap_c')) == 1, 'abandonner puis la reprendre : pas de seconde publication')
st, d, _ = call('POST', 'ap_admin', '/actions/ap_c', {'card_id': 'TZ-A3', 'status': 'en_cours'}); ok(st in (200, 201) and cnt("SELECT COUNT(*) FROM actions WHERE user_id = 'ap_c' AND card_id = 'TZ-A3'") == 1 and len(autos('ap_c')) == 1, 'un administrateur qui agit POUR un membre ne publie pas à sa place')
aid = call('POST', 'ap_d', '/my-actions', {'card_id': 'TZ-A1', 'status': 'selected'})[1]['id']; optin('ap_d', True); call('PUT', 'ap_d', f'/actions/ap_d/{aid}', {'status': 'en_cours'}); ok(len(autos('ap_d')) == 1 and autos('ap_d')[0][1] == 'action_started', 'changer l\'état par identifiant d\'action (l\'autre route) publie aussi')
call('PUT', 'ap_d', f'/actions/ap_d/{aid}', {'status': 'validee'}); ok(len(autos('ap_d')) == 1 and autos('ap_d')[0][1] == 'action_finished', 'et le terminer met à jour la même publication')
aid2 = call('POST', 'ap_a', '/my-actions', {'card_id': 'TZ-A7', 'status': 'selected'})[1]['id']; n = len(autos('ap_a')); call('PUT', 'ap_admin', f'/actions/ap_a/{aid2}', {'status': 'validee'}); ok(len(autos('ap_a')) == n, 'terminer par identifiant PAR un administrateur : ne publie pas pour le membre')

section("Parcours")
st, d, _ = call('POST', 'ap_f', '/parcours/PAR-01/start', {}); optin('ap_f', True)
if st == 200 and d.get('requires_payment'): sql("INSERT IGNORE INTO parcours_credits (user_id, source, created_at) VALUES ('ap_f', 'test', NOW())") if False else None
st, d, _ = call('POST', 'ap_g', '/parcours/PAR-01/start', {}); ok(st == 200 and d.get('ok') is True and d.get('share_prompt') == {'ref_type': 'parcours', 'ref_id': 'PAR-01', 'event': 'started'} and not autos('ap_g'), 'démarrer un parcours sans avoir répondu : démarré, aucune publication, la question est à poser')
optin('ap_g', True); call('POST', 'ap_g', '/parcours/PAR-01/start', {}); ok(not autos('ap_g'), 'le parcours est déjà actif : « déjà actif », aucune publication (le démarrage a eu lieu avant le oui)')
sql("DELETE FROM user_parcours WHERE user_id = 'ap_g'"); st, d, _ = call('POST', 'ap_g', '/parcours/PAR-01/start', {}); a = autos('ap_g'); ok(st == 200 and d['ok'] is True and 'share_prompt' not in d and len(a) == 1 and a[0][1] == 'parcours_started', 'démarrer un parcours : une publication « parcours démarré »')
P = post('ap_g', int(a[0][0])); ok(P['auto']['items'] == [{'ref_type': 'parcours', 'ref_id': 'PAR-01', 'title': 'Trouver tes vrais clients, sans internet', 'difficulty': None, 'slug': sql("SELECT slug FROM public_parcours WHERE id = 'PAR-01'"), 'unavailable': False}] and P['auto']['items'][0]['slug'], 'elle désigne le parcours par son titre SEULEMENT : pas de difficulté (un parcours n\'en a pas) ; son slug sert à ouvrir sa page')
call('POST', 'ap_g', '/parcours/PAR-01/start', {}); ok(len(autos('ap_g')) == 1, 'reprendre un parcours déjà actif : rien de nouveau')
st, d, _ = call('POST', 'ap_g', '/parcours/PAR-DOM-01/start', {}); ok(st == 200 and d.get('requires_payment') is True and len(autos('ap_g')) == 1, 'un parcours en attente de PAIEMENT ne publie pas : le démarrage n\'est pas effectif')
st, d, _ = call('POST', 'ap_g', '/parcours/PAR-01/complete', {}); a = autos('ap_g'); ok(st == 200 and len(a) == 1 and a[0][1] == 'parcours_finished' and post('ap_g', int(a[0][0]))['auto']['event'] == 'parcours_finished', 'terminer le parcours : la MÊME publication passe à « terminé »')
call('POST', 'ap_g', '/parcours/PAR-01/complete', {}); ok(len(autos('ap_g')) == 1 and autos('ap_g')[0][1] == 'parcours_finished', 'terminer deux fois : rien de plus')
call('POST', 'ap_h', '/parcours/PAR-01/complete', {}); optin('ap_h', True); call('POST', 'ap_h', '/parcours/PAR-01/complete', {}); ok(not autos('ap_h'), 'terminer un parcours qu\'on n\'a pas commencé : rien')

section("Plafond quotidien et regroupement")
raw_setting('auto_posts_daily_cap', 2); optin('ap_i', True)
for c in ('TZ-A1', 'TZ-A2'): take('ap_i', c)
ok(len(autos('ap_i')) == 2 and all(not r[1].startswith('group') for r in autos('ap_i')), 'sous le plafond (2) : une publication par action')
take('ap_i', 'TZ-A3'); g = autos('ap_i', "AND auto_event = 'group_started'"); ok(len(g) == 1 and len(autos('ap_i')) == 3, 'au-delà du plafond : les événements sont regroupés dans UNE publication « groupée »')
for c in ('TZ-A4', 'TZ-A5', 'TZ-A6'): take('ap_i', c)
g = autos('ap_i', "AND auto_event = 'group_started'"); ok(len(g) == 1 and len(autos('ap_i')) == 3, 'les suivants rejoignent la MÊME publication groupée (pas d\'autres publications)')
P = post('ap_i', int(g[0][0])); ok(P['auto']['event'] == 'group_started' and P['auto']['count'] == 4 and P['auto']['types'] == {'action': 4} and [i['ref_id'] for i in P['auto']['items']] == ['TZ-A3', 'TZ-A4', 'TZ-A5'] and P['body'] == '', 'la publication groupée : 4 éléments (types comptés sur TOUS), les 3 premiers nommés (le reste est un « +N » pour l\'interface)')
finish('ap_i', 'TZ-A1'); singles = autos('ap_i', "AND auto_event NOT LIKE 'group%'"); ok(len(autos('ap_i')) == 3 and any(r[1] == 'action_finished' for r in singles), 'terminer une action déjà publiée seule : mise à jour de SA publication, le plafond n\'est pas entamé')
finish('ap_i', 'TZ-A3'); gf = autos('ap_i', "AND auto_event = 'group_finished'"); ok(len(gf) == 1 and len(autos('ap_i')) == 4, 'terminer une action comprise dans le GROUPE : la fin est un nouvel événement, regroupé à part (« terminées »)')
ok(post('ap_i', int(g[0][0]))['auto']['count'] == 4, 'et le groupe des « démarrées » n\'est pas modifié')
finish('ap_i', 'TZ-A4'); ok(post('ap_i', int(gf[0][0]))['auto']['count'] == 2 and len(autos('ap_i')) == 4, 'une autre fin rejoint le groupe des « terminées »')
take('ap_i', 'TZ-A7'); ok(len(autos('ap_i')) == 4 and post('ap_i', int(g[0][0]))['auto']['count'] == 5, 'un autre démarrage le même jour : encore dans le groupe')
sql("UPDATE posts SET auto_day = auto_day - INTERVAL 1 DAY WHERE user_id = 'ap_i'"); take('ap_i', 'TZ-A8'); ok(len(autos('ap_i', "AND auto_event = 'action_started'")) == 2 and len(autos('ap_i')) == 5, 'le lendemain : le plafond repart de zéro, une publication individuelle'); unset('auto_posts_daily_cap')

mk_user('ap_m', 'client_essentiel', 'Mia', 'Morel'); optin('ap_m', True); raw_setting('auto_posts_daily_cap', 1); take('ap_m', 'TZ-A1'); take('ap_m', 'TZ-A2'); call('POST', 'ap_m', '/parcours/PAR-01/start', {})
gm = autos('ap_m', "AND auto_event = 'group_started'"); Pm = post('ap_m', int(gm[0][0])) if gm else {'auto': {}}
ok(len(gm) == 1 and Pm['auto'].get('count') == 2 and Pm['auto'].get('types') == {'action': 1, 'parcours': 1} and [i['ref_type'] for i in Pm['auto']['items']] == ['action', 'parcours'], 'un groupe MIXTE (une action et un parcours) : le serveur compte les types sur tous les éléments, pour que l\'interface écrive « éléments » et non « actions »'); unset('auto_posts_daily_cap')

section("Réglages de /control")
setting('ap_admin', 'auto_posts_enabled', False); mk_user('ap_n', 'client_essentiel', 'Nina', 'Noir'); st, d, _ = take('ap_n', 'TZ-A1'); ok(st == 201 and 'share_prompt' not in d and not autos('ap_n'), 'fonction coupée : rien ne se publie ET la question n\'est pas posée (inutile)')
setting('ap_admin', 'auto_posts_enabled', True); setting('ap_admin', 'auto_posts_action_finished', False); mk_user('ap_o', 'client_essentiel', 'Oscar', 'Orme'); optin('ap_o', True); take('ap_o', 'TZ-A1'); finish('ap_o', 'TZ-A1'); a = autos('ap_o'); ok(len(a) == 1 and a[0][1] == 'action_started', 'événement « action terminée » coupé : la fin ne publie rien et ne met PAS à jour la publication de démarrage')
setting('ap_admin', 'auto_posts_action_finished', True); setting('ap_admin', 'auto_posts_action_started', False); take('ap_o', 'TZ-A2'); ok(len(autos('ap_o')) == 1, 'événement « action démarrée » coupé : rien')
setting('ap_admin', 'auto_posts_action_started', True); setting('ap_admin', 'auto_posts_parcours_started', False); call('POST', 'ap_o', '/parcours/PAR-01/start', {}); ok(len(autos('ap_o')) == 1, 'événement « parcours démarré » coupé : rien')
setting('ap_admin', 'auto_posts_parcours_started', True); setting('ap_admin', 'auto_posts_parcours_finished', False); sql("DELETE FROM user_parcours WHERE user_id = 'ap_o'"); call('POST', 'ap_o', '/parcours/PAR-01/start', {}); call('POST', 'ap_o', '/parcours/PAR-01/complete', {}); ok(autos('ap_o')[-1][1] == 'parcours_started', 'événement « parcours terminé » coupé : le parcours reste « démarré »'); setting('ap_admin', 'auto_posts_parcours_finished', True)
st, d, _ = setting('ap_admin', 'auto_posts_daily_cap', 0); ok(st == 422 and setting('ap_admin', 'auto_posts_daily_cap', 21)[0] == 422, 'plafond quotidien : de 1 à 20 (0 et 21 refusés)')
ok(call('GET', 'ap_o', '/auto-posts/preference')[1]['events'] == {'action_started': True, 'action_finished': True, 'parcours_started': True, 'parcours_finished': True}, 'l\'état des événements est exposé au membre (pour ses réglages)')

section("Visibilité, blocage, filtres")
optin('ap_e', True, visibility='network'); take('ap_e', 'TZ-A1'); pe = autos('ap_e')[0]; ok(pe[2] == 'network', 'visibilité choisie par le membre : appliquée à ses publications automatiques')
ids = lambda who, qs='': [p['id'] for p in call('GET', who, '/posts?tab=explorer' + qs)[1]['items']]
ok(int(pe[0]) not in ids('ap_a') and int(pe[0]) in ids('ap_e'), 'visibilité « réseau » : un membre hors de son réseau ne la voit pas, l\'auteur si')
optin('ap_e', True, visibility='all'); take('ap_e', 'TZ-A2'); pe2 = autos('ap_e')[1]; ok(int(pe2[0]) in ids('ap_a'), 'visibilité « tous » : visible dans l\'onglet Explorer')
call('POST', 'ap_e', '/blocks', {'user_id': 'ap_a', 'block': True}); ok(int(pe2[0]) not in ids('ap_a'), 'les règles de blocage s\'appliquent aux publications automatiques comme aux autres'); call('POST', 'ap_e', '/blocks', {'user_id': 'ap_a', 'block': False})
st, d, _ = call('POST', 'ap_e', '/posts', {'body': 'Une publication écrite', 'theme_id': THEME, 'visibility': 'all'}); mine = d['post']['id']
ok(int(pe2[0]) in ids('ap_a') and mine in ids('ap_a'), 'sans filtre : les publications écrites ET les automatiques')
ok(int(pe2[0]) not in ids('ap_a', '&auto=0') and mine in ids('ap_a', '&auto=0'), 'filtre auto=0 : le lecteur masque les avancées automatiques, garde les autres')
ok(int(pe2[0]) in ids('ap_a', f'&theme={THEME_AUTO}') and mine not in ids('ap_a', f'&theme={THEME_AUTO}'), 'le thème « Avancées » filtre exactement les publications automatiques')

section("Encourager")
a = int(autos('ap_e')[1][0]); sql("DELETE FROM notifications WHERE user_id = 'ap_e'")
st, d, _ = call('POST', 'ap_f', f'/posts/{a}/comments', {'body': 'Bravo !'}); N = notifs('ap_e'); ok(st == 201 and len(N) == 1 and N[0]['kind'] == 'cheer' and N[0]['actor']['id'] == 'ap_f' and N[0]['title'] == 'Deuxième action', 'un commentaire sur une publication AUTOMATIQUE prévient l\'auteur par une notification de nature « cheer », dont l\'objet est le titre de l\'action')
cid = d['comment']['id']; sql("DELETE FROM notifications WHERE user_id = 'ap_e'"); call('POST', 'ap_e', f'/posts/{a}/comments', {'body': 'Merci'}); ok(notifs('ap_e') == [], 'l\'auteur qui commente sa propre publication ne se notifie pas lui-même')
sql("DELETE FROM notifications WHERE user_id = 'ap_f'"); st, d, _ = call('POST', 'ap_e', f'/posts/{a}/comments', {'body': 'Merci à toi', 'parent_id': cid}); N = notifs('ap_f'); ok(st == 201 and len(N) == 1 and N[0]['kind'] == 'reply' and N[0]['title'] == 'Deuxième action', 'l\'auteur répond à un encouragement : l\'encourageant reçoit une réponse (nature « reply »), avec le titre')
sql("DELETE FROM notifications WHERE user_id = 'ap_e'"); call('POST', 'ap_a', f'/posts/{a}/comments', {'body': 'Je réponds ici', 'parent_id': cid}); N = notifs('ap_e'); ok(len(N) == 1 and N[0]['kind'] == 'post', 'une RÉPONSE d\'un tiers dans le fil n\'est pas un encouragement : nature « post » pour l\'auteur')
sql("DELETE FROM notifications WHERE user_id = 'ap_e'"); call('POST', 'ap_f', f'/posts/{mine}/comments', {'body': 'Sur une publication écrite'}); N = notifs('ap_e'); ok(len(N) == 1 and N[0]['kind'] == 'post' and N[0]['title'] == 'Une publication écrite', 'sur une publication ÉCRITE : rien ne change (nature « post », extrait du texte)')
sql("DELETE FROM notifications WHERE user_id = 'ap_e'"); call('POST', 'ap_f', f'/posts/{a}/like', {}); N = notifs('ap_e', 'like'); ok(len(N) == 1 and N[0]['title'] == 'Deuxième action', 'un j\'aime sur une publication automatique : l\'objet de la notification est le titre de l\'action (jamais vide)')

section("Protections")
st, d, _ = call('PUT', 'ap_e', f'/posts/{a}', {'body': 'Je modifie'}); ok(st == 403 and 'automatique' in d['error'], 'une publication automatique ne se modifie pas (403) : son texte est généré')
st, d, _ = call('POST', 'ap_e', '/posts', {'body': 'Dans le thème réservé', 'theme_id': THEME_AUTO, 'visibility': 'all'}); ok(st == 422 and 'theme_id' in d['errors'], 'on ne peut pas écrire une publication dans le thème « Avancées » (422)')
st, d, _ = call('PUT', 'ap_e', f'/posts/{mine}', {'theme_id': THEME_AUTO}); ok(st == 422 and 'theme_id' in d['errors'], 'ni y déplacer une publication existante (422)')
ok(call('DELETE', 'ap_a', f'/posts/{a}')[0] == 403, 'un autre membre ne peut pas la supprimer (403)')
st, d, _ = call('PUT', 'ap_admin', f'/admin-messaging-settings/themes/{THEME_AUTO}', {'active': False}); ok(st == 409 and cnt(f"SELECT active FROM post_themes WHERE id = {THEME_AUTO}") == 1, 'le thème réservé ne peut pas être désactivé (409)')
st, d, _ = call('PUT', 'ap_admin', f'/admin-messaging-settings/themes/{THEME_AUTO}', {'label_en': 'Progress updates'}); ok(st == 200, 'mais son libellé peut être modifié'); call('PUT', 'ap_admin', f'/admin-messaging-settings/themes/{THEME_AUTO}', {'label_en': 'Progress'})
st, d, _ = call('DELETE', 'ap_e', f'/posts/{a}'); ok(st == 200 and cnt(f"SELECT COUNT(*) FROM auto_post_refs WHERE post_id = {a}") == 0 and cnt(f"SELECT COUNT(*) FROM comments WHERE post_id = {a}") == 0 and cnt(f"SELECT COUNT(*) FROM notifications WHERE subject_type = 'post' AND subject_id = '{a}'") == 0, 'l\'auteur supprime sa publication automatique : ses éléments, commentaires et notifications partent avec elle')

section("Titre renommé, élément disparu")
pa = int(autos('ap_e')[0][0]); sql("UPDATE cards SET title = 'Titre renommé' WHERE id = 'TZ-A1'"); ok(post('ap_e', pa)['auto']['items'][0]['title'] == 'Titre renommé', 'le titre n\'est pas stocké : un renommage se voit tout de suite')
sql("UPDATE cards SET difficulty = 5 WHERE id = 'TZ-A1'"); ok(post('ap_e', pa)['auto']['items'][0]['difficulty'] == 5, 'ni la difficulté')
sql("DELETE FROM cards WHERE id = 'TZ-A1'"); it = post('ap_e', pa)['auto']['items'][0]; ok(it['unavailable'] is True and it['title'] is None, 'une action disparue du catalogue : « indisponible », sans titre (l\'interface l\'annonce, sans erreur)')

section("Résilience : un échec ne gêne jamais le membre")
mk_user('ap_r', 'client_essentiel', 'Rémi', 'Roux'); optin('ap_r', True); sql(f"UPDATE post_themes SET system_key = 'cache' WHERE id = {THEME_AUTO}")
st, d, _ = take('ap_r', 'TZ-A2'); ok(st == 201 and d['card_id'] == 'TZ-A2' and cnt("SELECT COUNT(*) FROM actions WHERE user_id = 'ap_r'") == 1 and not autos('ap_r'), 'le thème « Avancées » a disparu : l\'action est prise normalement (201), rien n\'est publié, aucune erreur pour le membre')
st, d, _ = finish('ap_r', 'TZ-A2'); ok(st == 200 and d['status'] == 'validee', 'et la terminer fonctionne aussi'); sql(f"UPDATE post_themes SET system_key = 'progress' WHERE id = {THEME_AUTO}")
r = php('echo json_encode(auto_post_event(db(), "ap_r", "action", "TZ-A2", "inconnu"));'); ok('"skipped"' in r and '"event"' in r, 'un événement inconnu est ignoré proprement')
r = php('echo json_encode(auto_post_event(db(), "ap_r", "carte", "TZ-A2", "started"));'); ok('"skipped"' in r, 'un type d\'élément inconnu est ignoré proprement')
r = php('echo json_encode(auto_post_event(db(), "ap_r", "action", "N-EXISTE-PAS", "started"));'); ok('unknown_ref' in r, 'un élément qui n\'existe pas ne publie rien')
ok(php('echo json_encode(auto_posts_status_event(null, "en_cours")) . json_encode(auto_posts_status_event("selected", "en_cours")) . json_encode(auto_posts_status_event("abandonnee", "en_cours")) . json_encode(auto_posts_status_event("en_cours", "en_cours")) . json_encode(auto_posts_status_event("en_cours", "validee")) . json_encode(auto_posts_status_event("validee", "validee")) . json_encode(auto_posts_status_event("en_cours", "irrelevant"));') == '"started""started""started"null"finished"nullnull', 'quels changements d\'état comptent : prise (neuve, proposée ou reprise après abandon), fin ; jamais « déjà en cours », « déjà terminé », « non pertinente »')

section("Suppression de compte")
mk_user('ap_s', 'client_essentiel', 'Sam', 'Sol'); optin('ap_s', True); take('ap_s', 'TZ-A2'); take('ap_s', 'TZ-A3'); call('POST', 'ap_s', '/posts', {'body': 'À garder ?', 'theme_id': THEME, 'visibility': 'all'})
ok(len(autos('ap_s')) == 2 and php('echo auto_posts_delete_for_user(db(), "ap_s");') == '2', 'la fonction de suppression de compte retire les 2 publications automatiques')
ok(len(autos('ap_s')) == 0 and cnt("SELECT COUNT(*) FROM auto_post_refs WHERE user_id = 'ap_s'") == 0 and cnt("SELECT COUNT(*) FROM posts WHERE user_id = 'ap_s' AND kind = 'member'") == 1, 'elles et leurs éléments ont disparu ; les publications écrites ne sont pas touchées (la suppression de compte reste à brancher : elle n\'existe pas encore dans l\'API)')

section("Réglages de type texte : messages rapides « Encourager »")
S = {s['key']: s for s in call('GET', 'ap_admin', '/admin-messaging-settings')[1]['settings']}
ok(all(S[f'cheer_quick_{i}_{l}']['type'] == 'text' and S[f'cheer_quick_{i}_{l}']['max'] == 60 for i in range(1, 5) for l in ('fr', 'en')) and S['cheer_quick_1_fr']['value'] == 'Bravo !' and S['cheer_quick_4_fr']['value'] == '', '8 messages rapides : 3 par défaut, le 4e vide ; 60 caractères au plus')
cfg = call('GET', 'ap_a', '/messaging-config')[1]['config']; ok(all(f'cheer_quick_{i}_{l}' in cfg for i in range(1, 5) for l in ('fr', 'en')) and cfg['cheer_quick_2_en'] == 'Keep going!', 'ils sont publics : l\'interface les lit dans la configuration, sans droit d\'administration')
st, d, _ = setting('ap_admin', 'cheer_quick_1_fr', '  Super   travail !  '); ok(st == 200 and call('GET', 'ap_a', '/messaging-config')[1]['config']['cheer_quick_1_fr'] == 'Super   travail !', 'enregistré, espaces de début et de fin retirés')
setting('ap_admin', 'cheer_quick_1_fr', 'Ligne 1\nLigne 2\tfin'); ok(call('GET', 'ap_a', '/messaging-config')[1]['config']['cheer_quick_1_fr'] == 'Ligne 1 Ligne 2 fin', 'sauts de ligne et tabulations remplacés par une espace : un message rapide tient sur une ligne')
st, d, _ = setting('ap_admin', 'cheer_quick_1_fr', 'é' * 61); ok(st == 422 and 'cheer_quick_1_fr' in d['errors'], '61 caractères : refusé (422)'); st, d, _ = setting('ap_admin', 'cheer_quick_1_fr', 'é' * 60); ok(st == 200, '60 caractères accentués : accepté (on compte des caractères, pas des octets)')
ok(setting('ap_admin', 'cheer_quick_1_fr', 5)[0] == 422 and setting('ap_admin', 'cheer_quick_1_fr', ['x'])[0] == 422 and setting('ap_admin', 'cheer_quick_1_fr', True)[0] == 422, 'une valeur qui n\'est pas du texte : 422')
st, d, _ = setting('ap_admin', 'cheer_quick_4_en', ''); ok(st == 200 and call('GET', 'ap_a', '/messaging-config')[1]['config']['cheer_quick_4_en'] == '', 'un message vidé est retiré (valeur vide acceptée)')
st, d, _ = setting('ap_admin', 'cheer_quick_1_fr', None); ok(st == 200 and call('GET', 'ap_a', '/messaging-config')[1]['config']['cheer_quick_1_fr'] == 'Bravo !', 'réinitialiser (null) : retour au message par défaut')
ok(setting('ap_a', 'cheer_quick_1_fr', 'Pirate')[0] == 403, 'un membre ne peut pas modifier ces messages (403)')

wipe(); sql("DELETE FROM users WHERE id LIKE 'ap\\_%'"); sql("DELETE FROM categories WHERE id = 'tzcat'")
for k in ('auto_posts_enabled', 'auto_posts_action_started', 'auto_posts_action_finished', 'auto_posts_parcours_started', 'auto_posts_parcours_finished', 'auto_posts_daily_cap', 'cheer_quick_1_fr', 'cheer_quick_4_en'): unset(k)
sql(f"UPDATE post_themes SET system_key = 'progress' WHERE id = {THEME_AUTO}")
summary()
