Line data Source code
1 : #include "test_helpers.h"
2 : #include <string.h>
3 : #include <stdlib.h>
4 : #include <stdint.h>
5 : #include <locale.h>
6 : #include <fcntl.h>
7 : #include <unistd.h>
8 :
9 : /*
10 : * Include the full domain source so all static helpers are visible in
11 : * this translation unit. email_service.c is NOT added to CMakeLists.txt
12 : * as a separate source — the #include below is the only compilation unit
13 : * that defines its symbols.
14 : */
15 : #include "../../libemail/src/domain/email_service.c"
16 :
17 1 : void test_email_service(void) {
18 :
19 1 : setlocale(LC_ALL, "");
20 1 : local_store_init("imaps://test.example.com", "testuser");
21 :
22 : /* ── count_visual_rows ───────────────────────────────────────────── */
23 :
24 : /* Short lines: visual rows == logical lines (all fit within term_cols) */
25 1 : ASSERT(count_visual_rows(NULL, 80) == 0, "cvr: NULL → 0");
26 1 : ASSERT(count_visual_rows("", 80) == 0, "cvr: empty → 0");
27 1 : ASSERT(count_visual_rows("abc", 80) == 1, "cvr: single line → 1");
28 1 : ASSERT(count_visual_rows("a\nb", 80) == 2, "cvr: two lines → 2");
29 1 : ASSERT(count_visual_rows("a\nb\nc\n", 80) == 4, "cvr: trailing newline → 4");
30 :
31 : /* A line exactly term_cols wide → 1 visual row */
32 : {
33 1 : char exact[81]; memset(exact, 'X', 80); exact[80] = '\0';
34 1 : ASSERT(count_visual_rows(exact, 80) == 1, "cvr: 80-char line → 1 row");
35 : }
36 :
37 : /* A line wider than term_cols → multiple visual rows */
38 : {
39 1 : char wide[161]; memset(wide, 'X', 160); wide[160] = '\0';
40 : /* 160-char line on 80-col terminal → 2 visual rows (+ terminating segment) */
41 1 : char body[163]; snprintf(body, sizeof(body), "%s\n", wide);
42 1 : int vr = count_visual_rows(body, 80);
43 1 : ASSERT(vr == 3, "cvr: 160-char line+\\n → 3 rows (2 for URL, 1 trailing)");
44 : }
45 :
46 : /* A long URL (no newline) → single logical line counted as multiple visual rows */
47 : {
48 1 : char url[201]; memset(url, 'x', 200); url[200] = '\0';
49 : /* 200 chars on 80-col terminal = ceil(200/80) = 3 visual rows */
50 1 : ASSERT(count_visual_rows(url, 80) == 3, "cvr: 200-char url → 3 rows");
51 : }
52 :
53 : /* With ANSI escapes: invisible bytes not counted toward visible cols */
54 1 : ASSERT(count_visual_rows("\033[1mhello\033[22m", 80) == 1,
55 : "cvr: ANSI-wrapped line → 1 row");
56 :
57 : /* ── word_wrap ───────────────────────────────────────────────────── */
58 :
59 : /* NULL input → NULL */
60 : {
61 1 : char *r = word_wrap(NULL, 40);
62 1 : ASSERT(r == NULL, "word_wrap: NULL input → NULL");
63 : }
64 :
65 : /* Short text that fits entirely — no wrapping needed */
66 : {
67 1 : char *r = word_wrap("Hello world", 40);
68 1 : ASSERT(r != NULL, "word_wrap: short text not NULL");
69 1 : ASSERT(strstr(r, "Hello world") != NULL, "word_wrap: short text passthrough");
70 1 : free(r);
71 : }
72 :
73 : /* Word break at space (lines 169-174): width=25, long text with spaces */
74 : {
75 1 : char *r = word_wrap("The quick brown fox jumps over the lazy dog", 25);
76 1 : ASSERT(r != NULL, "word_wrap: word break not NULL");
77 1 : ASSERT(strstr(r, "\n") != NULL, "word_wrap: word break produces newline");
78 1 : free(r);
79 : }
80 :
81 : /* Hard break — no spaces (lines 185-188): width=20, 25-char word */
82 : {
83 1 : char *r = word_wrap("aaaaaaaaaaaaaaaaaaaaaaaaa", 20);
84 1 : ASSERT(r != NULL, "word_wrap: hard break not NULL");
85 1 : ASSERT(strstr(r, "\n") != NULL, "word_wrap: hard break produces newline");
86 1 : free(r);
87 : }
88 :
89 : /* 2-byte UTF-8 lead byte (line 143: *p < 0xE0): é = \xC3\xA9 */
90 : {
91 1 : char *r = word_wrap("\xC3\xA9\xC3\xA9\xC3\xA9 test", 40);
92 1 : ASSERT(r != NULL, "word_wrap: 2-byte UTF-8 not NULL");
93 1 : free(r);
94 : }
95 :
96 : /* 3-byte UTF-8 lead byte (line 144: *p < 0xF0): 中 = \xE4\xB8\xAD */
97 : {
98 1 : char *r = word_wrap("\xE4\xB8\xAD text", 40);
99 1 : ASSERT(r != NULL, "word_wrap: 3-byte UTF-8 not NULL");
100 1 : free(r);
101 : }
102 :
103 : /* 4-byte UTF-8 lead byte (line 145: *p < 0xF8): U+10000 = \xF0\x90\x80\x80 */
104 : {
105 1 : char *r = word_wrap("\xF0\x90\x80\x80 test", 40);
106 1 : ASSERT(r != NULL, "word_wrap: 4-byte UTF-8 not NULL");
107 1 : free(r);
108 : }
109 :
110 : /* Invalid lead byte < 0xC2 (line 142: continuation byte as lead) */
111 : {
112 1 : char *r = word_wrap("\x80 bad", 40);
113 1 : ASSERT(r != NULL, "word_wrap: 0x80 lead byte not NULL");
114 1 : free(r);
115 : }
116 :
117 : /* Invalid lead byte >= 0xF8 (line 146: else branch) */
118 : {
119 1 : char *r = word_wrap("\xFE bad", 40);
120 1 : ASSERT(r != NULL, "word_wrap: 0xFE lead byte not NULL");
121 1 : free(r);
122 : }
123 :
124 : /* Continuation byte mismatch (line 148): 2-byte start \xC3 + non-continuation \x41 */
125 : {
126 1 : char *r = word_wrap("\xC3\x41 bad", 40);
127 1 : ASSERT(r != NULL, "word_wrap: truncated multibyte not NULL");
128 1 : free(r);
129 : }
130 :
131 : /* Multi-line input — exercises the outer loop past eol */
132 : {
133 1 : char *r = word_wrap("first line\nsecond line\n", 40);
134 1 : ASSERT(r != NULL, "word_wrap: multi-line not NULL");
135 1 : ASSERT(strstr(r, "first line") != NULL, "word_wrap: multi-line first");
136 1 : ASSERT(strstr(r, "second line") != NULL, "word_wrap: multi-line second");
137 1 : free(r);
138 : }
139 :
140 : /* ── ansi_scan ───────────────────────────────────────────────────── */
141 :
142 : /* Empty content → all zeros */
143 : {
144 1 : AnsiState st = {0};
145 1 : ansi_scan("", "", &st);
146 1 : ASSERT(st.bold==0 && st.italic==0 && st.uline==0 && st.strike==0,
147 : "ansi_scan: empty → no state");
148 1 : ASSERT(st.fg_on==0 && st.bg_on==0, "ansi_scan: empty → no color");
149 : }
150 :
151 : /* Bold on/off */
152 : {
153 1 : AnsiState st = {0};
154 1 : const char *s = "\033[1mtext\033[22m";
155 1 : ansi_scan(s, s + strlen(s), &st);
156 1 : ASSERT(st.bold == 0, "ansi_scan: bold on then off → 0");
157 :
158 1 : AnsiState st2 = {0};
159 1 : const char *s2 = "\033[1mtext";
160 1 : ansi_scan(s2, s2 + strlen(s2), &st2);
161 1 : ASSERT(st2.bold == 1, "ansi_scan: bold on, no off → 1");
162 : }
163 :
164 : /* Italic on/off */
165 : {
166 1 : AnsiState st = {0};
167 1 : const char *s = "\033[3m";
168 1 : ansi_scan(s, s + strlen(s), &st);
169 1 : ASSERT(st.italic == 1, "ansi_scan: italic on → 1");
170 :
171 1 : st.italic = 1;
172 1 : const char *s2 = "\033[23m";
173 1 : ansi_scan(s2, s2 + strlen(s2), &st);
174 1 : ASSERT(st.italic == 0, "ansi_scan: italic off → 0");
175 : }
176 :
177 : /* Underline on/off */
178 : {
179 1 : AnsiState st = {0};
180 1 : const char *s = "\033[4m";
181 1 : ansi_scan(s, s + strlen(s), &st);
182 1 : ASSERT(st.uline == 1, "ansi_scan: uline on → 1");
183 :
184 1 : const char *s2 = "\033[24m";
185 1 : ansi_scan(s2, s2 + strlen(s2), &st);
186 1 : ASSERT(st.uline == 0, "ansi_scan: uline off → 0");
187 : }
188 :
189 : /* Strikethrough on/off */
190 : {
191 1 : AnsiState st = {0};
192 1 : const char *s = "\033[9m";
193 1 : ansi_scan(s, s + strlen(s), &st);
194 1 : ASSERT(st.strike == 1, "ansi_scan: strike on → 1");
195 :
196 1 : const char *s2 = "\033[29m";
197 1 : ansi_scan(s2, s2 + strlen(s2), &st);
198 1 : ASSERT(st.strike == 0, "ansi_scan: strike off → 0");
199 : }
200 :
201 : /* Foreground color set and reset */
202 : {
203 1 : AnsiState st = {0};
204 1 : const char *s = "\033[38;2;255;0;128m";
205 1 : ansi_scan(s, s + strlen(s), &st);
206 1 : ASSERT(st.fg_on == 1, "ansi_scan: fg on → 1");
207 1 : ASSERT(st.fg_r == 255 && st.fg_g == 0 && st.fg_b == 128,
208 : "ansi_scan: fg RGB correct");
209 :
210 1 : const char *s2 = "\033[39m";
211 1 : ansi_scan(s2, s2 + strlen(s2), &st);
212 1 : ASSERT(st.fg_on == 0, "ansi_scan: fg reset → 0");
213 : }
214 :
215 : /* Background color set and reset */
216 : {
217 1 : AnsiState st = {0};
218 1 : const char *s = "\033[48;2;0;64;255m";
219 1 : ansi_scan(s, s + strlen(s), &st);
220 1 : ASSERT(st.bg_on == 1, "ansi_scan: bg on → 1");
221 1 : ASSERT(st.bg_r == 0 && st.bg_g == 64 && st.bg_b == 255,
222 : "ansi_scan: bg RGB correct");
223 :
224 1 : const char *s2 = "\033[49m";
225 1 : ansi_scan(s2, s2 + strlen(s2), &st);
226 1 : ASSERT(st.bg_on == 0, "ansi_scan: bg reset → 0");
227 : }
228 :
229 : /* Full reset \033[0m clears all accumulated state */
230 : {
231 1 : AnsiState st = {0};
232 1 : const char *s = "\033[1m\033[3m\033[38;2;255;0;0m\033[0m";
233 1 : ansi_scan(s, s + strlen(s), &st);
234 1 : ASSERT(st.bold==0 && st.italic==0 && st.fg_on==0,
235 : "ansi_scan: full reset clears all");
236 : }
237 :
238 : /* Partial scan: only up to a mid-point in the string */
239 : {
240 : /* Scan only the first segment (bold+color open), stop before close */
241 1 : const char *body = "\033[1m\033[38;2;255;0;0mLine 0\nLine 1\n\033[22m\033[39m";
242 1 : const char *nl = strchr(body, '\n'); /* end of "Line 0" */
243 1 : AnsiState st = {0};
244 1 : ansi_scan(body, nl, &st);
245 1 : ASSERT(st.bold == 1, "ansi_scan: partial scan bold open");
246 1 : ASSERT(st.fg_on == 1, "ansi_scan: partial scan fg open");
247 : }
248 :
249 : /* ── print_body_page ─────────────────────────────────────────────── */
250 : /*
251 : * Redirect stdout to /dev/null so the printed lines do not pollute
252 : * the test runner output. Restore after.
253 : */
254 : {
255 1 : fflush(stdout);
256 1 : int saved_fd = dup(STDOUT_FILENO);
257 1 : int null_fd = open("/dev/null", O_WRONLY);
258 1 : if (null_fd >= 0) dup2(null_fd, STDOUT_FILENO);
259 1 : if (null_fd >= 0) close(null_fd);
260 :
261 : /* Print lines 1-2 of a 4-line body (normal newline path) */
262 1 : print_body_page("Line 0\nLine 1\nLine 2\nLine 3\n", 1, 2, 80);
263 :
264 : /* Body does not end with '\n': last segment hits the else branch
265 : * (printf("%s\n", p); break;) at lines 255-257 */
266 1 : print_body_page("Line 0\nNo newline here", 1, 5, 80);
267 :
268 : /* from_line == 0, single print */
269 1 : print_body_page("only line", 0, 1, 80);
270 :
271 1 : fflush(stdout);
272 1 : dup2(saved_fd, STDOUT_FILENO);
273 1 : close(saved_fd);
274 : }
275 :
276 : /*
277 : * Regression test: ANSI state must be replayed at page boundaries.
278 : *
279 : * A multi-line styled span (e.g. <div style="color:red">) produces:
280 : * \033[38;2;255;0;0mLine 0\nLine 1\nLine 2\n\n\033[39m
281 : *
282 : * When paginating from line 1 onward, the fg-color escape from line 0
283 : * would have been SKIPPED. Without the fix, Line 1 and Line 2 appeared
284 : * in the terminal's default color — and if the terminal had a dark theme
285 : * and the email also set background:white, the result was white-on-white.
286 : *
287 : * The fix (ansi_scan + ansi_replay) re-emits the color escape before the
288 : * first visible line. This test captures stdout via a pipe and asserts
289 : * the replayed escape is present.
290 : */
291 : {
292 : /* Body that html_render() would produce for a multi-line color span */
293 1 : const char *body =
294 : "\033[38;2;255;0;0mLine 0\n" /* fg red open on line 0 */
295 : "Line 1\n"
296 : "Line 2\n"
297 : "\033[39m"; /* fg reset after last line */
298 :
299 1 : int pipefd[2];
300 1 : if (pipe(pipefd) != 0) { ASSERT(0, "page ANSI replay: pipe failed"); goto skip_replay_fg; }
301 1 : fflush(stdout);
302 1 : int saved = dup(STDOUT_FILENO);
303 1 : dup2(pipefd[1], STDOUT_FILENO);
304 1 : close(pipefd[1]);
305 :
306 : /* Skip line 0; print lines 1-2 */
307 1 : print_body_page(body, 1, 2, 80);
308 :
309 1 : fflush(stdout);
310 1 : dup2(saved, STDOUT_FILENO);
311 1 : close(saved);
312 :
313 1 : char buf[256] = {0};
314 1 : ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
315 1 : close(pipefd[0]);
316 1 : buf[n > 0 ? n : 0] = '\0';
317 :
318 : /* The replayed fg-red escape must appear before "Line 1" */
319 1 : const char *esc = strstr(buf, "\033[38;2;255;0;0m");
320 1 : const char *line1 = strstr(buf, "Line 1");
321 1 : ASSERT(esc != NULL,
322 : "page ANSI replay: fg color escape present in page-2 output");
323 1 : ASSERT(line1 != NULL,
324 : "page ANSI replay: Line 1 present in output");
325 1 : ASSERT(esc < line1,
326 : "page ANSI replay: fg escape precedes Line 1");
327 1 : skip_replay_fg:;
328 : }
329 :
330 : /*
331 : * Regression test: background color must also be replayed.
332 : * This models the exact scenario that caused white-on-white:
333 : * a <div style="background-color:white"> spanning multiple lines.
334 : */
335 : {
336 1 : const char *body =
337 : "\033[48;2;255;255;255mLine 0\n" /* bg white on line 0 */
338 : "Line 1\n"
339 : "\033[49m";
340 :
341 1 : int pipefd[2];
342 1 : if (pipe(pipefd) != 0) { ASSERT(0, "page ANSI replay bg: pipe failed"); goto skip_replay_bg; }
343 1 : fflush(stdout);
344 1 : int saved = dup(STDOUT_FILENO);
345 1 : dup2(pipefd[1], STDOUT_FILENO);
346 1 : close(pipefd[1]);
347 :
348 1 : print_body_page(body, 1, 1, 80);
349 :
350 1 : fflush(stdout);
351 1 : dup2(saved, STDOUT_FILENO);
352 1 : close(saved);
353 :
354 1 : char buf[256] = {0};
355 1 : ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
356 1 : close(pipefd[0]);
357 1 : buf[n > 0 ? n : 0] = '\0';
358 :
359 1 : const char *esc = strstr(buf, "\033[48;2;255;255;255m");
360 1 : const char *line1 = strstr(buf, "Line 1");
361 1 : ASSERT(esc != NULL,
362 : "page ANSI replay bg: bg color escape present in page-2 output");
363 1 : ASSERT(esc < line1,
364 : "page ANSI replay bg: bg escape precedes Line 1");
365 1 : skip_replay_bg:;
366 : }
367 :
368 : /* ── print_padded_col (non-ASCII paths, lines 83-91) ─────────────── */
369 : /*
370 : * Redirect stdout to /dev/null to avoid polluting test output.
371 : * print_padded_col writes to stdout via fwrite/putchar.
372 : */
373 : {
374 1 : fflush(stdout);
375 1 : int saved_fd2 = dup(STDOUT_FILENO);
376 1 : int null_fd2 = open("/dev/null", O_WRONLY);
377 1 : if (null_fd2 >= 0) dup2(null_fd2, STDOUT_FILENO);
378 1 : if (null_fd2 >= 0) close(null_fd2);
379 :
380 : /* 0x80 = invalid lead byte → line 83 */
381 1 : print_padded_col("\x80 bad", 20);
382 :
383 : /* 2-byte UTF-8: é = \xC3\xA9 → line 84 */
384 1 : print_padded_col("\xC3\xA9 cafe", 20);
385 :
386 : /* 3-byte UTF-8: 中 = \xE4\xB8\xAD → line 85 */
387 1 : print_padded_col("\xE4\xB8\xAD word", 20);
388 :
389 : /* 4-byte UTF-8: U+10000 = \xF0\x90\x80\x80 → line 86 */
390 1 : print_padded_col("\xF0\x90\x80\x80 hi", 20);
391 :
392 : /* 0xFE = invalid lead byte >= 0xF8 → line 87 */
393 1 : print_padded_col("\xFE bad", 20);
394 :
395 : /* Truncated 2-byte: \xC3 then 'A' (not continuation) → lines 90-91 */
396 1 : print_padded_col("\xC3\x41 trunc", 20);
397 :
398 1 : fflush(stdout);
399 1 : dup2(saved_fd2, STDOUT_FILENO);
400 1 : close(saved_fd2);
401 : }
402 :
403 : /*
404 : * Regression: visual row budget in print_body_page.
405 : *
406 : * Body has 1 normal line + 1 very wide line (wider than term_cols) +
407 : * 2 more normal lines. With a visual row budget of 3 on a 40-col
408 : * terminal, the wide line consumes multiple visual rows, so the 3rd
409 : * normal line should NOT appear in the output.
410 : *
411 : * This proves print_body_page stops at the visual row budget, not the
412 : * logical line count.
413 : */
414 : {
415 : /* Build a 120-char URL-like token (fits on 1 logical line, 3 visual rows on 40-col) */
416 1 : char wide[121]; memset(wide, 'W', 120); wide[120] = '\0';
417 1 : char body_vr[256];
418 1 : snprintf(body_vr, sizeof(body_vr),
419 : "NormalA\n%s\nNormalB\nNormalC\n", wide);
420 :
421 1 : int pipefd[2];
422 1 : if (pipe(pipefd) != 0) {
423 0 : ASSERT(0, "visual rows: pipe failed");
424 : goto skip_vr_test;
425 : }
426 1 : fflush(stdout);
427 1 : int saved_vr = dup(STDOUT_FILENO);
428 1 : dup2(pipefd[1], STDOUT_FILENO);
429 1 : close(pipefd[1]);
430 :
431 : /* budget = 4 visual rows on 40-col terminal:
432 : * NormalA = 1 row (total 1)
433 : * wide 120 = 3 rows (total 4) → fits in budget
434 : * NormalB = 1 row (total 5 > 4) → should NOT appear
435 : * NormalC → should NOT appear */
436 1 : print_body_page(body_vr, 0, 4, 40);
437 :
438 1 : fflush(stdout);
439 1 : dup2(saved_vr, STDOUT_FILENO);
440 1 : close(saved_vr);
441 :
442 1 : char buf_vr[512] = {0};
443 1 : ssize_t n_vr = read(pipefd[0], buf_vr, sizeof(buf_vr) - 1);
444 1 : close(pipefd[0]);
445 1 : buf_vr[n_vr > 0 ? n_vr : 0] = '\0';
446 :
447 1 : ASSERT(strstr(buf_vr, "NormalA") != NULL,
448 : "visual rows: NormalA shown (fits in budget)");
449 1 : ASSERT(strstr(buf_vr, wide) != NULL,
450 : "visual rows: wide line shown (fits in budget)");
451 1 : ASSERT(strstr(buf_vr, "NormalB") == NULL,
452 : "visual rows: NormalB NOT shown (budget exhausted)");
453 1 : ASSERT(strstr(buf_vr, "NormalC") == NULL,
454 : "visual rows: NormalC NOT shown (budget exhausted)");
455 1 : skip_vr_test:;
456 : }
457 :
458 : /* ── print_clean — truncation at max_cols ───────────────────────── */
459 : /*
460 : * Regression test for ce09877: print_clean must stop emitting characters
461 : * once the visible column count reaches max_cols, so that header values
462 : * (From/Subject/Date) never overflow the 80-column display width.
463 : *
464 : * We capture stdout via a pipe, call print_clean with a 200-char ASCII
465 : * string and max_cols=10, then verify the captured output is ≤ 10 bytes.
466 : */
467 : {
468 1 : char long_str[201];
469 1 : memset(long_str, 'A', 200);
470 1 : long_str[200] = '\0';
471 :
472 1 : int pipefd[2];
473 1 : if (pipe(pipefd) != 0) {
474 0 : ASSERT(0, "print_clean truncation: pipe failed");
475 : goto skip_print_clean;
476 : }
477 1 : fflush(stdout);
478 1 : int saved_pc = dup(STDOUT_FILENO);
479 1 : dup2(pipefd[1], STDOUT_FILENO);
480 1 : close(pipefd[1]);
481 :
482 1 : print_clean(long_str, "(none)", 10);
483 :
484 1 : fflush(stdout);
485 1 : dup2(saved_pc, STDOUT_FILENO);
486 1 : close(saved_pc);
487 :
488 1 : char buf_pc[256] = {0};
489 1 : ssize_t n_pc = read(pipefd[0], buf_pc, sizeof(buf_pc) - 1);
490 1 : close(pipefd[0]);
491 1 : buf_pc[n_pc > 0 ? n_pc : 0] = '\0';
492 :
493 1 : ASSERT((int)strlen(buf_pc) <= 10,
494 : "print_clean: output truncated to max_cols=10");
495 1 : ASSERT(strlen(buf_pc) > 0,
496 : "print_clean: output is non-empty");
497 1 : skip_print_clean:;
498 : }
499 :
500 : /* NULL input falls back to fallback string */
501 : {
502 1 : int pipefd[2];
503 1 : if (pipe(pipefd) != 0) {
504 0 : ASSERT(0, "print_clean fallback: pipe failed");
505 : goto skip_print_clean_fb;
506 : }
507 1 : fflush(stdout);
508 1 : int saved_fb = dup(STDOUT_FILENO);
509 1 : dup2(pipefd[1], STDOUT_FILENO);
510 1 : close(pipefd[1]);
511 :
512 1 : print_clean(NULL, "(none)", 20);
513 :
514 1 : fflush(stdout);
515 1 : dup2(saved_fb, STDOUT_FILENO);
516 1 : close(saved_fb);
517 :
518 1 : char buf_fb[64] = {0};
519 1 : ssize_t n_fb = read(pipefd[0], buf_fb, sizeof(buf_fb) - 1);
520 1 : close(pipefd[0]);
521 1 : buf_fb[n_fb > 0 ? n_fb : 0] = '\0';
522 :
523 1 : ASSERT(strcmp(buf_fb, "(none)") == 0,
524 : "print_clean: NULL input uses fallback");
525 1 : skip_print_clean_fb:;
526 : }
527 :
528 : /* ── cmp_uid_entry ───────────────────────────────────────────────── */
529 : {
530 1 : MsgEntry a = {100, MSG_FLAG_UNSEEN, 1000}; /* unseen */
531 1 : MsgEntry b = {200, 0, 2000}; /* seen */
532 : /* unseen before seen regardless of date */
533 1 : ASSERT(cmp_uid_entry(&a, &b) < 0, "cmp_uid_entry: unseen before seen");
534 1 : ASSERT(cmp_uid_entry(&b, &a) > 0, "cmp_uid_entry: seen after unseen");
535 : }
536 : {
537 1 : MsgEntry c = {100, MSG_FLAG_UNSEEN, 1000};
538 1 : MsgEntry d = {200, MSG_FLAG_UNSEEN, 2000};
539 : /* both unseen: newer date (higher epoch) first */
540 1 : ASSERT(cmp_uid_entry(&c, &d) > 0, "cmp_uid_entry: older date after newer");
541 1 : ASSERT(cmp_uid_entry(&d, &c) < 0, "cmp_uid_entry: newer date before older");
542 : }
543 : {
544 1 : MsgEntry e = {100, MSG_FLAG_FLAGGED, 500};
545 1 : MsgEntry f = {200, 0, 500};
546 : /* flagged (read) before plain read */
547 1 : ASSERT(cmp_uid_entry(&e, &f) < 0, "cmp_uid_entry: flagged before rest");
548 : }
549 : {
550 1 : MsgEntry g = {100, 0, 0};
551 1 : MsgEntry h = {100, 0, 0};
552 : /* equal: cmp == 0 */
553 1 : ASSERT(cmp_uid_entry(&g, &h) == 0, "cmp_uid_entry: equal entries → 0");
554 : }
555 :
556 : /* ── is_last_sibling ─────────────────────────────────────────────── */
557 :
558 : /* Root-level two items: first is not last, second is */
559 : {
560 1 : char *names[] = {"A", "B"};
561 1 : ASSERT(is_last_sibling(names, 2, 0, '.') == 0,
562 : "is_last_sibling: A not last (B follows)");
563 1 : ASSERT(is_last_sibling(names, 2, 1, '.') == 1,
564 : "is_last_sibling: B is last");
565 : }
566 :
567 : /* parent_len == 0 path (line 582): root-level item with multiple followers */
568 : {
569 1 : char *names[] = {"A", "B", "C"};
570 1 : ASSERT(is_last_sibling(names, 3, 0, '.') == 0,
571 : "is_last_sibling: root level, A not last");
572 : }
573 :
574 : /* line 587: jumped to a different parent subtree → return 1 */
575 : {
576 : /* INBOX, INBOX.A, INBOX.B, Other — sorted */
577 1 : char *names[] = {"INBOX", "INBOX.A", "INBOX.B", "Other"};
578 : /* INBOX.A: sibling INBOX.B follows → not last */
579 1 : ASSERT(is_last_sibling(names, 4, 1, '.') == 0,
580 : "is_last_sibling: INBOX.A not last");
581 : /* INBOX.B: next is Other (different parent subtree) → last */
582 1 : ASSERT(is_last_sibling(names, 4, 2, '.') == 1,
583 : "is_last_sibling: INBOX.B is last (diff parent)");
584 : }
585 :
586 : /* Single item → always last */
587 : {
588 1 : char *names[] = {"INBOX"};
589 1 : ASSERT(is_last_sibling(names, 1, 0, '.') == 1,
590 : "is_last_sibling: single item is last");
591 : }
592 :
593 : /* ── ancestor_is_last ────────────────────────────────────────────── */
594 :
595 : /* Root-level ancestor with a sibling following: parent_len == 0, return 0 (line 630) */
596 : {
597 1 : char *names[] = {"INBOX", "INBOX.A", "INBOX.B", "Sent"};
598 : /* For INBOX.A at level=0: ancestor "INBOX" is NOT the last root (Sent follows) */
599 1 : int r = ancestor_is_last(names, 4, 1, 0, '.');
600 1 : ASSERT(r == 0, "ancestor_is_last: INBOX not last root");
601 : }
602 :
603 : /* Root-level ancestor that IS last */
604 : {
605 1 : char *names[] = {"INBOX", "INBOX.A", "Sent"};
606 : /* For Sent (index=2) at level=0: nothing after it → last */
607 1 : int r = ancestor_is_last(names, 3, 2, 0, '.');
608 1 : ASSERT(r == 1, "ancestor_is_last: Sent is last root");
609 : }
610 :
611 : /* level=0 with follower: parent_len==0 → return 0 (covers line 630) */
612 : {
613 1 : char *names[] = {"A.X", "A.Y", "B.Z"};
614 : /* A.Y's root-level ancestor is "A"; "B.Z" follows at root → return 0 */
615 1 : int r = ancestor_is_last(names, 3, 1, 0, '.');
616 1 : ASSERT(r == 0, "ancestor_is_last: level=0, another root item follows → 0");
617 : }
618 :
619 : /* line 636: jumped to different parent subtree → return 1 (level > 0) */
620 : {
621 : /* A.B.Y's ancestor at level=1 is "A.B"; parent of "A.B" is "A".
622 : * After A.B.Y's subtree, C.D has parent "C" ≠ "A" → return 1. */
623 1 : char *names[] = {"A.B.X", "A.B.Y", "C.D"};
624 1 : int r = ancestor_is_last(names, 3, 1, 1, '.');
625 1 : ASSERT(r == 1, "ancestor_is_last: level=1, different grandparent → 1");
626 : }
627 :
628 : /* Only one root-level folder (INBOX) → ancestor is last */
629 : {
630 1 : char *names[] = {"INBOX.A", "INBOX.A.X", "INBOX.A.Y", "INBOX.B"};
631 : /* All entries share root "INBOX"; nothing at a different root → last=1 */
632 1 : int r = ancestor_is_last(names, 4, 2, 0, '.');
633 1 : ASSERT(r == 1, "ancestor_is_last: INBOX is only root → 1");
634 : }
635 :
636 : /* ── HTML-only MIME: CSS must not leak into rendered output ──────── */
637 : /*
638 : * Regression test for show_uid_interactive: when an email has only a
639 : * text/html part (no text/plain), the body must be rendered through
640 : * html_render(), not passed through as raw text. In particular, any
641 : * <style> block must be suppressed and visible body text must appear.
642 : */
643 : {
644 : /* Minimal MIME message: HTML-only, with an embedded <style> block */
645 1 : const char *mime_msg =
646 : "MIME-Version: 1.0\r\n"
647 : "Content-Type: text/html; charset=UTF-8\r\n"
648 : "\r\n"
649 : "<html>"
650 : "<head><style>body { color: red; font-family: Arial; }</style></head>"
651 : "<body><b>Visible Text</b></body>"
652 : "</html>";
653 :
654 1 : char *html = mime_get_html_part(mime_msg);
655 1 : ASSERT(html != NULL, "html-only mime: html part found");
656 :
657 1 : char *rendered = html_render(html, 0, 0);
658 1 : free(html);
659 1 : ASSERT(rendered != NULL, "html-only mime: render not NULL");
660 :
661 : /* Visible content must appear */
662 1 : ASSERT(strstr(rendered, "Visible Text") != NULL,
663 : "html-only mime: body text present in output");
664 :
665 : /* CSS must be suppressed */
666 1 : ASSERT(strstr(rendered, "color") == NULL,
667 : "html-only mime: CSS property 'color' not in output");
668 1 : ASSERT(strstr(rendered, "font-family") == NULL,
669 : "html-only mime: CSS property 'font-family' not in output");
670 1 : ASSERT(strstr(rendered, "Arial") == NULL,
671 : "html-only mime: CSS value 'Arial' not in output");
672 :
673 1 : free(rendered);
674 : }
675 :
676 : /* ── show_uid_interactive: uses correct folder, not cfg->folder ──── */
677 : /*
678 : * Regression test for subfolder message open bug.
679 : *
680 : * When the user presses Enter on a message in a subfolder (e.g. "munka/ai"),
681 : * show_uid_interactive must look up the message in that subfolder's cache —
682 : * NOT in cfg->folder (which is always "INBOX").
683 : *
684 : * Setup:
685 : * - Pre-populate cache under "test_subfolder" with UID 7777.
686 : * - Config has .folder = "INBOX" (wrong folder — the bug).
687 : * - Inject ESC via pipe into STDIN_FILENO so the function exits cleanly.
688 : *
689 : * If the function uses cfg->folder ("INBOX"):
690 : * local_msg_exists("INBOX", 7777) → false → fetch fails → returns -1.
691 : * If the function uses the correct folder ("test_subfolder"):
692 : * local_msg_exists("test_subfolder", 7777) → true → loads OK → ESC → returns 1.
693 : */
694 : {
695 : /* Minimal plain-text MIME message */
696 1 : const char *sf_mime =
697 : "MIME-Version: 1.0\r\n"
698 : "Content-Type: text/plain; charset=UTF-8\r\n"
699 : "Subject: Subfolder test\r\n"
700 : "From: test@example.com\r\n"
701 : "\r\n"
702 : "Subfolder message body.\r\n";
703 :
704 : /* Pre-populate cache under the correct subfolder */
705 1 : int saved_rc = local_msg_save("test_subfolder", 7777,
706 : sf_mime, strlen(sf_mime));
707 1 : if (saved_rc != 0) {
708 0 : ASSERT(0, "show_uid_interactive subfolder: local_msg_save failed");
709 : goto skip_subfolder_test;
710 : }
711 :
712 : /* Config intentionally has the wrong folder (the bug) */
713 1 : Config sf_cfg;
714 1 : memset(&sf_cfg, 0, sizeof(sf_cfg));
715 1 : sf_cfg.folder = "INBOX";
716 :
717 : /* Inject ESC (\033) into stdin via pipe so the function exits */
718 1 : int sf_pipe[2];
719 1 : if (pipe(sf_pipe) != 0) {
720 0 : ASSERT(0, "show_uid_interactive subfolder: pipe failed");
721 : goto skip_subfolder_test;
722 : }
723 1 : unsigned char esc_byte = '\033';
724 1 : ssize_t _w = write(sf_pipe[1], &esc_byte, 1);
725 : (void)_w;
726 1 : close(sf_pipe[1]);
727 :
728 : /* Redirect stdin to pipe read end */
729 1 : int saved_stdin = dup(STDIN_FILENO);
730 1 : dup2(sf_pipe[0], STDIN_FILENO);
731 1 : close(sf_pipe[0]);
732 :
733 : /* Redirect stdout + stderr to /dev/null (suppress TUI output) */
734 1 : fflush(stdout); fflush(stderr);
735 1 : int sf_null = open("/dev/null", O_WRONLY);
736 1 : int saved_stdout = dup(STDOUT_FILENO);
737 1 : int saved_stderr = dup(STDERR_FILENO);
738 1 : if (sf_null >= 0) {
739 1 : dup2(sf_null, STDOUT_FILENO);
740 1 : dup2(sf_null, STDERR_FILENO);
741 1 : close(sf_null);
742 : }
743 :
744 : /* Call with new signature: explicit folder parameter */
745 1 : int sf_ret = show_uid_interactive(&sf_cfg, "test_subfolder", 7777, 25);
746 :
747 : /* Restore stdin, stdout, stderr */
748 1 : fflush(stdout); fflush(stderr);
749 1 : dup2(saved_stdin, STDIN_FILENO); close(saved_stdin);
750 1 : dup2(saved_stdout, STDOUT_FILENO); close(saved_stdout);
751 1 : dup2(saved_stderr, STDERR_FILENO); close(saved_stderr);
752 :
753 : /* ESC → returns 0 (back to list); "not found in INBOX" → returns -1 */
754 1 : ASSERT(sf_ret == 0,
755 : "show_uid_interactive: uses correct folder (not cfg->folder)");
756 :
757 1 : skip_subfolder_test:;
758 : }
759 : }
|