#!/usr/bin/env python3
"""Fil de publications (CDC n° 14 et 15) : publier, modifier, supprimer, visibilité (tous / mon réseau), liste noire, j'aime, enregistrements,
commentaires sur deux niveaux, notifications, paramètres de /control, envoi de photo (quota)."""
import base64, json, os, re, sys, urllib.error, urllib.request, uuid
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib import API, ROOT, call, mk_user, ok, reset_rate_limits, reset_themes, sql, summary, tok

UPL = ROOT + '/uploads/artisans'
def photo(user, name='p1.webp', size=200):
    d = f'{UPL}/{user}/posts'; os.makedirs(d, exist_ok=True); open(f'{d}/{name}', 'wb').write(b'x' * size); return f'/uploads/artisans/{user}/posts/{name}'
def gone(path): return not os.path.exists(ROOT + path)
def setting(k, v): sql(f"INSERT INTO messaging_settings (setting_key, setting_value) VALUES ('{k}', '{json.dumps(v)}') ON DUPLICATE KEY UPDATE setting_value = '{json.dumps(v)}'")
def 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}')")
def unfollow(a, b): sql(f"DELETE FROM member_follows WHERE follower_id = '{a}' AND followed_id = '{b}'")
def feed(who, qs=''): st, d, _ = call('GET', who, '/posts' + qs); return st, d
def ids(d): return [x['id'] for x in d['items']]
def publish(who, body='Un texte', **kw):
    st, d, _ = call('POST', who, '/posts', {'body': body, 'theme_id': THEME, **kw}); assert st == 201, (st, d); return d['post']
def notifs(who, qs=''): return call('GET', who, '/notifications' + qs)[1]['items']
def section(titre):
    print('\n■ ' + titre); reset_rate_limits()      # les limites de débit sont réelles : chaque section repart d'un compteur vide

def reset():
    sql("DELETE cl FROM comment_likes cl JOIN comments c ON c.id = cl.comment_id JOIN posts p ON p.id = c.post_id WHERE p.user_id LIKE 'm\\_%'")
    sql("DELETE c FROM comments c JOIN posts p ON p.id = c.post_id WHERE p.user_id LIKE 'm\\_%'")
    for t in ('post_likes', 'post_saves', 'post_media'): sql(f"DELETE x FROM {t} x JOIN posts p ON p.id = x.post_id WHERE p.user_id LIKE 'm\\_%'")
    sql("DELETE FROM posts WHERE user_id LIKE 'm\\_%'; DELETE FROM notifications WHERE user_id LIKE 'm\\_%'; DELETE FROM notification_prefs WHERE user_id LIKE 'm\\_%'; DELETE FROM member_follows WHERE follower_id LIKE 'm\\_%' OR followed_id LIKE 'm\\_%'; DELETE FROM users WHERE id LIKE 'm\\_%'")
    sql("DELETE FROM messaging_settings WHERE setting_key IN ('post_max_chars','comment_max_chars','images_max_per_post','network_definition','badge_kind','storage_quota_mb_per_member')")
    reset_themes()
    os.system(f'rm -rf {UPL}/m_*'); reset_rate_limits()

reset()
mk_user('m_a', 'client_premium', 'Anne', 'Durand'); mk_user('m_b', 'prospect', 'Bruno', 'Blanc'); mk_user('m_c', 'prospect', 'Carla', 'Costa'); mk_user('m_d', 'prospect', 'Denis', 'Dupuis'); mk_user('m_admin', 'admin', 'Alice', 'Admin')
sql("UPDATE users SET craft='Potier' WHERE id='m_a'")
THEMES = call('GET', 'm_a', '/messaging-config')[1]['themes']; THEME = THEMES[0]['id']; THEME2 = THEMES[1]['id']

section("Accès")
ok(call('GET', None, '/posts')[0] == 401 and call('POST', None, '/posts', {'body': 'x', 'theme_id': THEME})[0] == 401 and call('GET', None, '/posts/1/comments')[0] == 401, 'visiteur : 401 partout')
ok(call('GET', 'm_a', '/posts')[0] == 200 and call('GET', 'm_admin', '/posts')[0] == 200, 'tous les rôles ont accès au fil')

section("Publier")
p = publish('m_a', '  Ma nouvelle collection  ')
ok(p['body'] == 'Ma nouvelle collection' and p['visibility'] == 'all' and p['theme']['id'] == THEME and p['theme']['label_fr'] == 'Leads' and p['theme']['label_en'] == 'Leads' and p['photos'] == [] and p['likes'] == 0 and p['comments'] == 0 and p['mine'] is True and p['liked_by_me'] is False and p['saved_by_me'] is False and p['edited'] is False, 'publication créée : texte nettoyé, visibilité « tous » par défaut, thème, compteurs à 0, « mine », jamais modifiée')
ok({k: v for k, v in p['author'].items() if k != 'active'} == {'id': 'm_a', 'name': 'Anne D.', 'avatar_url': None, 'badge': 'Potier', 'badge_kind': 'specialty', 'badge_level': None} and p['author']['active'] is True and re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', p['created_at']), 'auteur « Anne D. » (jamais le nom complet), étiquette = spécialité, date en UTC')
st, d, _ = call('POST', 'm_a', '/posts', {'body': 'a\r\nb', 'theme_id': str(THEME), 'visibility': 'network'}); ok(st == 201 and d['post']['body'] == 'a\nb' and d['post']['visibility'] == 'network', 'retours Windows normalisés ; identifiant de thème en chaîne accepté ; visibilité « mon réseau »')
n0 = sql("SELECT COUNT(*) FROM posts")
for body, field, why in (({'theme_id': THEME}, 'body', 'texte absent'), ({'body': '', 'theme_id': THEME}, 'body', 'texte vide'), ({'body': '   \n ', 'theme_id': THEME}, 'body', 'que des espaces'), ({'body': 123, 'theme_id': THEME}, 'body', 'texte pas une chaîne'), ({'body': 'a\x00b', 'theme_id': THEME}, 'body', 'caractère de contrôle'), ({'body': 'x'}, 'theme_id', 'thème absent'), ({'body': 'x', 'theme_id': 99999}, 'theme_id', 'thème inconnu'), ({'body': 'x', 'theme_id': 'abc'}, 'theme_id', 'thème non numérique'), ({'body': 'x', 'theme_id': THEME, 'visibility': 'secret'}, 'visibility', 'visibilité inconnue'), ({'body': 'x', 'theme_id': THEME, 'photos': 'a.webp'}, 'photos', 'photos pas une liste'), ({'body': 'x', 'theme_id': THEME, 'photos': {'a': 1}}, 'photos', 'photos : objet')):
    st, d, _ = call('POST', 'm_a', '/posts', body); ok(st == 422 and field in d['errors'], f'refusé (422, champ « {field} ») : {why}')
st, d, _ = call('POST', 'm_a', '/posts', {'body': '', 'theme_id': 99999}); ok(st == 422 and set(d['errors']) == {'body', 'theme_id'}, 'plusieurs erreurs : toutes renvoyées d\'un coup')
ok(sql("SELECT COUNT(*) FROM posts") == n0, 'après tous ces refus : aucune publication créée')
setting('post_max_chars', 100); ok(call('POST', 'm_a', '/posts', {'body': 'é' * 100, 'theme_id': THEME})[0] == 201 and call('POST', 'm_a', '/posts', {'body': 'é' * 101, 'theme_id': THEME})[0] == 422, 'longueur : le paramètre post_max_chars de /control s\'applique tout de suite (100 caractères acceptés, 101 refusés, accents comptés comme 1)'); unset('post_max_chars')
ok(call('POST', 'm_a', '/posts', {'body': 'é' * 2001, 'theme_id': THEME})[0] == 422 and call('POST', 'm_a', '/posts', {'body': 'é' * 2000, 'theme_id': THEME})[0] == 201, 'valeur par défaut : 2000 caractères')
sql(f"UPDATE post_themes SET active = 0 WHERE id = {THEME2}"); st, d, _ = call('POST', 'm_a', '/posts', {'body': 'x', 'theme_id': THEME2}); ok(st == 422 and 'theme_id' in d['errors'], 'thème désactivé : refusé pour une nouvelle publication'); sql(f"UPDATE post_themes SET active = 1 WHERE id = {THEME2}")

section("Photos")
a1, a2, a3 = photo('m_a', 'p1.webp'), photo('m_a', 'p2.webp'), photo('m_a', 'p3.webp'); b1 = photo('m_b', 'q1.webp')
st, d, _ = call('POST', 'm_a', '/posts', {'body': 'Avec photos', 'theme_id': THEME, 'photos': [a2, a1]}); pp = d['post']
ok(st == 201 and pp['photos'] == [a2, a1], 'photos : enregistrées dans l\'ordre donné')
st, d, _ = call('POST', 'm_a', '/posts', {'body': 'URL absolue', 'theme_id': THEME, 'photos': ['https://dev-api.uluana.art' + a3]}); ok(st == 201 and d['post']['photos'] == [a3], 'l\'URL absolue renvoyée par l\'envoi est ramenée au chemin du site')
for photos, why in (([b1], 'photo d\'un AUTRE membre'), (['/uploads/artisans/m_a/posts/inconnue.webp'], 'fichier inexistant'), (['https://evil.example/uploads/artisans/m_a/posts/p1.webp'.replace('https://evil.example', 'https://evil.example') + '?x=1'], 'paramètres d\'URL ajoutés'), (['/uploads/artisans/m_a/posts/../../m_b/posts/q1.webp'], 'traversée de dossier'), (['/uploads/artisans/m_a/gallery/p1.webp'], 'autre dossier que posts'), ([a1, a1], 'doublon'), ([42], 'pas du texte'), ([None], 'valeur nulle')):
    st, d, _ = call('POST', 'm_a', '/posts', {'body': 'x', 'theme_id': THEME, 'photos': photos}); ok(st == 422 and 'photos' in d['errors'], f'photo refusée : {why}')
setting('images_max_per_post', 2); ok(call('POST', 'm_a', '/posts', {'body': 'x', 'theme_id': THEME, 'photos': [a1, a2]})[0] == 201 and call('POST', 'm_a', '/posts', {'body': 'x', 'theme_id': THEME, 'photos': [a1, a2, a3]})[0] == 422, 'images_max_per_post (paramètre de /control) : 2 acceptées, 3 refusées'); unset('images_max_per_post')

section("Le fil : ordre, pagination, thème")
reset(); mk_user('m_a', 'client_premium', 'Anne', 'Durand'); mk_user('m_b', 'prospect', 'Bruno', 'Blanc'); mk_user('m_c', 'prospect', 'Carla', 'Costa'); mk_user('m_d', 'prospect', 'Denis', 'Dupuis'); mk_user('m_admin', 'admin', 'Alice', 'Admin'); sql("UPDATE users SET craft='Potier' WHERE id='m_a'")
THEMES = call('GET', 'm_a', '/messaging-config')[1]['themes']; THEME, THEME2 = THEMES[0]['id'], THEMES[1]['id']
P = [publish('m_a' if i % 2 else 'm_b', f'Texte {i}', **({'theme_id': THEME2} if i in (2, 4) else {})) for i in range(1, 8)]
st, d = feed('m_c'); order = [x['id'] for x in P][::-1]
ok(st == 200 and ids(d) == order and d['has_more'] is False, 'explorer : toutes les publications, la plus récente d\'abord')
st, d = feed('m_c', '?limit=3'); ok(ids(d) == order[:3] and d['has_more'] is True and bool(re.fullmatch(r'\d{14}-' + str(order[2]), str(d['next_before']))), 'limit=3 : 3 éléments, has_more, next_before = curseur opaque (date de publication + identifiant du dernier)')
st, d2 = feed('m_c', f'?limit=3&before={d["next_before"]}'); st, d3 = feed('m_c', f'?limit=3&before={d2["next_before"]}'); ok(ids(d2) == order[3:6] and ids(d3) == order[6:] and d3['has_more'] is False, 'pages suivantes : la suite exacte, sans trou ni doublon')
ok(ids(feed('m_c', f'?theme={THEME2}')[1]) == [P[3]['id'], P[1]['id']] and feed('m_c', '?theme=99999')[1]['items'] == [], 'filtre par thème ; thème sans publication : liste vide')
ok(feed('m_c', '?tab=autre')[0] == 400 and len(feed('m_c', '?limit=9999')[1]['items']) == 7 and feed('m_c', '?limit[]=1&theme[]=2&tab[]=x')[0] == 200, 'onglet inconnu : 400 ; limite bornée ; paramètres en tableau : aucune erreur')
ok(feed('m_c')[1]['items'][0]['author']['name'] == 'Anne D.' and feed('m_c')[1]['items'][1]['author']['name'] == 'Bruno B.', 'chaque publication porte son auteur (la plus récente est d\'Anne, la précédente de Bruno)')

section("Visibilité « mon réseau »")
N = publish('m_a', 'Pour mon réseau', visibility='network')['id']
follow('m_a', 'm_b'); follow('m_c', 'm_a')
ok(N in ids(feed('m_a')[1]), 'l\'auteur voit toujours ses publications « mon réseau »')
ok(N in ids(feed('m_b')[1]) and call('GET', 'm_b', f'/posts/{N}')[0] == 200, 'm_b (que l\'AUTEUR suit) : voit la publication « mon réseau »')
ok(N not in ids(feed('m_c')[1]) and call('GET', 'm_c', f'/posts/{N}')[0] == 404 and N not in ids(feed('m_d')[1]), 'm_c (qui SUIT l\'auteur, mais que l\'auteur ne suit pas) et m_d : ne la voient pas (404, jamais de « pourquoi »)')
ok(N not in ids(feed('m_c', '?tab=network')[1]) and set(x['author']['id'] for x in feed('m_c', '?tab=network')[1]['items']) == {'m_a'}, 'onglet « Ton réseau » de m_c : les publications de m_a qu\'il peut voir, sans la « mon réseau »')
ok(feed('m_b', '?tab=network')[1]['items'] == [], 'onglet « Ton réseau » de m_b, qui ne suit personne : vide')
setting('network_definition', 'mutual')
ok(N not in ids(feed('m_b')[1]), 'définition « réciproque » (paramètre de /control) : m_b ne suit pas l\'auteur en retour, il ne la voit plus'); follow('m_b', 'm_a')
ok(N in ids(feed('m_b')[1]) and N in ids(feed('m_b', '?tab=network')[1]) and N not in ids(feed('m_c')[1]), 'réciproque : m_b suit l\'auteur ET l\'auteur le suit → visible, aussi dans « Ton réseau » ; m_c reste exclu')
follow('m_a', 'm_c'); ok(N in ids(feed('m_c')[1]) and N in ids(feed('m_c', '?tab=network')[1]), 'm_c et l\'auteur se suivent : visible'); unfollow('m_a', 'm_c'); unfollow('m_c', 'm_a'); unfollow('m_b', 'm_a'); unset('network_definition')
ok(N in ids(feed('m_b')[1]) and N not in ids(feed('m_c')[1]), 'retour à « personnes suivies » : comme avant')

section("Liste noire et modération")
H = publish('m_b', 'Vue ou pas')['id']; ok(H in ids(feed('m_c')[1]), 'avant : visible')
sql("UPDATE users SET collab_settings='{\"blacklist\":[\"m_c@test.test\"]}' WHERE id='m_b'")
ok(H not in ids(feed('m_c')[1]) and call('GET', 'm_c', f'/posts/{H}')[0] == 404 and call('POST', 'm_c', f'/posts/{H}/like')[0] == 404 and call('POST', 'm_c', f'/posts/{H}/comments', {'body': 'x'})[0] == 404 and H in ids(feed('m_b')[1]), 'm_b a mis m_c en liste noire : m_c ne voit, n\'aime, ni ne commente ses publications (404) ; m_b voit les siennes')
sql("UPDATE users SET collab_settings=NULL WHERE id='m_b'; UPDATE users SET collab_settings='{\"blacklist\":[\"m_b@test.test\"]}' WHERE id='m_c'"); ok(H not in ids(feed('m_c')[1]), 'sens inverse : m_c a mis m_b en liste noire, il ne voit plus ses publications'); sql("UPDATE users SET collab_settings=NULL WHERE id='m_c'")
sql(f"UPDATE posts SET status='hidden' WHERE id={H}"); ok(H not in ids(feed('m_b')[1]) and call('GET', 'm_b', f'/posts/{H}')[0] == 404, 'publication masquée par la modération : invisible pour tous, y compris son auteur'); sql(f"UPDATE posts SET status='published' WHERE id={H}")
ok(call('GET', 'm_a', '/posts/abc')[0] == 404 and call('GET', 'm_a', '/posts/99999')[0] == 404 and call('GET', 'm_a', f'/posts/{H}/inconnu')[0] == 404 and call('DELETE', 'm_a', '/posts')[0] == 404, 'identifiant non numérique, inconnu, sous-route inconnue, DELETE sans identifiant : 404')

section("Modifier")
E = publish('m_a', 'Avant', photos=[photo('m_a', 'e1.webp'), photo('m_a', 'e2.webp')])
st, d, _ = call('PUT', 'm_a', f'/posts/{E["id"]}', {'body': 'Après'}); ok(st == 200 and d['post']['body'] == 'Après' and d['post']['edited'] is True and d['post']['photos'] == E['photos'] and d['post']['theme']['id'] == THEME, 'modifier le texte seul : le reste (thème, photos) ne bouge pas ; « modifiée » devient vrai')
st, d, _ = call('PUT', 'm_a', f'/posts/{E["id"]}', {'theme_id': THEME2, 'visibility': 'network'}); ok(st == 200 and d['post']['theme']['id'] == THEME2 and d['post']['visibility'] == 'network' and d['post']['body'] == 'Après', 'thème et visibilité modifiables séparément')
st, d, _ = call('PUT', 'm_a', f'/posts/{E["id"]}', {'photos': [E['photos'][0], photo('m_a', 'e3.webp')]}); ok(st == 200 and d['post']['photos'] == [E['photos'][0], '/uploads/artisans/m_a/posts/e3.webp'] and gone(E['photos'][1]) and not gone(E['photos'][0]), 'photos : une retirée, une ajoutée ; le fichier RETIRÉ est supprimé du disque, l\'autre reste')
sql(f"UPDATE post_themes SET active = 0 WHERE id = {THEME2}"); ok(call('PUT', 'm_a', f'/posts/{E["id"]}', {'body': 'Encore'})[0] == 200 and call('PUT', 'm_a', f'/posts/{E["id"]}', {'theme_id': THEME2})[0] == 200 and call('PUT', 'm_a', f'/posts/{E["id"]}', {'theme_id': THEME})[0] == 200 and call('PUT', 'm_a', f'/posts/{E["id"]}', {'theme_id': THEME2})[0] == 422, 'thème désactivé : on peut le CONSERVER, pas le choisir de nouveau'); sql(f"UPDATE post_themes SET active = 1 WHERE id = {THEME2}")
for body, why in (({}, 'rien à modifier'), ({'body': ''}, 'texte vide'), ({'theme_id': 99999}, 'thème inconnu'), ({'visibility': 'x'}, 'visibilité inconnue'), ({'photos': [b1]}, 'photo d\'un autre')): ok(call('PUT', 'm_a', f'/posts/{E["id"]}', body)[0] == 422, f'modification refusée : {why}')
ok(call('PUT', 'm_b', f'/posts/{E["id"]}', {'body': 'Piraté'})[0] in (403, 404) and call('GET', 'm_a', f'/posts/{E["id"]}')[1]['post']['body'] == 'Encore', 'un autre membre ne peut pas modifier (visible : 403 ; invisible : 404), le texte est intact')
ok(call('PUT', 'm_a', f'/posts/{N}', {'body': 'x'})[0] == 200 and call('PUT', 'm_c', f'/posts/{H}', {'body': 'x'})[0] in (403, 404) and call('PUT', 'm_c', f'/posts/{P[0]["id"]}', {'body': 'x'})[0] == 403, 'visible mais pas à lui : 403')

section("J'aime")
G = publish('m_a', 'Un joli vase')['id']
st, d, _ = call('POST', 'm_b', f'/posts/{G}/like', {}); ok(st == 200 and d == {'liked': True, 'likes': 1}, 'aimer : { liked: true, likes: 1 }')
ok(call('POST', 'm_b', f'/posts/{G}/like', {'like': True})[1] == {'liked': True, 'likes': 1}, 'idempotent : aimer deux fois ne compte qu\'une fois')
call('POST', 'm_c', f'/posts/{G}/like'); ok(call('GET', 'm_b', f'/posts/{G}')[1]['post']['likes'] == 2 and call('GET', 'm_b', f'/posts/{G}')[1]['post']['liked_by_me'] is True and call('GET', 'm_d', f'/posts/{G}')[1]['post']['liked_by_me'] is False, 'compteur 2 ; « liked_by_me » propre à chacun')
n = [x for x in notifs('m_a') if x['type'] == 'like']
ok(len(n) == 2 and {x['actor']['id'] for x in n} == {'m_b', 'm_c'} and n[0]['kind'] == 'post' and n[0]['title'] == 'Un joli vase' and n[0]['subject_type'] == 'post' and n[0]['subject_id'] == str(G) and n[0]['post_id'] == G, 'l\'auteur est notifié par chaque nouvel « j\'aime » (type like, titre = début du texte, post_id)')
call('POST', 'm_b', f'/posts/{G}/like', {'like': False}); call('POST', 'm_b', f'/posts/{G}/like', {'like': True}); ok(len([x for x in notifs('m_a') if x['type'] == 'like']) == 2, 'retirer puis remettre son « j\'aime » tant que l\'auteur n\'a pas lu : pas de seconde notification')
st, d, _ = call('POST', 'm_b', f'/posts/{G}/like', {'like': False}); ok(d == {'liked': False, 'likes': 1}, 'retirer : { liked: false, likes: 1 }'); ok(call('POST', 'm_b', f'/posts/{G}/like', {'like': False})[1]['likes'] == 1, 'retirer deux fois : sans effet')
ok(call('POST', 'm_b', f'/posts/{G}/like', {'like': 'oui'})[0] == 400 and call('POST', 'm_b', f'/posts/{G}/like', {'like': 5})[0] == 400, 'valeur invalide : 400')
before = len(notifs('m_a')); call('POST', 'm_a', f'/posts/{G}/like'); ok(len(notifs('m_a')) == before and call('GET', 'm_a', f'/posts/{G}')[1]['post']['liked_by_me'] is True, 'aimer sa propre publication : permis, sans notification')
call('PUT', 'm_a', '/notification-prefs', {'types': {'like': {'in_app': False}}}); call('POST', 'm_d', f'/posts/{G}/like'); ok(not any(x['actor']['id'] == 'm_d' for x in notifs('m_a')), 'l\'auteur a coupé les « j\'aime » : l\'« j\'aime » est enregistré, pas de notification'); call('PUT', 'm_a', '/notification-prefs', {'types': {'like': {'in_app': True}}})

section("Enregistrements (signets)")
st, d, _ = call('POST', 'm_b', f'/posts/{G}/save'); ok(st == 200 and d['saved'] is True, 'enregistrer une publication'); ok(call('POST', 'm_b', f'/posts/{G}/save', {'save': True})[0] == 200 and sql(f"SELECT COUNT(*) FROM post_saves WHERE post_id={G}") == '1', 'idempotent : une seule ligne')
ok(ids(feed('m_b', '?tab=saved')[1]) == [G] and feed('m_c', '?tab=saved')[1]['items'] == [] and call('GET', 'm_b', f'/posts/{G}')[1]['post']['saved_by_me'] is True, 'onglet « Enregistrés » : seulement les siennes ; « saved_by_me » propre à chacun')
sql("UPDATE users SET collab_settings='{\"blacklist\":[\"m_b@test.test\"]}' WHERE id='m_a'"); ok(feed('m_b', '?tab=saved')[1]['items'] == [], 'une publication enregistrée dont l\'auteur est devenu invisible n\'est plus listée'); sql("UPDATE users SET collab_settings=NULL WHERE id='m_a'")
ok(call('POST', 'm_b', f'/posts/{G}/save', {'save': False})[0] == 200 and feed('m_b', '?tab=saved')[1]['items'] == [] and call('POST', 'm_b', f'/posts/{G}/save', {'save': 'x'})[0] == 400, 'retirer ; valeur invalide : 400')

section("Commentaires")
st, d, _ = call('POST', 'm_b', f'/posts/{G}/comments', {'body': '  Magnifique !  '}); c1 = d['comment']
ok(st == 201 and c1['body'] == 'Magnifique !' and c1['parent_id'] is None and (c1['author']['id'], c1['author']['name']) == ('m_b', 'Bruno B.') and isinstance(c1['author']['active'], bool) and c1['mine'] is True and c1['likes'] == 0 and c1['edited'] is False and c1['post_id'] == G, 'commentaire créé')
st, d, _ = call('POST', 'm_c', f'/posts/{G}/comments', {'body': 'Je suis d\'accord', 'parent_id': c1['id']}); r1 = d['comment']; ok(st == 201 and r1['parent_id'] == c1['id'], 'réponse à un commentaire (deuxième niveau)')
for body, why in (({'body': 'x', 'parent_id': r1['id']}, 'réponse à une réponse (3 niveaux)'), ({'body': 'x', 'parent_id': 99999}, 'parent inconnu'), ({'body': ''}, 'texte vide'), ({'body': 5}, 'texte pas une chaîne'), ({'body': 'x', 'parent_id': 'abc'}, 'parent non numérique')):
    ok(call('POST', 'm_d', f'/posts/{G}/comments', body)[0] == 422, f'commentaire refusé : {why}')
other = publish('m_a', 'Autre publication')['id']; cx = call('POST', 'm_b', f'/posts/{other}/comments', {'body': 'ailleurs'})[1]['comment']; ok(call('POST', 'm_d', f'/posts/{G}/comments', {'body': 'x', 'parent_id': cx['id']})[0] == 422, 'parent d\'une AUTRE publication : refusé')
setting('comment_max_chars', 50); ok(call('POST', 'm_d', f'/posts/{G}/comments', {'body': 'é' * 50})[0] == 201 and call('POST', 'm_d', f'/posts/{G}/comments', {'body': 'é' * 51})[0] == 422, 'comment_max_chars (paramètre de /control) : 50 acceptés, 51 refusés'); unset('comment_max_chars')
L = call('GET', 'm_d', f'/posts/{G}/comments')[1]; ok([c['id'] for c in L['items']][:2] == [c1['id'], r1['id']] and L['has_more'] is False and [c['id'] for c in L['items']] == sorted(c['id'] for c in L['items']), 'liste : du plus ancien au plus récent, réponses après leur parent')
ok(next(x for x in feed('m_d')[1]['items'] if x['id'] == G)['comments'] == 3, 'le compteur de la publication compte tous les commentaires (3)')
sql("UPDATE users SET collab_settings='{\"blacklist\":[\"m_d@test.test\"]}' WHERE id='m_c'"); L = call('GET', 'm_d', f'/posts/{G}/comments')[1]['items']; ok(r1['id'] not in [c['id'] for c in L] and c1['id'] in [c['id'] for c in L], 'le commentaire d\'un auteur en liste noire est masqué, les autres restent'); sql("UPDATE users SET collab_settings=NULL WHERE id='m_c'")
ok(call('PUT', 'm_b', f'/posts/{G}/comments/{c1["id"]}', {'body': 'Superbe !'})[1]['comment']['body'] == 'Superbe !' and call('PUT', 'm_b', f'/posts/{G}/comments/{c1["id"]}', {'body': 'Superbe !'})[1]['comment']['edited'] is True, 'modifier son commentaire : texte changé, « modifié »')
ok(call('PUT', 'm_c', f'/posts/{G}/comments/{c1["id"]}', {'body': 'x'})[0] == 403 and call('DELETE', 'm_c', f'/posts/{G}/comments/{c1["id"]}')[0] == 403 and call('PUT', 'm_b', f'/posts/{G}/comments/{c1["id"]}', {'body': ''})[0] == 422 and call('PUT', 'm_b', f'/posts/{G}/comments/99999', {'body': 'x'})[0] == 404 and call('PUT', 'm_b', f'/posts/{other}/comments/{c1["id"]}', {'body': 'x'})[0] == 404, 'modifier ou supprimer le commentaire d\'un autre : 403 ; texte vide : 422 ; commentaire inconnu ou d\'une autre publication : 404')
section("Commentaires : notifications et « j\'aime »")
nA = [x for x in notifs('m_a') if x['type'] == 'comment']; nB = [x for x in notifs('m_b') if x['type'] == 'comment']
ok(any(x['actor']['id'] == 'm_b' and x['kind'] == 'post' for x in nA) and any(x['actor']['id'] == 'm_c' and x['kind'] == 'post' for x in nA), 'l\'auteur de la publication est notifié de chaque commentaire (kind « post »)')
ok(len(nB) == 1 and nB[0]['actor']['id'] == 'm_c' and nB[0]['kind'] == 'reply' and nB[0]['post_id'] == G, 'l\'auteur du commentaire est notifié de la réponse (kind « reply »)')
ok(not any(x['actor']['id'] == 'm_c' and x['kind'] == 'post' and x['subject_id'] == str(G) for x in notifs('m_b')), 'et ne reçoit pas EN PLUS une notification « commentaire sur votre publication »')
ra = call('POST', 'm_a', f'/posts/{G}/comments', {'body': 'Merci à tous', 'parent_id': c1['id']})[1]['comment']; ok(len([x for x in notifs('m_a') if x['actor']['id'] == 'm_a']) == 0, 'commenter sa propre publication : aucune notification pour soi-même')
sql("DELETE FROM notifications WHERE user_id='m_a'"); call('POST', 'm_b', f'/posts/{G}/comments', {'body': 'Mon second', 'parent_id': c1['id']}); ok(len([x for x in notifs('m_a')]) == 1 and notifs('m_a')[0]['kind'] == 'post', 'réponse de m_b à SON PROPRE commentaire sur la publication de m_a : une seule notification (pour m_a), aucune pour lui-même')
st, d, _ = call('POST', 'm_c', f'/posts/{G}/comments/{c1["id"]}/like'); ok(st == 200 and d == {'liked': True, 'likes': 1} and call('POST', 'm_c', f'/posts/{G}/comments/{c1["id"]}/like')[1]['likes'] == 1, 'aimer un commentaire : idempotent')
nl = [x for x in notifs('m_b') if x['type'] == 'like']; ok(len(nl) == 1 and nl[0]['subject_type'] == 'comment' and nl[0]['subject_id'] == str(c1['id']) and nl[0]['kind'] == 'comment' and nl[0]['post_id'] == G, 'l\'auteur du commentaire est notifié (type like, sujet = le commentaire, avec la publication à ouvrir)')
ok(next(c for c in call('GET', 'm_c', f'/posts/{G}/comments')[1]['items'] if c['id'] == c1['id'])['liked_by_me'] is True and call('POST', 'm_c', f'/posts/{G}/comments/{c1["id"]}/like', {'like': False})[1] == {'liked': False, 'likes': 0} and call('POST', 'm_c', f'/posts/{G}/comments/{c1["id"]}/like', {'like': 'x'})[0] == 400, '« liked_by_me » propre à chacun ; retirer ; valeur invalide : 400')
call('POST', 'm_d', f'/posts/{G}/comments/{c1["id"]}/like'); d = call('DELETE', 'm_b', f'/posts/{G}/comments/{c1["id"]}')[1]
ok(d == {'ok': True, 'deleted': 4} and sql(f"SELECT COUNT(*) FROM comments WHERE id={c1['id']} OR parent_id={c1['id']}") == '0' and sql(f"SELECT COUNT(*) FROM comment_likes WHERE comment_id={c1['id']}") == '0', 'supprimer un commentaire supprime ses réponses (ici 3) et ses « j\'aime » : 4 supprimés')
ok(next(x for x in feed('m_d')[1]['items'] if x['id'] == G)['comments'] == 1, 'le compteur de la publication suit (reste le commentaire de m_d)')

section("Supprimer une publication")
D = publish('m_a', 'À supprimer', photos=[photo('m_a', 'd1.webp'), photo('m_a', 'd2.webp')]); cd = call('POST', 'm_b', f'/posts/{D["id"]}/comments', {'body': 'com'})[1]['comment']; call('POST', 'm_c', f'/posts/{D["id"]}/comments', {'body': 'rep', 'parent_id': cd['id']}); call('POST', 'm_b', f'/posts/{D["id"]}/like'); call('POST', 'm_c', f'/posts/{D["id"]}/save'); call('POST', 'm_c', f'/posts/{D["id"]}/comments/{cd["id"]}/like')
ok(any(x['subject_id'] == str(D['id']) for x in notifs('m_a')), 'avant : l\'auteur a des notifications sur cette publication')
ok(call('DELETE', 'm_b', f'/posts/{D["id"]}')[0] == 403 and call('DELETE', 'm_d', f'/posts/{D["id"]}')[0] == 403 and call('GET', 'm_a', f'/posts/{D["id"]}')[0] == 200, 'un autre membre ne peut pas supprimer (403) ; la publication est intacte')
st, d, _ = call('DELETE', 'm_a', f'/posts/{D["id"]}'); ok(st == 200 and d == {'ok': True} and call('GET', 'm_a', f'/posts/{D["id"]}')[0] == 404, 'l\'auteur supprime : la publication disparaît')
ok(all(sql(q) == '0' for q in (f"SELECT COUNT(*) FROM comments WHERE post_id={D['id']}", f"SELECT COUNT(*) FROM post_likes WHERE post_id={D['id']}", f"SELECT COUNT(*) FROM post_saves WHERE post_id={D['id']}", f"SELECT COUNT(*) FROM post_media WHERE post_id={D['id']}", f"SELECT COUNT(*) FROM comment_likes WHERE comment_id={cd['id']}", f"SELECT COUNT(*) FROM notifications WHERE subject_type='post' AND subject_id='{D['id']}'")), 'tout ce qui s\'y rattachait est supprimé : commentaires, « j\'aime » (publication et commentaires), enregistrements, photos en base, notifications')
ok(all(gone(p) for p in D['photos']), 'les fichiers de photos sont supprimés du disque')
ok(call('DELETE', 'm_a', f'/posts/{D["id"]}')[0] == 404, 'supprimer deux fois : 404')

section("Étiquette à côté du nom")
x = feed('m_c')[1]['items']; ba = next(i for i in x if i['author']['id'] == 'm_a')['author']['badge']; bb = next(i for i in x if i['author']['id'] == 'm_b')['author']['badge']
ok(ba == 'Potier' and bb is None, 'spécialité (par défaut) : le métier de l\'auteur, rien s\'il n\'en a pas')
setting('badge_kind', 'status'); x = feed('m_c')[1]['items']; ok(next(i for i in x if i['author']['id'] == 'm_a')['author']['badge'] == 'Premium' and next(i for i in x if i['author']['id'] == 'm_b')['author']['badge'] is None, 'statut : « Premium » pour un membre premium, rien pour un prospect')
setting('badge_kind', 'level'); ok(all(i['author']['badge'] == 'Niveau ' + str(i['author']['badge_level']) and i['author']['badge_level'] >= 1 for i in feed('m_c')[1]['items']), 'niveau Uluana : « Niveau N » (1 pour qui n\'a aucun point), calculé comme sur le site (voir test_activity.py)'); unset('badge_kind')

section("Envoi de photo : espace de stockage")
PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')
def upload(who, fields):
    b = uuid.uuid4().hex; parts = b''
    for k, v in fields.items(): parts += f'--{b}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
    parts += f'--{b}\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n'.encode() + PNG + f'\r\n--{b}--\r\n'.encode()
    h = {'Host': 'dev-api.uluana.art', 'Content-Type': f'multipart/form-data; boundary={b}'}; 
    if who: h['Authorization'] = 'Bearer ' + tok(who)
    try:
        with urllib.request.urlopen(urllib.request.Request(API + '/upload', method='POST', data=parts, headers=h), timeout=30) as r: return r.status, json.loads(r.read() or b'{}')
    except urllib.error.HTTPError as e: return e.code, json.loads(e.read() or b'{}')
ok(upload(None, {'type': 'post_photo'})[0] == 401, 'envoi de photo de publication sans être connecté : 401')
photo('m_d', 'plein.webp', size=1_200_000); setting('storage_quota_mb_per_member', 1)
st, d = upload('m_d', {'type': 'post_photo'}); ok(st == 413 and 'Espace de stockage plein' in d['error'] and '1 Mo' in d['error'], 'quota de stockage atteint (paramètre de /control : 1 Mo) : refusé (413) avec un message clair')
photo('m_c', 'petit.webp', size=100); st, d = upload('m_c', {'type': 'post_photo'}); ok(st != 413, 'sous le quota : l\'envoi n\'est pas refusé pour ce motif (' + str(st) + ')')
st, d = upload('m_d', {'type': 'post_photo', 'user_id': 'm_c'}); ok(st == 413, 'on ne peut pas envoyer dans le dossier d\'un autre : l\'identifiant fourni est ignoré (le quota de m_d s\'applique)'); unset('storage_quota_mb_per_member')

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