feat: media_async + media_index modules, media_files table, tests

- media_async: thread-per-task async engine with primitives (sha256, sign, copy, uuid, file_size)
- media_index: media_files DB table with register_async for indexing chunks
- chat_msg: async media submission via register_async, simplified chat_msg_submit fields
- voice_recorder/attachment_sender: write single file (no chunk splitting on caller side)
- inputbar/messagelist: updated to new simplified submit interface
- test_media_async: 4-thread stress test (200K ops / 2 sec, 0 errors)
- test_media_index: 9 cases covering commit edge cases + async flow
- build: Makefile.am, CMakeLists.txt, utun_sources.cmake updated
This commit is contained in:
evgeny committed 2026-07-29 14:15:10 +03:00
1 parent ce5a84da24
commit 65dbfa6813
43 files changed
+1983 -391

No files matched your search

+10
View File
@@ -60,6 +60,8 @@ check_PROGRAMS = \
test_uasync_socket_race \
test_ntp \
test_opus_codec \
test_media_async \
test_media_index \
bench_timeout_heap \
bench_uasync_timeouts
@@ -315,6 +317,14 @@ test_opus_codec_LDADD = $(COMMON_LIBS) $(top_builddir)/lib/libopus/libopus_inter
test_audio_compressor_SOURCES = test_audio_compressor.c
test_audio_compressor_LDADD = $(COMMON_LIBS) -lm
test_media_async_SOURCES = test_media_async.c
test_media_async_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/media_async -I$(top_srcdir)/lib
test_media_async_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_media_index_SOURCES = test_media_index.c
test_media_index_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/media_delivery -I$(top_srcdir)/src/media_async -I$(top_srcdir)/lib
test_media_index_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
# Copy test configs to build directory (tests run from build/tests/)
all-local: copy-test-configs
+4 -4
View File
@@ -67,7 +67,7 @@ static void test_basic_push_flush(void) {
audio_compressor_config_t cfg = {0};
cfg.sample_rate = 48000; cfg.channels = 1;
cfg.block_duration_ms = 20; cfg.lookback_ms = 200; cfg.lookahead_ms = 100;
cfg.max_gain_db = 30.0f; cfg.rise_rate_per_500ms = 2.0f; cfg.target_level = 0.25f;
cfg.max_gain_db = 30.0f; cfg.rise_rate_per_sec = 4.0f; cfg.target_level = 1.0f;
audio_compressor_configure(ac, &cfg);
int16_t in[960];
@@ -97,7 +97,7 @@ static void test_silence_in_silence_out(void) {
audio_compressor_config_t cfg = {0};
cfg.sample_rate = 48000; cfg.channels = 1;
cfg.block_duration_ms = 20; cfg.lookback_ms = 200; cfg.lookahead_ms = 100;
cfg.max_gain_db = 30.0f; cfg.rise_rate_per_500ms = 2.0f; cfg.target_level = 0.25f;
cfg.max_gain_db = 30.0f; cfg.rise_rate_per_sec = 4.0f; cfg.target_level = 1.0f;
audio_compressor_configure(ac, &cfg);
int16_t in[960]; fill_silence(in, 960);
@@ -121,7 +121,7 @@ static void test_reset(void) {
audio_compressor_config_t cfg = {0};
cfg.sample_rate = 48000; cfg.channels = 1;
cfg.block_duration_ms = 20; cfg.lookback_ms = 200; cfg.lookahead_ms = 100;
cfg.max_gain_db = 30.0f; cfg.rise_rate_per_500ms = 2.0f; cfg.target_level = 0.25f;
cfg.max_gain_db = 30.0f; cfg.rise_rate_per_sec = 4.0f; cfg.target_level = 1.0f;
audio_compressor_configure(ac, &cfg);
int16_t in[960];
@@ -156,7 +156,7 @@ static void test_stress(void) {
cfg.block_duration_ms = 20; cfg.lookback_ms = (rand() % 200) + 50;
cfg.lookahead_ms = (rand() % 100) + 20;
cfg.max_gain_db = (float)(rand() % 31);
cfg.rise_rate_per_500ms = 1.1f + (float)(rand() % 90) / 10.0f;
cfg.rise_rate_per_sec = 0.4f + (float)(rand() % 27) / 10.0f;
cfg.target_level = 0.1f + (float)(rand() % 30) / 100.0f;
audio_compressor_configure(ac, &cfg);
+299
View File
@@ -0,0 +1,299 @@
// test_media_async.c — стресс-тест примитивов media_async (многопоточность + корректность)
//
// 4 потока одновременно выполняют ma_* операции на файлах 1K/5K/10K.
// Тест длится 2 секунды, затем потоки останавливаются.
// Проверяется корректность каждого результата и отсутствие заклиниваний.
#include "../src/media_async/media_async.h"
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <openssl/evp.h>
#define NUM_THREADS 4
#define TEST_TIMEOUT_SEC 2
#define NUM_FILES 3
#define SIZE_1K 1024
#define SIZE_5K 5120
#define SIZE_10K 10240
#define NUM_OPS 5
static volatile int g_running = 1;
static const char* g_op_names[NUM_OPS] = {
"SHA256-file", "Sign-block", "Copy-file",
"UUID", "FileSize"
};
struct thread_stats {
int tid;
int op_count;
int errors;
int op_counts[NUM_OPS];
double max_time_us[NUM_OPS]; // максимальное время (микросекунды) по каждой операции
};
static char g_test_dir[256];
static char g_file_paths[NUM_FILES][512];
static uint8_t g_file_hashes[NUM_FILES][32];
static int g_file_sizes[NUM_FILES];
static uint8_t g_test_data[NUM_FILES][SIZE_10K];
static uint8_t g_ed25519_privkey[32];
static uint8_t g_expected_sigs[NUM_FILES][64];
static int64_t now_us(void) {
struct timeval tv; gettimeofday(&tv, NULL);
return (int64_t)tv.tv_sec * 1000000LL + tv.tv_usec;
}
static void fill_test_data(uint8_t* buf, int size, int seed) {
unsigned s = (unsigned)seed;
for (int i = 0; i < size; i++) {
s = s * 1103515245 + 12345;
buf[i] = (uint8_t)((s >> 16) & 0xFF);
}
}
static void* worker_thread(void* arg) {
struct thread_stats* st = (struct thread_stats*)arg;
unsigned s = (unsigned)(time(NULL) ^ (st->tid * 0x9E3779B9 << 8));
while (g_running) {
s = s * 1103515245 + 12345;
int op = (int)((s >> 16) % 4); // random: 0=SHA256, 1=Sign, 2=Copy, 3=UUID+FileSize
s = s * 1103515245 + 12345;
int fi = (int)((s >> 16) % NUM_FILES);
int size = g_file_sizes[fi];
int real_op = -1;
int64_t t0, elapsed;
switch (op) {
case 0: { // SHA256 file
real_op = 0;
uint8_t hash[32];
t0 = now_us();
int rc = ma_sha256_file(g_file_paths[fi], hash);
elapsed = now_us() - t0;
if (rc != 0 || memcmp(hash, g_file_hashes[fi], 32) != 0) st->errors++;
break;
}
case 1: { // Ed25519 sign
real_op = 1;
uint8_t sig[64];
t0 = now_us();
int rc = ma_sign_block(g_ed25519_privkey, g_test_data[fi], (size_t)size, sig);
elapsed = now_us() - t0;
if (rc != 0 || memcmp(sig, g_expected_sigs[fi], 64) != 0) st->errors++;
break;
}
case 2: { // copy file
real_op = 2;
char dst[512];
snprintf(dst, sizeof(dst), "%s/cp_t%d_n%d.bin", g_test_dir, st->tid, st->op_count);
t0 = now_us();
int rc = ma_copy_file(g_file_paths[fi], dst);
elapsed = now_us() - t0;
if (rc == 0) {
uint8_t hash[32];
if (ma_sha256_file(dst, hash) != 0 || memcmp(hash, g_file_hashes[fi], 32) != 0)
st->errors++;
unlink(dst);
} else st->errors++;
break;
}
case 3: { // UUID
real_op = 3;
uint8_t uuid[16];
t0 = now_us();
ma_uuid(uuid);
elapsed = now_us() - t0;
int zero = 1;
for (int i = 0; i < 16; i++) { if (uuid[i] != 0) { zero = 0; break; } }
if (zero) st->errors++;
// file_size отдельно с замером времени
t0 = now_us();
int64_t fsz = ma_file_size(g_file_paths[fi]);
int64_t et = now_us() - t0;
if (fsz != g_file_sizes[fi]) st->errors++;
st->op_counts[4]++;
if (et > st->max_time_us[4]) st->max_time_us[4] = (double)et;
break;
}
}
st->op_count++;
if (real_op >= 0) {
st->op_counts[real_op]++;
if (elapsed > st->max_time_us[real_op])
st->max_time_us[real_op] = (double)elapsed;
}
}
return NULL;
}
int main(void) {
int ret = 0;
snprintf(g_test_dir, sizeof(g_test_dir), "/tmp/test_media_async_%d", (int)getpid());
if (mkdir(g_test_dir, 0755) != 0) {
perror("mkdir"); return 1;
}
g_file_sizes[0] = SIZE_1K;
g_file_sizes[1] = SIZE_5K;
g_file_sizes[2] = SIZE_10K;
for (int i = 0; i < NUM_FILES; i++) {
fill_test_data(g_test_data[i], g_file_sizes[i], 42 + i * 100);
snprintf(g_file_paths[i], sizeof(g_file_paths[i]), "%s/test_%dk.bin", g_test_dir, g_file_sizes[i] / 1024);
FILE* f = fopen(g_file_paths[i], "wb");
if (!f) { perror("fopen"); ret = 1; goto cleanup; }
fwrite(g_test_data[i], 1, (size_t)g_file_sizes[i], f);
fclose(f);
ma_sha256_file(g_file_paths[i], g_file_hashes[i]);
}
{
EVP_PKEY_CTX* pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL);
if (!pctx) { fprintf(stderr, "EVP_PKEY_CTX_new_id failed\n"); ret = 1; goto cleanup; }
EVP_PKEY_keygen_init(pctx);
EVP_PKEY* pkey = NULL;
if (EVP_PKEY_keygen(pctx, &pkey) != 1 || !pkey) {
fprintf(stderr, "EVP_PKEY_keygen failed\n"); EVP_PKEY_CTX_free(pctx); ret = 1; goto cleanup;
}
EVP_PKEY_CTX_free(pctx);
size_t keylen = 32;
EVP_PKEY_get_raw_private_key(pkey, g_ed25519_privkey, &keylen);
EVP_PKEY_free(pkey);
for (int i = 0; i < NUM_FILES; i++)
ma_sign_block(g_ed25519_privkey, g_test_data[i], (size_t)g_file_sizes[i], g_expected_sigs[i]);
}
pthread_t threads[NUM_THREADS];
struct thread_stats stats[NUM_THREADS];
memset(stats, 0, sizeof(stats));
for (int i = 0; i < NUM_THREADS; i++) {
stats[i].tid = i;
if (pthread_create(&threads[i], NULL, worker_thread, &stats[i]) != 0) {
fprintf(stderr, "pthread_create(%d) failed\n", i);
g_running = 0;
for (int j = 0; j < i; j++) pthread_join(threads[j], NULL);
ret = 1; goto cleanup;
}
}
sleep(TEST_TIMEOUT_SEC);
g_running = 0;
for (int i = 0; i < NUM_THREADS; i++)
pthread_join(threads[i], NULL);
printf("\n=== media_async stress test ===\n");
printf("Timeout: %d sec, Threads: %d, Files: 1K/5K/10K\n\n", TEST_TIMEOUT_SEC, NUM_THREADS);
int total_ops = 0, total_errors = 0;
int totals_op[NUM_OPS] = {0};
double max_time_all[NUM_OPS] = {0};
for (int i = 0; i < NUM_THREADS; i++) {
printf("Thread %d: %d ops (", i, stats[i].op_count);
for (int o = 0; o < NUM_OPS; o++) {
if (o > 0) printf(" ");
printf("%s:%d", g_op_names[o], stats[i].op_counts[o]);
totals_op[o] += stats[i].op_counts[o];
}
printf(") errors=%d\n", stats[i].errors);
printf(" max-time(us): ");
for (int o = 0; o < NUM_OPS; o++) {
if (o > 0) printf(" ");
printf("%s:%.0f", g_op_names[o], stats[i].max_time_us[o]);
if (stats[i].max_time_us[o] > max_time_all[o]) max_time_all[o] = stats[i].max_time_us[o];
}
printf("\n");
total_ops += stats[i].op_count;
total_errors += stats[i].errors;
}
printf("\nTotal: %d ops, %d errors\n", total_ops, total_errors);
printf("Op totals: ");
for (int o = 0; o < NUM_OPS; o++) printf("%s:%d ", g_op_names[o], totals_op[o]);
printf("\nMax time(us): ");
for (int o = 0; o < NUM_OPS; o++) printf("%s:%.0f ", g_op_names[o], max_time_all[o]);
printf("\n\n");
/* ─── sanity checks ─── */
// минимальный throughput: хотя бы 1000 ops на поток за 2 сек
int min_ops_per_thread = 1000;
// разбалансировка потоков: самый медленный поток не хуже 0.1× от самого быстрого
double balance_ratio = 0.1;
// макс допустимое время SHA256/Sign/Copy/USB операции (микросекунды)
double max_us_sha = 100000; // 100ms
double max_us_sign = 100000;
double max_us_copy = 100000;
double max_us_uuid = 5000; // 5ms
double max_us_filesize = 5000;
int min_ops = stats[0].op_count, max_ops = stats[0].op_count;
for (int i = 1; i < NUM_THREADS; i++) {
if (stats[i].op_count < min_ops) min_ops = stats[i].op_count;
if (stats[i].op_count > max_ops) max_ops = stats[i].op_count;
}
printf("Sanity checks:\n");
if (total_errors > 0) {
printf(" FAIL: %d errors\n", total_errors);
ret = 1;
}
if (total_ops < min_ops_per_thread * NUM_THREADS) {
printf(" FAIL: total ops %d < %d (throughput too low)\n",
total_ops, min_ops_per_thread * NUM_THREADS);
ret = 1;
} else {
printf(" OK: total ops=%d >= %d\n", total_ops, min_ops_per_thread * NUM_THREADS);
}
if (max_ops > 0 && min_ops < (int)(max_ops * balance_ratio)) {
printf(" FAIL: thread imbalance min=%d max=%d (ratio %.3f < %.1f)\n",
min_ops, max_ops, max_ops > 0 ? (double)min_ops / max_ops : 0, balance_ratio);
ret = 1;
} else {
printf(" OK: thread balance min=%d max=%d (ratio %.3f)\n",
min_ops, max_ops, max_ops > 0 ? (double)min_ops / max_ops : 0);
}
double limits[NUM_OPS] = { max_us_sha, max_us_sign, max_us_copy, max_us_uuid, max_us_filesize };
for (int o = 0; o < NUM_OPS; o++) {
if (max_time_all[o] > limits[o]) {
printf(" FAIL: %s max-time %.0fus > %.0fus\n", g_op_names[o], max_time_all[o], limits[o]);
ret = 1;
} else if (max_time_all[o] <= 0.0) {
printf(" WARN: %s max-time 0 (no measurements?)\n", g_op_names[o]);
} else {
printf(" OK: %s max-time %.0fus\n", g_op_names[o], max_time_all[o]);
}
}
if (ret == 0) {
printf("\nPASS: %d ops, 0 errors, all checks OK\n", total_ops);
} else {
printf("\nFAIL: sanity checks failed\n");
}
cleanup:
for (int i = 0; i < NUM_FILES; i++) unlink(g_file_paths[i]);
rmdir(g_test_dir);
return ret;
}
+510
View File
@@ -0,0 +1,510 @@
// test_media_index.c — юнит-тесты media_index (commit + register_async)
//
// Покрытие:
// 1. 1 чанк, точный размер
// 2. N чанков, точное совпадение
// 3. Последний чанк меньше
// 4. Файл меньше размера чанка
// 5. Много чанков (>3)
// 6. media_base с trailing slash
// 7. media_base без trailing slash
// 8. node_sign верификация (Ed25519 verify)
// 9. Разные chat_id
// 10. Полный async flow (register_async + copy=1)
#include "../src/media_delivery/media_index.h"
#include "../src/media_async/media_async.h"
#include "../lib/u_async.h"
#include "../lib/mem.h"
#include "../lib/debug_config.h"
#include <openssl/evp.h>
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#define BS 100
#define NODE_ID 0xDEADBEEF
static int g_passed = 0;
static int g_failed = 0;
static int g_total = 0;
#define TEST(name) do { \
g_total++; \
printf(" %-55s", name); \
} while(0)
#define OK() do { g_passed++; printf("OK\n"); } while(0)
#define FAIL(fmt, ...) do { \
g_failed++; \
printf("FAIL: " fmt "\n", ##__VA_ARGS__); \
} while(0)
/* ─── helpers ─── */
static sqlite3* make_db(void) {
sqlite3* db = NULL;
sqlite3_open(":memory:", &db);
sqlite3_exec(db, "PRAGMA journal_mode=memory", NULL, NULL, NULL);
return db;
}
static void free_db(sqlite3* db) { if (db) sqlite3_close(db); }
static void derive_pubkey(const uint8_t priv[32], uint8_t pub[32]) {
EVP_PKEY* pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, priv, 32);
if (pkey) {
size_t l = 32;
EVP_PKEY_get_raw_public_key(pkey, pub, &l);
EVP_PKEY_free(pkey);
}
}
static int ed25519_verify(const uint8_t pub[32], const uint8_t* msg, size_t len, const uint8_t sig[64]) {
EVP_PKEY* pkey = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, NULL, pub, 32);
if (!pkey) return -1;
EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
if (!mdctx) { EVP_PKEY_free(pkey); return -1; }
if (EVP_DigestVerifyInit(mdctx, NULL, NULL, NULL, pkey) != 1) {
EVP_MD_CTX_free(mdctx); EVP_PKEY_free(pkey); return -1;
}
int rc = EVP_DigestVerify(mdctx, sig, 64, msg, len);
EVP_MD_CTX_free(mdctx); EVP_PKEY_free(pkey);
return rc == 1 ? 0 : -1;
}
static int row_count(sqlite3* db, const uint8_t* media_id) {
sqlite3_stmt* st = NULL;
sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM media_files WHERE media_id=?", -1, &st, NULL);
sqlite3_bind_blob(st, 1, media_id, 16, SQLITE_STATIC);
int c = 0;
if (sqlite3_step(st) == SQLITE_ROW) c = sqlite3_column_int(st, 0);
sqlite3_finalize(st);
return c;
}
/* заполняет result рандомными данными для тестирования commit */
static void fill_result(struct media_index_result* r, int64_t file_size, int64_t block_size, int num_blocks) {
memset(r, 0, sizeof(*r));
r->file_size = file_size;
r->block_size = block_size;
r->num_blocks = num_blocks;
ma_uuid(r->media_id);
ma_uuid(r->content_hash);
ma_uuid(r->content_hash + 16);
r->block_ids = u_malloc((size_t)num_blocks * 16);
r->block_sigs = u_malloc((size_t)num_blocks * 64);
for (int i = 0; i < num_blocks; i++) {
ma_uuid(r->block_ids + i * 16);
ma_uuid(r->block_sigs + i * 64);
ma_uuid(r->block_sigs + i * 64 + 16);
ma_uuid(r->block_sigs + i * 64 + 32);
ma_uuid(r->block_sigs + i * 64 + 48);
}
}
static int check_row(sqlite3* db, const uint8_t* media_id, int chunk,
const uint8_t* content_hash, int64_t file_size,
int64_t chunk_size, int64_t offset, uint64_t node_id,
const char* chat_id, const char* location,
const uint8_t* node_pubkey) {
sqlite3_stmt* st = NULL;
sqlite3_prepare_v2(db,
"SELECT block_id,content_hash,chat_id,location,node_id,node_sign,timestamp,file_size,chunk_size,chunk,offset"
" FROM media_files WHERE media_id=? AND chunk=?",
-1, &st, NULL);
if (!st) return -1;
sqlite3_bind_blob(st, 1, media_id, 16, SQLITE_STATIC);
sqlite3_bind_int(st, 2, chunk);
if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return -1; }
const uint8_t* bid = (const uint8_t*)sqlite3_column_blob(st, 0);
int bid_len = sqlite3_column_bytes(st, 0);
const uint8_t* ch = (const uint8_t*)sqlite3_column_blob(st, 1);
int ch_len = sqlite3_column_bytes(st, 1);
const char* cid = (const char*)sqlite3_column_text(st, 2);
const char* loc = (const char*)sqlite3_column_text(st, 3);
uint64_t nid = (uint64_t)sqlite3_column_int64(st, 4);
const uint8_t* nsig = (const uint8_t*)sqlite3_column_blob(st, 5);
int nsig_len = sqlite3_column_bytes(st, 5);
int64_t ts = sqlite3_column_int64(st, 6);
int64_t fsz = sqlite3_column_int64(st, 7);
int64_t csz = sqlite3_column_int64(st, 8);
int c = sqlite3_column_int(st, 9);
int64_t off = sqlite3_column_int64(st, 10);
int err = 0;
if (!bid || bid_len != 16) { printf("bad block_id "); err = -1; }
if (!ch || ch_len != 32 || memcmp(ch, content_hash, 32) != 0) { printf("bad content_hash "); err = -1; }
if (!cid || strcmp(cid, chat_id) != 0) { printf("bad chat_id "); err = -1; }
if (!loc || strcmp(loc, location) != 0) { printf("bad location '%s'!='%s' ", loc ? loc : "NULL", location); err = -1; }
if (nid != node_id) { printf("bad node_id %llu!=%llu ", (unsigned long long)nid, (unsigned long long)node_id); err = -1; }
if (!nsig || nsig_len != 64) { printf("bad node_sign "); err = -1; }
if (ts <= 0) { printf("bad timestamp %lld ", (long long)ts); err = -1; }
if (fsz != file_size) { printf("bad file_size %lld!=%lld ", (long long)fsz, (long long)file_size); err = -1; }
if (csz != chunk_size) { printf("bad chunk_size %lld!=%lld ", (long long)csz, (long long)chunk_size); err = -1; }
if (c != chunk) { printf("bad chunk %d!=%d ", c, chunk); err = -1; }
if (off != offset) { printf("bad offset %lld!=%lld ", (long long)off, (long long)offset); err = -1; }
if (err == 0 && nsig && node_pubkey) {
uint8_t sig_msg[92];
size_t soff = 0;
memcpy(sig_msg + soff, media_id, 16); soff += 16;
memcpy(sig_msg + soff, content_hash, 32); soff += 32;
memcpy(sig_msg + soff, &ts, 8); soff += 8;
memcpy(sig_msg + soff, &fsz, 8); soff += 8;
memcpy(sig_msg + soff, &csz, 8); soff += 8;
int32_t cn = (int32_t)chunk; memcpy(sig_msg + soff, &cn, 4); soff += 4;
memcpy(sig_msg + soff, &off, 8); soff += 8;
memcpy(sig_msg + soff, &node_id, 8); soff += 8;
if (ed25519_verify(node_pubkey, sig_msg, soff, nsig) != 0)
{ printf("bad node_sign verify "); err = -1; }
}
sqlite3_finalize(st);
return err;
}
/* ─── тесты ─── */
static int test_single_chunk(void) {
TEST("single chunk, exact size (100/100)");
sqlite3* db = make_db();
media_index_init(db);
uint8_t priv[32], pub[32];
ma_uuid(priv); ma_uuid(priv + 16);
derive_pubkey(priv, pub);
struct media_index_result r;
fill_result(&r, 100, 100, 1);
int rc = media_index_commit(db, &r, NODE_ID, priv, "ch1",
"/data/ch1/file.bin", "/data");
if (rc != 0) { FAIL("commit=%d", rc); media_index_result_free(&r); free_db(db); return -1; }
if (row_count(db, r.media_id) != 1) { FAIL("row count != 1"); media_index_result_free(&r); free_db(db); return -1; }
int cr = check_row(db, r.media_id, 0, r.content_hash, 100, 100, 0, NODE_ID, "ch1",
"ch1/file.bin", pub);
if (cr != 0) { FAIL("check_row"); media_index_result_free(&r); free_db(db); return -1; }
OK();
media_index_result_free(&r);
free_db(db);
return 0;
}
static int test_exact_multiple(void) {
TEST("3 chunks, exact match (300/100)");
sqlite3* db = make_db();
media_index_init(db);
uint8_t priv[32], pub[32];
ma_uuid(priv); ma_uuid(priv + 16); derive_pubkey(priv, pub);
struct media_index_result r;
fill_result(&r, 300, 100, 3);
if (media_index_commit(db, &r, NODE_ID, priv, "ch2", "/data/ch2/file.bin", "/data") != 0)
{ FAIL("commit"); media_index_result_free(&r); free_db(db); return -1; }
if (row_count(db, r.media_id) != 3) { FAIL("row count != 3"); media_index_result_free(&r); free_db(db); return -1; }
if (check_row(db, r.media_id, 0, r.content_hash, 300, 100, 0, NODE_ID, "ch2", "ch2/file.bin", pub) != 0)
{ FAIL("chunk 0"); media_index_result_free(&r); free_db(db); return -1; }
if (check_row(db, r.media_id, 1, r.content_hash, 300, 100, 100, NODE_ID, "ch2", "ch2/file.bin", pub) != 0)
{ FAIL("chunk 1"); media_index_result_free(&r); free_db(db); return -1; }
if (check_row(db, r.media_id, 2, r.content_hash, 300, 100, 200, NODE_ID, "ch2", "ch2/file.bin", pub) != 0)
{ FAIL("chunk 2"); media_index_result_free(&r); free_db(db); return -1; }
OK();
media_index_result_free(&r); free_db(db);
return 0;
}
static int test_partial_last(void) {
TEST("last chunk smaller (250/100 → 3 chunks)");
sqlite3* db = make_db();
media_index_init(db);
uint8_t priv[32], pub[32];
ma_uuid(priv); ma_uuid(priv + 16); derive_pubkey(priv, pub);
struct media_index_result r;
fill_result(&r, 250, 100, 3);
if (media_index_commit(db, &r, NODE_ID, priv, "ch3", "/data/ch3/file.bin", "/data") != 0)
{ FAIL("commit"); media_index_result_free(&r); free_db(db); return -1; }
if (row_count(db, r.media_id) != 3) { FAIL("row count != 3"); media_index_result_free(&r); free_db(db); return -1; }
if (check_row(db, r.media_id, 0, r.content_hash, 250, 100, 0, NODE_ID, "ch3", "ch3/file.bin", pub) != 0)
{ FAIL("chunk 0"); media_index_result_free(&r); free_db(db); return -1; }
if (check_row(db, r.media_id, 1, r.content_hash, 250, 100, 100, NODE_ID, "ch3", "ch3/file.bin", pub) != 0)
{ FAIL("chunk 1"); media_index_result_free(&r); free_db(db); return -1; }
if (check_row(db, r.media_id, 2, r.content_hash, 250, 50, 200, NODE_ID, "ch3", "ch3/file.bin", pub) != 0)
{ FAIL("chunk 2 (last, chunk_size=50)"); media_index_result_free(&r); free_db(db); return -1; }
OK();
media_index_result_free(&r); free_db(db);
return 0;
}
static int test_file_smaller_than_block(void) {
TEST("file smaller than block (50/100 → 1 chunk)");
sqlite3* db = make_db();
media_index_init(db);
uint8_t priv[32], pub[32];
ma_uuid(priv); ma_uuid(priv + 16); derive_pubkey(priv, pub);
struct media_index_result r;
fill_result(&r, 50, 100, 1);
if (media_index_commit(db, &r, NODE_ID, priv, "ch4", "/data/ch4/file.bin", "/data") != 0)
{ FAIL("commit"); media_index_result_free(&r); free_db(db); return -1; }
if (row_count(db, r.media_id) != 1) { FAIL("row count != 1"); media_index_result_free(&r); free_db(db); return -1; }
if (check_row(db, r.media_id, 0, r.content_hash, 50, 50, 0, NODE_ID, "ch4", "ch4/file.bin", pub) != 0)
{ FAIL("chunk 0"); media_index_result_free(&r); free_db(db); return -1; }
OK();
media_index_result_free(&r); free_db(db);
return 0;
}
static int test_many_chunks(void) {
TEST("8 chunks (800/100)");
sqlite3* db = make_db();
media_index_init(db);
uint8_t priv[32], pub[32];
ma_uuid(priv); ma_uuid(priv + 16); derive_pubkey(priv, pub);
struct media_index_result r;
fill_result(&r, 800, 100, 8);
if (media_index_commit(db, &r, NODE_ID, priv, "ch5", "/data/ch5/file.bin", "/data") != 0)
{ FAIL("commit"); media_index_result_free(&r); free_db(db); return -1; }
if (row_count(db, r.media_id) != 8) { FAIL("row count != 8"); media_index_result_free(&r); free_db(db); return -1; }
// все block_id уникальны
for (int i = 0; i < 8; i++) {
for (int j = i + 1; j < 8; j++) {
if (memcmp(r.block_ids + i * 16, r.block_ids + j * 16, 16) == 0)
{ FAIL("block_id collision i=%d j=%d", i, j); media_index_result_free(&r); free_db(db); return -1; }
}
}
for (int i = 0; i < 8; i++) {
int64_t cs = 100, off = i * 100;
if (check_row(db, r.media_id, i, r.content_hash, 800, cs, off, NODE_ID, "ch5", "ch5/file.bin", pub) != 0)
{ FAIL("chunk %d", i); media_index_result_free(&r); free_db(db); return -1; }
}
OK();
media_index_result_free(&r); free_db(db);
return 0;
}
static int test_location_trailing_slash(void) {
TEST("media_base with trailing slash");
sqlite3* db = make_db();
media_index_init(db);
uint8_t priv[32], pub[32];
ma_uuid(priv); ma_uuid(priv + 16); derive_pubkey(priv, pub);
struct media_index_result r;
fill_result(&r, 100, 100, 1);
if (media_index_commit(db, &r, NODE_ID, priv, "tc", "/root/media/tc/v.bin", "/root/") != 0)
{ FAIL("commit"); media_index_result_free(&r); free_db(db); return -1; }
if (check_row(db, r.media_id, 0, r.content_hash, 100, 100, 0, NODE_ID, "tc", "media/tc/v.bin", pub) != 0)
{ FAIL("check_row"); media_index_result_free(&r); free_db(db); return -1; }
OK();
media_index_result_free(&r); free_db(db);
return 0;
}
static int test_location_no_slash(void) {
TEST("media_base without trailing slash");
sqlite3* db = make_db();
media_index_init(db);
uint8_t priv[32], pub[32];
ma_uuid(priv); ma_uuid(priv + 16); derive_pubkey(priv, pub);
struct media_index_result r;
fill_result(&r, 100, 100, 1);
if (media_index_commit(db, &r, NODE_ID, priv, "tc2", "/root/media/tc2/v.bin", "/root") != 0)
{ FAIL("commit"); media_index_result_free(&r); free_db(db); return -1; }
if (check_row(db, r.media_id, 0, r.content_hash, 100, 100, 0, NODE_ID, "tc2", "media/tc2/v.bin", pub) != 0)
{ FAIL("check_row"); media_index_result_free(&r); free_db(db); return -1; }
OK();
media_index_result_free(&r); free_db(db);
return 0;
}
static int test_different_chat_ids(void) {
TEST("two commits with different chat_ids");
sqlite3* db = make_db();
media_index_init(db);
uint8_t priv[32], pub[32];
ma_uuid(priv); ma_uuid(priv + 16); derive_pubkey(priv, pub);
struct media_index_result r1, r2;
fill_result(&r1, 100, 100, 1);
fill_result(&r2, 200, 100, 2);
if (media_index_commit(db, &r1, NODE_ID, priv, "chatA", "/d/cA/f.bin", "/d") != 0)
{ FAIL("commit1"); goto err; }
if (media_index_commit(db, &r2, NODE_ID + 1, priv, "chatB", "/d/cB/f.bin", "/d") != 0)
{ FAIL("commit2"); goto err; }
if (row_count(db, r1.media_id) != 1) { FAIL("r1 count"); goto err; }
if (row_count(db, r2.media_id) != 2) { FAIL("r2 count"); goto err; }
if (check_row(db, r1.media_id, 0, r1.content_hash, 100, 100, 0, NODE_ID, "chatA", "cA/f.bin", pub) != 0)
{ FAIL("r1 row"); goto err; }
if (check_row(db, r2.media_id, 0, r2.content_hash, 200, 100, 0, NODE_ID+1, "chatB", "cB/f.bin", pub) != 0)
{ FAIL("r2 row 0"); goto err; }
if (check_row(db, r2.media_id, 1, r2.content_hash, 200, 100, 100, NODE_ID+1, "chatB", "cB/f.bin", pub) != 0)
{ FAIL("r2 row 1"); goto err; }
OK();
media_index_result_free(&r1); media_index_result_free(&r2); free_db(db);
return 0;
err:
media_index_result_free(&r1); media_index_result_free(&r2); free_db(db);
return -1;
}
/* ─── интеграционный тест: register_async ─── */
struct async_ctx {
volatile int done;
int err;
struct media_index_result result;
};
static void on_registered(void* arg, int err, const struct media_index_result* r) {
struct async_ctx* ctx = (struct async_ctx*)arg;
ctx->err = err;
if (!err && r) {
memcpy(&ctx->result, r, sizeof(*r));
if (r->block_ids) {
ctx->result.block_ids = u_malloc((size_t)r->num_blocks * 16);
memcpy(ctx->result.block_ids, r->block_ids, (size_t)r->num_blocks * 16);
}
if (r->block_sigs) {
ctx->result.block_sigs = u_malloc((size_t)r->num_blocks * 64);
memcpy(ctx->result.block_sigs, r->block_sigs, (size_t)r->num_blocks * 64);
}
} else memset(&ctx->result, 0, sizeof(ctx->result));
ctx->done = 1;
}
static void on_timeout(void* arg) {
volatile int* done = (volatile int*)arg;
if (done) *done = 1;
}
static int create_file(const char* path, int size) {
FILE* f = fopen(path, "wb");
if (!f) return -1;
for (int i = 0; i < size; i++) fputc((i * 7 + 13) & 0xFF, f);
fclose(f);
return 0;
}
static int test_register_async_flow(void) {
TEST("register_async: copy file + verify DB");
char tempd[256];
snprintf(tempd, sizeof(tempd), "/tmp/test_media_idx_%d", (int)getpid());
mkdir(tempd, 0755);
char src[512]; snprintf(src, sizeof(src), "%s/src.bin", tempd);
char dst[512]; snprintf(dst, sizeof(dst), "%s/media/ch_test/dst.bin", tempd);
{
char t1[512], t2[512];
snprintf(t1, sizeof(t1), "%s/media", tempd); mkdir(t1, 0755);
snprintf(t2, sizeof(t2), "%s/media/ch_test", tempd); mkdir(t2, 0755);
}
create_file(src, 400); // 400 bytes → 4 chunks of 100
struct UASYNC* ua = uasync_create();
if (!ua) { FAIL("uasync_create"); rmdir(tempd); return -1; }
struct async_ctx ctx = {0};
void* th = uasync_set_timeout(ua, 50000, &ctx.done, on_timeout, "tout_async");
struct media_async* ma = media_async_create();
sqlite3* db = make_db();
media_index_init(db);
uint8_t priv[32], pub[32];
ma_uuid(priv); ma_uuid(priv + 16); derive_pubkey(priv, pub);
media_index_register_async(ma, ua, db, 0xCAFE, priv, "ch_test",
src, dst, 1, tempd, on_registered, &ctx);
while (!ctx.done) uasync_poll(ua, 100);
uasync_cancel_timeout(ua, th);
if (ctx.err != 0) { FAIL("register_async err=%d", ctx.err); goto cleanup; }
{ struct stat st; if (stat(dst, &st) != 0) { FAIL("dst file not created"); goto cleanup; } }
int nb = ctx.result.num_blocks;
int64_t bs = ctx.result.block_size;
int64_t fs = ctx.result.file_size;
if (fs != 400) { FAIL("file_size=%lld != 400", (long long)fs); goto cleanup; }
if (nb < 1 || nb > 400) { FAIL("num_blocks=%d unexpected", nb); goto cleanup; }
if (row_count(db, ctx.result.media_id) != nb) { FAIL("row count %d != %d", row_count(db, ctx.result.media_id), nb); goto cleanup; }
for (int i = 0; i < nb; i++) {
int64_t cs = (i == nb - 1) ? fs - i * bs : bs;
int64_t off = i * bs;
if (check_row(db, ctx.result.media_id, i, ctx.result.content_hash, fs, cs, off, 0xCAFE, "ch_test",
"media/ch_test/dst.bin", pub) != 0)
{ FAIL("chunk %d", i); goto cleanup; }
}
// block_ids уникальны
for (int i = 0; i < nb; i++)
for (int j = i+1; j < nb; j++)
if (memcmp(ctx.result.block_ids + i*16, ctx.result.block_ids + j*16, 16) == 0)
{ FAIL("block_id collision i=%d j=%d", i, j); goto cleanup; }
OK();
cleanup:
media_index_result_free(&ctx.result);
free_db(db);
media_async_destroy(ma);
uasync_destroy(ua, 0);
unlink(src); unlink(dst);
{ char t1[512]; snprintf(t1,sizeof(t1),"%s/media/ch_test",tempd); rmdir(t1); }
{ char t2[512]; snprintf(t2,sizeof(t2),"%s/media",tempd); rmdir(t2); }
rmdir(tempd);
return 0;
}
/* ─── main ─── */
int main(void) {
debug_set_level(DEBUG_LEVEL_ERROR);
printf("=== test_media_index ===\n\n");
test_single_chunk();
test_exact_multiple();
test_partial_last();
test_file_smaller_than_block();
test_many_chunks();
test_location_trailing_slash();
test_location_no_slash();
test_different_chat_ids();
test_register_async_flow();
printf("\nResults: %d/%d passed, %d failed\n", g_passed, g_total, g_failed);
fflush(stdout);
return g_failed ? 1 : 0;
}