File size: 32,604 Bytes
a670808
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
"""Uploading a manually renamed episode to the Database Channel and having the channel post bot publish it:
the two chats in the settings, the Channel option of a manual format, the offer, the upload, the notice in the
communication chat and the answers of the channel post bot."""
import asyncio
import re
import time
from types import SimpleNamespace

import pytest
from telethon import errors

from bot import panels
from bot.app import App
from bot.config import ConfigError, load_config
from bot.db import Database, Format
from bot.dbflow import classify, episode_of, line_of, notice_text, reply_target
from bot.formats import default_auto_format
from bot.pipeline import Renamer
from bot.queue import Job
from bot.settings_ops import parse_format_channel

from .appkit import ADMIN_CFG, OWNER, STRANGER, click, data_of, env_for, say
from .tgfakes import FakeClient, FakeEvent, dispatch, document_message, flat_buttons

DB = -1002000000001  # the Database Channel
COMM = -1003000000002  # the communication chat
POST_BOT = 8950016484
SHOW = -1004000000003  # the channel a format publishes in
NAME = "[S01E05] Tokyo Revengers [1080p].mkv"


# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------


@pytest.fixture
async def up(tmp_path):
    """Like ``ctx``, with uploading to the Database Channel switched on (and short waits)."""
    cfg = load_config(env_for(tmp_path, DB_Channel=str(DB), Communication_chat=str(COMM)))
    db = Database(f"sqlite+aiosqlite:///{tmp_path}/app.db")
    await db.init()
    client = FakeClient()
    app = App(client, cfg, db)
    app.dbflow.ack_wait, app.dbflow.result_wait = 2.0, 4.0
    app._register()
    yield SimpleNamespace(app=app, client=client, db=db, cfg=cfg, tmp=tmp_path, flow=app.dbflow)
    await app.shutdown()
    await db.close()


def notices(up):
    """What was written in the communication chat: ``[(message id, text, kwargs)]``."""
    return [(2000 + n, text, kw) for n, (entity, text, kw) in enumerate(up.client.sent, 1) if entity == COMM]


async def wait_for(predicate, timeout=3.0):
    end = time.monotonic() + timeout
    while not predicate():
        assert time.monotonic() < end, "timed out"
        await asyncio.sleep(0.005)


async def the_notice(up, nth=1):
    await wait_for(lambda: len(notices(up)) >= nth)
    return notices(up)[nth - 1]


def ref_of(text):
    return re.search(r"(?m)^Ref: (\w+)$", text).group(1)


def last_edit(up):
    return up.client.edited[-1][1]


async def post_bot_says(up, text, *, reply_to=None, sender=POST_BOT, chat=COMM):
    ev = FakeEvent(sender, text, chat=chat, private=False, reply_to_id=reply_to)
    await dispatch(up.client, ev)
    return ev


async def start_upload(up, *, channel=SHOW, name=NAME, mid=555, offer_id=77, caption=""):
    """The renamed episode is in the user's chat, and the offer is answered with Yes."""
    up.client.messages[mid] = document_message(name, 10 * 1024 * 1024, mid=mid, caption=caption)
    return await click(up, OWNER, f"dbu:y:{OWNER}:{mid}:{channel or 0}", msg_id=offer_id)


def manual_job(name=NAME, channel=SHOW, kind="manual", chat=OWNER):
    fmt = {"id": 5, "name": "One Piece", "kind": kind, "channel": channel}
    return Job(
        id=1, user_id=OWNER, chat_id=chat, msg_id=40, file_name="original.mkv", size=1, new_name=name, fmt=fmt,
        status_msg=SimpleNamespace(id=99),
    )


# ---------------------------------------------------------------------------
# settings
# ---------------------------------------------------------------------------


def test_both_chats_switch_uploading_on(tmp_path):
    cfg = load_config(env_for(tmp_path, DB_CHANNEL=str(DB), COMMUNICATION_CHAT=str(COMM)))
    assert (cfg.db_channel, cfg.comm_chat, cfg.upload_enabled, cfg.post_bot_id) == (DB, COMM, True, 8950016484)


@pytest.mark.parametrize("db_name, comm_name", [("DB_CHANNEL", "COMMUNICATION_CHAT"), ("db_channel", "communication_chat"), ("DB_Channel", "Communication_chat")])
def test_the_names_may_be_written_in_any_case(tmp_path, db_name, comm_name):
    cfg = load_config(env_for(tmp_path, **{db_name: str(DB), comm_name: str(COMM)}))
    assert (cfg.db_channel, cfg.comm_chat, cfg.upload_enabled) == (DB, COMM, True)


@pytest.mark.parametrize("extra", [{"DB_CHANNEL": str(DB)}, {"COMMUNICATION_CHAT": str(COMM)}, {}])
def test_one_chat_alone_does_not_switch_it_on(tmp_path, extra):
    assert load_config(env_for(tmp_path, **extra)).upload_enabled is False


def test_a_bare_channel_id_gets_its_minus_100(tmp_path):
    cfg = load_config(env_for(tmp_path, DB_CHANNEL="2000000001", COMMUNICATION_CHAT="3000000002"))
    assert (cfg.db_channel, cfg.comm_chat) == (-1002000000001, -1003000000002)


@pytest.mark.parametrize("name", ["DB_CHANNEL", "COMMUNICATION_CHAT", "CHANNEL_POST_BOT_ID"])
def test_an_id_that_is_not_a_number_is_a_configuration_error(tmp_path, name):
    with pytest.raises(ConfigError, match=name):
        load_config(env_for(tmp_path, **{name: "@somewhere"}))


@pytest.mark.parametrize("value", ["", "0", "off", "OFF", "none"])
def test_empty_or_off_means_not_set(tmp_path, value):
    cfg = load_config(env_for(tmp_path, DB_CHANNEL=value, COMMUNICATION_CHAT=value))
    assert cfg.db_channel is None and cfg.comm_chat is None and cfg.upload_enabled is False


def test_the_post_bot_id_can_be_changed(tmp_path):
    assert load_config(env_for(tmp_path, CHANNEL_POST_BOT_ID="12345")).post_bot_id == 12345


async def test_without_both_chats_nothing_is_offered_and_the_buttons_say_so(ctx):
    assert ctx.app.dbflow is None and ctx.app.renamer.on_delivered is None
    cb = await click(ctx, OWNER, f"dbu:y:{OWNER}:555:{SHOW}")
    assert cb.alerts and "switched off" in cb.alerts[0] and not ctx.client.forwarded and not ctx.client.sent


async def test_with_both_chats_the_offer_is_hooked_into_the_pipeline(up):
    assert up.app.renamer.on_delivered == up.flow.offer


# ---------------------------------------------------------------------------
# the Channel option of a manual format
# ---------------------------------------------------------------------------


async def test_a_manual_format_keeps_its_channel(ctx):
    fmt = await ctx.db.create_format(OWNER, "One Piece")
    assert fmt["channel"] is None
    assert (await ctx.db.update_format(OWNER, fmt["id"], channel=SHOW))["channel"] == SHOW
    assert (await ctx.db.get_format(OWNER, fmt["id"]))["channel"] == SHOW
    assert [f["channel"] for f in await ctx.db.list_formats(OWNER, "manual")] == [SHOW]
    assert (await ctx.db.update_format(OWNER, fmt["id"], channel=None))["channel"] is None


async def test_an_auto_format_has_no_channel(ctx):
    fmt = await ctx.db.create_format(OWNER, "Auto One", kind="auto")
    assert (await ctx.db.update_format(OWNER, fmt["id"], channel=SHOW))["channel"] is None


async def test_formats_saved_before_the_option_have_no_channel(ctx):
    async with ctx.db._sessions() as session:
        session.add(Format(user_id=OWNER, name="Old", data='{"template": "{title}", "kind": "manual"}'))
        session.add(Format(user_id=OWNER, name="Odd", data='{"kind": "manual", "channel": "-100123"}'))  # not a number: ignored
        await session.commit()
    assert [f["channel"] for f in await ctx.db.list_formats(OWNER)] == [None, None]


async def test_a_copy_keeps_the_channel_only_for_the_owners_own_manual_copy(ctx):
    fmt = await ctx.db.create_format(OWNER, "One Piece")
    await ctx.db.update_format(OWNER, fmt["id"], channel=SHOW)
    auto = await ctx.db.copy_format(OWNER, OWNER, fmt["id"], kind="auto")
    assert auto["kind"] == "auto" and auto["channel"] is None
    again = await ctx.db.copy_format(OWNER, OWNER, fmt["id"])
    assert again["kind"] == "manual" and again["channel"] == SHOW
    theirs = await ctx.db.copy_format(ADMIN_CFG, OWNER, fmt["id"])  # somebody else adds it from a share card
    assert theirs["kind"] == "manual" and theirs["channel"] is None
    back = await ctx.db.copy_format(OWNER, OWNER, auto["id"], kind="manual")
    assert back["channel"] is None
    assert (await ctx.db.get_format(OWNER, fmt["id"]))["channel"] == SHOW  # the original is untouched


@pytest.mark.parametrize(
    "text, expected",
    [
        ("-1001234567890", -1001234567890),
        ("  -1001234567890 ", -1001234567890),
        ("1234567890", -1001234567890),
        ("https://t.me/c/1234567890/5", -1001234567890),
        ("t.me/c/1234567890", -1001234567890),
        ("off", None), ("None", None), ("-", None), ("clear", None),
    ],
)
def test_a_channel_id_is_read(text, expected):
    assert parse_format_channel(text) == (True, expected, "")


@pytest.mark.parametrize("text", ["", "abc", "@mychannel", "https://t.me/mychannel", "-12345", "123", "-1001234567890x", "12 34567890"])
def test_anything_else_is_refused_with_the_way_to_write_it(text):
    ok, value, message = parse_format_channel(text)
    assert not ok and value is None and "-1001234567890" in message


async def test_without_the_two_chats_the_format_has_no_channel_option(ctx):
    fmt = await ctx.db.create_format(OWNER, "One Piece")
    text, buttons = (await click(ctx, OWNER, f"fm:v:{fmt['id']}")).edits[-1]
    assert "Channel" not in text and not [d for d in data_of(buttons) if "channel" in d]
    cb = await click(ctx, OWNER, f"fm:in:{fmt['id']}:channel")
    assert cb.alerts and OWNER not in ctx.app.pending


async def test_a_manual_format_shows_the_channel_option_and_an_auto_format_does_not(up):
    fmt = await up.db.create_format(OWNER, "One Piece")
    i = fmt["id"]
    text, buttons = (await click(up, OWNER, f"fm:v:{i}")).edits[-1]
    assert "<b>Channel</b> ▸ — (optional)" in text
    assert f"fm:in:{i}:channel" in data_of(buttons) and f"fm:chc:{i}" not in data_of(buttons)
    auto = await up.db.create_format(OWNER, "Auto One", kind="auto")
    text, buttons = (await click(up, OWNER, f"fm:v:{auto['id']}")).edits[-1]
    assert "Channel" not in text and f"fm:in:{auto['id']}:channel" not in data_of(buttons)
    cb = await click(up, OWNER, f"fm:in:{auto['id']}:channel")
    assert cb.alerts and OWNER not in up.app.pending


async def test_the_channel_is_typed_in_checked_and_cleared(up):
    fmt = await up.db.create_format(OWNER, "One Piece")
    i = fmt["id"]
    cb = await click(up, OWNER, f"fm:in:{i}:channel", msg_id=710)
    assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["channel"] and up.app.pending[OWNER].kind == "fmt:channel"
    assert data_of(cb.edits[-1][1]) == [f"fm:cx:{i}"]
    ev = await say(up, OWNER, "@not_an_id")
    assert "❌" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in up.app.pending
    ev = await say(up, OWNER, "1234567890")  # a bare channel id
    assert ev.deleted and OWNER not in up.app.pending
    assert (await up.db.get_format(OWNER, i))["channel"] == -1001234567890
    chat, text, kw = up.client.edited[-1]
    assert kw["message"] == 710 and "Channel saved" in text and "<code>-1001234567890</code>" in text
    text, buttons = (await click(up, OWNER, f"fm:v:{i}")).edits[-1]
    assert "<b>Channel</b> ▸ <code>-1001234567890</code>" in text and f"fm:chc:{i}" in data_of(buttons)
    cb = await click(up, OWNER, f"fm:chc:{i}")  # the button clears it
    assert (await up.db.get_format(OWNER, i))["channel"] is None and "— (optional)" in cb.edits[-1][0]
    await up.db.update_format(OWNER, i, channel=SHOW)
    await click(up, OWNER, f"fm:in:{i}:channel", msg_id=711)
    await say(up, OWNER, "off")  # and so does typing off
    assert (await up.db.get_format(OWNER, i))["channel"] is None


async def test_the_channel_of_a_deleted_format_is_handled(up):
    fmt = await up.db.create_format(OWNER, "One Piece")
    await click(up, OWNER, f"fm:in:{fmt['id']}:channel")
    await up.db.delete_format(OWNER, fmt["id"])
    ev = await say(up, OWNER, "-1001234567890")
    assert "no longer exists" in ev.texts[0] and OWNER not in up.app.pending


async def test_the_format_screen_with_a_channel_still_fits_a_caption(up):
    fmt = await up.db.create_format(OWNER, "N" * 30, template="T" * 200)
    await up.db.update_format(OWNER, fmt["id"], channel=-1009999999999, caption="C" * 700)
    text, _rows = panels.format_view(await up.db.get_format(OWNER, fmt["id"]), upload_on=True)
    assert len(re.sub(r"<[^>]+>", "", text)) < 1024


def test_a_shared_format_card_does_not_show_the_channel():
    fmt = {**default_auto_format(), "kind": "manual", "name": "One Piece", "id": 5, "channel": SHOW}
    text, _rows = panels.shared_format(fmt, "token")
    assert str(SHOW) not in text and "Channel" not in text


# ---------------------------------------------------------------------------
# the messages
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "name, episode",
    [
        (NAME, "05"), ("One Piece - E1147.mkv", "1147"), ("Black Clover - S2 E05.mkv", "05"),
        ("Tokyo Revengers Movie.mkv", ""), ("", ""),
    ],
)
def test_the_episode_number_is_read_from_the_renamed_file(name, episode):
    assert episode_of(name) == episode


def test_the_notice_has_what_the_post_bot_needs():
    text = notice_text(episode="05", channel=SHOW, file=NAME, post_id=4001, ref="ab12cd")
    assert text.splitlines() == [
        "File uploaded to the Database Channel", "Episode: 05", f"Channel: {SHOW}", f"File: {NAME}", "Post: 4001", "Ref: ab12cd"
    ]
    bare = notice_text(episode="", channel=SHOW, file=NAME, post_id=None, ref="ab12cd")
    assert "Episode:" not in bare and "Post:" not in bare and f"Channel: {SHOW}" in bare


def test_the_label_lines_of_a_message_are_found():
    text = "✅ Post published successfully\nChannel: Black Clover English\nPost: https://t.me/c/1/2\nRef: ab12cd"
    assert line_of("channel", text) == "Black Clover English" and line_of("post", text) == "https://t.me/c/1/2"
    assert line_of("ref", text) == "ab12cd" and line_of("episode", text) == "" and line_of("ref", None) == ""


@pytest.mark.parametrize(
    "text, expected",
    [
        ("📥 Message received - checking the Database Channel…\nRef: ab12", ("ack", "")),
        ("message received", ("ack", "")),
        ("✅ Post published successfully\nChannel: X", ("ok", "")),
        ("POST PUBLISHED SUCCESSFULLY", ("ok", "")),
        ("❌ Publication failed: no button found\nFile: a.mkv\nRef: ab12", ("fail", "no button found")),
        ("publication failed:   many   spaces  here", ("fail", "many spaces here")),
        ("Publication failed", ("fail", "")),
        ("❌ Publication failed: no post published successfully", ("fail", "no post published successfully")),  # a failure wins
        ("hello", ("other", "")), ("", ("other", "")), (None, ("other", "")),
    ],
)
def test_what_the_post_bot_says_is_told_apart(text, expected):
    assert classify(text) == expected


def test_the_reply_target_is_read_from_the_message():
    assert reply_target(FakeEvent(POST_BOT, "x", reply_to_id=7)) == 7
    assert reply_target(FakeEvent(POST_BOT, "x")) is None


# ---------------------------------------------------------------------------
# the offer after /rename
# ---------------------------------------------------------------------------


async def test_the_offer_asks_yes_or_no_after_the_episode(up):
    await up.app.dbflow.offer(manual_job(), OWNER, SimpleNamespace(id=555))
    (entity, text, kw), = up.client.sent
    assert entity == OWNER and kw["reply_to"] == 555 and kw["parse_mode"] == "html"
    assert "Upload this episode to the Database Channel?" in text and NAME in text
    assert "Episode: <b>05</b>" in text and f"<code>{SHOW}</code>" in text
    assert flat_buttons(kw["buttons"]) == [("✅ Yes", f"dbu:y:{OWNER}:555:{SHOW}"), ("❌ No", "dbu:n")]


def test_the_buttons_fit_telegrams_limit_even_for_the_biggest_ids():
    data = f"dbu:y:{-1009999999999}:{2147483647}:{-1009999999999}"
    assert len(data.encode()) <= 64


async def test_the_offer_for_a_format_without_a_channel_says_it_is_only_uploaded(up):
    await up.app.dbflow.offer(manual_job(channel=None), OWNER, SimpleNamespace(id=555))
    (_entity, text, kw), = up.client.sent
    assert "has no Channel" in text and "no post is made" in text and "Episode:" not in text
    assert data_of(kw["buttons"])[0] == f"dbu:y:{OWNER}:555:0"


async def test_the_offer_warns_when_the_file_name_has_no_episode_number(up):
    await up.app.dbflow.offer(manual_job(name="Tokyo Revengers Movie.mkv"), OWNER, SimpleNamespace(id=555))
    assert "No episode number found" in up.client.sent[0][1]


async def test_a_file_sent_to_a_channel_is_still_offered_in_the_chat_of_the_user(up):
    await up.app.dbflow.offer(manual_job(), SHOW, SimpleNamespace(id=555))
    (entity, _text, kw), = up.client.sent
    assert entity == OWNER and kw["reply_to"] == 40  # the file the user sent, not the one in the channel
    assert f"dbu:y:{SHOW}:555:{SHOW}" in data_of(kw["buttons"])


async def test_nothing_is_offered_without_a_message_to_point_at(up):
    await up.app.dbflow.offer(manual_job(), OWNER, None)
    assert not up.client.sent


class Reporter:
    def __init__(self, log):
        self.log = log

    async def finish(self, text):
        self.log.append("finished")


async def deliver(up, job, hook, target_channel=None, log=None):
    """Run the delivery step of a rename; ``log`` says in what order the file, the Done message and the hook came."""
    log = [] if log is None else log
    renamer = Renamer(up.client, up.cfg, up.db)
    renamer.on_delivered = hook
    if target_channel:
        await up.db.update_settings(OWNER, target_channel=target_channel)
    original_send = up.client.send_file

    async def send_file(*args, **kw):
        log.append("sent")
        return await original_send(*args, **kw)

    up.client.send_file = send_file
    await renamer._deliver(
        job, await up.db.get_settings(OWNER), document_message(NAME, 10), "input", caption=None, attrs=[], as_document=True,
        thumb=None, final_size=10, warnings=[], rates="", started=time.monotonic(), reporter=Reporter(log),
    )
    return log


async def test_a_manual_rename_is_offered_after_the_file_and_the_done_message(up):
    log, calls = [], []

    async def hook(job, target, sent):
        log.append("offer")
        calls.append((job.id, target, sent.id))

    job = manual_job()
    await deliver(up, job, hook, log=log)
    assert job.state == "done" and calls == [(1, OWNER, 3001)] and log == ["sent", "finished", "offer"]


@pytest.mark.parametrize("kind", ["auto", None])
async def test_auto_renames_are_not_offered(up, kind):
    called = []

    async def hook(*args):
        called.append(args)

    job = manual_job(kind=kind or "auto")
    if kind is None:
        job.fmt = None  # an auto rename of someone who has no auto format
    await deliver(up, job, hook)
    assert called == [] and job.state == "done"


async def test_a_failing_offer_does_not_spoil_the_rename(up):
    async def hook(*args):
        raise RuntimeError("telegram said no")

    job = manual_job()
    log = await deliver(up, job, hook)
    assert job.state == "done" and log == ["sent", "finished"]


async def test_without_the_hook_nothing_happens(up):
    job = manual_job()
    assert await deliver(up, job, None) == ["sent", "finished"] and job.state == "done"


async def test_a_file_sent_to_the_target_channel_is_offered_with_that_channel_as_its_chat(up):
    seen = []

    async def hook(job, target, sent):
        seen.append(target)

    await deliver(up, manual_job(), hook, target_channel=SHOW)
    assert seen == [SHOW]


# ---------------------------------------------------------------------------
# Yes: upload, tell the communication chat, read the answers
# ---------------------------------------------------------------------------


async def test_yes_copies_the_episode_and_tells_the_communication_chat(up):
    cb = await start_upload(up, caption="<b>x</b>")
    assert cb.edits[-1][0].startswith("⏳ <b>Processing…</b>") and cb.edits[-1][1] is None and cb.answers
    nid, text, kw = await the_notice(up)
    # copied inside Telegram: nothing was downloaded, nothing was uploaded again
    assert up.client.forwarded == [(DB, 555, OWNER, {"drop_author": True})]
    assert up.client.downloads == [] and up.client.files == []
    ref = ref_of(text)
    assert text.splitlines() == [
        "File uploaded to the Database Channel", "Episode: 05", f"Channel: {SHOW}", f"File: {NAME}", "Post: 4001", f"Ref: {ref}"
    ]
    assert kw["parse_mode"] is None  # plain text: nothing in a file name can be taken for formatting


async def test_the_whole_story_from_yes_to_published(up):
    await start_upload(up)
    nid, text, _ = await the_notice(up)
    ref = ref_of(text)
    await post_bot_says(up, f"📥 Message received - checking the Database Channel…\nRef: {ref}", reply_to=nid)
    await wait_for(lambda: len(up.client.edited) >= 2)
    assert "channel post bot got it" in last_edit(up) and "Waiting" not in last_edit(up)
    done = (
        "✅ Post published successfully\nChannel: Black Clover English\nPost: https://t.me/c/1/2\n"
        f"Button: Download Episode 05 → https://t.me/x\nEpisode: 05\nFile: {NAME}\nRef: {ref}"
    )
    await post_bot_says(up, done, reply_to=nid)
    await up.flow.idle()
    texts = [t for _e, t, _k in up.client.edited]
    assert len(texts) == 3
    assert "Waiting for the channel post bot" in texts[0] and "Uploaded to the Database Channel" in texts[0]
    assert "Post published successfully" in texts[2] and "Black Clover English" in texts[2] and "https://t.me/c/1/2" in texts[2]
    assert NAME in texts[2]
    assert {kw["message"] for _e, _t, kw in up.client.edited} == {77} and {e for e, _t, _k in up.client.edited} == {OWNER}
    assert all(kw["buttons"] is None for _e, _t, kw in up.client.edited)
    assert up.flow.flows == {} and up.flow._busy == set()


async def test_a_failure_of_the_post_bot_is_shown_with_its_reason(up):
    await start_upload(up)
    nid, text, _ = await the_notice(up)
    await post_bot_says(up, f"📥 Message received - checking the Database Channel…\nRef: {ref_of(text)}", reply_to=nid)
    reason = "The latest post of the Database Channel is the file “Other.mkv”, not the one that was reported - nothing was published."
    await post_bot_says(up, f"❌ Publication failed: {reason}\nFile: {NAME}\nRef: {ref_of(text)}", reply_to=nid)
    await up.flow.idle()
    final = last_edit(up)
    assert final.startswith("❌ <b>Publication failed</b>") and reason in final and NAME in final


async def test_names_and_reasons_are_escaped_in_the_edits_but_not_in_the_notice(up):
    await start_upload(up, name="[S01E05] Tom & Jerry <HD>.mkv")
    nid, text, _ = await the_notice(up)
    assert "File: [S01E05] Tom & Jerry <HD>.mkv" in text
    await post_bot_says(up, "❌ Publication failed: bad <b>thing</b> & more\nRef: " + ref_of(text), reply_to=nid)
    await up.flow.idle()
    final = last_edit(up)
    assert "Tom &amp; Jerry &lt;HD&gt;" in final and "bad &lt;b&gt;thing&lt;/b&gt; &amp; more" in final


async def test_no_answer_at_all_means_the_channel_post_bot_is_down(up):
    up.flow.ack_wait = 0.1
    await start_upload(up)
    await up.flow.idle()
    final = last_edit(up)
    assert "down or not responding" in final and "The episode is in the Database Channel" in final and "nothing was published" in final
    assert up.flow.flows == {} and up.flow._busy == set()


async def test_an_acknowledged_notice_that_is_never_answered_is_not_called_down(up):
    up.flow.ack_wait, up.flow.result_wait = 2.0, 0.15
    await start_upload(up)
    nid, text, _ = await the_notice(up)
    await post_bot_says(up, f"📥 Message received - checking the Database Channel…\nRef: {ref_of(text)}", reply_to=nid)
    await up.flow.idle()
    final = last_edit(up)
    assert "No word from the channel post bot" in final and "down" not in final


async def test_an_answer_without_the_acknowledgement_still_counts(up):
    await start_upload(up)
    nid, text, _ = await the_notice(up)
    await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(text)}", reply_to=nid)
    await up.flow.idle()
    assert "Post published successfully" in last_edit(up)


async def test_answers_are_matched_by_the_ref_alone_or_by_the_reply_alone(up):
    await start_upload(up, offer_id=71, mid=601)
    nid, text, _ = await the_notice(up)
    await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(text)}")  # not a reply: the Ref says whose it is
    await up.flow.idle()
    assert "Post published successfully" in last_edit(up)

    await start_upload(up, offer_id=72, mid=602)
    nid, text, _ = await the_notice(up, 2)
    await post_bot_says(up, "❌ Publication failed: nope", reply_to=nid)  # no Ref: the reply says whose it is
    await up.flow.idle()
    assert "Publication failed" in last_edit(up) and "nope" in last_edit(up)


async def test_with_two_uploads_waiting_the_ref_tells_whose_answer_it_is(up):
    up.client.messages[601] = document_message("[S01E05] A [1080p].mkv", 10, mid=601)
    up.client.messages[602] = document_message("[S01E06] A [1080p].mkv", 10, mid=602)
    await click(up, OWNER, f"dbu:y:{OWNER}:601:{SHOW}", msg_id=71)
    await click(up, OWNER, f"dbu:y:{OWNER}:602:{SHOW}", msg_id=72)
    first, second = await the_notice(up, 1), await the_notice(up, 2)
    await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(second[1])}")  # not a reply, and two are waiting
    await wait_for(lambda: any(kw["message"] == 72 and "published successfully" in t for _e, t, kw in up.client.edited))
    assert not any(kw["message"] == 71 and "published successfully" in t for _e, t, kw in up.client.edited)
    assert len(up.flow.flows) == 1 and ref_of(first[1]) in up.flow.flows  # the first is still waiting
    await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(first[1])}")
    await up.flow.idle()


async def test_a_plain_message_counts_when_only_one_upload_is_waiting(up):
    await start_upload(up)
    await the_notice(up)
    await post_bot_says(up, "✅ Post published successfully")
    await up.flow.idle()
    assert "Post published successfully" in last_edit(up)


async def test_other_senders_other_chats_other_replies_and_other_words_are_ignored(up):
    await start_upload(up)
    nid, text, _ = await the_notice(up)
    ref = ref_of(text)
    ok = f"✅ Post published successfully\nRef: {ref}"
    await post_bot_says(up, ok, reply_to=nid, sender=STRANGER)  # somebody else
    await post_bot_says(up, ok, reply_to=nid, chat=-1005000000005)  # another chat
    await post_bot_says(up, f"hello there\nRef: {ref}", reply_to=nid)  # not an answer
    await post_bot_says(up, "✅ Post published successfully", reply_to=1)  # a reply to something else
    await asyncio.sleep(0.05)
    assert up.flow.flows[ref].inbox.empty() and len(up.client.edited) == 1
    await post_bot_says(up, ok, reply_to=nid)
    await up.flow.idle()
    assert "Post published successfully" in last_edit(up)


async def test_two_uploads_at_once_each_get_their_own_answer(up):
    up.client.messages[601] = document_message("[S01E05] A [1080p].mkv", 10, mid=601)
    up.client.messages[602] = document_message("[S01E06] A [1080p].mkv", 10, mid=602)
    await click(up, OWNER, f"dbu:y:{OWNER}:601:{SHOW}", msg_id=71)
    await click(up, OWNER, f"dbu:y:{OWNER}:602:{SHOW}", msg_id=72)
    first, second = await the_notice(up, 1), await the_notice(up, 2)
    assert "Episode: 05" in first[1] and "Episode: 06" in second[1] and ref_of(first[1]) != ref_of(second[1])
    await post_bot_says(up, f"❌ Publication failed: nope\nRef: {ref_of(second[1])}", reply_to=second[0])  # out of order
    await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(first[1])}", reply_to=first[0])
    await up.flow.idle()
    last = {kw["message"]: text for _e, text, kw in up.client.edited}
    assert "Post published successfully" in last[71] and "Publication failed" in last[72]


async def test_a_format_without_a_channel_is_only_uploaded(up):
    await start_upload(up, channel=None)
    await up.flow.idle()
    assert len(up.client.forwarded) == 1 and not notices(up)
    final = last_edit(up)
    assert final.startswith("✅ <b>Uploaded to the Database Channel</b>") and "has no Channel" in final and NAME in final
    assert up.flow.flows == {}


async def test_when_forwarding_is_refused_the_file_is_sent_again_by_its_id(up):
    up.client.forward_errors.append(errors.ChatForwardsRestrictedError(None))
    await start_upload(up, caption="Some caption")
    nid, text, _ = await the_notice(up)
    assert up.client.forwarded == []
    (entity, file, kw), = up.client.files
    assert entity == DB and file is up.client.messages[555].media  # the file itself is not sent again, only its id
    assert kw["caption"] == "Some caption" and kw["formatting_entities"] is None and kw["parse_mode"] is None
    assert "Post: 3001" in text and up.client.downloads == []


async def test_a_failed_upload_can_be_tried_again(up):
    up.client.forward_errors.append(RuntimeError("no rights"))
    up.client.fail_send_file = True
    await start_upload(up)
    await up.flow.idle()
    chat, text, kw = up.client.edited[-1]
    assert "The upload to the Database Channel failed" in text and "RuntimeError: cannot send file" in text and "admin" in text
    assert flat_buttons(kw["buttons"]) == [("🔁 Try again", f"dbu:y:{OWNER}:555:{SHOW}"), ("✖ Close", "x:close")]
    assert not notices(up) and up.flow._busy == set()  # nothing was announced
    up.client.fail_send_file = False
    await click(up, OWNER, f"dbu:y:{OWNER}:555:{SHOW}", msg_id=77)
    await the_notice(up)
    assert len(up.client.forwarded) == 1


async def test_an_episode_that_was_deleted_is_reported(up):
    await click(up, OWNER, f"dbu:y:{OWNER}:999:{SHOW}", msg_id=77)
    await up.flow.idle()
    assert "not there anymore" in last_edit(up) and not up.client.forwarded and not notices(up)


async def test_a_second_tap_while_it_is_working_does_nothing(up):
    await start_upload(up)
    again = await click(up, OWNER, f"dbu:y:{OWNER}:555:{SHOW}", msg_id=77)
    assert again.toasts == ["I'm already on it…"] and again.edits == []
    await the_notice(up)
    await asyncio.sleep(0.05)
    assert len(up.client.forwarded) == 1 and len(notices(up)) == 1


async def test_no_does_not_upload(up):
    cb = await click(up, OWNER, "dbu:n", msg_id=77)
    assert "not uploaded" in cb.edits[-1][0] and cb.edits[-1][1] is None and cb.answers
    assert not up.client.forwarded and not up.client.sent


async def test_a_notice_that_cannot_be_sent_is_reported(up):
    up.client.message_errors[COMM] = RuntimeError("chat not found")
    await start_upload(up)
    await up.flow.idle()
    final = last_edit(up)
    assert "could not write in the communication chat" in final and "chat not found" in final and "Uploaded" in final
    assert len(up.client.forwarded) == 1 and up.flow.flows == {} and up.flow._busy == set()


async def test_an_unexpected_error_is_shown_instead_of_leaving_processing_on_the_screen(up):
    async def broken(*args, **kw):
        raise RuntimeError("boom")

    up.client.get_messages = broken
    await start_upload(up)
    await up.flow.idle()
    assert "Something went wrong" in last_edit(up) and "RuntimeError: boom" in last_edit(up) and up.flow._busy == set()


async def test_a_message_that_was_deleted_meanwhile_does_not_break_the_flow(up):
    async def gone(*args, **kw):
        raise errors.MessageIdInvalidError(None)

    up.client.edit_message = gone
    await start_upload(up)
    nid, text, _ = await the_notice(up)
    await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(text)}", reply_to=nid)
    await up.flow.idle()
    assert up.flow.flows == {} and up.flow._busy == set()


async def test_shutdown_stops_the_waiting(up):
    await start_upload(up)
    await the_notice(up)
    await up.app.shutdown()
    assert up.flow._tasks == set() and up.flow.flows == {} and up.flow._busy == set()


async def test_strangers_cannot_press_the_buttons(up):
    up.client.messages[555] = document_message(NAME, 10, mid=555)
    await click(up, STRANGER, f"dbu:y:{STRANGER}:555:{SHOW}")
    assert not up.client.forwarded and not up.flow._tasks


@pytest.mark.parametrize("data", ["dbu", "dbu:y", "dbu:y:1:2", "dbu:y:a:b:c", "dbu:z", "dbu:y:1:2:3:4"])
async def test_a_broken_button_does_nothing(up, data):
    cb = await click(up, OWNER, data)
    assert cb.answers and not up.client.forwarded and up.flow._busy == set() and not up.flow._tasks