File size: 32,621 Bytes
65651b1
 
 
 
 
 
 
 
c71e4ac
65651b1
 
 
 
 
 
 
c71e4ac
65651b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c71e4ac
 
 
 
65651b1
 
 
 
c71e4ac
65651b1
 
c71e4ac
 
65651b1
 
 
 
 
 
c71e4ac
 
 
65651b1
c71e4ac
65651b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c71e4ac
 
 
 
65651b1
 
c71e4ac
65651b1
 
c71e4ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c71e4ac
65651b1
 
c71e4ac
 
 
 
65651b1
c71e4ac
65651b1
 
 
 
c71e4ac
 
65651b1
 
c71e4ac
65651b1
c71e4ac
65651b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""A post Telegram holds back - a copyright strike shows "This message couldn't be displayed on your device due to
copyright infringement" instead of the post: /shift builds the copy from what My posts saved of it, /repost leaves it
alone. The notice itself is never copied."""
import asyncio

from telethon import functions, types

from app.repost_engine import RepostOptions, plan_repost, saved_lookup
from app.restrictions import PLACEHOLDER_NOTE
from app.shift_engine import run_shift
from app.tgutil import button_url

from .dbutil import db_url_for, fresh_db
from .fakes import FakeChannelClient, make_msg, markup, photo_media, rpc, service_msg, url_btn
from .harness import norm
from .test_repost_flow import fill, make_app
from .test_restricted import CHANNEL_NOTICE, NOTICE, strike
from .test_shift import SRC, old_posts, shift_app
from .test_sync import saved


def run(coro):
    return asyncio.run(coro)


def originals():
    """The posts of the source channel as they were before the strike."""
    return {
        1: make_msg(1, "Welcome", [types.MessageEntityBold(0, 7)]),
        2: make_msg(
            2, "Episode 2 is out", [types.MessageEntityBold(0, 9)],
            markup=markup([url_btn("Download", "https://t.me/goku?start=AAA")]),
        ),
        3: make_msg(3, "Caption with link", [types.MessageEntityTextUrl(13, 4, "https://t.me/srcch/5")], media=photo_media(31)),
        4: service_msg(4),
        5: make_msg(5, "Fifth", reply_to=2),
        6: make_msg(6, "Sixth"),
    }


def held_version(m, flagged=True):
    """What Telegram shows of post `m` after the strike: the notice in place of the post (and, if `flagged`, its own
    restriction mark on the message). Id, album and reply are still there."""
    h = make_msg(
        m.id, NOTICE, out=m.out, grouped_id=m.grouped_id, reply_to=m.reply_to.reply_to_msg_id if m.reply_to else None
    )
    return strike(h) if flagged else h


def live_posts(orig, held, flagged=True):
    return {i: (held_version(m, flagged) if i in held else m) for i, m in orig.items()}


async def save_rows(db, orig, skip=()):
    for i, m in orig.items():
        if i not in skip and not isinstance(m, types.MessageService):
            await saved(db, m)


async def held_setup(tmp_path, *, held=(2, 3, 5), no_row=(), flagged=True, file_available=True, orig=None, last=None):
    """The source (id 1) after the strike, the destination (id 2) and a shift s1 over all of it; My posts has what the
    source's posts looked like before."""
    orig = originals() if orig is None else orig
    src = FakeChannelClient(live_posts(orig, held, flagged), username="srcch", title="Anime Source")
    dst = src.add_channel(FakeChannelClient(old_posts(), username="dstch", cid=2, title="Anime Backup"))
    if file_available:  # Telegram still knows the files: they were not taken down
        for m in orig.values():
            src._remember_media(m)
    db = await fresh_db(db_url_for(tmp_path))
    await db.save_channel(1, 5, "Anime Source", "srcch", 1)
    await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
    await save_rows(db, orig, no_row)
    await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=1, last_id=last or max(orig), user_id=1)
    return src, dst, db, await db.get_channel(2), await db.get_shift("s1")


def forwarded_ids(chan):
    return [i for r in chan.requests if isinstance(r, functions.messages.ForwardMessagesRequest) for i in r.id]


def sent(chan, kind):
    return [r for r in chan.requests if isinstance(r, kind)]


def shown(chan):
    return {i: m.message for i, m in chan.msgs.items() if i > 10}


# ============================================================================================ /shift, engine
def test_a_held_back_post_is_copied_from_my_posts_and_never_as_the_notice(tmp_path):
    async def go():
        for flagged in (True, False):  # Telegram's own mark on the message, or only the notice as its text
            src, dst, db, dst_row, shift = await held_setup(tmp_path / str(flagged), flagged=flagged)
            res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
            assert (res.failed, res.aborted, res.held_back, res.media_lost) == ([], None, [], [])
            assert res.from_saved == 3 and res.copied_units == 5 and res.used_saved
            # copies: 1->11 2->12 3->13 5->14 6->15 (4 is a service message)
            assert shown(dst) == {
                11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth"
            }
            assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
            # only the posts Telegram shows as they are were copied by Telegram itself
            assert forwarded_ids(dst) == [1, 6]
            # text, formatting and buttons come from My posts
            assert [(e.offset, e.length) for e in dst.msgs[12].entities] == [(0, 9)]
            btn = dst.msgs[12].reply_markup.rows[0].buttons[0]
            assert (btn.text, button_url(btn)) == ("Download", "https://t.me/goku?start=AAA")
            # the photo of the saved post is there, and the link to the post that was copied later points at its copy
            assert dst.msgs[13].media.photo.id == 31
            assert dst.msgs[13].entities[0].url == "https://t.me/dstch/14"
            # a post that answered another answers the copy of it
            assert dst.msgs[14].reply_to.reply_to_msg_id == 12
            # My posts knows the copies as they are
            row = await db.find_by_message(2, 13)
            assert (row.text, row.media_kind) == ("Caption with link", "photo")
            assert (await db.find_by_message(2, 12)).buttons == [[{"t": "Download", "u": "https://t.me/goku?start=AAA"}]]
            # the source is only read
            assert not [r for r in src.requests if type(r).__name__.startswith(("Edit", "Delete", "Send", "Forward"))]
            await db.close()

    run(go())


def test_a_post_that_telegram_shows_as_it_is_still_comes_from_the_channel(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(2,))
        # My posts has an older text for post 1 (somebody edited it and the sync did not run yet): the channel wins
        row = await db.find_by_message(1, 1)
        await db.update_post(row.id, text="an older text")
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.from_saved == 1 and res.held_back == []
        assert dst.msgs[11].message == "Welcome" and dst.msgs[12].message == "Episode 2 is out"
        assert forwarded_ids(dst) == [1, 3, 6]  # post 5 answers a post, so it is posted again; 2 comes from My posts
        await db.close()

    run(go())


def test_a_post_with_telegrams_mark_comes_from_my_posts_even_when_telegram_still_hands_it_out(tmp_path):
    """The rule of a struck channel: what My posts saved wins over what Telegram hands out now - however intact that
    looks. Only a post without a saved copy is copied as Telegram hands it out (it is the post itself, not a notice)."""

    async def go():
        orig = originals()
        live = dict(orig)
        live[2] = strike(make_msg(2, "Episode 2 is out", [types.MessageEntityBold(0, 9)], markup=orig[2].reply_markup))
        live[3] = strike(make_msg(3, "Caption as Telegram hands it out", media=photo_media(31)))  # differs from My posts
        src = FakeChannelClient(live, username="srcch", title="Anime Source")
        dst = src.add_channel(FakeChannelClient(old_posts(), username="dstch", cid=2, title="Anime Backup"))
        for m in orig.values():
            src._remember_media(m)
        db = await fresh_db(db_url_for(tmp_path))
        await db.save_channel(1, 5, "Anime Source", "srcch", 1)
        await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
        await save_rows(db, orig, skip=(2,))  # no saved copy of post 2; post 3 has one
        await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=1, last_id=6, user_id=1)
        res = await run_shift(src, db, await db.get_shift("s1"), await db.get_channel(2), 99, delay=0, settle_pause=0)
        assert res.held_back == [] and res.failed == [] and res.copied_units == 5
        assert res.from_saved == 1  # post 3
        assert res.live_restricted == 1  # post 2: marked, but nothing saved to use instead and its content is intact
        assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth"}
        assert forwarded_ids(dst) == [1, 6]  # a restricted post is never copied by Telegram itself
        await db.close()

    run(go())


def test_a_post_that_telegram_empties_out_is_held_back_too(tmp_path):
    async def go():
        orig = originals()
        live = dict(orig)
        live[2] = strike(make_msg(2, ""), "Removed after a copyright complaint")  # nothing of the post is handed out
        src = FakeChannelClient(live, username="srcch", title="Anime Source")
        dst = src.add_channel(FakeChannelClient(old_posts(), username="dstch", cid=2, title="Anime Backup"))
        db = await fresh_db(db_url_for(tmp_path))
        await db.save_channel(1, 5, "Anime Source", "srcch", 1)
        await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
        await save_rows(db, orig)
        await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=1, last_id=6, user_id=1)
        res = await run_shift(src, db, await db.get_shift("s1"), await db.get_channel(2), 99, delay=0, settle_pause=0)
        assert res.from_saved == 1 and res.held_back == [] and res.failed == []
        assert dst.msgs[12].message == "Episode 2 is out" and res.held_why == "Removed after a copyright complaint"
        await db.close()

    run(go())


def test_telegrams_notice_is_never_posted_even_when_my_posts_holds_it_as_the_text(tmp_path):
    """A saved copy made from a restricted post can hold Telegram's notice as its text (an older version did that), and a
    post may really say it: either way it is indistinguishable from the notice, so it is not copied - and reported."""

    async def go():
        orig = originals()
        orig[6] = make_msg(6, NOTICE)
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(2, 3, 5), orig=orig, flagged=False)
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.from_saved == 3 and res.held_back == [6] and res.failed == []
        assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth"}
        assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
        assert 6 not in forwarded_ids(dst) and res.held_why == PLACEHOLDER_NOTE
        await db.close()

    run(go())


def test_a_saved_copy_that_holds_the_notice_is_not_used_for_a_restricted_post(tmp_path):
    async def go():
        orig = originals()
        orig[5] = make_msg(5, "Fifth")  # answers nothing
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(2, 3), no_row=(2,), orig=orig)
        await db.create_post(  # what an older version saved when it synced the struck post
            channel_id=1, message_id=2, status="sent", source="adopted", created_by=1, link_preview=False,
            text=NOTICE, entities=[], buttons=[], media_kind=None, media_file_id=None,
        )
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        # post 2: Telegram shows the notice and the saved copy is the notice too: nothing to copy; post 3 is fine
        assert res.held_back == [2] and res.from_saved == 1 and res.failed == []
        assert shown(dst) == {11: "Welcome", 12: "Caption with link", 13: "Fifth", 14: "Sixth"}
        assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
        await db.close()

    run(go())


def test_an_answer_to_a_post_that_could_not_be_copied_follows_when_that_post_has_its_copy(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(2,), no_row=(2,))  # post 5 answers post 2
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.held_back == [2] and res.failed == [(5, "ReplyParentMissing")]  # it would answer nothing
        assert shown(dst) == {11: "Welcome", 12: "Caption with link", 13: "Sixth"}
        await save_rows(db, {2: originals()[2]})  # the saved copy arrives
        again = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert again.held_back == [] and again.failed == [] and again.copied_units == 2
        assert dst.msgs[14].message == "Episode 2 is out" and dst.msgs[15].message == "Fifth"
        assert dst.msgs[15].reply_to.reply_to_msg_id == 14  # the answer answers the copy
        await db.close()

    run(go())


def test_a_held_back_post_without_a_saved_copy_is_skipped_and_reported(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, no_row=(3,))
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.held_back == [3] and res.from_saved == 2 and res.failed == [] and res.aborted is None
        assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Fifth", 14: "Sixth"}
        assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
        assert res.held_why == NOTICE
        # once My posts has it (a backup put back with /import) the rest can be done
        await save_rows(db, {3: originals()[3]}, skip=())
        again = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert again.held_back == [] and again.from_saved == 1 and again.copied_units == 1 and again.skipped_done == 4
        assert dst.msgs[15].message == "Caption with link" and dst.msgs[15].media.photo.id == 31
        await db.close()

    run(go())


def test_a_channel_that_is_not_in_my_posts_at_all_has_nothing_to_copy_held_back_posts_from(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(1, 2, 3, 5, 6), no_row=(1, 2, 3, 5, 6))
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.held_back == [1, 2, 3, 5, 6] and res.copied_units == 0 and res.failed == []
        assert shown(dst) == {}
        await db.close()

    run(go())


def test_a_post_with_nothing_saved_in_it_is_not_posted_empty(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(6,), no_row=(6,))
        await db.create_post(
            channel_id=1, message_id=6, status="sent", source="adopted", created_by=1, link_preview=False,
            text="  ", entities=[], buttons=[], media_kind=None, media_file_id=None,
        )
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.held_back == [6] and 15 not in dst.msgs
        await db.close()

    run(go())


# ------------------------------------------------------------------------------------------------ the media
def test_a_file_reference_that_has_expired_is_tried_again_without_it(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path)
        original = dst._do_SendMediaRequest
        tries = []

        def picky(req):
            tries.append(req.media.id.file_reference)
            if req.media.id.file_reference:  # the saved reference is old
                raise rpc("FileReferenceExpiredError")
            return original(req)

        dst._do_SendMediaRequest = picky
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert tries == [b"ref31", b""] and res.media_lost == [] and res.failed == []
        assert dst.msgs[13].media.photo.id == 31
        await db.close()

    run(go())


def test_a_file_telegram_refuses_goes_out_without_it_and_is_reported(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, file_available=False)  # taken down
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.media_lost == [3] and res.failed == [] and res.from_saved == 3 and res.aborted is None
        assert dst.msgs[13].message == "Caption with link" and dst.msgs[13].media is None
        assert len(sent(dst, functions.messages.SendMediaRequest)) == 2  # as saved, then without the file reference
        # the entry in My posts says what the post really is
        row = await db.find_by_message(2, 13)
        assert (row.text, row.media_kind, row.media_file_id) == ("Caption with link", None, None)
        await db.close()

    run(go())


def test_a_post_that_is_only_a_refused_file_fails_and_the_others_are_copied(tmp_path):
    async def go():
        orig = originals()
        orig[7] = make_msg(7, "", media=photo_media(71))
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(7,), orig=orig, file_available=False, last=7)
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.failed == [(7, "MediaEmptyError")] and res.media_lost == []
        assert res.copied_units == 5 and 16 not in dst.msgs  # 1 2 3 5 6 were copied; 7 has no text to go out with
        await db.close()

    run(go())


def test_a_held_back_album_is_rebuilt_as_an_album(tmp_path):
    async def go():
        orig = originals()
        orig[8] = make_msg(8, "", media=photo_media(81), grouped_id=80)
        orig[9] = make_msg(9, "album caption", [types.MessageEntityBold(0, 5)], media=photo_media(82), grouped_id=80)
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(8, 9), orig=orig, last=9)
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.from_saved == 1 and res.held_back == [] and res.failed == []  # an album is one post
        album = [dst.msgs[i] for i in (16, 17)]
        assert album[0].grouped_id and album[0].grouped_id == album[1].grouped_id
        assert [m.media.photo.id for m in album] == [81, 82]
        assert album[1].message == "album caption" and [(e.offset, e.length) for e in album[1].entities] == [(0, 5)]
        await db.close()

    run(go())


def test_an_album_of_which_one_picture_has_no_saved_copy_is_skipped_whole(tmp_path):
    async def go():
        orig = originals()
        orig[8] = make_msg(8, "", media=photo_media(81), grouped_id=80)
        orig[9] = make_msg(9, "album caption", media=photo_media(82), grouped_id=80)
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(8, 9), no_row=(9,), orig=orig, last=9)
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.held_back == [8] and 16 not in dst.msgs
        await db.close()

    run(go())


# ------------------------------------------------------------------------------------------------ the plan
def test_the_plan_counts_what_is_copied_from_my_posts_and_what_is_skipped(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, no_row=(5,))
        lookup = saved_lookup(db, 1)
        plan = await plan_repost(src, SRC, RepostOptions(), saved=lookup, use_saved=True)
        # posts 1 2 3 6 are copied (2 and 3 from My posts); 5 is held back without a saved copy; 4 is a service message
        assert (plan.units, plan.held_saved, plan.held_skipped, plan.service) == (4, 2, 1, 1)
        assert plan.held_why == NOTICE and plan.channel_restricted is None
        assert plan.media == 1 and plan.n_buttons == 0 and plan.error is None  # counted from the saved posts
        # as /repost sees it: nothing held back is copied
        plan = await plan_repost(src, SRC, RepostOptions(), saved=lookup)
        assert (plan.units, plan.held_saved, plan.held_skipped) == (2, 0, 3)
        await db.close()

    run(go())


def test_the_plan_says_so_when_the_whole_channel_is_restricted_and_nothing_can_be_copied(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(1, 2, 3, 5, 6), no_row=(1, 2, 3, 5, 6))
        src.restriction = NOTICE
        plan = await plan_repost(src, SRC, RepostOptions(), saved=saved_lookup(db, 1), use_saved=True)
        assert plan.channel_restricted == NOTICE and plan.units == 0 and plan.held_skipped == 5
        assert "holds back every post" in plan.error and "no saved copy" in plan.error
        await db.close()

    run(go())


def test_a_normal_channel_is_planned_as_before(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=())
        plan = await plan_repost(src, SRC, RepostOptions(), saved=saved_lookup(db, 1), use_saved=True)
        assert (plan.units, plan.held_saved, plan.held_skipped, plan.held_why, plan.channel_restricted) == (5, 0, 0, None, None)
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.from_saved == 0 and res.held_back == [] and res.copied_units == 5
        await db.close()

    run(go())


# ============================================================================================ the owner's view
async def held_app(tmp_path, *, held=(2, 3, 5), no_row=(), file_available=True):
    app, dst = await shift_app(tmp_path)
    orig = originals()
    app.tg._media.clear()  # the account knows no file yet (shift_app had filled the channel with other posts)
    fill(app.tg, live_posts(orig, held))
    if file_available:
        for m in orig.values():
            app.tg._remember_media(m)
    await save_rows(app.db, orig, no_row)
    return app, dst


def buttons(app):
    return [t for row in app.out.last_buttons() for t in row]


def test_the_shift_plan_and_result_explain_what_comes_from_my_posts(tmp_path):
    async def go():
        app, dst = await held_app(tmp_path, no_row=(5,))
        app.tg.restriction = CHANNEL_NOTICE  # the whole channel is struck: My posts is used for every post it has
        await app.text("/shift @srcch @dstch")
        plan = norm(app.out.last_text)
        assert "Telegram restricts Anime Source itself" in plan and "unavailable due to copyright infringement" in plan
        assert "4 post(s) of Anime Source are restricted by Telegram" in plan
        assert "copied from what My posts saved of them" in plan and "not from what Telegram shows now" in plan
        assert "Telegram shows only its notice for 1 more post(s)" in plan and "no saved copy" in plan and "/import" in plan
        assert "4 posts" in plan  # 1, 2, 3 and 6 are copied
        assert "The new posts are read back" in plan
        assert not any(isinstance(r, functions.messages.SendMessageRequest) for r in dst.requests)  # a plan posts nothing

        await app.press(app.out.callback_data("Start copying"))
        done = norm(app.out.last_text)
        assert "4 post(s) are restricted by Telegram" in done and "copied from what My posts saved of them" in done
        assert "1 post(s): Telegram shows only its notice and My posts has no saved copy of them" in done and "#5" in done
        assert "not everything was copied" in done  # so the rest can be tried again later
        assert any("Try the rest again" in t for t in buttons(app))
        assert "hides" not in done  # nothing the new posts show is restricted
        assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
        assert forwarded_ids(dst) == []  # a struck channel: nothing is copied by Telegram itself

        # the missing copy arrives (a backup put back with /import): the rest can be copied
        await save_rows(app.db, {5: originals()[5]})
        await app.press(app.out.callback_data("Try the rest again"))
        done = norm(app.out.last_text)
        assert "message(s) copied" in done and "Undo" in " ".join(buttons(app))
        assert dst.msgs[15].message == "Fifth"
        await app.db.close()

    run(go())


def test_a_refused_file_is_named_in_the_result(tmp_path):
    async def go():
        app, dst = await held_app(tmp_path, file_available=False)
        await app.text("/shift @srcch @dstch")
        await app.press(app.out.callback_data("Start copying"))
        done = norm(app.out.last_text)
        assert "1 of them went out without their media" in done and "#3" in done
        await app.db.close()

    run(go())


def test_the_usage_tells_about_posts_telegram_holds_back(tmp_path):
    async def go():
        app, _ = await shift_app(tmp_path)
        await app.text("/shift")
        text = norm(app.out.last_text)
        assert "copyright infringement" in text and "copied from what My posts saved of it" in text
        await app.db.close()

    run(go())


# ============================================================================================ /repost
def repost_posts():
    return {
        1: make_msg(1, "First post"),
        2: make_msg(2, "Second post", markup=markup([url_btn("Watch", "https://t.me/goku?start=BBB")])),
        3: make_msg(3, "Third post"),
    }


def test_a_repost_never_copies_the_notice_and_never_deletes_what_it_could_not_copy(tmp_path):
    async def go():
        orig = repost_posts()
        app = await make_app(tmp_path, msgs=live_posts(orig, (2,)))
        await save_rows(app.db, orig)
        await app.text("/repost --no-typed")
        await app.press(app.out.callback_data("Test"))
        plan = norm(app.out.last_text)
        assert "Telegram holds back 1 post(s) of Test" in plan and "Copying the notice would be wrong" in plan
        assert "2 posts" in plan  # posts 1 and 3
        await app.press(app.out.callback_data("Start copying"))
        done = norm(app.out.last_text)
        assert "1 post(s) are held back by Telegram" in done and "#2" in done and "the old posts are not deleted" in done
        assert "not everything was copied" in done
        assert not any("Delete the old posts" in t for t in buttons(app))  # nothing may be deleted
        # the channel: the three posts, the held back one stays as it is, two copies at the end - and no notice copy
        texts = [m.message for _, m in sorted(app.tg.msgs.items())]
        assert texts == ["First post", NOTICE, "Third post", "First post", "Third post"]
        await app.db.close()

    run(go())


def test_a_repost_of_a_channel_without_held_back_posts_is_unchanged(tmp_path):
    async def go():
        app = await make_app(tmp_path, msgs=repost_posts())
        await app.text("/repost --no-typed")
        await app.press(app.out.callback_data("Test"))
        assert "holds back" not in norm(app.out.last_text)
        await app.press(app.out.callback_data("Start copying"))
        done = norm(app.out.last_text)
        assert "all posts copied in order" in done and "held back" not in done
        assert any("Delete the old posts" in t for t in buttons(app))
        await app.db.close()

    run(go())


# ============================================================================ a source that can't be read at all
def unreadable(chan):
    """The channel is banned or private for the bot: nothing in it can be read any more."""
    chan.fail["GetMessagesRequest"] = rpc("ChannelPrivateError")
    chan.fail["GetFullChannelRequest"] = rpc("ChannelPrivateError")


def test_a_source_that_cannot_be_read_is_copied_from_my_posts_alone(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=())
        unreadable(src)
        await db.set_mark("s1", "fromdb")
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.from_db and res.used_saved and res.failed == [] and res.aborted is None and res.held_back == []
        assert res.from_saved == 5 and res.copied_units == 5
        # copies: 1->11 2->12 3->13 5->14 6->15 (there is nothing saved of the service message 4)
        assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth"}
        assert forwarded_ids(dst) == []  # nothing is copied by Telegram: the source can't be read
        btn = dst.msgs[12].reply_markup.rows[0].buttons[0]
        assert (btn.text, button_url(btn)) == ("Download", "https://t.me/goku?start=AAA")
        assert dst.msgs[13].media.photo.id == 31
        assert dst.msgs[13].entities[0].url == "https://t.me/dstch/14"  # the link to post 5 follows its copy
        assert dst.msgs[14].reply_to is None  # My posts does not know what a post answered
        assert (await db.find_by_message(2, 13)).media_kind == "photo"
        await db.close()

    run(go())


def test_the_range_is_respected_and_a_stopped_shift_goes_on_from_my_posts(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=())
        unreadable(src)
        await db.set_mark("s1", "fromdb")
        calls = []

        async def prog(r):
            calls.append(r.copied_units)

        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0, progress=prog, should_stop=lambda: len(calls) >= 2)
        assert res.stopped and res.copied_units == 2 and shown(dst) == {11: "Welcome", 12: "Episode 2 is out"}
        again = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert again.from_db and not again.stopped and again.copied_units == 3 and again.skipped_done == 2
        assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth"}
        await db.close()

    run(go())


def test_a_post_with_nothing_to_post_is_not_taken_from_my_posts(tmp_path):
    async def go():
        src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(), no_row=(2,))
        await db.create_post(
            channel_id=1, message_id=2, status="sent", source="adopted", created_by=1, link_preview=False,
            text="", entities=[], buttons=[], media_kind=None, media_file_id=None,
        )
        unreadable(src)
        await db.set_mark("s1", "fromdb")
        res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
        assert res.copied_units == 4 and res.failed == []
        assert shown(dst) == {11: "Welcome", 12: "Caption with link", 13: "Fifth", 14: "Sixth"}
        await db.close()

    run(go())


def test_the_shift_command_offers_my_posts_when_the_source_can_not_be_read(tmp_path):
    async def go():
        app, dst = await held_app(tmp_path, held=())
        unreadable(app.tg)
        await app.text("/shift @srcch @dstch")
        plan = norm(app.out.last_text)
        assert "I can't read Anime Source" in plan and "My posts has the saved posts of it" in plan
        assert "5 posts" in plan and "Post ids 1-6" in plan
        assert "replies and albums" in plan and "from what My posts saved of it" in plan
        assert dst.msgs.keys() == set(range(1, 11))  # a plan posts nothing

        await app.press(app.out.callback_data("Start copying"))
        done = norm(app.out.last_text)
        assert "5 message(s) copied" in done and "The source can't be read, so 5 post(s) were copied" in done
        sid = app.out.callback_data("Undo").split(":")[1]
        assert await app.db.marks_of(sid) == {"fromdb"}
        assert dst.msgs[12].message == "Episode 2 is out"

        await app.press(app.out.callback_data("Undo"))
        await app.press(app.out.callback_data("Yes, remove"))
        assert "5 copied message(s) removed" in norm(app.out.last_text)
        await app.db.close()

    run(go())


def test_the_range_of_a_shift_from_my_posts(tmp_path):
    async def go():
        app, dst = await held_app(tmp_path, held=())
        unreadable(app.tg)
        await app.text("/shift @srcch @dstch 2 3")
        plan = norm(app.out.last_text)
        assert "Post ids 2-3" in plan and "2 posts" in plan
        await app.press(app.out.callback_data("Start copying"))
        assert [m.message for i, m in sorted(dst.msgs.items()) if i > 10] == ["Episode 2 is out", "Caption with link"]
        await app.db.close()

    run(go())


def test_an_unreadable_source_that_my_posts_does_not_know_is_still_an_error(tmp_path):
    async def go():
        app, dst = await shift_app(tmp_path)  # My posts has nothing of the source
        unreadable(app.tg)
        await app.text("/shift @srcch @dstch")
        text = norm(app.out.last_text)
        assert "I can't read Anime Source" in text and "no saved posts of it" in text
        assert not any(isinstance(r, functions.messages.SendMessageRequest) for r in dst.requests)
        await app.db.close()

    run(go())