]> git.ipfire.org Git - thirdparty/dovecot/core.git/commitdiff
fts: Add unit test for search argument parsing
authorMichael M Slusarz <michael.slusarz@open-xchange.com>
Mon, 11 May 2026 20:19:54 +0000 (14:19 -0600)
committerMichael M Slusarz <michael.slusarz@open-xchange.com>
Mon, 11 May 2026 20:19:54 +0000 (14:19 -0600)
src/plugins/fts/Makefile.am
src/plugins/fts/test-fts-search-args.c [new file with mode: 0644]

index 4485cf4010873bcffb907db45b3d844d6ea15ca2..646d9a31b364275c97ab1b7bbac4e8ae1bf24573 100644 (file)
@@ -23,7 +23,7 @@ lib20_fts_plugin_la_LDFLAGS = -module -avoid-version
 module_LTLIBRARIES = \
        lib20_fts_plugin.la
 
-lib20_fts_plugin_la_LIBADD = ../../lib-language/libdovecot-language.la
+lib20_fts_plugin_la_LIBADD = $(LIBDOVECOT_LIBLANG)
 
 lib20_fts_plugin_la_SOURCES = \
        fts-api.c \
@@ -70,3 +70,15 @@ doveadm_module_LTLIBRARIES = \
 
 lib20_doveadm_fts_plugin_la_SOURCES = \
        doveadm-fts.c
+
+test_programs = test-fts-search-args
+
+test_fts_search_args_SOURCES = test-fts-search-args.c
+test_fts_search_args_CPPFLAGS = $(AM_CPPFLAGS) -I$(top_srcdir)/src/lib-test
+test_fts_search_args_LDADD = \
+       $(LIBDOVECOT_LIBLANG) \
+       $(LIBDOVECOT_STORAGE) \
+       $(LIBDOVECOT)
+test_fts_search_args_DEPENDENCIES = $(LIBDOVECOT_DEPS) $(LIBDOVECOT_STORAGE_DEPS) $(LIBDOVECOT_LIBLANG_DEPS)
+
+include $(top_srcdir)/Makefile.test.include
diff --git a/src/plugins/fts/test-fts-search-args.c b/src/plugins/fts/test-fts-search-args.c
new file mode 100644 (file)
index 0000000..d9c49b1
--- /dev/null
@@ -0,0 +1,867 @@
+/* Copyright (c) Dovecot authors, see the included COPYING file */
+
+#include "lib.h"
+#include "array.h"
+#include "str.h"
+#include "test-common.h"
+#include "mail-search-build.h"
+#include "mail-search-parser.h"
+#include "mail-search.h"
+#include "mail-user.h"
+#include "mail-namespace.h"
+#include "fts-api-private.h"
+#include "fts-search-args.h"
+#include "lang-user.h"
+#include "lang-tokenizer.h"
+#include "lang-filter.h"
+
+/* Mocks */
+static const char *const *mock_tokens;
+static int mock_token_idx;
+
+static void test_lang_tokenizer_reset(struct lang_tokenizer *tok ATTR_UNUSED) {
+       mock_token_idx = 0;
+}
+
+static int test_lang_tokenizer_next(struct lang_tokenizer *tok ATTR_UNUSED,
+                                    const void *data ATTR_UNUSED,
+                                    size_t len ATTR_UNUSED,
+                                    const char **token_r,
+                                    const char **error_r ATTR_UNUSED) {
+       if (mock_tokens == NULL || mock_tokens[mock_token_idx] == NULL) return 0;
+       *token_r = mock_tokens[mock_token_idx++];
+       return 1;
+}
+
+static int test_lang_tokenizer_final(struct lang_tokenizer *tok ATTR_UNUSED,
+                                     const char **token_r ATTR_UNUSED,
+                                     const char **error_r ATTR_UNUSED) {
+       return 0;
+}
+
+static int test_lang_filter(struct lang_filter *filter ATTR_UNUSED,
+                           const char **token ATTR_UNUSED,
+                           const char **error_r ATTR_UNUSED) {
+       /* Return 1 (keep) */
+       return 1;
+}
+
+static struct language_user test_lang_user_struct;
+static ARRAY_TYPE(language_user) test_languages;
+
+static const ARRAY_TYPE(language_user) *
+test_lang_user_get_all_languages(struct mail_user *user ATTR_UNUSED) {
+       return &test_languages;
+}
+
+static const ARRAY_TYPE(language_user) *
+test_lang_user_get_data_languages(struct mail_user *user ATTR_UNUSED) {
+       return &test_languages;
+}
+
+static bool test_fts_header_has_language(const char *name ATTR_UNUSED) {
+       return FALSE;
+}
+
+/* Redirects */
+#define lang_tokenizer_reset test_lang_tokenizer_reset
+#define lang_tokenizer_next test_lang_tokenizer_next
+#define lang_tokenizer_final test_lang_tokenizer_final
+/* Use function macro to avoid renaming struct lang_filter */
+#define lang_filter(f, t, e) test_lang_filter(f, t, e)
+#define lang_user_get_all_languages test_lang_user_get_all_languages
+#define lang_user_get_data_languages test_lang_user_get_data_languages
+#define fts_header_has_language test_fts_header_has_language
+
+#include "fts-search-args.c"
+
+/* Tests */
+
+static struct fts_backend *test_backend;
+static struct mail_user *test_user;
+static struct mail_namespace *test_ns;
+
+static void test_setup(void)
+{
+       test_user = i_new(struct mail_user, 1);
+       test_ns = i_new(struct mail_namespace, 1);
+       test_ns->user = test_user;
+
+       test_backend = i_new(struct fts_backend, 1);
+       test_backend->ns = test_ns;
+       test_backend->event = event_create(NULL);
+       test_backend->flags = FTS_BACKEND_FLAG_TOKENIZED_INPUT;
+
+       i_array_init(&test_languages, 1);
+       struct language_user *item = &test_lang_user_struct;
+       array_push_back(&test_languages, &item);
+}
+
+static void test_teardown(void)
+{
+       array_free(&test_languages);
+       event_unref(&test_backend->event);
+       i_free(test_backend);
+       i_free(test_ns);
+       i_free(test_user);
+}
+
+static void test_single_word(void)
+{
+       test_begin("single word");
+
+       /* Setup mock tokens for "foo" */
+       static const char *const tokens[] = { "foo", NULL };
+       mock_tokens = tokens;
+       mock_token_idx = 0;
+
+       struct mail_search_args *args = mail_search_build_init();
+       struct mail_search_arg *arg = p_new(args->pool, struct mail_search_arg, 1);
+       arg->type = SEARCH_TEXT;
+       arg->value.str = "foo";
+       args->args = arg;
+
+       test_assert(fts_search_arg_expand(test_backend, args->pool, &args->args) == 0);
+
+       struct mail_search_arg *arg_exp = args->args;
+
+       /* Structure:
+          arg_exp (SEARCH_OR) -> subargs (SEARCH_SUB) -> subargs (SEARCH_OR) -> subargs ("foo")
+       */
+
+       test_assert(arg_exp->type == SEARCH_OR);
+       struct mail_search_arg *sub = arg_exp->value.subargs;
+       test_assert(sub->type == SEARCH_SUB);
+       struct mail_search_arg *or_arg = sub->value.subargs;
+       test_assert(or_arg->type == SEARCH_OR);
+
+       struct mail_search_arg *term = or_arg->value.subargs;
+       test_assert_strcmp(term->value.str, "foo");
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(term->next == NULL);
+
+       mail_search_args_simplify(args);
+
+       /* Simplified Structure:
+          SEARCH_TEXT ("foo")
+       */
+       arg = args->args;
+       test_assert(arg->type == SEARCH_TEXT);
+       test_assert_strcmp(arg->value.str, "foo");
+       test_assert(HAS_NO_BITS(arg->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(arg->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(arg->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(arg->next == NULL);
+
+       mail_search_args_unref(&args);
+       test_end();
+}
+
+static void test_phrase_old_args_structure(void)
+{
+       test_begin("phrase (old args structure)");
+
+       /* Setup mock tokens for "foo bar" */
+       static const char *const tokens[] = { "foo", "bar", NULL };
+       mock_tokens = tokens;
+       mock_token_idx = 0;
+
+       struct mail_search_args *args = mail_search_build_init();
+       struct mail_search_arg *arg = p_new(args->pool, struct mail_search_arg, 1);
+       arg->type = SEARCH_TEXT;
+       arg->value.str = "foo bar";
+       args->args = arg;
+
+       test_assert(fts_search_arg_expand(test_backend, args->pool, &args->args) == 0);
+
+       struct mail_search_arg *arg_exp = args->args;
+
+       /* Structure:
+        * OR flags=0 str=''
+        *   SUB flags=0 str=''
+        *     OR flags=0 str=''
+        *       TEXT flags=8 str='bar'
+        *       TEXT flags=2 str='foo bar'
+        *     OR flags=0 str=''
+        *       TEXT flags=12 str='foo'
+        *       TEXT flags=2 str='foo bar'
+        */
+
+       test_assert(arg_exp->type == SEARCH_OR);
+       struct mail_search_arg *sub = arg_exp->value.subargs;
+       test_assert(sub->type == SEARCH_SUB);
+
+       struct mail_search_arg *p = sub->value.subargs;
+
+       test_assert(p->type == SEARCH_OR);
+       test_assert(HAS_NO_BITS(p->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(p->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(p->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       struct mail_search_arg *or1 = p->value.subargs;
+
+       test_assert(or1 != NULL);
+       test_assert(or1->type == SEARCH_TEXT);
+       test_assert_strcmp(or1->value.str, "bar");
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(or1->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       or1 = or1->next;
+
+       test_assert(or1 != NULL);
+       test_assert(or1->type == SEARCH_TEXT);
+       test_assert_strcmp(or1->value.str, "foo bar");
+       test_assert(HAS_ANY_BITS(or1->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       or1 = or1->next;
+
+       test_assert(or1 == NULL);
+
+       p = p->next;
+
+       test_assert(p != NULL);
+
+       struct mail_search_arg *or2 = p->value.subargs;
+
+       test_assert(or2 != NULL);
+       test_assert(or2->type == SEARCH_TEXT);
+       test_assert_strcmp(or2->value.str, "foo");
+       test_assert(HAS_NO_BITS(or2->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_ANY_BITS(or2->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(or2->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       or2 = or2->next;
+
+       test_assert(or2 != NULL);
+       test_assert(or2->type == SEARCH_TEXT);
+       test_assert_strcmp(or2->value.str, "foo bar");
+       test_assert(HAS_ANY_BITS(or2->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(or2->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(or2->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       or2 = or2->next;
+
+       test_assert(or2 == NULL);
+
+       mail_search_args_simplify(args);
+
+       /* Simplified Structure:
+        * OR flags=0 str=''
+        *   SUB flags=0 str=''
+        *     TEXT flags=8 str='bar'
+        *     TEXT flags=12 str='foo'
+        *   TEXT flags=2 str='foo bar'
+        */
+       arg = args->args;
+       test_assert(arg->type == SEARCH_OR);
+
+       /* Check children of OR */
+       struct mail_search_arg *child1 = arg->value.subargs;
+       test_assert(child1 != NULL);
+
+       /* Child 1 should be SUB("foo", "bar") */
+       test_assert(child1->type == SEARCH_SUB);
+       struct mail_search_arg *sub_child = child1->value.subargs;
+
+       test_assert(sub_child != NULL);
+       /* SUB children order: "bar" then "foo" */
+       test_assert(sub_child->type == SEARCH_TEXT);
+       test_assert_strcmp(sub_child->value.str, "bar");
+       test_assert(HAS_NO_BITS(sub_child->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_ANY_BITS(sub_child->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(HAS_NO_BITS(sub_child->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+
+       sub_child = sub_child->next;
+       test_assert(sub_child != NULL);
+       test_assert(sub_child->type == SEARCH_TEXT);
+       test_assert_strcmp(sub_child->value.str, "foo");
+       test_assert(HAS_NO_BITS(sub_child->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_ANY_BITS(sub_child->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(sub_child->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       test_assert(sub_child->next == NULL);
+
+       /* Child 2 should be TEXT "foo bar" */
+       struct mail_search_arg *child2 = child1->next;
+       test_assert(child2 != NULL);
+       test_assert(child2->type == SEARCH_TEXT);
+       test_assert_strcmp(child2->value.str, "foo bar");
+       test_assert(HAS_ANY_BITS(child2->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(child2->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(HAS_NO_BITS(child2->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+
+       test_assert(child2->next == NULL);
+
+       mail_search_args_unref(&args);
+       test_end();
+}
+
+static void test_phrase_new_args_structure(void)
+{
+       test_begin("phrase (new args structure)");
+
+       test_backend->flags |= FTS_BACKEND_FLAG_SEARCH_ARGS_V2;
+
+       /* Setup mock tokens for "foo bar" */
+       static const char *const tokens[] = { "foo", "bar", NULL };
+       mock_tokens = tokens;
+       mock_token_idx = 0;
+
+       struct mail_search_args *args = mail_search_build_init();
+       struct mail_search_arg *arg = p_new(args->pool, struct mail_search_arg, 1);
+       arg->type = SEARCH_TEXT;
+       arg->value.str = "foo bar";
+       args->args = arg;
+
+       test_assert(fts_search_arg_expand(test_backend, args->pool, &args->args) == 0);
+
+       struct mail_search_arg *arg_exp = args->args;
+
+       /* Structure:
+        * OR flags=0 str=''
+        *   SUB flags=0 str=''
+        *     OR flags=0 str=''
+        *       TEXT flags=8 str='bar'
+        *     OR flags=0 str=''
+        *       TEXT flags=12 str='foo'
+        *     TEXT flags=2 str='foo bar'
+        */
+
+       test_assert(arg_exp->type == SEARCH_OR);
+       struct mail_search_arg *sub_or = arg_exp->value.subargs;
+
+       test_assert(sub_or->type == SEARCH_SUB);
+       struct mail_search_arg *sub = sub_or->value.subargs;
+
+       test_assert(sub->type == SEARCH_OR);
+       test_assert(HAS_NO_BITS(sub->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(sub->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(sub->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       struct mail_search_arg *or1 = sub->value.subargs;
+
+       test_assert(or1 != NULL);
+       test_assert(or1->type == SEARCH_TEXT);
+       test_assert_strcmp(or1->value.str, "bar");
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(or1->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       test_assert(or1->next == NULL);
+
+       sub = sub->next;
+
+       test_assert(sub->type == SEARCH_OR);
+       test_assert(HAS_NO_BITS(sub->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(sub->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(sub->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       struct mail_search_arg *or2 = sub->value.subargs;
+
+       test_assert(or2 != NULL);
+       test_assert(or2->type == SEARCH_TEXT);
+       test_assert_strcmp(or2->value.str, "foo");
+       test_assert(HAS_NO_BITS(or2->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_ANY_BITS(or2->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(or2->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       test_assert(or2->next == NULL);
+
+       sub = sub->next;
+
+       test_assert(sub != NULL);
+       test_assert(sub->type == SEARCH_TEXT);
+       test_assert_strcmp(sub->value.str, "foo bar");
+       test_assert(HAS_ANY_BITS(sub->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(sub->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(sub->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       test_assert(sub->next == NULL);
+
+       mail_search_args_simplify(args);
+
+       /* Simplified Structure:
+       * TEXT flags=8 str='bar'
+       * TEXT flags=12 str='foo'
+       * TEXT flags=2 str='foo bar'
+       */
+
+       arg = args->args;
+
+       test_assert(arg != NULL);
+       test_assert(arg->type == SEARCH_TEXT);
+       test_assert_strcmp(arg->value.str, "bar");
+
+       arg = arg->next;
+
+       test_assert(arg != NULL);
+       test_assert(arg->type == SEARCH_TEXT);
+       test_assert_strcmp(arg->value.str, "foo");
+
+       arg = arg->next;
+
+       test_assert(arg != NULL);
+       test_assert(arg->type == SEARCH_TEXT);
+       test_assert_strcmp(arg->value.str, "foo bar");
+
+       test_assert(arg->next == NULL);
+
+       mail_search_args_unref(&args);
+       test_end();
+}
+
+static void test_phrase_with_tab_whitespace(void)
+{
+       test_begin("phrase with tab whitespace");
+
+       test_backend->flags |= FTS_BACKEND_FLAG_SEARCH_ARGS_V2;
+
+       /* Setup mock tokens for "foo\tbar" (tab-separated, should be a phrase) */
+       static const char *const tokens[] = { "foo", "bar", NULL };
+       mock_tokens = tokens;
+       mock_token_idx = 0;
+
+       struct mail_search_args *args = mail_search_build_init();
+       struct mail_search_arg *arg = p_new(args->pool, struct mail_search_arg, 1);
+       arg->type = SEARCH_TEXT;
+       arg->value.str = "foo\tbar";
+       args->args = arg;
+
+       test_assert(fts_search_arg_expand(test_backend, args->pool,
+                                         &args->args) == 0);
+
+       struct mail_search_arg *arg_exp = args->args;
+
+       /* Structure with phrase_and=true and is_phrase=true (tab detected):
+        *
+        * OR
+        *   SUB
+        *     OR
+        *       TEXT flags=8 str='bar'        (PHRASE_TERM)
+        *     OR
+        *       TEXT flags=12 str='foo'       (PHRASE_FIRST_TERM | PHRASE_TERM)
+        *     TEXT flags=2 str='foo\tbar'     (PHRASE_FULL)
+        */
+
+       test_assert(arg_exp->type == SEARCH_OR);
+       struct mail_search_arg *sub = arg_exp->value.subargs;
+       test_assert(sub->type == SEARCH_SUB);
+
+       /* First OR: tokenized "bar" (PHRASE_TERM only) */
+       struct mail_search_arg *or1 = sub->value.subargs;
+       test_assert(or1 != NULL && or1->type == SEARCH_OR);
+
+       struct mail_search_arg *term = or1->value.subargs;
+       test_assert(term != NULL && term->type == SEARCH_TEXT);
+       test_assert_strcmp(term->value.str, "bar");
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(term->next == NULL);  /* OR has exactly one child */
+
+       /* Second OR: tokenized "foo" (PHRASE_FIRST_TERM | PHRASE_TERM) */
+       or1 = or1->next;
+       test_assert(or1 != NULL && or1->type == SEARCH_OR);
+
+       term = or1->value.subargs;
+       test_assert(term != NULL && term->type == SEARCH_TEXT);
+       test_assert_strcmp(term->value.str, "foo");
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_ANY_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(term->next == NULL);  /* OR has exactly one child */
+
+       /* Third: full phrase "foo\tbar" (PHRASE_FULL) */
+       or1 = or1->next;
+       test_assert(or1 != NULL && or1->type == SEARCH_TEXT);
+       test_assert_strcmp(or1->value.str, "foo\tbar");
+       test_assert(HAS_ANY_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(or1->next == NULL);  /* End of SUB children */
+
+       mail_search_args_unref(&args);
+       test_end();
+}
+
+static void test_three_word_phrase(void)
+{
+       test_begin("three word phrase (new args structure)");
+
+       test_backend->flags |= FTS_BACKEND_FLAG_SEARCH_ARGS_V2;
+
+       /* Setup mock tokens for "foo bar baz" (three-word phrase) */
+       static const char *const tokens[] = { "foo", "bar", "baz", NULL };
+       mock_tokens = tokens;
+       mock_token_idx = 0;
+
+       struct mail_search_args *args = mail_search_build_init();
+       struct mail_search_arg *arg = p_new(args->pool, struct mail_search_arg, 1);
+       arg->type = SEARCH_TEXT;
+       arg->value.str = "foo bar baz";
+       args->args = arg;
+
+       test_assert(fts_search_arg_expand(test_backend, args->pool,
+                                         &args->args) == 0);
+
+       struct mail_search_arg *arg_exp = args->args;
+
+       /* Structure with phrase_and=true and is_phrase=true:
+        *
+        * OR
+        *   SUB
+        *     OR
+        *       TEXT flags=8 str='baz'        (PHRASE_TERM)
+        *     OR
+        *       TEXT flags=8 str='bar'        (PHRASE_TERM)
+        *     OR
+        *       TEXT flags=12 str='foo'       (PHRASE_FIRST_TERM | PHRASE_TERM)
+        *     TEXT flags=2 str='foo bar baz'  (PHRASE_FULL)
+        */
+
+       test_assert(arg_exp->type == SEARCH_OR);
+       struct mail_search_arg *sub = arg_exp->value.subargs;
+       test_assert(sub->type == SEARCH_SUB);
+
+       /* First OR: last token "baz" (PHRASE_TERM only) */
+       struct mail_search_arg *or1 = sub->value.subargs;
+       test_assert(or1 != NULL && or1->type == SEARCH_OR);
+
+       struct mail_search_arg *term = or1->value.subargs;
+       test_assert(term != NULL && term->type == SEARCH_TEXT);
+       test_assert_strcmp(term->value.str, "baz");
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(term->next == NULL);
+
+       /* Second OR: middle token "bar" (PHRASE_TERM only) */
+       or1 = or1->next;
+       test_assert(or1 != NULL && or1->type == SEARCH_OR);
+
+       term = or1->value.subargs;
+       test_assert(term != NULL && term->type == SEARCH_TEXT);
+       test_assert_strcmp(term->value.str, "bar");
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(term->next == NULL);
+
+       /* Third OR: first token "foo" (PHRASE_FIRST_TERM | PHRASE_TERM) */
+       or1 = or1->next;
+       test_assert(or1 != NULL && or1->type == SEARCH_OR);
+
+       term = or1->value.subargs;
+       test_assert(term != NULL && term->type == SEARCH_TEXT);
+       test_assert_strcmp(term->value.str, "foo");
+       test_assert(HAS_NO_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_ANY_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_ANY_BITS(term->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(term->next == NULL);
+
+       /* Fourth: full phrase "foo bar baz" (PHRASE_FULL) */
+       or1 = or1->next;
+       test_assert(or1 != NULL && or1->type == SEARCH_TEXT);
+       test_assert_strcmp(or1->value.str, "foo bar baz");
+       test_assert(HAS_ANY_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FIRST_TERM));
+       test_assert(HAS_NO_BITS(or1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+       test_assert(or1->next == NULL);
+
+       mail_search_args_unref(&args);
+       test_end();
+}
+
+static void test_single_word_split(void)
+{
+       test_begin("single word split (e.g. email or dotted word)");
+
+       test_backend->flags |= FTS_BACKEND_FLAG_SEARCH_ARGS_V2;
+
+       /* Setup mock tokens for "foo.bar" (a single word that tokenizes into
+          multiple parts) */
+       static const char *const tokens[] = { "foo", "bar", NULL };
+       mock_tokens = tokens;
+       mock_token_idx = 0;
+
+       struct mail_search_args *args = mail_search_build_init();
+       struct mail_search_arg *arg = p_new(args->pool, struct mail_search_arg, 1);
+       arg->type = SEARCH_TEXT;
+       arg->value.str = "foo.bar";
+       args->args = arg;
+
+       test_assert(fts_search_arg_expand(test_backend, args->pool,
+                                         &args->args) == 0);
+
+       struct mail_search_arg *arg_exp = args->args;
+
+       /* Under the broken logic, this single word would be treated as a phrase
+          (PHRASE_FULL added, and phrase parsing applied).
+          With the fix, it should just be expanded normally without phrase
+          flags. */
+
+       test_assert(arg_exp->type == SEARCH_OR);
+       struct mail_search_arg *sub = arg_exp->value.subargs;
+       test_assert(sub->type == SEARCH_SUB);
+
+       /* Check first token expansion ("bar").
+          With the bug, the phrase logic passes NULL for orig_token, so
+          "foo.bar" is missing, and "bar" has the PHRASE_TERM flag set. */
+       struct mail_search_arg *or1 = sub->value.subargs;
+       test_assert(or1 != NULL && or1->type == SEARCH_OR);
+
+       struct mail_search_arg *term1 = or1->value.subargs;
+       test_assert(term1 != NULL && term1->type == SEARCH_TEXT);
+       test_assert_strcmp(term1->value.str, "bar");
+       test_assert(HAS_NO_BITS(term1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       term1 = term1->next;
+       test_assert(term1 != NULL && term1->type == SEARCH_TEXT);
+       test_assert_strcmp(term1->value.str, "foo.bar");
+       test_assert(HAS_NO_BITS(term1->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+
+       /* Check second token expansion ("foo") */
+       struct mail_search_arg *or2 = or1->next;
+       test_assert(or2 != NULL && or2->type == SEARCH_OR);
+
+       struct mail_search_arg *term2 = or2->value.subargs;
+       test_assert(term2 != NULL && term2->type == SEARCH_TEXT);
+       test_assert_strcmp(term2->value.str, "foo");
+       test_assert(HAS_NO_BITS(term2->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_TERM));
+
+       term2 = term2->next;
+       test_assert(term2 != NULL && term2->type == SEARCH_TEXT);
+       test_assert_strcmp(term2->value.str, "foo.bar");
+       test_assert(HAS_NO_BITS(term2->value.search_flags,
+                               MAIL_SEARCH_ARG_FLAG_PHRASE_FULL));
+
+       test_assert(or2->next == NULL);
+
+       mail_search_args_unref(&args);
+       test_end();
+}
+
+/* Setup test_backend->flags with the required search args before calling. */
+static void test_phrase_with_sibling_next_helper(void)
+{
+       /* Setup mock tokens for "foo bar" */
+       static const char *const tokens[] = { "foo", "bar", NULL };
+       mock_tokens = tokens;
+       mock_token_idx = 0;
+
+       /* Build a search chain: phrase "foo bar" AND before:2025 */
+       struct mail_search_args *args = mail_search_build_init();
+
+       /* Create the sibling criterion (SEARCH_BEFORE) */
+       struct mail_search_arg *sibling = p_new(args->pool,
+                                               struct mail_search_arg, 1);
+       sibling->type = SEARCH_BEFORE;
+       sibling->value.time = 1735689600; /* 2025-01-01 */
+
+       /* Create the phrase arg with next pointing to sibling */
+       struct mail_search_arg *phrase_arg = p_new(args->pool,
+                                                  struct mail_search_arg, 1);
+       phrase_arg->type = SEARCH_TEXT;
+       phrase_arg->value.str = "foo bar";
+       phrase_arg->next = sibling;
+
+       args->args = phrase_arg;
+
+       /* Expand the phrase arg */
+       test_assert(fts_search_arg_expand(test_backend, args->pool,
+                                                 &args->args) == 0);
+
+       /* The expanded args should have next pointing to sibling
+          (preserved at the OR level). */
+       struct mail_search_arg *arg_exp = args->args;
+       test_assert(arg_exp->type == SEARCH_OR);
+       test_assert(arg_exp->next == sibling);
+
+       /* Inside the OR, the SUB's children should have no dangling
+          next. This is the key assertion: the phrase-full arg must
+          NOT have next pointing to the sibling (that's the bug
+          being fixed). */
+       struct mail_search_arg *sub = arg_exp->value.subargs;
+       test_assert(sub != NULL && sub->type == SEARCH_SUB);
+
+       struct mail_search_arg *child = sub->value.subargs;
+       while (child != NULL) {
+               if (child->type == SEARCH_TEXT &&
+                   HAS_ANY_BITS(child->value.search_flags,
+                                MAIL_SEARCH_ARG_FLAG_PHRASE_FULL)) {
+                       test_assert(child->next == NULL);
+               }
+
+               child = child->next;
+       }
+
+       /* Verify simplify completes without hang/corruption */
+       mail_search_args_simplify(args);
+
+       /* After simplify, the sibling should still be reachable
+          somewhere in the tree. */
+       arg_exp = args->args;
+       test_assert(arg_exp != NULL);
+       test_assert(sibling->next == NULL);
+
+       mail_search_args_unref(&args);
+}
+
+static void test_phrase_with_sibling_next(void)
+{
+       test_begin("phrase with sibling next pointer");
+
+       /* old (v1) args structure */
+       test_backend->flags &= ~FTS_BACKEND_FLAG_SEARCH_ARGS_V2;
+       test_phrase_with_sibling_next_helper();
+
+       /* SEARCH_ARGS_V2 */
+       test_backend->flags |= FTS_BACKEND_FLAG_SEARCH_ARGS_V2;
+       test_phrase_with_sibling_next_helper();
+
+       test_end();
+}
+
+static void test_create_or_next_pointer(void)
+{
+       test_begin("fts_search_arg_create_or next pointer correctness");
+
+       /* This test verifies that fts_search_arg_create_or() correctly clears
+          the next pointer on copied args, matching the fix for the phrase
+          search path. */
+
+       unsigned int i;
+       struct mail_search_args *args = mail_search_build_init();
+
+       /* Create an orig arg with a next pointer (simulating a sibling) */
+       struct mail_search_arg *sibling = p_new(args->pool,
+                                                       struct mail_search_arg, 1);
+       sibling->type = SEARCH_BEFORE;
+       sibling->value.time = 1735689600;
+
+       struct mail_search_arg *orig_arg = p_new(args->pool,
+                                                        struct mail_search_arg, 1);
+       orig_arg->type = SEARCH_TEXT;
+       orig_arg->value.str = "test phrase";
+       orig_arg->next = sibling;
+
+       /* Create token array */
+       ARRAY_TYPE(const_string) tokens;
+       const char *strs[] = { "bar", "foo", "test phrase" };
+       t_array_init(&tokens, 3);
+       for (i = 0; i < 3; i++)
+               array_append(&tokens, &strs[i], 1);
+
+       /* Call fts_search_arg_create_or */
+       struct mail_search_arg *or_arg = fts_search_arg_create_or(orig_arg,
+                                                                  args->pool, &tokens);
+
+       /* The OR should NOT have the sibling as next */
+       test_assert(or_arg->next == NULL);
+
+       /* Walk the OR's child chain and verify no child points to sibling */
+       struct mail_search_arg *child = or_arg->value.subargs;
+       while (child != NULL) {
+               test_assert(child->next != sibling);
+               child = child->next;
+       }
+
+       /* The sibling should still be intact and separate */
+       test_assert(sibling->next == NULL);
+       test_assert(sibling->type == SEARCH_BEFORE);
+
+       mail_search_args_unref(&args);
+       test_end();
+}
+
+int main(void)
+{
+       static void (*const test_functions[])(void) = {
+               test_setup,
+               test_single_word,
+               test_phrase_old_args_structure,
+               test_phrase_new_args_structure,
+               test_phrase_with_tab_whitespace,
+               test_three_word_phrase,
+               test_single_word_split,
+               test_phrase_with_sibling_next,
+               test_create_or_next_pointer,
+               test_teardown,
+               NULL
+       };
+       return test_run(test_functions);
+}