File size: 42,222 Bytes
58f00c9
 
 
 
 
 
 
 
 
 
84e63a1
58f00c9
 
 
c3c6394
58f00c9
 
6c3a31d
d55a0af
 
6c3a31d
58f00c9
1753544
58f00c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b737d7a
 
58f00c9
 
 
 
 
 
 
 
 
b737d7a
58f00c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b737d7a
 
 
 
d55a0af
 
b737d7a
6c3a31d
b737d7a
58f00c9
b737d7a
d55a0af
6c3a31d
 
58f00c9
d55a0af
 
 
 
 
 
 
 
 
 
 
 
58f00c9
 
 
 
 
 
 
d55a0af
b737d7a
 
6c3a31d
b737d7a
6c3a31d
1753544
 
58f00c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b737d7a
 
58f00c9
b737d7a
6c3a31d
 
b737d7a
 
 
 
6c3a31d
b737d7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c3a31d
58f00c9
 
b737d7a
 
 
 
 
 
 
 
 
 
58f00c9
 
 
 
 
 
 
 
 
 
 
5f400f8
58f00c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3c6394
d55a0af
c3c6394
 
 
 
 
 
 
d55a0af
c3c6394
 
 
5f400f8
c3c6394
 
 
 
 
 
 
5f400f8
c3c6394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f400f8
c3c6394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58f00c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a00abaf
 
 
 
 
 
 
 
 
 
 
58f00c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b737d7a
58f00c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a00abaf
58f00c9
 
 
 
 
 
6c3a31d
 
 
58f00c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b737d7a
 
 
58f00c9
b737d7a
58f00c9
b737d7a
 
58f00c9
 
 
 
 
 
 
 
 
 
 
 
b737d7a
58f00c9
 
 
b737d7a
 
58f00c9
 
 
b737d7a
 
58f00c9
 
b737d7a
58f00c9
b737d7a
58f00c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84e63a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58f00c9
 
 
 
 
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
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
"""Handler-level tests: real Telethon event builders, fake events and client."""
import asyncio
import shutil
import time
from pathlib import Path

import pytest
from telethon import functions

from bot import app as appmod
from bot import errorlog, panels
from bot.app import COMMANDS, OWNER_COMMANDS, PANEL_NAMES, App, Pending, cmd_pattern, is_renameable
from bot.config import load_config
from bot.db import Database
from bot.queue import JobQueue

from .conftest import needs_ffmpeg
from .appkit import (
    ADMIN_CFG, ADMIN_DB, EPISODE, EPISODE_NEW, LOG, OWNER, STRANGER, auto_thumb, autofmt, click, data_of, edit_auto, env_for, jpeg,
    prefs, say, send_doc, set_auto_thumb,
)
from .tgfakes import (
    FakeCallback, FakeClient, FakeEvent, FakeMessage, dispatch, document_message, flat_buttons, make_file, variables_line,
)

# ---------------------------------------------------------------------------
# access control & routing
# ---------------------------------------------------------------------------


async def test_strangers_are_ignored_and_reported_once(ctx):
    for text in ("/start", "hello", "/rename x", "/cancel"):
        assert (await say(ctx, STRANGER, text)).out == []
    assert (await send_doc(ctx, STRANGER)).out == []
    cb = await click(ctx, STRANGER, "m:home")
    assert cb.edits == [] and not ctx.app.queue.jobs
    reports = [t for chat, t, _kw in ctx.client.sent if chat == LOG]
    assert len(reports) == 1 and str(STRANGER) in reports[0] and f"/addadmin {STRANGER}" in reports[0]


async def test_a_plain_message_from_a_stranger_is_also_reported(ctx):
    await say(ctx, STRANGER, "hi there")
    assert len([1 for chat, _t, _k in ctx.client.sent if chat == LOG]) == 1


async def test_owner_and_admins_from_config_and_db_are_allowed(ctx):
    await ctx.db.add_admin(ADMIN_DB, OWNER)
    ctx.app.admins |= set(await ctx.db.list_admins())
    for uid in (OWNER, ADMIN_CFG, ADMIN_DB):
        assert (await say(ctx, uid, "/help")).out, uid
    assert ctx.client.sent == []  # nobody was reported


async def test_group_chats_are_ignored(ctx):
    assert (await say(ctx, OWNER, "/start", private=False)).out == []
    assert (await say(ctx, STRANGER, "/start", private=False)).out == []
    assert ctx.client.sent == []


async def test_owner_only_commands_are_refused_for_admins(ctx):
    for text in ("/addadmin 123456", "/deladmin 123456", "/admins"):
        ev = await say(ctx, ADMIN_CFG, text)
        assert ev.texts == ["⛔ Only the owner can do that."], text
    assert 123456 not in ctx.app.admins
    assert (await click(ctx, ADMIN_CFG, "m:admin")).alerts == ["Owner only."]
    assert (await click(ctx, ADMIN_CFG, "adm:stats")).alerts == ["Owner only."]
    assert (await click(ctx, ADMIN_CFG, "adm:clear")).alerts == ["Owner only."]
    assert ctx.client.sent == []


def test_cmd_pattern_accepts_args_bot_suffix_and_any_case():
    p = cmd_pattern("rename")
    assert p.match("/rename New Name").group(1) == "New Name"
    assert p.match("/rename@MyBot New Name").group(1) == "New Name"
    assert p.match("/RENAME").group(1) is None
    assert p.match("/rename\nline one\nline two").group(1) == "line one\nline two"
    assert not p.match("/renamex") and not p.match("rename") and not p.match("/re")


def test_every_menu_command_has_a_handler_and_telegram_compatible_text(ctx):
    table = ctx.app._command_table()
    for name, description in COMMANDS:
        assert name in table
        assert name.islower() and 1 <= len(name) <= 32
        assert 3 <= len(description) <= 256
    assert OWNER_COMMANDS <= set(table)
    assert {"settitle", "setauthor", "setartist", "setaudio", "setsubtitle", "setvideo", "removewords", "setchannel"} <= set(table)
    assert dict(COMMANDS)["status"] == "Show basic bot uptime, ping, and version information."
    assert dict(COMMANDS)["stats"] == "Show detailed system stats such as users, CPU, RAM, and disk usage."


async def test_setup_registers_command_menu_loads_admins_and_survives_log_channel_errors(tmp_path):
    cfg = load_config(env_for(tmp_path))
    db = Database(f"sqlite+aiosqlite:///{tmp_path}/setup.db")
    await db.init()
    await db.add_admin(ADMIN_DB, OWNER)
    client = FakeClient()
    client.bad_entities.add(LOG)
    app = App(client, cfg, db)
    await app.setup()
    try:
        assert client.parse_mode == "html"
        assert ADMIN_DB in app.admins and ADMIN_CFG in app.admins
        (request,) = [r for r in client.requests if isinstance(r, functions.bots.SetBotCommandsRequest)]
        assert [c.command for c in request.commands] == [c for c, _ in COMMANDS]
        assert len(client.handlers) > 10
    finally:
        await app.shutdown()
        await db.close()


# ---------------------------------------------------------------------------
# /start, /help and the one-line settings commands
# ---------------------------------------------------------------------------


async def test_start_shows_the_home_panel_and_hides_admin_from_admins(ctx):
    ev = await say(ctx, OWNER, "/start")
    text, buttons, kw = ev.out[0]
    assert "Auto Rename Bot" in text and kw["parse_mode"] == "html"
    assert {"m:auto", "m:rename", "m:meta", "m:ftype", "m:formats", "m:help", "m:admin"} <= set(data_of(buttons))
    assert "m:cap" not in data_of(buttons)  # the caption and the thumbnail are edited in /formats
    assert "m:admin" not in data_of((await say(ctx, ADMIN_CFG, "/start")).out[0][1])


@pytest.mark.parametrize("text", ["/start payload", "/start@SomeBot", "/START"])
async def test_start_variants(ctx, text):
    assert (await say(ctx, OWNER, text)).out


async def test_start_and_help(ctx):
    ctx.app.pending[OWNER] = Pending("remove", OWNER, 1, "rename", False, time.time() + 100)
    await say(ctx, OWNER, "/start")
    assert OWNER not in ctx.app.pending
    ev = await say(ctx, OWNER, "/help")
    assert "How to use" in ev.texts[0] and "x:close" in data_of(ev.out[0][1])


async def test_mode_command(ctx):
    ev = await say(ctx, OWNER, "/mode caption")
    assert (await prefs(ctx))["mode"] == "caption" and "CAPTION" in ev.texts[0]
    for text in ("/mode", "/mode nonsense"):
        ev = await say(ctx, OWNER, text)
        assert "RENAME MODE" in ev.texts[0] and "set:mode:filename" in data_of(ev.out[0][1])
    assert (await prefs(ctx))["mode"] == "caption"
    await say(ctx, OWNER, "/mode FILENAME")
    assert (await prefs(ctx))["mode"] == "filename"


async def test_filetype_command_and_aliases(ctx):
    for arg, expected in (("video", "video"), ("audio", "audio"), ("document", "document"), ("original", "same"),
                          ("doc", "document"), ("file", "document"), ("same", "same")):
        await say(ctx, OWNER, f"/filetype {arg}")
        assert (await prefs(ctx))["file_type"] == expected, arg
    ev = await say(ctx, OWNER, "/filetype")
    assert "FILE TYPE" in ev.texts[0]
    ev = await say(ctx, OWNER, "/filetype bogus")
    assert "FILE TYPE" in ev.texts[0] and (await prefs(ctx))["file_type"] == "same"


async def test_metadata_command(ctx):
    await say(ctx, OWNER, "/metadata on")
    assert (await prefs(ctx))["metadata_on"] is True
    await say(ctx, OWNER, "/metadata off")
    assert (await prefs(ctx))["metadata_on"] is False
    ev = await say(ctx, OWNER, "/metadata")
    assert "currently: Off" in ev.texts[0]


async def test_the_template_and_caption_of_the_auto_format_are_edited_in_its_formats_screen(ctx):
    auto = await ctx.db.create_format(OWNER, "Mine", kind="auto")
    fid = auto["id"]
    await click(ctx, OWNER, f"fm:in:{fid}:template", msg_id=500)
    await say(ctx, OWNER, "[S{season}E{episode}] {title} [{quality}]", mid=60)
    assert "Template saved" in ctx.client.edited[-1][1]
    assert (await autofmt(ctx))["template"] == "[S{season}E{episode}] {title} [{quality}]"
    assert "template" not in await prefs(ctx) and "caption" not in await prefs(ctx)
    assert "[S{season}E{episode}] {title} [{quality}]" in (await click(ctx, OWNER, "m:rename")).edits[-1][0]  # shown there, not edited

    await click(ctx, OWNER, f"fm:in:{fid}:caption", msg_id=501)
    await say(ctx, OWNER, "<b>{filename}</b> {size}", mid=61)
    assert (await autofmt(ctx))["caption"] == "<b>{filename}</b> {size}"
    assert await ctx.db.count_formats(OWNER) == 1  # both went into the same format


@pytest.mark.parametrize("command", ["template", "caption", "thumb", "delthumb"])
async def test_the_template_caption_and_thumbnail_commands_are_gone(ctx, command):
    assert command not in [name for name, _description in COMMANDS] and command not in ctx.app._command_table()
    await set_auto_thumb(ctx, b"\xff\xd8thumb")
    before = await autofmt(ctx)
    ev = await say(ctx, OWNER, f"/{command} something {{title}}")
    assert ev.out == []  # not a command any more: nothing answers it
    assert await autofmt(ctx) == before and await auto_thumb(ctx) == b"\xff\xd8thumb"


async def test_removewords_command(ctx):
    await say(ctx, OWNER, "/removewords @Chan, [Group]")
    assert (await prefs(ctx))["remove_words"] == ["@Chan", "[Group]"]
    assert "@Chan, [Group]" in (await say(ctx, OWNER, "/removewords")).texts[0]
    await say(ctx, OWNER, "/removewords off")
    assert (await prefs(ctx))["remove_words"] == []


async def test_template_and_caption_prompts_list_each_variable_as_its_own_copyable_block(ctx):
    fid = (await ctx.db.create_format(OWNER, "Mine", kind="auto"))["id"]
    line, blocks = variables_line((await click(ctx, OWNER, f"fm:in:{fid}:template")).edits[-1][0])
    assert blocks == line.split(": ", 1)[1].split() and "{quality}" in blocks and "{3episode}" in blocks and "{4episode}" not in blocks and len(blocks) == 11
    line, blocks = variables_line((await click(ctx, OWNER, f"fm:in:{fid}:caption")).edits[-1][0])
    assert blocks == line.split(": ", 1)[1].split() and "{size}" in blocks and "{3episode}" in blocks and "{4episode}" not in blocks and len(blocks) == 13


async def test_settings_are_per_user(ctx):
    await say(ctx, OWNER, "/mode caption")
    await say(ctx, ADMIN_CFG, "/filetype audio")
    assert (await prefs(ctx, OWNER))["mode"] == "caption" and (await prefs(ctx, OWNER))["file_type"] == "same"
    assert (await prefs(ctx, ADMIN_CFG))["mode"] == "filename" and (await prefs(ctx, ADMIN_CFG))["file_type"] == "audio"


@pytest.mark.parametrize("command,field", [
    ("settitle", "title"), ("setauthor", "author"), ("setartist", "artist"),
    ("setaudio", "audio"), ("setsubtitle", "subtitle"), ("setvideo", "video"),
])
async def test_metadata_field_commands(ctx, command, field):
    ev = await say(ctx, OWNER, f"/{command} {{lang}} Value")
    assert (await prefs(ctx))["meta"][field] == "{lang} Value" and "✅" in ev.texts[0]
    assert "{lang} Value" in (await say(ctx, OWNER, f"/{command}")).texts[0]
    await say(ctx, OWNER, f"/{command} off")
    assert (await prefs(ctx))["meta"][field] == ""


async def test_keepaudio_by_language_and_by_number(ctx):
    ev = await say(ctx, OWNER, "/keepaudio English Japanese")
    s = await prefs(ctx)
    assert s["keep_audio"] == ["english", "japanese"] and s["metadata_on"] is True and "turned it On" in ev.texts[0]
    await say(ctx, OWNER, "/keepaudio 2 1")
    assert (await prefs(ctx))["keep_audio"] == ["2", "1"]
    assert "2, 1" in (await say(ctx, OWNER, "/keepaudio")).texts[0]
    await say(ctx, OWNER, "/keepaudio off")
    assert (await prefs(ctx))["keep_audio"] == []
    ev = await say(ctx, OWNER, "/keepaudio 0")
    assert ev.texts[0].startswith("❌")


async def test_keepsubtitle_is_independent_of_keepaudio(ctx):
    await say(ctx, OWNER, "/keepaudio 2 1")
    await say(ctx, OWNER, "/keepsubtitle english")
    s = await prefs(ctx)
    assert s["keep_audio"] == ["2", "1"] and s["keep_subtitle"] == ["english"]
    await say(ctx, OWNER, "/keepsubtitle off")
    s = await prefs(ctx)
    assert s["keep_audio"] == ["2", "1"] and s["keep_subtitle"] == []


async def test_setchannel(ctx):
    assert "not set" in (await say(ctx, OWNER, "/setchannel")).texts[0]
    ev = await say(ctx, OWNER, "/setchannel -1002223334445")
    assert (await prefs(ctx))["target_channel"] == -1002223334445 and "✅" in ev.texts[0]
    ev = await say(ctx, OWNER, "/setchannel @name")
    assert ev.texts[0].startswith("❌") and (await prefs(ctx))["target_channel"] == -1002223334445
    ctx.client.bad_entities.add(-1005556667778)
    ev = await say(ctx, OWNER, "/setchannel -1005556667778")
    assert "can't access" in ev.texts[0] and (await prefs(ctx))["target_channel"] == -1002223334445
    await say(ctx, OWNER, "/setchannel off")
    assert (await prefs(ctx))["target_channel"] is None


# ---------------------------------------------------------------------------
# thumbnails
# ---------------------------------------------------------------------------


async def send_photo(ctx, mid=20, sender=OWNER, data=None, **kw):
    ev = FakeEvent(sender, message=FakeMessage("", mid=mid, photo=True, **kw), photo_bytes=jpeg() if data is None else data)
    await dispatch(ctx.client, ev)
    return ev


async def test_a_photo_sent_or_forwarded_to_the_bot_is_not_made_a_thumbnail(ctx):
    ev = await send_photo(ctx)
    assert ev.out == [] and ctx.client.files == [] and ctx.client.edited == []  # no answer, nothing saved
    assert await ctx.db.count_formats(OWNER) == 0  # not even a format was made for it

    auto = await ctx.db.create_format(OWNER, "Auto", kind="auto")
    manual = await ctx.db.create_format(OWNER, "Manual", kind="manual")
    assert (await send_photo(ctx, 21)).out == []
    assert (await send_photo(ctx, 22, fwd=True)).out == []  # a forwarded one is no different
    assert await ctx.db.get_format_thumb(OWNER, auto["id"]) is None and await ctx.db.get_format_thumb(OWNER, manual["id"]) is None
    assert not (await autofmt(ctx))["has_thumb"] and ctx.client.files == []


async def test_only_the_thumbnail_prompt_of_a_format_takes_a_photo(ctx):
    auto = await ctx.db.create_format(OWNER, "Auto", kind="auto")
    for kind in ("remove", "mt_title", "keep_audio"):  # no other prompt takes it
        ctx.app.pending[OWNER] = Pending(kind, OWNER, 1, "rename", False, time.time() + 100)
        assert (await send_photo(ctx)).out == [] and ctx.app.pending[OWNER].kind == kind
    ctx.app.pending[OWNER] = Pending("fmt:template", OWNER, 1, "formats", False, time.time() + 100, fid=auto["id"])
    assert (await send_photo(ctx)).out == [] and ctx.app.pending[OWNER].kind == "fmt:template"
    ctx.app.pending[OWNER] = Pending("fmt:thumb", OWNER, 1, "formats", False, time.time() - 1, fid=auto["id"])  # one that ran out
    assert (await send_photo(ctx)).out == []
    assert await ctx.db.get_format_thumb(OWNER, auto["id"]) is None

    ctx.app.pending[OWNER] = Pending("fmt:thumb", OWNER, 500, "formats", False, time.time() + 100, fid=auto["id"])
    await send_photo(ctx, 30)  # the one that is waiting for it does
    assert (await ctx.db.get_format_thumb(OWNER, auto["id"]))[:2] == b"\xff\xd8" and OWNER not in ctx.app.pending


async def test_unreadable_photos_are_reported_while_a_format_waits_for_its_thumbnail(ctx):
    auto = await ctx.db.create_format(OWNER, "Auto", kind="auto")
    for data, said in ((b"not an image", "couldn't read"), (None, "couldn't download")):
        ctx.app.pending[OWNER] = Pending("fmt:thumb", OWNER, 500, "formats", False, time.time() + 100, fid=auto["id"])
        ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=data)
        await dispatch(ctx.client, ev)
        assert said in ev.texts[0] and OWNER in ctx.app.pending  # and the prompt stays open for another try
    assert await ctx.db.get_format_thumb(OWNER, auto["id"]) is None
    stranger = await send_photo(ctx, sender=STRANGER)
    assert stranger.out == [] and await autofmt(ctx, STRANGER) is None


async def test_the_buttons_of_the_old_caption_template_and_thumbnail_screens_do_nothing(ctx):
    auto = await set_auto_thumb(ctx, b"\xff\xd8keep")
    await edit_auto(ctx, template="{title}", caption="<i>{filename}</i>")
    before = await autofmt(ctx)
    old = ["m:cap", "in:template", "in:caption", "clr:caption"] + [f"th:{a}{b}" for a in ("view", "del", "rm") for b in ("", f":{auto['id']}")]
    for data in old:
        cb = await click(ctx, OWNER, data)
        assert cb.edits == [] and cb.alerts == [] and not cb.deleted, data
    assert not ctx.app.pending and ctx.client.files == []
    assert await autofmt(ctx) == before and await auto_thumb(ctx) == b"\xff\xd8keep"  # nothing was changed


# ---------------------------------------------------------------------------
# renaming
# ---------------------------------------------------------------------------


async def test_a_file_is_queued_with_the_template_name_and_a_cancel_button(ctx):
    ev = await send_doc(ctx, OWNER, mid=10)
    (job,) = ctx.app.queue.jobs.values()
    assert job.new_name == EPISODE_NEW and (job.user_id, job.chat_id, job.msg_id) == (OWNER, OWNER, 10)
    assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and job.fmt is None and job.state == "queued"
    text, buttons, kw = ev.out[0]
    assert "Queued" in text and "starting soon" in text and EPISODE_NEW in text
    assert flat_buttons(buttons) == [("✖ Cancel", f"job:x:{job.id}")]
    assert job.status_msg is not None

    ev = await send_doc(ctx, OWNER, "Another.File.mp4", mid=11)
    assert "1 ahead of it" in ev.texts[0]


async def test_caption_mode_reads_the_caption(ctx):
    await say(ctx, OWNER, "/mode caption")
    await send_doc(ctx, OWNER, "xyz123.mkv", caption="Tokyo Revengers S02E07 720p", mid=12)
    (job,) = ctx.app.queue.jobs.values()
    assert job.new_name.endswith(".mkv") and "Tokyo Revengers" in job.new_name and "S02E07" in job.new_name


async def test_remove_words_are_applied_to_the_new_name(ctx):
    await say(ctx, OWNER, "/removewords @SomeChannel")
    await send_doc(ctx, OWNER, "[@SomeChannel] Tokyo Revengers - 05 (1080p).mkv")
    (job,) = ctx.app.queue.jobs.values()
    assert "SomeChannel" not in job.new_name and "Tokyo Revengers" in job.new_name


async def test_the_one_piece_style_name_gets_its_episode_number(ctx):
    await edit_auto(ctx, template="One Piece - E{episode}")
    ev = await send_doc(ctx, OWNER, "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv", mid=21)
    (job,) = ctx.app.queue.jobs.values()
    assert job.new_name == "One Piece - E1147.mkv" and job.notes == []
    assert "One Piece - E1147.mkv" in ev.texts[0] and "⚠️" not in ev.texts[0]


async def test_a_template_variable_that_cannot_be_filled_is_flagged(ctx):
    await edit_auto(ctx, template="One Piece - E{episode}")
    ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
    (job,) = ctx.app.queue.jobs.values()
    assert job.new_name == "One Piece - E.mkv"
    assert "⚠️" in ev.texts[0] and "{episode}" in ev.texts[0] and "file name" in ev.texts[0] and "change the template" in ev.texts[0]
    assert job.notes and "{episode}" in job.notes[0]

    await say(ctx, OWNER, "/mode caption")
    ev = await send_doc(ctx, OWNER, "Plain Movie.mkv", mid=22)
    assert "in the caption" in ev.texts[0]


async def test_the_default_template_never_warns(ctx):
    ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
    assert "⚠️" not in ev.texts[0]  # bracket groups vanish on purpose


class SlowEvent(FakeEvent):
    """A message whose reply takes a while, like a slow round trip to Telegram."""

    def __init__(self, *a, delay=0.0, **kw):
        super().__init__(*a, **kw)
        self.delay = delay

    async def reply(self, text=None, **kw):
        await asyncio.sleep(self.delay)
        return await super().respond(text, **kw)


async def test_jobs_are_ordered_by_the_message_that_triggered_them(ctx):
    await send_doc(ctx, OWNER, "B.mkv", mid=31)
    await send_doc(ctx, OWNER, "A.mkv", mid=30)
    await send_doc(ctx, OWNER, "old.mkv", 1024, mid=34)
    assert sorted(j.order for j in ctx.app.queue.jobs.values()) == [30, 31, 34]
    assert [j.file_name for j in ctx.app.queue.for_user(OWNER)] == ["A.mkv", "B.mkv", "old.mkv"]


async def test_a_forwarded_batch_comes_out_in_the_order_it_was_sent(ctx):
    """Telethon handles messages that arrive together concurrently; the results must still be in order."""
    ran = []

    async def runner(job):
        ran.append(job.file_name)

    ctx.app.queue = JobQueue(runner, workers=1, settle=0.3)
    ctx.app.queue.start()
    await asyncio.sleep(0.02)  # the worker is idle, waiting for files
    names = {
        1145: "1145 - One Piece [Dual] 720p @Anime_X_Universe.mkv",
        1146: "1146 - One Piece [Dual] 720p @Anime_X_Universe.mkv",
        1147: "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv",
    }
    delays = {1145: 0.10, 1146: 0.00, 1147: 0.05}  # the replies finish in a different order than the files arrived
    events = [
        SlowEvent(OWNER, message=document_message(name, 1024, mid=mid), delay=delays[mid]) for mid, name in names.items()
    ]
    await asyncio.gather(*(dispatch(ctx.client, ev) for ev in reversed(events)))
    await asyncio.sleep(0.7)
    assert ran == [names[1145], names[1146], names[1147]]


async def test_oversized_files_are_refused(ctx):
    ev = await send_doc(ctx, OWNER, size=101 * 1024 * 1024)
    assert "limit is 100.00 MB" in ev.texts[0] and not ctx.app.queue.jobs
    ev = await send_doc(ctx, OWNER, size=100 * 1024 * 1024)  # exactly at the limit is fine
    assert "Queued" in ev.texts[0]


async def test_stickers_voice_notes_and_gifs_are_not_renamed(ctx):
    for flags in ({"sticker": True}, {"voice": True}, {"gif": True}, {"video_note": True}):
        ev = FakeEvent(OWNER, message=FakeMessage("", file=make_file("x.webm"), **flags))
        await dispatch(ctx.client, ev)
        assert ev.out == [], flags
    assert not ctx.app.queue.jobs
    assert is_renameable(document_message()) and not is_renameable(FakeMessage("text"))


async def test_a_file_without_a_name_gets_a_generated_one(ctx):
    ev = FakeEvent(OWNER, message=FakeMessage("", mid=77, file=make_file(None, 1024, "video/mp4", ext=".mp4"), video=True))
    await dispatch(ctx.client, ev)
    (job,) = ctx.app.queue.jobs.values()
    assert job.file_name == "video_77.mp4" and job.new_name.endswith(".mp4")


# ---------------------------------------------------------------------------
# queue and cancel
# ---------------------------------------------------------------------------


async def test_queue_listing_is_scoped_to_the_user_unless_owner(ctx):
    assert "Nothing in the queue" in (await say(ctx, OWNER, "/queue")).texts[0]
    await send_doc(ctx, OWNER, "Owner.File.mkv")
    await send_doc(ctx, ADMIN_CFG, "Admin.File.mkv")
    admin_view = (await say(ctx, ADMIN_CFG, "/queue")).texts[0]
    assert "Admin File" in admin_view and "Owner File" not in admin_view
    owner_view = (await say(ctx, OWNER, "/queue")).texts[0]
    assert "Admin File" in owner_view and "Owner File" in owner_view and str(ADMIN_CFG) in owner_view


async def test_cancel_commands_respect_ownership(ctx):
    await send_doc(ctx, OWNER, "Owner.File.mkv")      # job 1
    await send_doc(ctx, ADMIN_CFG, "Admin.File.mkv")  # job 2
    await send_doc(ctx, ADMIN_CFG, "Admin.Two.mkv")   # job 3

    assert "couldn't find" in (await say(ctx, ADMIN_CFG, "/cancel 1")).texts[0]  # not theirs
    assert ctx.app.queue.jobs[1].state == "queued"
    assert "Cancelling 2 job(s)" in (await say(ctx, ADMIN_CFG, "/cancel all")).texts[0]  # only their own
    assert set(ctx.app.queue.jobs) == {1}
    assert "no active jobs" in (await say(ctx, ADMIN_CFG, "/cancel")).texts[0]

    await send_doc(ctx, ADMIN_CFG, "Admin.Three.mkv")  # job 4
    assert "Cancelling 2 job(s)" in (await say(ctx, OWNER, "/cancel all")).texts[0]  # owner: jobs 1 and 4
    assert not ctx.app.queue.jobs


async def test_cancelled_queued_jobs_update_their_status_message(ctx):
    await send_doc(ctx, OWNER)
    await say(ctx, OWNER, "/cancel 1")
    for _ in range(5):
        await asyncio.sleep(0)
    (entity, text, kw), = ctx.client.edited
    assert "Cancelled" in text and kw["buttons"] is None


async def test_cancel_button_on_a_queued_file(ctx):
    await send_doc(ctx, OWNER)
    await send_doc(ctx, OWNER, "Second.mkv")
    cb = await click(ctx, ADMIN_CFG, "job:x:1")  # somebody else's job
    assert cb.toasts == ["That job already finished."] and 1 in ctx.app.queue.jobs
    cb = await click(ctx, OWNER, "job:x:1")
    assert cb.toasts == ["Cancelling…"] and 1 not in ctx.app.queue.jobs
    assert (await click(ctx, OWNER, "job:x:1")).toasts == ["That job already finished."]
    admin_doc = await send_doc(ctx, ADMIN_CFG, "Admin.mkv")
    assert admin_doc.out
    assert (await click(ctx, OWNER, "job:x:3")).toasts == ["Cancelling…"]  # the owner may cancel anything


async def test_cancel_clears_a_pending_prompt_first(ctx):
    ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
    await send_doc(ctx, OWNER)
    ev = await say(ctx, OWNER, "/cancel")
    assert "nothing was changed" in ev.texts[0] and OWNER not in ctx.app.pending
    assert ctx.app.queue.jobs  # the job is untouched
    assert "Cancelling 1 job(s)" in (await say(ctx, OWNER, "/cancel")).texts[0]


# ---------------------------------------------------------------------------
# admin management, status and stats
# ---------------------------------------------------------------------------


async def test_owner_manages_admins(ctx):
    ev = await say(ctx, OWNER, "/addadmin 555555")
    assert "can now use the bot" in ev.texts[0] and 555555 in ctx.app.admins
    assert 555555 in await ctx.db.list_admins()
    assert (await say(ctx, 555555, "/help")).out  # works immediately
    assert "already have access" in (await say(ctx, OWNER, "/addadmin 555555")).texts[0]
    assert "already have access" in (await say(ctx, OWNER, f"/addadmin {OWNER}")).texts[0]
    assert "already have access" in (await say(ctx, OWNER, f"/addadmin {ADMIN_CFG}")).texts[0]
    for bad in ("/addadmin", "/addadmin abc", "/addadmin 12", "/addadmin @user"):
        assert "Usage" in (await say(ctx, OWNER, bad)).texts[0], bad

    listing = (await say(ctx, OWNER, "/admins")).texts[0]
    assert str(OWNER) in listing and "555555" in listing and str(ADMIN_CFG) in listing

    assert "was removed" in (await say(ctx, OWNER, "/deladmin 555555")).texts[0]
    assert 555555 not in ctx.app.admins and 555555 not in await ctx.db.list_admins()
    assert (await say(ctx, 555555, "/help")).out == []  # access gone
    assert "wasn't in the list" in (await say(ctx, OWNER, "/deladmin 555555")).texts[0]
    assert "ADMIN_IDS" in (await say(ctx, OWNER, f"/deladmin {ADMIN_CFG}")).texts[0]
    assert ADMIN_CFG in ctx.app.admins
    assert "Usage" in (await say(ctx, OWNER, "/deladmin")).texts[0]


async def test_status_pings_telegram_and_reports_version_info(ctx):
    ev = await say(ctx, OWNER, "/status")
    text = ev.texts[0]
    assert "Bot status" in text and "Uptime" in text and "Ping" in text and "Version" in text and "Telethon" in text
    assert any(isinstance(r, functions.PingRequest) for r in ctx.client.requests)
    assert (await say(ctx, ADMIN_CFG, "/status")).out  # admins may use it too


async def test_status_shows_what_makes_transfers_fast_and_how_they_went(ctx, monkeypatch):
    from bot import fasttransfer

    monkeypatch.setattr(fasttransfer, "stats", fasttransfer.TransferStats(fast_down=4, fast_up=3, small_up=1))
    text = (await say(ctx, OWNER, "/status")).texts[0]
    assert "<b>Speed</b>" in text and "Encryption: cryptg ✅" in text and "Parallel transfer:" in text
    assert "<b>Downloads:</b> 4 parallel · 0 standard" in text and "<b>Uploads:</b> 3 parallel · 1 up to 10 MB · 0 standard" in text
    fasttransfer.stats.fell_back("upload", RuntimeError("boom"))
    assert "failed 1×" in (await say(ctx, OWNER, "/status")).texts[0]


async def test_status_survives_a_failed_ping(ctx):
    async def broken(request):
        raise ConnectionError("offline")

    ctx.client.__class__ = type("Broken", (FakeClient,), {"__call__": lambda self, request: broken(request)})
    assert "n/a" in (await say(ctx, OWNER, "/status")).texts[0]


async def test_stats_reports_users_files_and_system_usage(ctx):
    await say(ctx, ADMIN_CFG, "/help")
    await ctx.db.record_job(OWNER, 3 * 1024 ** 3)
    text = (await say(ctx, OWNER, "/stats")).texts[0]
    for expected in ("System stats", "Users", "Files renamed", "3.00 GB", "Queue", "CPU", "RAM", "Disk"):
        assert expected in text, expected
    assert (await say(ctx, ADMIN_CFG, "/stats")).out
    assert (await say(ctx, STRANGER, "/stats")).out == []


# ---------------------------------------------------------------------------
# /tracks
# ---------------------------------------------------------------------------


async def test_tracks_needs_a_reply_to_a_file(ctx):
    for reply in (None, FakeMessage("text", mid=3)):
        assert "Reply to a video or audio file" in (await say(ctx, OWNER, "/tracks", reply=reply)).texts[0]


@needs_ffmpeg
async def test_tracks_lists_numbered_audio_and_subtitles_and_cleans_up(ctx, media_dir, monkeypatch):
    calls = []

    async def fake_download(client, message, dest, **kw):
        calls.append(kw)
        shutil.copy(media_dir / "sample.mkv", dest)

    monkeypatch.setattr(appmod.transfer, "download", fake_download)
    reply = document_message("Show.mkv", (media_dir / "sample.mkv").stat().st_size, mid=33)
    ev = await say(ctx, OWNER, "/tracks", reply=reply, mid=34)
    assert ev.texts == ["🔎 Reading the tracks…"]
    (status, text, kw), = ctx.client.edited
    assert status.id == 1001 and "Show.mkv" in text
    assert "Audio" in text and "Subtitles" in text and "Japanese" in text and "English" in text and "use these numbers" in text
    assert len(calls) == 1 and calls[0]["max_bytes"] == appmod.TRACKS_PROBE_BYTES
    assert not list(Path(ctx.cfg.temp_dir).glob("tracks*"))


@needs_ffmpeg
async def test_tracks_downloads_the_whole_file_when_the_start_is_not_enough(ctx, media_dir, monkeypatch):
    calls = []

    async def fake_download(client, message, dest, **kw):
        calls.append(kw)
        if "max_bytes" in kw:
            Path(dest).write_bytes(b"\x00" * 4096)  # e.g. an mp4 whose index is at the end
        else:
            shutil.copy(media_dir / "sample.mp4", dest)

    monkeypatch.setattr(appmod.transfer, "download", fake_download)
    reply = document_message("Show.mp4", 40 * 1024 * 1024, mid=33)
    await say(ctx, OWNER, "/tracks", reply=reply, mid=34)
    assert len(calls) == 2 and "max_bytes" not in calls[1]
    assert "Audio" in ctx.client.edited[-1][1] and "needs the whole file" in ctx.client.edited[0][1]


@needs_ffmpeg
async def test_tracks_gives_up_gracefully(ctx, monkeypatch):
    async def junk_download(client, message, dest, **kw):
        Path(dest).write_bytes(b"\x00" * 4096)

    monkeypatch.setattr(appmod.transfer, "download", junk_download)
    for size in (2 * 1024 * 1024, 900 * 1024 * 1024):  # small file / too big for a full download
        ctx.client.edited.clear()
        reply = document_message("Bad.mkv", size, mid=33)
        await say(ctx, OWNER, "/tracks", reply=reply, mid=34)
        assert "couldn't read any tracks" in ctx.client.edited[-1][1], size
    assert not list(Path(ctx.cfg.temp_dir).glob("tracks*"))


# ---------------------------------------------------------------------------
# menus (callbacks)
# ---------------------------------------------------------------------------


async def test_menu_navigation_edits_the_message_in_place(ctx):
    cb = await click(ctx, OWNER, "m:meta")
    text, buttons = cb.edits[-1]
    assert "metadata is currently" in text and "m:tool" in data_of(buttons)
    assert cb.answers == [(None, False)]
    cb = await click(ctx, OWNER, "m:tool")
    assert "TOOL" in cb.edits[-1][0] and "m:meta" in data_of(cb.edits[-1][1])
    cb = await click(ctx, OWNER, "m:home")
    assert "Auto Rename Bot" in cb.edits[-1][0]
    cb = await click(ctx, OWNER, "m:admin")
    assert "ADMIN" in cb.edits[-1][0]
    cb = await click(ctx, OWNER, "m:bogus")
    assert cb.edits == [] and cb.answers == [(None, False)]


async def test_toggles_update_settings_and_refresh_the_panel(ctx):
    cb = await click(ctx, OWNER, "set:mode:caption")
    assert (await prefs(ctx))["mode"] == "caption" and cb.toasts == ["Mode: Caption"]
    assert "Caption ✅" in [label for label, _d in flat_buttons(cb.edits[-1][1])]
    cb = await click(ctx, OWNER, "set:ftype:video")
    assert (await prefs(ctx))["file_type"] == "video" and cb.toasts == ["File type: Video"]
    cb = await click(ctx, OWNER, "set:ftype:same")
    assert cb.toasts == ["File type: Same as original"]
    cb = await click(ctx, OWNER, "set:meta:on")
    assert (await prefs(ctx))["metadata_on"] is True and cb.toasts == ["Metadata ON"]
    await click(ctx, OWNER, "set:meta:off")
    assert (await prefs(ctx))["metadata_on"] is False

    before = await prefs(ctx)
    for junk in ("set:mode:bogus", "set:nothing:x", "set:ftype:zip", "set:meta:maybe", "set:mode"):
        cb = await click(ctx, OWNER, junk)
        assert cb.edits == [] and cb.answers == [(None, False)], junk
    assert await prefs(ctx) == before


async def test_clear_buttons(ctx):
    await say(ctx, OWNER, "/keepaudio 2 1")
    await say(ctx, OWNER, "/keepsubtitle 1")
    cb = await click(ctx, OWNER, "clr:keep_audio")
    s = await prefs(ctx)
    assert s["keep_audio"] == [] and s["keep_subtitle"] == ["1"] and "TOOL" in cb.edits[-1][0]
    await click(ctx, OWNER, "clr:keep_subtitle")
    assert (await prefs(ctx))["keep_subtitle"] == []
    cb = await click(ctx, OWNER, "clr:whatever")
    assert cb.edits == []


async def test_close_deletes_the_message_and_falls_back_to_editing(ctx):
    ctx.app.pending[OWNER] = Pending("remove", OWNER, 1, "rename", False, time.time() + 100)
    cb = await click(ctx, OWNER, "x:close")
    assert cb.deleted and OWNER not in ctx.app.pending
    old = FakeCallback(OWNER, "x:close")
    old.delete_error = RuntimeError("MESSAGE_DELETE_FORBIDDEN")
    await dispatch(ctx.client, old)
    assert old.edits == [("Closed.", None)] and not old.deleted


async def test_metadata_help_and_unknown_callbacks(ctx):
    cb = await click(ctx, OWNER, "help:meta")
    assert "How to set metadata" in cb.edits[-1][0]
    for junk in ("", "zzz", "adm", "th:what", "job:x:abc", "in:nope"):
        cb = await click(ctx, OWNER, junk)
        assert cb.answers and not cb.alerts, junk


async def test_admin_panel_buttons(ctx):
    await send_doc(ctx, OWNER)
    await send_doc(ctx, OWNER, "Two.mkv")
    cb = await click(ctx, OWNER, "adm:stats", chat=OWNER)
    (chat, text, kw), = ctx.client.sent
    assert chat == OWNER and "System stats" in text and "2 waiting" in text
    await click(ctx, OWNER, "adm:status")
    assert "Bot status" in ctx.client.sent[-1][1] and "<b>Speed</b>" in ctx.client.sent[-1][1]
    cb = await click(ctx, OWNER, "adm:clear")
    assert cb.alerts == ["Cancelling 2 job(s)."] and not ctx.app.queue.jobs


@pytest.mark.parametrize("panel", sorted(PANEL_NAMES))
async def test_every_button_of_every_panel_is_handled(ctx, panel):
    await set_auto_thumb(ctx, b"\xff\xd8thumb")
    await ctx.db.create_format(OWNER, "Other auto", kind="auto")
    await ctx.db.create_format(OWNER, "Manual one", kind="manual")
    _text, rows = await ctx.app.panel(OWNER, panel)
    clicked = 0
    for row in rows:
        for _label, data in row:
            cb = await click(ctx, OWNER, data)
            assert cb.answers or cb.deleted, (panel, data)
            assert not any("went wrong" in a for a in cb.alerts), (panel, data)
            clicked += 1
    assert clicked >= 1


@pytest.mark.parametrize("kind", sorted(panels.PROMPTS))
async def test_every_prompt_opens_and_cancels(ctx, kind):
    cb = await click(ctx, OWNER, f"in:{kind}")
    p = ctx.app.pending[OWNER]
    assert p.kind == kind and p.back in PANEL_NAMES and not p.is_photo
    text, buttons = cb.edits[-1]
    assert text == panels.PROMPTS[kind] and data_of(buttons) == [f"in:cancel:{p.back}"]
    cb = await click(ctx, OWNER, f"in:cancel:{p.back}")
    assert OWNER not in ctx.app.pending and cb.toasts == ["Cancelled."] and cb.edits


# ---------------------------------------------------------------------------
# "tap a button, then send the value"
# ---------------------------------------------------------------------------


async def test_remove_words_prompt_flow(ctx):
    await click(ctx, OWNER, "in:remove", msg_id=500)
    ev = await say(ctx, OWNER, "@Chan, [Group]", mid=60)
    assert ev.deleted and OWNER not in ctx.app.pending  # the typed value is removed to keep the chat tidy
    assert (await prefs(ctx))["remove_words"] == ["@Chan", "[Group]"]
    (chat, text, kw), = ctx.client.edited
    assert chat == OWNER and kw["message"] == 500 and "I'll strip" in text and "RENAME MODE" in text
    assert "set:mode:filename" in data_of(kw["buttons"]) and "in:template" not in data_of(kw["buttons"])


async def test_keep_audio_prompt_flow_enables_metadata_and_returns_to_the_tool_panel(ctx):
    await click(ctx, OWNER, "in:keep_audio")
    await say(ctx, OWNER, "English Japanese")
    s = await prefs(ctx)
    assert s["keep_audio"] == ["english", "japanese"] and s["metadata_on"] is True
    (chat, text, kw), = ctx.client.edited
    assert "TOOL" in text and "English, Japanese" in text and "in:keep_subtitle" in data_of(kw["buttons"])


async def test_invalid_input_keeps_the_prompt_open(ctx):
    await click(ctx, OWNER, "in:mt_title")
    ev = await say(ctx, OWNER, "x" * 300)
    assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and not ev.deleted
    assert OWNER in ctx.app.pending and ctx.client.edited == []
    ev = await say(ctx, OWNER, "My Title")
    assert OWNER not in ctx.app.pending and (await prefs(ctx))["meta"]["title"] == "My Title"


async def test_expired_prompts_and_other_users_text_are_ignored(ctx):
    await click(ctx, OWNER, "in:mt_title")
    assert (await say(ctx, ADMIN_CFG, "Title")).out == []  # not their prompt
    assert OWNER in ctx.app.pending
    ctx.app.pending[OWNER].expires = time.time() - 1
    ev = await say(ctx, OWNER, "Title")
    assert ev.out == [] and not ev.deleted and OWNER not in ctx.app.pending
    assert (await prefs(ctx))["meta"]["title"] == "" and (await prefs(ctx, ADMIN_CFG))["meta"]["title"] == ""  # nothing was saved


async def test_plain_text_without_a_prompt_is_ignored(ctx):
    ev = await say(ctx, OWNER, "hello bot")
    assert ev.out == [] and not ev.deleted


async def test_prompt_on_a_photo_banner_message_is_flagged_and_long_updates_fall_back(ctx, monkeypatch):
    await click(ctx, OWNER, "in:mt_title", photo=True)
    assert ctx.app.pending[OWNER].is_photo and ctx.app.pending[OWNER].back == "meta"
    monkeypatch.setattr(appmod, "visible_len", lambda _t: 5000)  # pretend it would not fit a caption
    await say(ctx, OWNER, "My Title")
    (_chat, text, _kw), = ctx.client.edited
    assert "Open the menu to see the update" in text and "My Title" in text
    assert (await prefs(ctx))["meta"]["title"] == "My Title"


async def test_when_the_menu_message_cannot_be_edited_a_new_one_is_sent(ctx):
    async def refuse(*a, **k):
        raise RuntimeError("MESSAGE_ID_INVALID")

    await click(ctx, OWNER, "in:remove")
    ctx.client.edit_message = refuse
    ev = await say(ctx, OWNER, "@Chan, [Group]")
    assert (await prefs(ctx))["remove_words"] == ["@Chan", "[Group]"]
    assert "I'll strip" in ev.texts[0] and ev.out[0][1]  # sent as a fresh message with buttons


# ---------------------------------------------------------------------------
# robustness
# ---------------------------------------------------------------------------


async def test_handler_failures_are_reported_to_the_user(ctx, monkeypatch):
    async def boom(*a, **k):
        raise RuntimeError("database is down")

    monkeypatch.setattr(ctx.db, "get_settings", boom)
    ev = await say(ctx, OWNER, "/start")
    assert ev.texts == ["⚠️ Something went wrong. Please try again."]
    cb = await click(ctx, OWNER, "m:meta")
    assert cb.alerts == ["⚠️ Something went wrong."]
    # and the bot keeps working afterwards
    monkeypatch.undo()
    assert "Auto Rename Bot" in (await say(ctx, OWNER, "/start")).texts[0]


async def test_handler_failures_reach_the_log_channel_with_the_update_and_the_traceback(ctx, monkeypatch):
    channel_log = errorlog.install(ctx.app._log_send, errorlog.secrets_from(ctx.cfg))  # the root logger, as in main.py

    async def boom(*a, **k):
        raise RuntimeError("database is down")

    monkeypatch.setattr(ctx.db, "get_settings", boom)
    ev = await say(ctx, OWNER, "/start")
    await channel_log.aclose()
    assert ev.texts == ["⚠️ Something went wrong. Please try again."]
    (_chat, text, kw), = [s for s in ctx.client.sent if s[0] == LOG]
    assert kw["parse_mode"] == "html"
    assert text.startswith(
        '<pre><code class="language-python">Error handling update:\n'
        f"FakeEvent(sender_id={OWNER}, chat_id={OWNER}, message_id=1, text='/start')\n\nTraceback (most recent call last):"
    )
    assert text.endswith("RuntimeError: database is down</code></pre>")


async def test_an_error_inside_a_helper_still_names_the_update_that_started_it(ctx, monkeypatch):
    channel_log = errorlog.install(ctx.app._log_send)

    async def boom(*a, **k):
        raise RuntimeError("telegraph exploded")

    monkeypatch.setattr(ctx.app, "_mediainfo", boom)
    reply = document_message("Movie.mkv", 5 * 1024 * 1024)
    await say(ctx, OWNER, "/mediainfo", reply=reply)
    await channel_log.aclose()
    (_chat, text, _kw), = [s for s in ctx.client.sent if s[0] == LOG]
    assert text.startswith('<pre><code class="language-python">/mediainfo failed\nFakeEvent(sender_id=')
    assert "text='/mediainfo'" in text and text.endswith("RuntimeError: telegraph exploded</code></pre>")


async def test_a_file_is_handled_by_exactly_one_handler(ctx):
    ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
    ev = await send_doc(ctx, OWNER)
    assert len(ev.out) == 1 and "Queued" in ev.texts[0]
    assert OWNER in ctx.app.pending  # a file does not feed the text prompt