LCOV - code coverage report
Current view: top level - tests/functional - test_upload_download.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 99.8 % 583 582
Test Date: 2026-04-20 19:54:22 Functions: 100.0 % 43 43

            Line data    Source code
       1              : /**
       2              :  * @file test_upload_download.c
       3              :  * @brief FT-06 — upload / download functional tests.
       4              :  *
       5              :  * Drives the full end-to-end flow of:
       6              :  *   - upload.saveFilePart (small), upload.saveBigFilePart (>= 10 MiB),
       7              :  *     followed by messages.sendMedia (InputMediaUploadedDocument /
       8              :  *     InputMediaUploadedPhoto).
       9              :  *   - upload.getFile chunked download with EOF detection on short chunk.
      10              :  *   - FILE_MIGRATE_X surface via the `wrong_dc` out-parameter.
      11              :  *
      12              :  * Real AES-IGE + SHA-256 on both sides. The files live in /tmp and are
      13              :  * recreated per-test so concurrent runs don't collide.
      14              :  */
      15              : 
      16              : #include "test_helpers.h"
      17              : 
      18              : #include "mock_socket.h"
      19              : #include "mock_tel_server.h"
      20              : 
      21              : #include "api_call.h"
      22              : #include "mtproto_session.h"
      23              : #include "transport.h"
      24              : #include "app/session_store.h"
      25              : #include "tl_registry.h"
      26              : #include "tl_serial.h"
      27              : 
      28              : #include "domain/write/upload.h"
      29              : #include "domain/read/media.h"
      30              : 
      31              : #include <stdio.h>
      32              : #include <stdlib.h>
      33              : #include <string.h>
      34              : #include <sys/stat.h>
      35              : #include <unistd.h>
      36              : 
      37              : #define CRC_upload_saveFilePart        0xb304a621U
      38              : #define CRC_upload_saveBigFilePart     0xde7b673dU
      39              : #define CRC_messages_sendMedia         0x7547c966U
      40              : #define CRC_upload_getFile             0xbe5335beU
      41              : #define CRC_upload_file                0x096a18d5U
      42              : #define CRC_storage_filePartial        0x40bc6f52U   /* storage.filePartial */
      43              : #define CRC_storage_fileJpeg           0x7efe0e   /* storage.fileJpeg */
      44              : 
      45           42 : static void with_tmp_home(const char *tag) {
      46              :     char tmp[256];
      47           42 :     snprintf(tmp, sizeof(tmp), "/tmp/tg-cli-ft-media-%s", tag);
      48              :     char bin[512];
      49           42 :     snprintf(bin, sizeof(bin), "%s/.config/tg-cli/session.bin", tmp);
      50           42 :     (void)unlink(bin);
      51           42 :     setenv("HOME", tmp, 1);
      52           42 : }
      53              : 
      54           42 : static void connect_mock(Transport *t) {
      55           42 :     transport_init(t);
      56           42 :     ASSERT(transport_connect(t, "127.0.0.1", 443) == 0, "connect");
      57              : }
      58              : 
      59           42 : static void init_cfg(ApiConfig *cfg) {
      60           42 :     api_config_init(cfg);
      61           42 :     cfg->api_id = 12345;
      62           42 :     cfg->api_hash = "deadbeefcafebabef00dbaadfeedc0de";
      63           42 : }
      64              : 
      65           42 : static void load_session(MtProtoSession *s) {
      66           42 :     ASSERT(mt_server_seed_session(2, NULL, NULL, NULL) == 0, "seed");
      67           42 :     mtproto_session_init(s);
      68           42 :     int dc = 0;
      69           42 :     ASSERT(session_store_load(s, &dc) == 0, "load");
      70              : }
      71              : 
      72              : /* Write a tempfile of @p size bytes filled with a deterministic byte
      73              :  * pattern and return its path (owned by a static buffer, caller must
      74              :  * unlink when done). */
      75           18 : static const char *make_tempfile(const char *name, size_t size) {
      76              :     static char path[256];
      77           18 :     snprintf(path, sizeof(path), "/tmp/tg-cli-fixture-%s.bin", name);
      78           18 :     FILE *fp = fopen(path, "wb");
      79           18 :     if (!fp) return NULL;
      80           18 :     uint8_t *buf = (uint8_t *)malloc(size);
      81           18 :     if (!buf) { fclose(fp); return NULL; }
      82     22038546 :     for (size_t i = 0; i < size; ++i) buf[i] = (uint8_t)(i & 0xFFu);
      83           18 :     size_t written = fwrite(buf, 1, size, fp);
      84           18 :     free(buf);
      85           18 :     fclose(fp);
      86           18 :     return (written == size) ? path : NULL;
      87              : }
      88              : 
      89              : /* ================================================================ */
      90              : /* Counters & state used by responders                              */
      91              : /* ================================================================ */
      92              : 
      93              : static int g_save_file_part_calls = 0;
      94              : static int g_save_big_file_part_calls = 0;
      95              : static int g_get_file_calls = 0;
      96              : static int g_send_media_calls = 0;
      97              : 
      98           42 : static void reset_counters(void) {
      99           42 :     g_save_file_part_calls = 0;
     100           42 :     g_save_big_file_part_calls = 0;
     101           42 :     g_get_file_calls = 0;
     102           42 :     g_send_media_calls = 0;
     103           42 : }
     104              : 
     105              : /* ================================================================ */
     106              : /* Responders                                                       */
     107              : /* ================================================================ */
     108              : 
     109           60 : static void reply_bool_true(MtRpcContext *ctx) {
     110              :     TlWriter w;
     111           60 :     tl_writer_init(&w);
     112           60 :     tl_write_uint32(&w, TL_boolTrue);
     113           60 :     mt_server_reply_result(ctx, w.data, w.len);
     114           60 :     tl_writer_free(&w);
     115           60 : }
     116              : 
     117           14 : static void on_save_file_part(MtRpcContext *ctx) {
     118           14 :     g_save_file_part_calls++;
     119           14 :     reply_bool_true(ctx);
     120           14 : }
     121              : 
     122           42 : static void on_save_big_file_part(MtRpcContext *ctx) {
     123           42 :     g_save_big_file_part_calls++;
     124           42 :     reply_bool_true(ctx);
     125           42 : }
     126              : 
     127              : /* messages.sendMedia → minimal updates envelope. */
     128           14 : static void on_send_media(MtRpcContext *ctx) {
     129           14 :     g_send_media_calls++;
     130              :     TlWriter w;
     131           14 :     tl_writer_init(&w);
     132           14 :     tl_write_uint32(&w, TL_updates);
     133           14 :     tl_write_uint32(&w, TL_vector); tl_write_uint32(&w, 0); /* updates */
     134           14 :     tl_write_uint32(&w, TL_vector); tl_write_uint32(&w, 0); /* users */
     135           14 :     tl_write_uint32(&w, TL_vector); tl_write_uint32(&w, 0); /* chats */
     136           14 :     tl_write_int32 (&w, 0); tl_write_int32 (&w, 0);
     137           14 :     mt_server_reply_result(ctx, w.data, w.len);
     138           14 :     tl_writer_free(&w);
     139           14 : }
     140              : 
     141              : /* upload.file with a caller-supplied payload length. */
     142           16 : static void reply_upload_file(MtRpcContext *ctx,
     143              :                               const uint8_t *bytes, size_t len) {
     144              :     TlWriter w;
     145           16 :     tl_writer_init(&w);
     146           16 :     tl_write_uint32(&w, CRC_upload_file);
     147              :     /* storage.FileType — pick storage.filePartial so no extension lookup */
     148           16 :     tl_write_uint32(&w, CRC_storage_filePartial);
     149           16 :     tl_write_int32 (&w, 0);                       /* mtime */
     150           16 :     tl_write_bytes (&w, bytes, len);
     151           16 :     mt_server_reply_result(ctx, w.data, w.len);
     152           16 :     tl_writer_free(&w);
     153           16 : }
     154              : 
     155              : /* Single-shot download: always returns a short chunk (EOF immediately). */
     156            8 : static void on_get_file_short(MtRpcContext *ctx) {
     157            8 :     g_get_file_calls++;
     158              :     uint8_t payload[128];
     159         1032 :     for (size_t i = 0; i < sizeof(payload); ++i)
     160         1024 :         payload[i] = (uint8_t)(i ^ 0xA5u);
     161            8 :     reply_upload_file(ctx, payload, sizeof(payload));
     162            8 : }
     163              : 
     164              : /* Two-chunk download: first call returns exactly CHUNK_SIZE (128 KiB)
     165              :  * of data (signals "more to come"), second call returns a short chunk
     166              :  * (signals EOF). */
     167            8 : static void on_get_file_two_chunks(MtRpcContext *ctx) {
     168            8 :     g_get_file_calls++;
     169            8 :     if (g_get_file_calls == 1) {
     170              :         /* Full chunk — 128 KiB of deterministic data. */
     171            4 :         uint8_t *full = (uint8_t *)malloc(128 * 1024);
     172            4 :         if (!full) return;
     173       524292 :         for (size_t i = 0; i < 128 * 1024; ++i) full[i] = (uint8_t)(i & 0xFFu);
     174            4 :         reply_upload_file(ctx, full, 128 * 1024);
     175            4 :         free(full);
     176              :     } else {
     177              :         uint8_t tail[64];
     178            4 :         memset(tail, 0x5A, sizeof(tail));
     179            4 :         reply_upload_file(ctx, tail, sizeof(tail));
     180              :     }
     181              : }
     182              : 
     183              : /* upload.getFile always returns FILE_MIGRATE_3. */
     184            4 : static void on_get_file_migrate(MtRpcContext *ctx) {
     185            4 :     g_get_file_calls++;
     186            4 :     mt_server_reply_error(ctx, 303, "FILE_MIGRATE_3");
     187            4 : }
     188              : 
     189              : /* ================================================================ */
     190              : /* Tests                                                            */
     191              : /* ================================================================ */
     192              : 
     193            2 : static void test_upload_small_document(void) {
     194            2 :     with_tmp_home("up-small");
     195            2 :     mt_server_init(); mt_server_reset();
     196            2 :     reset_counters();
     197            2 :     MtProtoSession s; load_session(&s);
     198            2 :     mt_server_expect(CRC_upload_saveFilePart, on_save_file_part, NULL);
     199            2 :     mt_server_expect(CRC_messages_sendMedia,  on_send_media,     NULL);
     200              : 
     201              :     /* 1 KiB file — one saveFilePart call. */
     202            2 :     const char *path = make_tempfile("up-small", 1024);
     203            2 :     ASSERT(path != NULL, "tempfile created");
     204              : 
     205            2 :     ApiConfig cfg; init_cfg(&cfg);
     206            2 :     Transport t; connect_mock(&t);
     207              : 
     208            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     209            2 :     RpcError err = {0};
     210            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, path,
     211              :                             "a tiny file", "text/plain", &err) == 0,
     212              :            "domain_send_file ok");
     213            2 :     ASSERT(g_save_file_part_calls == 1, "exactly one saveFilePart call");
     214              : 
     215            2 :     unlink(path);
     216            2 :     transport_close(&t);
     217            2 :     mt_server_reset();
     218              : }
     219              : 
     220            2 : static void test_upload_multi_chunk_document(void) {
     221            2 :     with_tmp_home("up-multi");
     222            2 :     mt_server_init(); mt_server_reset();
     223            2 :     reset_counters();
     224            2 :     MtProtoSession s; load_session(&s);
     225            2 :     mt_server_expect(CRC_upload_saveFilePart, on_save_file_part, NULL);
     226            2 :     mt_server_expect(CRC_messages_sendMedia,  on_send_media,     NULL);
     227              : 
     228              :     /* 513 KiB → 2 saveFilePart calls (chunk = 512 KiB). */
     229            2 :     const char *path = make_tempfile("up-multi", 513 * 1024);
     230            2 :     ASSERT(path != NULL, "tempfile created");
     231              : 
     232            2 :     ApiConfig cfg; init_cfg(&cfg);
     233            2 :     Transport t; connect_mock(&t);
     234              : 
     235            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     236            2 :     RpcError err = {0};
     237            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, path,
     238              :                             NULL, NULL, &err) == 0,
     239              :            "send_file 513 KiB ok");
     240            2 :     ASSERT(g_save_file_part_calls == 2, "two saveFilePart calls");
     241              : 
     242            2 :     unlink(path);
     243            2 :     transport_close(&t);
     244            2 :     mt_server_reset();
     245              : }
     246              : 
     247            2 : static void test_upload_big_file_uses_big_part(void) {
     248            2 :     with_tmp_home("up-big");
     249            2 :     mt_server_init(); mt_server_reset();
     250            2 :     reset_counters();
     251            2 :     MtProtoSession s; load_session(&s);
     252            2 :     mt_server_expect(CRC_upload_saveBigFilePart, on_save_big_file_part, NULL);
     253            2 :     mt_server_expect(CRC_messages_sendMedia,     on_send_media,         NULL);
     254              : 
     255              :     /* UPLOAD_BIG_THRESHOLD = 10 MiB — go just past it. */
     256            2 :     size_t sz = (size_t)UPLOAD_BIG_THRESHOLD + 1024;
     257            2 :     const char *path = make_tempfile("up-big", sz);
     258            2 :     ASSERT(path != NULL, "tempfile created");
     259              : 
     260            2 :     ApiConfig cfg; init_cfg(&cfg);
     261            2 :     Transport t; connect_mock(&t);
     262              : 
     263            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     264            2 :     RpcError err = {0};
     265            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, path,
     266              :                             NULL, NULL, &err) == 0,
     267              :            "send_file >=10 MiB ok");
     268              :     /* 10 MiB + 1 KiB ÷ 512 KiB chunk = 21 parts. */
     269            2 :     ASSERT(g_save_big_file_part_calls >= 21,
     270              :            ">= 21 saveBigFilePart calls");
     271            2 :     ASSERT(g_save_file_part_calls == 0, "no small saveFilePart for big file");
     272              : 
     273            2 :     unlink(path);
     274            2 :     transport_close(&t);
     275            2 :     mt_server_reset();
     276              : }
     277              : 
     278            2 : static void test_upload_photo_uses_saveFilePart(void) {
     279            2 :     with_tmp_home("up-photo");
     280            2 :     mt_server_init(); mt_server_reset();
     281            2 :     reset_counters();
     282            2 :     MtProtoSession s; load_session(&s);
     283            2 :     mt_server_expect(CRC_upload_saveFilePart, on_save_file_part, NULL);
     284            2 :     mt_server_expect(CRC_messages_sendMedia,  on_send_media,     NULL);
     285              : 
     286            2 :     const char *path = make_tempfile("up-photo", 2048);
     287            2 :     ASSERT(path != NULL, "tempfile created");
     288              : 
     289            2 :     ApiConfig cfg; init_cfg(&cfg);
     290            2 :     Transport t; connect_mock(&t);
     291              : 
     292            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     293            2 :     RpcError err = {0};
     294            2 :     ASSERT(domain_send_photo(&cfg, &s, &t, &self, path, "pic", &err) == 0,
     295              :            "send_photo ok");
     296            2 :     ASSERT(g_save_file_part_calls == 1, "photo uploaded as one small part");
     297              : 
     298            2 :     unlink(path);
     299            2 :     transport_close(&t);
     300            2 :     mt_server_reset();
     301              : }
     302              : 
     303              : /* Populate a MediaInfo struct with values the downloader expects. */
     304            8 : static void make_media_info(MediaInfo *mi) {
     305            8 :     memset(mi, 0, sizeof(*mi));
     306            8 :     mi->kind = MEDIA_PHOTO;
     307            8 :     mi->photo_id = 0xDEADBEEFLL;
     308            8 :     mi->access_hash = 0xCAFEBABELL;
     309            8 :     mi->dc_id = 2;
     310            8 :     mi->file_reference_len = 4;
     311            8 :     mi->file_reference[0] = 0x11;
     312            8 :     mi->file_reference[1] = 0x22;
     313            8 :     mi->file_reference[2] = 0x33;
     314            8 :     mi->file_reference[3] = 0x44;
     315            8 :     strcpy(mi->thumb_type, "y");
     316            8 : }
     317              : 
     318              : /* Populate a MediaInfo struct for a document download. */
     319            8 : static void make_doc_media_info(MediaInfo *mi) {
     320            8 :     memset(mi, 0, sizeof(*mi));
     321            8 :     mi->kind = MEDIA_DOCUMENT;
     322            8 :     mi->document_id = 0xFEEDC0FFEE1234LL;
     323            8 :     mi->access_hash = 0xABCDEF0123456789LL;
     324            8 :     mi->dc_id = 2;
     325            8 :     mi->file_reference_len = 4;
     326            8 :     mi->file_reference[0] = 0xAA;
     327            8 :     mi->file_reference[1] = 0xBB;
     328            8 :     mi->file_reference[2] = 0xCC;
     329            8 :     mi->file_reference[3] = 0xDD;
     330            8 :     strcpy(mi->document_filename, "hello.bin");
     331            8 : }
     332              : 
     333            2 : static void test_download_photo_short_chunk(void) {
     334            2 :     with_tmp_home("dl-short");
     335            2 :     mt_server_init(); mt_server_reset();
     336            2 :     reset_counters();
     337            2 :     MtProtoSession s; load_session(&s);
     338            2 :     mt_server_expect(CRC_upload_getFile, on_get_file_short, NULL);
     339              : 
     340            2 :     ApiConfig cfg; init_cfg(&cfg);
     341            2 :     Transport t; connect_mock(&t);
     342              : 
     343            2 :     MediaInfo mi; make_media_info(&mi);
     344            2 :     const char *out = "/tmp/tg-cli-ft-media-dl-short.bin";
     345            2 :     int wrong = -1;
     346            2 :     ASSERT(domain_download_photo(&cfg, &s, &t, &mi, out, &wrong) == 0,
     347              :            "download ok");
     348            2 :     ASSERT(wrong == 0, "no wrong_dc");
     349            2 :     ASSERT(g_get_file_calls == 1,
     350              :            "single call — EOF from short chunk on first try");
     351              : 
     352              :     struct stat st;
     353            2 :     ASSERT(stat(out, &st) == 0, "output exists");
     354            2 :     ASSERT(st.st_size == 128, "128 bytes written");
     355              : 
     356            2 :     unlink(out);
     357            2 :     transport_close(&t);
     358            2 :     mt_server_reset();
     359              : }
     360              : 
     361            2 : static void test_download_photo_two_chunks(void) {
     362            2 :     with_tmp_home("dl-two");
     363            2 :     mt_server_init(); mt_server_reset();
     364            2 :     reset_counters();
     365            2 :     MtProtoSession s; load_session(&s);
     366            2 :     mt_server_expect(CRC_upload_getFile, on_get_file_two_chunks, NULL);
     367              : 
     368            2 :     ApiConfig cfg; init_cfg(&cfg);
     369            2 :     Transport t; connect_mock(&t);
     370              : 
     371            2 :     MediaInfo mi; make_media_info(&mi);
     372            2 :     const char *out = "/tmp/tg-cli-ft-media-dl-two.bin";
     373            2 :     int wrong = -1;
     374            2 :     ASSERT(domain_download_photo(&cfg, &s, &t, &mi, out, &wrong) == 0,
     375              :            "download ok");
     376            2 :     ASSERT(wrong == 0, "no wrong_dc");
     377            2 :     ASSERT(g_get_file_calls == 2,
     378              :            "two calls: first full chunk, second EOF");
     379              : 
     380              :     struct stat st;
     381            2 :     ASSERT(stat(out, &st) == 0, "output exists");
     382            2 :     ASSERT(st.st_size == 128 * 1024 + 64, "128 KiB + 64 bytes written");
     383              : 
     384            2 :     unlink(out);
     385            2 :     transport_close(&t);
     386            2 :     mt_server_reset();
     387              : }
     388              : 
     389            2 : static void test_download_photo_file_migrate(void) {
     390            2 :     with_tmp_home("dl-mig");
     391            2 :     mt_server_init(); mt_server_reset();
     392            2 :     reset_counters();
     393            2 :     MtProtoSession s; load_session(&s);
     394            2 :     mt_server_expect(CRC_upload_getFile, on_get_file_migrate, NULL);
     395              : 
     396            2 :     ApiConfig cfg; init_cfg(&cfg);
     397            2 :     Transport t; connect_mock(&t);
     398              : 
     399            2 :     MediaInfo mi; make_media_info(&mi);
     400            2 :     const char *out = "/tmp/tg-cli-ft-media-dl-mig.bin";
     401            2 :     int wrong = -1;
     402            2 :     ASSERT(domain_download_photo(&cfg, &s, &t, &mi, out, &wrong) == -1,
     403              :            "download fails with migrate");
     404            2 :     ASSERT(wrong == 3, "wrong_dc surfaced as 3");
     405              : 
     406            2 :     unlink(out);
     407            2 :     transport_close(&t);
     408            2 :     mt_server_reset();
     409              : }
     410              : 
     411              : /* ================================================================ */
     412              : /* Document download tests (TEST-07)                                */
     413              : /* ================================================================ */
     414              : 
     415            2 : static void test_download_document_single_chunk(void) {
     416            2 :     with_tmp_home("dl-doc-short");
     417            2 :     mt_server_init(); mt_server_reset();
     418            2 :     reset_counters();
     419            2 :     MtProtoSession s; load_session(&s);
     420            2 :     mt_server_expect(CRC_upload_getFile, on_get_file_short, NULL);
     421              : 
     422            2 :     ApiConfig cfg; init_cfg(&cfg);
     423            2 :     Transport t; connect_mock(&t);
     424              : 
     425            2 :     MediaInfo mi; make_doc_media_info(&mi);
     426              :     /* Use document_filename as the output filename path. */
     427            2 :     const char *out = "/tmp/tg-cli-ft-media-dl-doc-short.bin";
     428            2 :     int wrong = -1;
     429            2 :     ASSERT(domain_download_document(&cfg, &s, &t, &mi, out, &wrong) == 0,
     430              :            "document download ok");
     431            2 :     ASSERT(wrong == 0, "no wrong_dc");
     432            2 :     ASSERT(g_get_file_calls == 1, "single chunk → EOF");
     433              : 
     434              :     struct stat st;
     435            2 :     ASSERT(stat(out, &st) == 0, "output file exists");
     436            2 :     ASSERT(st.st_size == 128, "128 bytes written");
     437              : 
     438              :     /* Verify content: on_get_file_short uses (i ^ 0xA5). */
     439            2 :     FILE *fp = fopen(out, "rb");
     440            2 :     ASSERT(fp != NULL, "can open output");
     441              :     uint8_t buf[128];
     442            2 :     ASSERT(fread(buf, 1, 128, fp) == 128, "read 128 bytes");
     443            2 :     fclose(fp);
     444            2 :     int content_ok = 1;
     445          258 :     for (int i = 0; i < 128; ++i) {
     446          256 :         if (buf[i] != (uint8_t)(i ^ 0xA5u)) { content_ok = 0; break; }
     447              :     }
     448            2 :     ASSERT(content_ok, "document content matches deterministic pattern");
     449              : 
     450            2 :     unlink(out);
     451            2 :     transport_close(&t);
     452            2 :     mt_server_reset();
     453              : }
     454              : 
     455            2 : static void test_download_document_two_chunks(void) {
     456            2 :     with_tmp_home("dl-doc-two");
     457            2 :     mt_server_init(); mt_server_reset();
     458            2 :     reset_counters();
     459            2 :     MtProtoSession s; load_session(&s);
     460            2 :     mt_server_expect(CRC_upload_getFile, on_get_file_two_chunks, NULL);
     461              : 
     462            2 :     ApiConfig cfg; init_cfg(&cfg);
     463            2 :     Transport t; connect_mock(&t);
     464              : 
     465            2 :     MediaInfo mi; make_doc_media_info(&mi);
     466            2 :     const char *out = "/tmp/tg-cli-ft-media-dl-doc-two.bin";
     467            2 :     int wrong = -1;
     468            2 :     ASSERT(domain_download_document(&cfg, &s, &t, &mi, out, &wrong) == 0,
     469              :            "document two-chunk download ok");
     470            2 :     ASSERT(wrong == 0, "no wrong_dc");
     471            2 :     ASSERT(g_get_file_calls == 2, "two calls: first full chunk, second EOF");
     472              : 
     473              :     struct stat st;
     474            2 :     ASSERT(stat(out, &st) == 0, "output file exists");
     475            2 :     ASSERT(st.st_size == 128 * 1024 + 64, "128 KiB + 64 bytes written");
     476              : 
     477            2 :     unlink(out);
     478            2 :     transport_close(&t);
     479            2 :     mt_server_reset();
     480              : }
     481              : 
     482            2 : static void test_download_document_file_migrate(void) {
     483            2 :     with_tmp_home("dl-doc-mig");
     484            2 :     mt_server_init(); mt_server_reset();
     485            2 :     reset_counters();
     486            2 :     MtProtoSession s; load_session(&s);
     487            2 :     mt_server_expect(CRC_upload_getFile, on_get_file_migrate, NULL);
     488              : 
     489            2 :     ApiConfig cfg; init_cfg(&cfg);
     490            2 :     Transport t; connect_mock(&t);
     491              : 
     492            2 :     MediaInfo mi; make_doc_media_info(&mi);
     493            2 :     const char *out = "/tmp/tg-cli-ft-media-dl-doc-mig.bin";
     494            2 :     int wrong = -1;
     495            2 :     ASSERT(domain_download_document(&cfg, &s, &t, &mi, out, &wrong) == -1,
     496              :            "document download fails with FILE_MIGRATE");
     497            2 :     ASSERT(wrong == 3, "wrong_dc surfaced as 3");
     498              : 
     499            2 :     unlink(out);
     500            2 :     transport_close(&t);
     501            2 :     mt_server_reset();
     502              : }
     503              : 
     504            2 : static void test_path_is_image(void) {
     505              :     /* Pure helper — no server — but lives here for coupling with the
     506              :      * upload module. */
     507            2 :     ASSERT(domain_path_is_image("foo.jpg"),  "jpg → image");
     508            2 :     ASSERT(domain_path_is_image("foo.PNG"),  "png → image (case)");
     509            2 :     ASSERT(domain_path_is_image("a/b.webp"), "webp → image");
     510            2 :     ASSERT(!domain_path_is_image("x.txt"),   "txt → not image");
     511            2 :     ASSERT(!domain_path_is_image("x"),       "no dot → not image");
     512            2 :     ASSERT(!domain_path_is_image(NULL),      "NULL → not image");
     513              : }
     514              : 
     515              : /* ================================================================ */
     516              : /* Caption propagation tests (TEST-16)                              */
     517              : /* ================================================================ */
     518              : 
     519              : /* Stores a copy of the sendMedia body for inspection. */
     520              : static uint8_t  g_send_media_body[4096];
     521              : static size_t   g_send_media_body_len = 0;
     522              : 
     523            4 : static void on_send_media_capture(MtRpcContext *ctx) {
     524              :     /* Save a copy of the full request body so the test can inspect it. */
     525            4 :     size_t cap = ctx->req_body_len;
     526            4 :     if (cap > sizeof(g_send_media_body))
     527            0 :         cap = sizeof(g_send_media_body);
     528            4 :     memcpy(g_send_media_body, ctx->req_body, cap);
     529            4 :     g_send_media_body_len = cap;
     530              : 
     531              :     /* Reply with a minimal updates envelope. */
     532              :     TlWriter w;
     533            4 :     tl_writer_init(&w);
     534            4 :     tl_write_uint32(&w, TL_updates);
     535            4 :     tl_write_uint32(&w, TL_vector); tl_write_uint32(&w, 0);
     536            4 :     tl_write_uint32(&w, TL_vector); tl_write_uint32(&w, 0);
     537            4 :     tl_write_uint32(&w, TL_vector); tl_write_uint32(&w, 0);
     538            4 :     tl_write_int32 (&w, 0); tl_write_int32 (&w, 0);
     539            4 :     mt_server_reply_result(ctx, w.data, w.len);
     540            4 :     tl_writer_free(&w);
     541            4 : }
     542              : 
     543              : /* Return 1 if @p needle (len @p nlen) appears anywhere inside
     544              :  * [body, body+blen).  Simple byte-scan, no dependency on <string.h>
     545              :  * memmem (which is a GNU extension). */
     546            4 : static int body_contains(const uint8_t *body, size_t blen,
     547              :                           const char *needle, size_t nlen) {
     548            4 :     if (nlen == 0 || blen < nlen) return 0;
     549          502 :     for (size_t i = 0; i <= blen - nlen; ++i) {
     550          500 :         if (memcmp(body + i, needle, nlen) == 0) return 1;
     551              :     }
     552            2 :     return 0;
     553              : }
     554              : 
     555              : /**
     556              :  * domain_send_file with caption "final version" → the exact byte sequence
     557              :  * "final version" must appear inside the messages.sendMedia wire body.
     558              :  */
     559            2 : static void test_send_file_caption_propagates(void) {
     560            2 :     with_tmp_home("cap-set");
     561            2 :     mt_server_init(); mt_server_reset();
     562            2 :     reset_counters();
     563            2 :     g_send_media_body_len = 0;
     564              : 
     565            2 :     MtProtoSession s; load_session(&s);
     566            2 :     mt_server_expect(CRC_upload_saveFilePart, on_save_file_part,     NULL);
     567            2 :     mt_server_expect(CRC_messages_sendMedia,  on_send_media_capture, NULL);
     568              : 
     569            2 :     const char *path = make_tempfile("cap-set", 512);
     570            2 :     ASSERT(path != NULL, "tempfile created");
     571              : 
     572            2 :     ApiConfig cfg; init_cfg(&cfg);
     573            2 :     Transport t; connect_mock(&t);
     574              : 
     575            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     576            2 :     RpcError err = {0};
     577            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, path,
     578              :                             "final version", "text/plain", &err) == 0,
     579              :            "domain_send_file with caption ok");
     580              : 
     581            2 :     const char *cap = "final version";
     582            2 :     ASSERT(body_contains(g_send_media_body, g_send_media_body_len,
     583              :                          cap, strlen(cap)),
     584              :            "caption 'final version' found in sendMedia wire bytes");
     585              : 
     586            2 :     unlink(path);
     587            2 :     transport_close(&t);
     588            2 :     mt_server_reset();
     589              : }
     590              : 
     591              : /**
     592              :  * domain_send_file with no caption (NULL) → empty TL string on the wire.
     593              :  * An empty TL string is encoded as a single 0x00 byte (length=0, no padding
     594              :  * needed since 1+0=1, padded to 4 → 3 zero pad bytes).  We check that the
     595              :  * literal string "final version" does NOT appear in the request.
     596              :  */
     597            2 : static void test_send_file_no_caption_empty_string(void) {
     598            2 :     with_tmp_home("cap-empty");
     599            2 :     mt_server_init(); mt_server_reset();
     600            2 :     reset_counters();
     601            2 :     g_send_media_body_len = 0;
     602              : 
     603            2 :     MtProtoSession s; load_session(&s);
     604            2 :     mt_server_expect(CRC_upload_saveFilePart, on_save_file_part,     NULL);
     605            2 :     mt_server_expect(CRC_messages_sendMedia,  on_send_media_capture, NULL);
     606              : 
     607            2 :     const char *path = make_tempfile("cap-empty", 512);
     608            2 :     ASSERT(path != NULL, "tempfile created");
     609              : 
     610            2 :     ApiConfig cfg; init_cfg(&cfg);
     611            2 :     Transport t; connect_mock(&t);
     612              : 
     613            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     614            2 :     RpcError err = {0};
     615            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, path,
     616              :                             NULL, "text/plain", &err) == 0,
     617              :            "domain_send_file without caption ok");
     618              : 
     619              :     /* The request body must NOT contain a non-empty caption string. */
     620            2 :     const char *cap = "final version";
     621            2 :     ASSERT(!body_contains(g_send_media_body, g_send_media_body_len,
     622              :                           cap, strlen(cap)),
     623              :            "no spurious caption in sendMedia wire bytes when caption is NULL");
     624              : 
     625            2 :     unlink(path);
     626            2 :     transport_close(&t);
     627            2 :     mt_server_reset();
     628              : }
     629              : 
     630              : /* ================================================================ */
     631              : /* Cache-reuse tests (TEST-08)                                      */
     632              : /* ================================================================ */
     633              : 
     634              : /**
     635              :  * Download a photo twice with the same media_id and output path.
     636              :  * The second call must not issue any upload.getFile RPC — it returns
     637              :  * the cached file instead.
     638              :  */
     639            2 : static void test_download_photo_cache_reuse(void) {
     640            2 :     with_tmp_home("dl-cache-photo");
     641            2 :     mt_server_init(); mt_server_reset();
     642            2 :     reset_counters();
     643            2 :     MtProtoSession s; load_session(&s);
     644            2 :     mt_server_expect(CRC_upload_getFile, on_get_file_short, NULL);
     645              : 
     646            2 :     ApiConfig cfg; init_cfg(&cfg);
     647            2 :     Transport t; connect_mock(&t);
     648              : 
     649            2 :     MediaInfo mi; make_media_info(&mi);
     650            2 :     const char *out = "/tmp/tg-cli-ft-media-dl-cache-photo.bin";
     651            2 :     unlink(out);
     652              : 
     653              :     /* First download — must hit the server. */
     654            2 :     int wrong = -1;
     655            2 :     ASSERT(domain_download_photo(&cfg, &s, &t, &mi, out, &wrong) == 0,
     656              :            "first download ok");
     657            2 :     ASSERT(wrong == 0, "no wrong_dc on first call");
     658            2 :     ASSERT(g_get_file_calls == 1, "first call fires one upload.getFile RPC");
     659              : 
     660              :     /* Second download with identical media_id and out_path — cache hit. */
     661            2 :     int calls_before = g_get_file_calls;
     662            2 :     wrong = -1;
     663            2 :     ASSERT(domain_download_photo(&cfg, &s, &t, &mi, out, &wrong) == 0,
     664              :            "second download ok (from cache)");
     665            2 :     ASSERT(g_get_file_calls == calls_before,
     666              :            "second call does NOT issue upload.getFile (cache hit)");
     667              : 
     668              :     /* File content must be identical to what the first download wrote. */
     669              :     struct stat st;
     670            2 :     ASSERT(stat(out, &st) == 0, "output file still exists");
     671            2 :     ASSERT(st.st_size == 128, "cached file size unchanged (128 bytes)");
     672              : 
     673            2 :     unlink(out);
     674            2 :     transport_close(&t);
     675            2 :     mt_server_reset();
     676              : }
     677              : 
     678              : /**
     679              :  * Download a document twice with the same document_id.
     680              :  * The second call must return the cached file without any RPC.
     681              :  */
     682            2 : static void test_download_document_cache_reuse(void) {
     683            2 :     with_tmp_home("dl-cache-doc");
     684            2 :     mt_server_init(); mt_server_reset();
     685            2 :     reset_counters();
     686            2 :     MtProtoSession s; load_session(&s);
     687            2 :     mt_server_expect(CRC_upload_getFile, on_get_file_short, NULL);
     688              : 
     689            2 :     ApiConfig cfg; init_cfg(&cfg);
     690            2 :     Transport t; connect_mock(&t);
     691              : 
     692            2 :     MediaInfo mi; make_doc_media_info(&mi);
     693            2 :     const char *out = "/tmp/tg-cli-ft-media-dl-cache-doc.bin";
     694            2 :     unlink(out);
     695              : 
     696              :     /* First download — server must be called. */
     697            2 :     int wrong = -1;
     698            2 :     ASSERT(domain_download_document(&cfg, &s, &t, &mi, out, &wrong) == 0,
     699              :            "first document download ok");
     700            2 :     ASSERT(wrong == 0, "no wrong_dc on first call");
     701            2 :     ASSERT(g_get_file_calls == 1, "first call fires one upload.getFile RPC");
     702              : 
     703              :     /* Second download — cache hit, no new RPC. */
     704            2 :     int calls_before = g_get_file_calls;
     705            2 :     wrong = -1;
     706            2 :     ASSERT(domain_download_document(&cfg, &s, &t, &mi, out, &wrong) == 0,
     707              :            "second document download ok (from cache)");
     708            2 :     ASSERT(g_get_file_calls == calls_before,
     709              :            "second document call does NOT issue upload.getFile (cache hit)");
     710              : 
     711              :     struct stat st;
     712            2 :     ASSERT(stat(out, &st) == 0, "cached document file still exists");
     713            2 :     ASSERT(st.st_size == 128, "cached document size unchanged (128 bytes)");
     714              : 
     715            2 :     unlink(out);
     716            2 :     transport_close(&t);
     717            2 :     mt_server_reset();
     718              : }
     719              : 
     720              : /* ================================================================ */
     721              : /* Invalid-path tests (TEST-17)                                     */
     722              : /* ================================================================ */
     723              : 
     724              : /**
     725              :  * NULL path: domain_send_file must return -1 immediately without
     726              :  * touching the wire.
     727              :  */
     728            2 : static void test_upload_null_path(void) {
     729            2 :     with_tmp_home("up-null");
     730            2 :     mt_server_init(); mt_server_reset();
     731            2 :     reset_counters();
     732            2 :     MtProtoSession s; load_session(&s);
     733              :     /* No responders registered — any RPC would cause the mock to fail. */
     734              : 
     735            2 :     ApiConfig cfg; init_cfg(&cfg);
     736            2 :     Transport t; connect_mock(&t);
     737              : 
     738            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     739            2 :     RpcError err = {0};
     740            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, NULL,
     741              :                             NULL, NULL, &err) == -1,
     742              :            "NULL path returns -1");
     743            2 :     ASSERT(mt_server_rpc_call_count() == 0,
     744              :            "NULL path: no RPC fired");
     745              : 
     746            2 :     transport_close(&t);
     747            2 :     mt_server_reset();
     748              : }
     749              : 
     750              : /**
     751              :  * Non-existent path: stat() fails with ENOENT so upload_chunk_phase
     752              :  * returns -1 before any RPC.
     753              :  */
     754            2 : static void test_upload_nonexistent_path(void) {
     755            2 :     with_tmp_home("up-noent");
     756            2 :     mt_server_init(); mt_server_reset();
     757            2 :     reset_counters();
     758            2 :     MtProtoSession s; load_session(&s);
     759              :     /* No responders registered. */
     760              : 
     761            2 :     ApiConfig cfg; init_cfg(&cfg);
     762            2 :     Transport t; connect_mock(&t);
     763              : 
     764            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     765            2 :     RpcError err = {0};
     766            2 :     const char *missing = "/tmp/tg-cli-this-file-does-not-exist-TEST17.bin";
     767            2 :     unlink(missing); /* ensure it really is absent */
     768            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, missing,
     769              :                             NULL, NULL, &err) == -1,
     770              :            "non-existent path returns -1");
     771            2 :     ASSERT(mt_server_rpc_call_count() == 0,
     772              :            "non-existent path: no RPC fired");
     773              : 
     774            2 :     transport_close(&t);
     775            2 :     mt_server_reset();
     776              : }
     777              : 
     778              : /**
     779              :  * File exceeding UPLOAD_MAX_SIZE (1.5 GiB): upload_chunk_phase rejects
     780              :  * the file before any RPC is issued.  We use a sparse file created with
     781              :  * truncate(2) — it reports a size of 2 GiB but consumes no disk space.
     782              :  */
     783            2 : static void test_upload_over_max_size_rejected(void) {
     784            2 :     with_tmp_home("up-overmax");
     785            2 :     mt_server_init(); mt_server_reset();
     786            2 :     reset_counters();
     787            2 :     MtProtoSession s; load_session(&s);
     788              :     /* No responders — any RPC would cause the mock to fail. */
     789              : 
     790              :     /* Create a sparse file whose reported size is 2 GiB (0x80000000 bytes),
     791              :      * comfortably above UPLOAD_MAX_SIZE = 1.5 GiB.  truncate(2) extends the
     792              :      * file without writing actual blocks, so this costs ~0 disk space. */
     793            2 :     const char *big_path = "/tmp/tg-cli-fixture-overmax-TEST18.bin";
     794              :     {
     795            2 :         FILE *fp = fopen(big_path, "wb");
     796            2 :         ASSERT(fp != NULL, "create sparse file placeholder");
     797            2 :         fclose(fp);
     798              :     }
     799              :     /* 2 GiB = 2 * 1024 * 1024 * 1024 */
     800            2 :     off_t two_gib = (off_t)2 * 1024 * 1024 * 1024;
     801            2 :     ASSERT(truncate(big_path, two_gib) == 0, "truncate to 2 GiB (sparse)");
     802              : 
     803            2 :     ApiConfig cfg; init_cfg(&cfg);
     804            2 :     Transport t; connect_mock(&t);
     805              : 
     806            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     807            2 :     RpcError err = {0};
     808            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, big_path,
     809              :                             NULL, NULL, &err) == -1,
     810              :            "2 GiB file returns -1 (over UPLOAD_MAX_SIZE)");
     811            2 :     ASSERT(mt_server_rpc_call_count() == 0,
     812              :            "over-max-size: no RPC fired");
     813              : 
     814            2 :     unlink(big_path);
     815            2 :     transport_close(&t);
     816            2 :     mt_server_reset();
     817              : }
     818              : 
     819              : /**
     820              :  * File just under UPLOAD_MAX_SIZE proceeds normally.  We use a small real
     821              :  * file (1 KiB) — the point is that the size-cap branch is NOT taken.
     822              :  * (The existing test_upload_small_document already covers this path, but
     823              :  * having it adjacent to the over-limit test makes the boundary explicit.)
     824              :  */
     825            2 : static void test_upload_under_max_size_proceeds(void) {
     826            2 :     with_tmp_home("up-undermax");
     827            2 :     mt_server_init(); mt_server_reset();
     828            2 :     reset_counters();
     829            2 :     MtProtoSession s; load_session(&s);
     830            2 :     mt_server_expect(CRC_upload_saveFilePart, on_save_file_part, NULL);
     831            2 :     mt_server_expect(CRC_messages_sendMedia,  on_send_media,     NULL);
     832              : 
     833            2 :     const char *path = make_tempfile("up-undermax", 1024);
     834            2 :     ASSERT(path != NULL, "1 KiB tempfile created");
     835              : 
     836            2 :     ApiConfig cfg; init_cfg(&cfg);
     837            2 :     Transport t; connect_mock(&t);
     838              : 
     839            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     840            2 :     RpcError err = {0};
     841            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, path,
     842              :                             NULL, NULL, &err) == 0,
     843              :            "1 KiB file (under UPLOAD_MAX_SIZE) succeeds");
     844            2 :     ASSERT(mt_server_rpc_call_count() >= 1,
     845              :            "under-max-size: at least one RPC fired");
     846              : 
     847            2 :     unlink(path);
     848            2 :     transport_close(&t);
     849            2 :     mt_server_reset();
     850              : }
     851              : 
     852              : /**
     853              :  * Empty file (0 bytes): upload_chunk_phase rejects st_size == 0 before
     854              :  * any RPC is issued.
     855              :  */
     856            2 : static void test_upload_empty_file(void) {
     857            2 :     with_tmp_home("up-empty");
     858            2 :     mt_server_init(); mt_server_reset();
     859            2 :     reset_counters();
     860            2 :     MtProtoSession s; load_session(&s);
     861              :     /* No responders registered. */
     862              : 
     863              :     /* Create a genuine 0-byte file. */
     864            2 :     const char *empty_path = "/tmp/tg-cli-fixture-empty-TEST17.bin";
     865            2 :     FILE *fp = fopen(empty_path, "wb");
     866            2 :     ASSERT(fp != NULL, "empty tempfile created");
     867            2 :     fclose(fp);
     868              : 
     869            2 :     ApiConfig cfg; init_cfg(&cfg);
     870            2 :     Transport t; connect_mock(&t);
     871              : 
     872            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     873            2 :     RpcError err = {0};
     874            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, empty_path,
     875              :                             NULL, NULL, &err) == -1,
     876              :            "empty file returns -1");
     877            2 :     ASSERT(mt_server_rpc_call_count() == 0,
     878              :            "empty file: no RPC fired");
     879              : 
     880            2 :     unlink(empty_path);
     881            2 :     transport_close(&t);
     882            2 :     mt_server_reset();
     883              : }
     884              : 
     885              : /* ================================================================ */
     886              : /* NETWORK_MIGRATE upload retry test (TEST-19)                      */
     887              : /* ================================================================ */
     888              : 
     889              : /**
     890              :  * upload.saveFilePart responder for the NETWORK_MIGRATE test.
     891              :  *
     892              :  * Call 1 (home DC): arms the reconnect detector and returns
     893              :  *   rpc_error(303, "NETWORK_MIGRATE_4") so the client switches to DC 4.
     894              :  * Call 2+ (DC 4 session, same mock server): returns boolTrue so the
     895              :  *   retried upload succeeds.
     896              :  */
     897            4 : static void on_save_file_part_migrate(MtRpcContext *ctx) {
     898            4 :     g_save_file_part_calls++;
     899            4 :     if (g_save_file_part_calls == 1) {
     900              :         /* Arm reconnect detection so the DC 4 transport's 0xEF marker is
     901              :          * parsed as a new connection rather than a frame length prefix. */
     902            2 :         mt_server_arm_reconnect();
     903            2 :         mt_server_reply_error(ctx, 303, "NETWORK_MIGRATE_4");
     904              :     } else {
     905            2 :         reply_bool_true(ctx);
     906              :     }
     907            4 : }
     908              : 
     909              : /**
     910              :  * FT-19 — NETWORK_MIGRATE_4 retry path for upload.saveFilePart.
     911              :  *
     912              :  * Scenario:
     913              :  *   1. Home DC (DC 2) returns NETWORK_MIGRATE_4 on the first saveFilePart.
     914              :  *   2. upload_chunk_phase opens DC 4 (pre-seeded session → fast path, no DH).
     915              :  *   3. Retried saveFilePart on DC 4 succeeds (boolTrue).
     916              :  *   4. messages.sendMedia fires on the home transport (DC 2).
     917              :  *
     918              :  * Verifications:
     919              :  *   - domain_send_file returns 0 (full success).
     920              :  *   - saveFilePart was called exactly twice: once home, once on DC 4.
     921              :  *   - sendMedia was called exactly once (home DC).
     922              :  */
     923            2 : static void test_upload_savefilepart_network_migrate(void) {
     924            2 :     with_tmp_home("up-mig");
     925            2 :     mt_server_init(); mt_server_reset();
     926            2 :     reset_counters();
     927              : 
     928            2 :     MtProtoSession s; load_session(&s);         /* seeds DC 2 */
     929            2 :     ASSERT(mt_server_seed_extra_dc(4) == 0,     "seed DC4 session");
     930              : 
     931            2 :     mt_server_expect(CRC_upload_saveFilePart, on_save_file_part_migrate, NULL);
     932            2 :     mt_server_expect(CRC_messages_sendMedia,  on_send_media,             NULL);
     933              : 
     934            2 :     const char *path = make_tempfile("up-mig", 1024);
     935            2 :     ASSERT(path != NULL, "tempfile created");
     936              : 
     937            2 :     ApiConfig cfg; init_cfg(&cfg);
     938            2 :     Transport t; connect_mock(&t);
     939              : 
     940            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
     941            2 :     RpcError err = {0};
     942            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, path,
     943              :                             "migrate test", "text/plain", &err) == 0,
     944              :            "domain_send_file succeeds after NETWORK_MIGRATE_4 retry");
     945              : 
     946              :     /* saveFilePart fired once on home DC (→ NETWORK_MIGRATE_4) and
     947              :      * once on DC 4 (→ boolTrue). */
     948            2 :     ASSERT(g_save_file_part_calls == 2,
     949              :            "saveFilePart called twice: once home DC, once DC 4");
     950              : 
     951              :     /* sendMedia fires exactly once on the home transport. */
     952            2 :     ASSERT(g_send_media_calls == 1,
     953              :            "sendMedia fired once on home DC after cross-DC upload");
     954              : 
     955            2 :     unlink(path);
     956            2 :     transport_close(&t);
     957            2 :     mt_server_reset();
     958              : }
     959              : 
     960              : /* ================================================================ */
     961              : /* FILE_MIGRATE upload retry test (TEST-20)                         */
     962              : /* ================================================================ */
     963              : 
     964              : /**
     965              :  * upload.saveFilePart responder for the FILE_MIGRATE test.
     966              :  *
     967              :  * Call 1 (home DC): arms the reconnect detector and returns
     968              :  *   rpc_error(303, "FILE_MIGRATE_3") so the client switches to DC 3.
     969              :  * Call 2+ (DC 3 session, same mock server): returns boolTrue so the
     970              :  *   retried upload succeeds.
     971              :  */
     972            4 : static void on_save_file_part_file_migrate(MtRpcContext *ctx) {
     973            4 :     g_save_file_part_calls++;
     974            4 :     if (g_save_file_part_calls == 1) {
     975            2 :         mt_server_arm_reconnect();
     976            2 :         mt_server_reply_error(ctx, 303, "FILE_MIGRATE_3");
     977              :     } else {
     978            2 :         reply_bool_true(ctx);
     979              :     }
     980            4 : }
     981              : 
     982              : /**
     983              :  * FT-20 — FILE_MIGRATE_3 retry path for upload.saveFilePart.
     984              :  *
     985              :  * Scenario:
     986              :  *   1. Home DC (DC 2) returns FILE_MIGRATE_3 on the first saveFilePart.
     987              :  *   2. upload_chunk_phase opens DC 3 (pre-seeded session → fast path, no DH).
     988              :  *   3. Retried saveFilePart on DC 3 succeeds (boolTrue).
     989              :  *   4. messages.sendMedia fires on the home transport (DC 2).
     990              :  *
     991              :  * Verifications:
     992              :  *   - domain_send_file returns 0 (full success).
     993              :  *   - saveFilePart was called exactly twice: once home, once on DC 3.
     994              :  *   - sendMedia was called exactly once (home DC).
     995              :  */
     996            2 : static void test_upload_savefilepart_file_migrate(void) {
     997            2 :     with_tmp_home("up-fmig");
     998            2 :     mt_server_init(); mt_server_reset();
     999            2 :     reset_counters();
    1000              : 
    1001            2 :     MtProtoSession s; load_session(&s);         /* seeds DC 2 */
    1002            2 :     ASSERT(mt_server_seed_extra_dc(3) == 0,     "seed DC3 session");
    1003              : 
    1004            2 :     mt_server_expect(CRC_upload_saveFilePart, on_save_file_part_file_migrate, NULL);
    1005            2 :     mt_server_expect(CRC_messages_sendMedia,  on_send_media,                  NULL);
    1006              : 
    1007            2 :     const char *path = make_tempfile("up-fmig", 1024);
    1008            2 :     ASSERT(path != NULL, "tempfile created");
    1009              : 
    1010            2 :     ApiConfig cfg; init_cfg(&cfg);
    1011            2 :     Transport t; connect_mock(&t);
    1012              : 
    1013            2 :     HistoryPeer self = { .kind = HISTORY_PEER_SELF };
    1014            2 :     RpcError err = {0};
    1015            2 :     ASSERT(domain_send_file(&cfg, &s, &t, &self, path,
    1016              :                             "file migrate test", "text/plain", &err) == 0,
    1017              :            "domain_send_file succeeds after FILE_MIGRATE_3 retry");
    1018              : 
    1019              :     /* saveFilePart fired once on home DC (→ FILE_MIGRATE_3) and
    1020              :      * once on DC 3 (→ boolTrue). */
    1021            2 :     ASSERT(g_save_file_part_calls == 2,
    1022              :            "saveFilePart called twice: once home DC, once DC 3");
    1023              : 
    1024              :     /* sendMedia fires exactly once on the home transport. */
    1025            2 :     ASSERT(g_send_media_calls == 1,
    1026              :            "sendMedia fired once on home DC after cross-DC FILE_MIGRATE upload");
    1027              : 
    1028            2 :     unlink(path);
    1029            2 :     transport_close(&t);
    1030            2 :     mt_server_reset();
    1031              : }
    1032              : 
    1033            2 : void run_upload_download_tests(void) {
    1034            2 :     RUN_TEST(test_upload_small_document);
    1035            2 :     RUN_TEST(test_upload_multi_chunk_document);
    1036            2 :     RUN_TEST(test_upload_big_file_uses_big_part);
    1037            2 :     RUN_TEST(test_upload_photo_uses_saveFilePart);
    1038            2 :     RUN_TEST(test_download_photo_short_chunk);
    1039            2 :     RUN_TEST(test_download_photo_two_chunks);
    1040            2 :     RUN_TEST(test_download_photo_file_migrate);
    1041            2 :     RUN_TEST(test_download_document_single_chunk);
    1042            2 :     RUN_TEST(test_download_document_two_chunks);
    1043            2 :     RUN_TEST(test_download_document_file_migrate);
    1044            2 :     RUN_TEST(test_path_is_image);
    1045            2 :     RUN_TEST(test_download_photo_cache_reuse);
    1046            2 :     RUN_TEST(test_download_document_cache_reuse);
    1047            2 :     RUN_TEST(test_send_file_caption_propagates);
    1048            2 :     RUN_TEST(test_send_file_no_caption_empty_string);
    1049            2 :     RUN_TEST(test_upload_null_path);
    1050            2 :     RUN_TEST(test_upload_nonexistent_path);
    1051            2 :     RUN_TEST(test_upload_empty_file);
    1052            2 :     RUN_TEST(test_upload_over_max_size_rejected);
    1053            2 :     RUN_TEST(test_upload_under_max_size_proceeds);
    1054            2 :     RUN_TEST(test_upload_savefilepart_network_migrate);
    1055            2 :     RUN_TEST(test_upload_savefilepart_file_migrate);
    1056            2 : }
        

Generated by: LCOV version 2.0-1