Line data Source code
1 : /**
2 : * @file test_pty_send_local.c
3 : * @brief PTY tests for local-first send behaviour (US-SL-01 to US-SL-04).
4 : *
5 : * US-SL-01: Successful SMTP send → message saved to local Sent folder,
6 : * queued in pending_appends.tsv, TUI shows "Saved locally".
7 : * US-SL-02: Failed SMTP send → message saved to local Drafts folder,
8 : * queued in pending_appends.tsv, TUI shows "Saved to Drafts".
9 : * US-SL-03: email-sync uploads pending messages and clears the queue.
10 : * US-SL-04: pending_appends.tsv survives between runs (persistent queue).
11 : *
12 : * Usage: test-pty-send-local <email-tui> <email-sync>
13 : * <mock-imap-server> <mock-smtp-server>
14 : */
15 :
16 : #define _DEFAULT_SOURCE
17 : #define _XOPEN_SOURCE 600
18 :
19 : #include "ptytest.h"
20 : #include "pty_assert.h"
21 :
22 : #include <dirent.h>
23 : #include <fcntl.h>
24 : #include <netinet/in.h>
25 : #include <signal.h>
26 : #include <stdio.h>
27 : #include <stdlib.h>
28 : #include <string.h>
29 : #include <sys/socket.h>
30 : #include <sys/stat.h>
31 : #include <sys/wait.h>
32 : #include <unistd.h>
33 :
34 : /* ── Test globals ────────────────────────────────────────────────────── */
35 :
36 : int g_tests_run = 0;
37 : int g_tests_failed = 0;
38 :
39 : static char g_tui_bin[512];
40 : static char g_sync_bin[512];
41 : static char g_imap_bin[512];
42 : static char g_smtp_bin[512];
43 :
44 : static char g_test_home[512];
45 : static char g_old_home[512];
46 : static char g_editor_script[600];
47 :
48 : static pid_t g_imap_pid = -1;
49 : static pid_t g_smtp_pid = -1;
50 :
51 : #define WAIT_MS 8000
52 : #define SETTLE_MS 400
53 : #define ROWS 24
54 : #define COLS 120
55 :
56 : #define RUN_TEST(fn) do { printf(" %s...\n", #fn); fflush(stdout); fn(); } while(0)
57 :
58 : /* Account directory under test HOME (username-based path) */
59 : #define ACCOUNT_DIR "/.local/share/email-cli/accounts/testuser@example.com"
60 :
61 : /* ── Fake editor ─────────────────────────────────────────────────────── */
62 :
63 23 : static void write_editor_script(void) {
64 23 : snprintf(g_editor_script, sizeof(g_editor_script),
65 : "%s/.fake_editor.sh", g_test_home);
66 23 : FILE *f = fopen(g_editor_script, "w");
67 23 : if (!f) return;
68 23 : fprintf(f, "#!/bin/sh\necho 'Test body' >> \"$1\"\n");
69 23 : fclose(f);
70 23 : chmod(g_editor_script, 0755);
71 23 : setenv("EDITOR", g_editor_script, 1);
72 : }
73 :
74 : /* ── Directory setup ──────────────────────────────────────────────────── */
75 :
76 29 : static void mkdirs(void) {
77 : char d[700];
78 29 : snprintf(d, sizeof(d), "%s/.config", g_test_home); mkdir(d, 0700);
79 29 : snprintf(d, sizeof(d), "%s/.config/email-cli", g_test_home); mkdir(d, 0700);
80 29 : snprintf(d, sizeof(d), "%s/.config/email-cli/accounts", g_test_home); mkdir(d, 0700);
81 29 : snprintf(d, sizeof(d), "%s/.config/email-cli/accounts/testuser", g_test_home); mkdir(d, 0700);
82 29 : snprintf(d, sizeof(d), "%s/.local", g_test_home); mkdir(d, 0700);
83 29 : snprintf(d, sizeof(d), "%s/.local/share", g_test_home); mkdir(d, 0700);
84 29 : snprintf(d, sizeof(d), "%s/.local/share/email-cli", g_test_home); mkdir(d, 0700);
85 29 : snprintf(d, sizeof(d), "%s/.local/share/email-cli/accounts", g_test_home); mkdir(d, 0700);
86 29 : snprintf(d, sizeof(d), "%s%s", g_test_home, ACCOUNT_DIR); mkdir(d, 0700);
87 29 : snprintf(d, sizeof(d), "%s%s/manifests", g_test_home, ACCOUNT_DIR); mkdir(d, 0700);
88 29 : }
89 :
90 23 : static void write_config(void) {
91 23 : mkdirs();
92 : char path[800];
93 23 : snprintf(path, sizeof(path),
94 : "%s/.config/email-cli/accounts/testuser/config.ini", g_test_home);
95 23 : FILE *fp = fopen(path, "w");
96 23 : if (!fp) return;
97 23 : fprintf(fp,
98 : "EMAIL_HOST=imaps://localhost:9993\n"
99 : "EMAIL_USER=testuser@example.com\n"
100 : "EMAIL_PASS=testpass\n"
101 : "EMAIL_FOLDER=INBOX\n"
102 : "SMTP_HOST=smtps://localhost:9025\n"
103 : "SMTP_PORT=9025\n"
104 : "SMTP_USER=testuser@example.com\n"
105 : "SMTP_PASS=testpass\n"
106 : "SSL_NO_VERIFY=1\n");
107 23 : fclose(fp);
108 23 : chmod(path, 0600);
109 : }
110 :
111 : /* ── Server helpers ───────────────────────────────────────────────────── */
112 :
113 53 : static int probe_port(int port) {
114 53 : int fd = socket(AF_INET, SOCK_STREAM, 0);
115 53 : if (fd < 0) return -1;
116 53 : struct sockaddr_in a = {0};
117 53 : a.sin_family = AF_INET;
118 53 : a.sin_port = htons((uint16_t)port);
119 53 : a.sin_addr.s_addr = htonl(0x7f000001);
120 53 : int r = connect(fd, (struct sockaddr *)&a, sizeof(a));
121 53 : close(fd);
122 53 : return r;
123 : }
124 :
125 53 : static void start_server(const char *bin, pid_t *pid_out) {
126 53 : *pid_out = fork();
127 66 : if (*pid_out < 0) return;
128 66 : if (*pid_out == 0) {
129 13 : int devnull = open("/dev/null", O_WRONLY);
130 13 : if (devnull >= 0) { dup2(devnull, 1); dup2(devnull, 2); close(devnull); }
131 13 : execl(bin, bin, (char *)NULL);
132 13 : _exit(127);
133 : }
134 53 : usleep(800000);
135 : }
136 :
137 61 : static void stop_server(pid_t *pid_out) {
138 61 : if (*pid_out > 0) {
139 44 : kill(*pid_out, SIGKILL);
140 44 : waitpid(*pid_out, NULL, 0);
141 44 : *pid_out = -1;
142 : }
143 61 : }
144 :
145 : /* Kill any process still listening on the given TCP port (cleanup of
146 : * lingering instances from previous test runs). */
147 5 : static void kill_listeners_on_port(int port) {
148 : char cmd[80];
149 5 : snprintf(cmd, sizeof(cmd), "fuser -k -KILL %d/tcp >/dev/null 2>&1", port);
150 5 : int _unused = system(cmd); (void)_unused;
151 5 : usleep(200000);
152 5 : }
153 :
154 29 : static void restart_imap(void) {
155 29 : stop_server(&g_imap_pid);
156 29 : usleep(200000);
157 29 : start_server(g_imap_bin, &g_imap_pid);
158 29 : for (int i = 0; i < 30 && probe_port(9993) != 0; i++) usleep(100000);
159 29 : }
160 :
161 24 : static void restart_smtp(void) {
162 24 : stop_server(&g_smtp_pid);
163 24 : usleep(200000);
164 24 : start_server(g_smtp_bin, &g_smtp_pid);
165 24 : for (int i = 0; i < 30 && probe_port(9025) != 0; i++) usleep(100000);
166 24 : }
167 :
168 : /* ── TUI / sync runners ───────────────────────────────────────────────── */
169 :
170 17 : static PtySession *tui_run(void) {
171 17 : const char *args[4] = { g_tui_bin, NULL };
172 17 : PtySession *s = pty_open(COLS, ROWS);
173 17 : if (!s) return NULL;
174 17 : if (pty_run(s, args) != 0) { pty_close(s); return NULL; }
175 13 : return s;
176 : }
177 :
178 3 : static PtySession *sync_run(const char **extra_args) {
179 : const char *argv[16];
180 3 : int n = 0;
181 3 : argv[n++] = g_sync_bin;
182 3 : if (extra_args)
183 0 : for (int i = 0; extra_args[i] && n < 15; i++)
184 0 : argv[n++] = extra_args[i];
185 3 : argv[n] = NULL;
186 3 : PtySession *s = pty_open(COLS, ROWS);
187 3 : if (!s) return NULL;
188 3 : if (pty_run(s, argv) != 0) { pty_close(s); return NULL; }
189 2 : return s;
190 : }
191 :
192 : /*
193 : * Navigate TUI to the folder browser for the test account.
194 : * Returns a running PtySession ready at the folder browser screen,
195 : * or NULL on failure.
196 : */
197 17 : static PtySession *tui_open_to_folders(void) {
198 17 : write_config();
199 17 : PtySession *s = tui_run();
200 13 : if (!s) return NULL;
201 13 : if (pty_wait_for(s, "testuser", WAIT_MS) != 0) { pty_close(s); return NULL; }
202 13 : pty_settle(s, SETTLE_MS);
203 13 : pty_send_key(s, PTY_KEY_ENTER);
204 13 : if (pty_wait_for(s, "Folders", WAIT_MS) != 0) { pty_close(s); return NULL; }
205 13 : pty_settle(s, SETTLE_MS);
206 13 : return s;
207 : }
208 :
209 : /*
210 : * Navigate TUI to the INBOX message list.
211 : */
212 17 : static PtySession *tui_open_to_inbox(void) {
213 17 : PtySession *s = tui_open_to_folders();
214 13 : if (!s) return NULL;
215 13 : pty_send_key(s, PTY_KEY_HOME);
216 91 : for (int i = 0; i < 6; i++) { pty_send_key(s, PTY_KEY_DOWN); pty_settle(s, 50); }
217 13 : if (pty_wait_for(s, "INBOX", WAIT_MS) != 0) { pty_close(s); return NULL; }
218 13 : pty_settle(s, SETTLE_MS);
219 13 : pty_send_key(s, PTY_KEY_ENTER);
220 13 : if (pty_wait_for(s, "message(s) in", WAIT_MS) != 0) { pty_close(s); return NULL; }
221 13 : pty_settle(s, SETTLE_MS);
222 13 : return s;
223 : }
224 :
225 : /* ── File-system helpers ──────────────────────────────────────────────── */
226 :
227 : /* Return 1 if the pending_appends.tsv file contains a line starting with folder. */
228 14 : static int pending_has_folder(const char *folder) {
229 : char path[800];
230 14 : snprintf(path, sizeof(path), "%s%s/pending_appends.tsv", g_test_home, ACCOUNT_DIR);
231 14 : FILE *fp = fopen(path, "r");
232 14 : if (!fp) return 0;
233 : char line[512];
234 14 : while (fgets(line, sizeof(line), fp)) {
235 14 : if (strncmp(line, folder, strlen(folder)) == 0 && line[strlen(folder)] == '\t') {
236 14 : fclose(fp);
237 14 : return 1;
238 : }
239 : }
240 0 : fclose(fp);
241 0 : return 0;
242 : }
243 :
244 : /* Return 1 if the pending_appends.tsv file is empty or does not exist. */
245 2 : static int pending_is_empty(void) {
246 : char path[800];
247 2 : snprintf(path, sizeof(path), "%s%s/pending_appends.tsv", g_test_home, ACCOUNT_DIR);
248 2 : FILE *fp = fopen(path, "r");
249 2 : if (!fp) return 1; /* does not exist → empty */
250 2 : int c = fgetc(fp);
251 2 : fclose(fp);
252 2 : return (c == EOF);
253 : }
254 :
255 : /* Delete pending_appends.tsv to reset state between tests. */
256 17 : static void pending_reset(void) {
257 : char path[800];
258 17 : snprintf(path, sizeof(path), "%s%s/pending_appends.tsv", g_test_home, ACCOUNT_DIR);
259 17 : unlink(path);
260 17 : }
261 :
262 : /* ══════════════════════════════════════════════════════════════════════
263 : * TC-SL-01 (US-SL-01): Successful SMTP send → local Sent + pending queue
264 : * ══════════════════════════════════════════════════════════════════════ */
265 :
266 6 : static void test_successful_send_saves_locally(void) {
267 6 : pending_reset();
268 6 : restart_imap(); restart_smtp();
269 6 : write_editor_script();
270 6 : PtySession *s = tui_open_to_inbox();
271 5 : ASSERT(s != NULL, "TC-SL-01: reached inbox");
272 :
273 : /* Press 'c' to open compose dialog */
274 5 : pty_send_str(s, "c");
275 5 : ASSERT_WAIT_FOR(s, "New Message", WAIT_MS);
276 5 : pty_settle(s, SETTLE_MS);
277 :
278 : /* Fill To: field and Tab to Subject */
279 5 : pty_send_str(s, "recipient@example.com");
280 5 : pty_send_key(s, PTY_KEY_TAB); /* To → Cc */
281 5 : pty_send_key(s, PTY_KEY_TAB); /* Cc → Bcc */
282 5 : pty_send_key(s, PTY_KEY_TAB); /* Bcc → Subject */
283 5 : pty_send_str(s, "TC-SL-01 Test Subject");
284 5 : pty_send_key(s, PTY_KEY_ENTER); /* open editor */
285 :
286 : /* Wait for send confirmation prompt, confirm, then check result */
287 : /* US-85: the post-compose review screen replaced the old "Send? [y/n]"
288 : * prompt; 's' sends from there. */
289 5 : ASSERT_WAIT_FOR(s, "Review: Message", WAIT_MS * 2);
290 5 : pty_send_str(s, "s");
291 5 : ASSERT_WAIT_FOR(s, "Sending", WAIT_MS);
292 5 : ASSERT_WAIT_FOR(s, "Message sent.", WAIT_MS);
293 5 : ASSERT_WAIT_FOR(s, "Saved locally", WAIT_MS);
294 :
295 5 : pty_close(s);
296 :
297 : /* Verify pending_appends.tsv has a Sent entry */
298 5 : ASSERT(pending_has_folder("Sent"), "TC-SL-01: pending_appends.tsv has Sent entry");
299 : }
300 :
301 : /* ══════════════════════════════════════════════════════════════════════
302 : * TC-SL-02 (US-SL-02): Failed SMTP send → local Drafts + pending queue
303 : * ══════════════════════════════════════════════════════════════════════ */
304 :
305 5 : static void test_failed_send_saves_to_drafts(void) {
306 5 : pending_reset();
307 5 : restart_imap();
308 5 : stop_server(&g_smtp_pid); /* kill the server we started */
309 5 : kill_listeners_on_port(9025); /* kill any lingering instance */
310 5 : write_editor_script();
311 5 : PtySession *s = tui_open_to_inbox();
312 4 : ASSERT(s != NULL, "TC-SL-02: reached inbox");
313 :
314 4 : pty_send_str(s, "c");
315 4 : ASSERT_WAIT_FOR(s, "New Message", WAIT_MS);
316 4 : pty_settle(s, SETTLE_MS);
317 :
318 4 : pty_send_str(s, "recipient@example.com");
319 4 : pty_send_key(s, PTY_KEY_TAB);
320 4 : pty_send_key(s, PTY_KEY_TAB);
321 4 : pty_send_key(s, PTY_KEY_TAB);
322 4 : pty_send_str(s, "TC-SL-02 Test Draft");
323 4 : pty_send_key(s, PTY_KEY_ENTER);
324 :
325 : /* Wait for send confirmation, confirm, then check failure path */
326 : /* US-85: the post-compose review screen replaced the old "Send? [y/n]"
327 : * prompt; 's' sends from there. */
328 4 : ASSERT_WAIT_FOR(s, "Review: Message", WAIT_MS * 2);
329 4 : pty_send_str(s, "s");
330 4 : ASSERT_WAIT_FOR(s, "Sending", WAIT_MS);
331 4 : ASSERT_WAIT_FOR(s, "Saved to Drafts", WAIT_MS);
332 :
333 4 : pty_close(s);
334 :
335 : /* Verify pending_appends.tsv has a Drafts entry */
336 4 : ASSERT(pending_has_folder("Drafts"), "TC-SL-02: pending_appends.tsv has Drafts entry");
337 : }
338 :
339 : /* ══════════════════════════════════════════════════════════════════════
340 : * TC-SL-03 (US-SL-03): email-sync uploads pending messages, clears queue
341 : * ══════════════════════════════════════════════════════════════════════ */
342 :
343 4 : static void test_sync_uploads_pending_and_clears_queue(void) {
344 : /* Seed pending_appends.tsv via a successful send first */
345 4 : pending_reset();
346 4 : restart_imap(); restart_smtp();
347 4 : write_editor_script();
348 4 : PtySession *s = tui_open_to_inbox();
349 3 : ASSERT(s != NULL, "TC-SL-03 setup: reached inbox");
350 :
351 3 : pty_send_str(s, "c");
352 3 : ASSERT_WAIT_FOR(s, "New Message", WAIT_MS);
353 3 : pty_settle(s, SETTLE_MS);
354 3 : pty_send_str(s, "recipient@example.com");
355 3 : pty_send_key(s, PTY_KEY_TAB);
356 3 : pty_send_key(s, PTY_KEY_TAB);
357 3 : pty_send_key(s, PTY_KEY_TAB);
358 3 : pty_send_str(s, "TC-SL-03 Sync Test");
359 3 : pty_send_key(s, PTY_KEY_ENTER);
360 : /* US-85: the post-compose review screen replaced the old "Send? [y/n]"
361 : * prompt; 's' sends from there. */
362 3 : ASSERT_WAIT_FOR(s, "Review: Message", WAIT_MS * 2);
363 3 : pty_send_str(s, "s");
364 3 : ASSERT_WAIT_FOR(s, "Saved locally", WAIT_MS);
365 3 : pty_close(s);
366 :
367 3 : ASSERT(pending_has_folder("Sent"), "TC-SL-03 setup: Sent entry queued before sync");
368 :
369 : /* Run email-sync to upload the pending message */
370 3 : PtySession *ss = sync_run(NULL);
371 2 : ASSERT(ss != NULL, "TC-SL-03: email-sync started");
372 2 : ASSERT_WAIT_FOR(ss, "uploaded", WAIT_MS);
373 2 : pty_close(ss);
374 :
375 : /* Queue should now be empty */
376 2 : ASSERT(pending_is_empty(), "TC-SL-03: pending_appends.tsv empty after sync");
377 : }
378 :
379 : /* ══════════════════════════════════════════════════════════════════════
380 : * TC-SL-04 (US-SL-04): pending queue survives process restart
381 : * ══════════════════════════════════════════════════════════════════════ */
382 :
383 2 : static void test_pending_queue_survives_restart(void) {
384 : /* Seed via a successful send */
385 2 : pending_reset();
386 2 : restart_imap(); restart_smtp();
387 2 : write_editor_script();
388 2 : PtySession *s = tui_open_to_inbox();
389 1 : ASSERT(s != NULL, "TC-SL-04 setup: reached inbox");
390 :
391 1 : pty_send_str(s, "c");
392 1 : ASSERT_WAIT_FOR(s, "New Message", WAIT_MS);
393 1 : pty_settle(s, SETTLE_MS);
394 1 : pty_send_str(s, "recipient@example.com");
395 1 : pty_send_key(s, PTY_KEY_TAB);
396 1 : pty_send_key(s, PTY_KEY_TAB);
397 1 : pty_send_key(s, PTY_KEY_TAB);
398 1 : pty_send_str(s, "TC-SL-04 Persist Test");
399 1 : pty_send_key(s, PTY_KEY_ENTER);
400 : /* US-85: the post-compose review screen replaced the old "Send? [y/n]"
401 : * prompt; 's' sends from there. */
402 1 : ASSERT_WAIT_FOR(s, "Review: Message", WAIT_MS * 2);
403 1 : pty_send_str(s, "s");
404 1 : ASSERT_WAIT_FOR(s, "Saved locally", WAIT_MS);
405 1 : pty_close(s);
406 :
407 1 : ASSERT(pending_has_folder("Sent"), "TC-SL-04: Sent entry in queue after send");
408 :
409 : /* Stop all servers; restart — queue must still be there */
410 1 : stop_server(&g_smtp_pid);
411 :
412 1 : ASSERT(pending_has_folder("Sent"), "TC-SL-04: Sent entry persists after process restart");
413 : }
414 :
415 : /* ── main ─────────────────────────────────────────────────────────────── */
416 :
417 6 : int main(int argc, char **argv) {
418 6 : printf("--- Local-first send PTY Tests (US-SL-01 … US-SL-04) ---\n\n");
419 :
420 6 : if (argc < 5) {
421 0 : fprintf(stderr,
422 : "Usage: %s <email-tui> <email-sync> "
423 : "<mock-imap-server> <mock-smtp-server>\n",
424 : argv[0]);
425 0 : return 1;
426 : }
427 :
428 6 : snprintf(g_tui_bin, sizeof(g_tui_bin), "%s", argv[1]);
429 6 : snprintf(g_sync_bin, sizeof(g_sync_bin), "%s", argv[2]);
430 6 : snprintf(g_imap_bin, sizeof(g_imap_bin), "%s", argv[3]);
431 6 : snprintf(g_smtp_bin, sizeof(g_smtp_bin), "%s", argv[4]);
432 :
433 6 : snprintf(g_test_home, sizeof(g_test_home), "/tmp/test-send-local-XXXXXX");
434 6 : if (!mkdtemp(g_test_home)) {
435 0 : perror("mkdtemp");
436 0 : return 1;
437 : }
438 6 : if (getenv("HOME"))
439 6 : snprintf(g_old_home, sizeof(g_old_home), "%s", getenv("HOME"));
440 6 : setenv("HOME", g_test_home, 1);
441 : /* XDG_*_HOME wins over $HOME in the binaries; if the caller's environment
442 : * sets them (GitHub runners do) the test home is bypassed and the setup
443 : * wizard opens instead of the configured account. */
444 6 : unsetenv("XDG_CONFIG_HOME");
445 6 : unsetenv("XDG_DATA_HOME");
446 6 : unsetenv("XDG_CACHE_HOME");
447 :
448 6 : mkdirs();
449 6 : write_config();
450 6 : write_editor_script();
451 :
452 6 : restart_imap();
453 6 : restart_smtp();
454 :
455 6 : printf("--- US-SL-01: Successful send saves locally ---\n");
456 6 : RUN_TEST(test_successful_send_saves_locally);
457 :
458 5 : printf("\n--- US-SL-02: Failed send saves to Drafts ---\n");
459 5 : RUN_TEST(test_failed_send_saves_to_drafts);
460 :
461 4 : printf("\n--- US-SL-03: Sync uploads pending messages ---\n");
462 4 : restart_imap(); restart_smtp();
463 4 : RUN_TEST(test_sync_uploads_pending_and_clears_queue);
464 :
465 2 : printf("\n--- US-SL-04: Pending queue survives restart ---\n");
466 2 : restart_imap(); restart_smtp();
467 2 : RUN_TEST(test_pending_queue_survives_restart);
468 :
469 1 : stop_server(&g_imap_pid);
470 1 : stop_server(&g_smtp_pid);
471 :
472 1 : if (g_old_home[0]) setenv("HOME", g_old_home, 1);
473 :
474 1 : printf("\n--- Test Results ---\n");
475 1 : printf("Tests Run: %d\n", g_tests_run);
476 1 : printf("Tests Passed: %d\n", g_tests_run - g_tests_failed);
477 1 : printf("Tests Failed: %d\n", g_tests_failed);
478 :
479 1 : return g_tests_failed > 0 ? 1 : 0;
480 : }
|