FTHybridPostProcessingParamsTest.java

package redis.clients.jedis.search.hybrid;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import redis.clients.jedis.CommandArguments;
import redis.clients.jedis.args.Rawable;
import redis.clients.jedis.args.RawableFactory;
import redis.clients.jedis.search.Apply;
import redis.clients.jedis.search.Filter;
import redis.clients.jedis.search.Limit;
import redis.clients.jedis.search.SearchProtocol;
import redis.clients.jedis.search.aggr.Group;
import redis.clients.jedis.search.aggr.Reducers;
import redis.clients.jedis.search.aggr.SortedField;

import java.util.Iterator;

import static org.junit.jupiter.api.Assertions.*;
import static redis.clients.jedis.search.SearchProtocol.SearchKeyword.LOAD;

public class FTHybridPostProcessingParamsTest {

  private FTHybridPostProcessingParams.Builder builder;

  @BeforeEach
  void setUp() {
    builder = FTHybridPostProcessingParams.builder();
  }

  @Nested
  class EqualityAndHashCodeTests {

    @Test
    public void equalsWithIdenticalParams() {
      FTHybridPostProcessingParams firstParam = builder.load("field1", "field2").build();
      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder()
          .load("field1", "field2").build();
      assertEquals(firstParam, secondParam);
    }

    @Test
    public void hashCodeWithIdenticalParams() {
      FTHybridPostProcessingParams firstParam = builder.load("field1", "field2").build();
      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder()
          .load("field1", "field2").build();
      assertEquals(firstParam.hashCode(), secondParam.hashCode());
    }

    @Test
    public void equalsWithDifferentLoadFields() {
      FTHybridPostProcessingParams firstParam = builder.load("field1").build();
      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder()
          .load("field2").build();
      assertNotEquals(firstParam, secondParam);
    }

    @Test
    public void hashCodeWithDifferentLoadFields() {
      FTHybridPostProcessingParams firstParam = builder.load("field1").build();
      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder()
          .load("field2").build();
      assertNotEquals(firstParam.hashCode(), secondParam.hashCode());
    }

    @Test
    public void equalsWithNull() {
      FTHybridPostProcessingParams firstParam = builder.load("field1").build();
      FTHybridPostProcessingParams secondParam = null;
      assertFalse(firstParam.equals(secondParam));
    }

    @Test
    public void equalsWithSameInstance() {
      FTHybridPostProcessingParams param = builder.load("field1").build();
      assertTrue(param.equals(param));
    }

    @Test
    public void equalsLoadAllVsLoadFields() {
      FTHybridPostProcessingParams firstParam = builder.loadAll().build();
      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder()
          .load("field1").build();
      assertNotEquals(firstParam, secondParam);
    }

    @Test
    public void equalsWithDifferentLimit() {
      FTHybridPostProcessingParams firstParam = builder.load("field1").limit(Limit.of(0, 10))
          .build();
      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder()
          .load("field1").limit(Limit.of(0, 20)).build();
      assertNotEquals(firstParam, secondParam);
    }
  }

  @Nested
  class LoadValidationTests {

    @Test
    public void loadNullThrowsException() {
      IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
        () -> builder.load((String[]) null));
      assertEquals("Fields must not be null", exception.getMessage());
    }

    @Test
    public void loadEmptyArrayThrowsException() {
      IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
        () -> builder.load(new String[0]));
      assertEquals("At least one field is required", exception.getMessage());
    }

    @Test
    public void loadWithWildcardThrowsException() {
      IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
        () -> builder.load("*"));
      assertEquals("Cannot use '*' in load(). Use loadAll() instead to load all fields.",
        exception.getMessage());
    }

    @Test
    public void loadWithWildcardMixedWithFieldsThrowsException() {
      IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
        () -> builder.load("field1", "*", "field2"));
      assertEquals("Cannot use '*' in load(). Use loadAll() instead to load all fields.",
        exception.getMessage());
    }

    @Test
    public void loadWithNullFieldThrowsException() {
      IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
        () -> builder.load("field1", null, "field2"));
      assertEquals("Field names cannot be null", exception.getMessage());
    }

    @Test
    public void loadAllDoesNotThrow() {
      assertDoesNotThrow(() -> builder.loadAll());
    }

    @Test
    public void loadWithValidFieldsDoesNotThrow() {
      assertDoesNotThrow(() -> builder.load("field1", "field2", "field3"));
    }
  }

  @Nested
  class BuilderTests {

    @Test
    public void lastLoadCallWins() {
      FTHybridPostProcessingParams firstParam = builder.load("field1").load("field2").build();

      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder()
          .load("field2").build();

      assertEquals(firstParam, secondParam);
    }

    @Test
    public void loadAllOverridesLoad() {
      FTHybridPostProcessingParams firstParam = builder.load("field1", "field2").loadAll().build();

      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder().loadAll()
          .build();

      assertEquals(firstParam, secondParam);
    }

    @Test
    public void loadOverridesLoadAll() {
      FTHybridPostProcessingParams firstParam = builder.loadAll().load("field1").build();

      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder()
          .load("field1").build();

      assertEquals(firstParam, secondParam);
    }

    @Test
    public void equalsWithSameFieldsDifferentOrder() {
      FTHybridPostProcessingParams firstParam = builder.load("field1", "field2").build();

      FTHybridPostProcessingParams secondParam = FTHybridPostProcessingParams.builder()
          .load("field2", "field1").build();

      assertNotEquals(firstParam, secondParam);
    }

    @Test
    public void loadWithAtPrefixPreserved() {
      FTHybridPostProcessingParams params = builder.load("@field1", "field2").build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Both should have @ prefix in the output
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(2), iter.next());
      assertEquals(RawableFactory.from("@field1"), iter.next());
      assertEquals(RawableFactory.from("@field2"), iter.next());
    }
  }

  @Nested
  class SortByTests {

    @Test
    public void sortByWithSingleField() {
      FTHybridPostProcessingParams params = builder.sortBy(SortedField.asc("@price")).build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID SORTBY 2 @price ASC
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(SearchProtocol.SearchKeyword.SORTBY, iter.next());
      assertEquals(RawableFactory.from(2), iter.next()); // 1 field * 2 = 2
      assertEquals(RawableFactory.from("@price"), iter.next());
      assertEquals(RawableFactory.from("ASC"), iter.next());
    }

    @Test
    public void sortByWithMultipleFields() {
      FTHybridPostProcessingParams params = builder
          .sortBy(SortedField.asc("@price"), SortedField.desc("@rating"), SortedField.asc("@brand"))
          .build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID SORTBY 6 @price ASC @rating DESC @brand ASC
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(SearchProtocol.SearchKeyword.SORTBY, iter.next());
      assertEquals(RawableFactory.from(6), iter.next()); // 3 fields * 2 = 6
      assertEquals(RawableFactory.from("@price"), iter.next());
      assertEquals(RawableFactory.from("ASC"), iter.next());
      assertEquals(RawableFactory.from("@rating"), iter.next());
      assertEquals(RawableFactory.from("DESC"), iter.next());
      assertEquals(RawableFactory.from("@brand"), iter.next());
      assertEquals(RawableFactory.from("ASC"), iter.next());
    }

    @Test
    public void sortByWithLoadAndLimit() {
      FTHybridPostProcessingParams params = builder.load("price", "rating")
          .sortBy(SortedField.desc("@price")).limit(Limit.of(0, 10)).build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LOAD 2 @price @rating SORTBY 2 @price DESC LIMIT 0 10
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(2), iter.next());
      assertEquals(RawableFactory.from("@price"), iter.next());
      assertEquals(RawableFactory.from("@rating"), iter.next());
      assertEquals(SearchProtocol.SearchKeyword.SORTBY, iter.next());
      assertEquals(RawableFactory.from(2), iter.next());
      assertEquals(RawableFactory.from("@price"), iter.next());
      assertEquals(RawableFactory.from("DESC"), iter.next());
      assertEquals(SearchProtocol.SearchKeyword.LIMIT, iter.next());
      assertEquals(RawableFactory.from(0), iter.next());
      assertEquals(RawableFactory.from(10), iter.next());
    }

    @Test
    public void lastSortByCallWins() {
      // When sortBy is called multiple times, the last call should win
      FTHybridPostProcessingParams params = builder.sortBy(SortedField.asc("@price"))
          .sortBy(SortedField.desc("@rating")).build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID SORTBY 2 @rating DESC (only the last sortBy)
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(SearchProtocol.SearchKeyword.SORTBY, iter.next());
      assertEquals(RawableFactory.from(2), iter.next());
      assertEquals(RawableFactory.from("@rating"), iter.next());
      assertEquals(RawableFactory.from("DESC"), iter.next());
    }

    @Test
    public void lastSortByNoSortCallWins() {
      // When both sortBy and noSort are set, noSort should take precedence
      FTHybridPostProcessingParams params = builder.sortBy(SortedField.asc("@price")).noSort()
          .build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID NOSORT (sortBy should be ignored)
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(SearchProtocol.SearchKeyword.NOSORT, iter.next());
      assertFalse(iter.hasNext());
    }

    @Test
    public void lastNoSortSortByCallWins() {
      // When both sortBy and noSort are set, noSort should take precedence
      FTHybridPostProcessingParams params = builder.noSort().sortBy(SortedField.asc("@price"))
          .build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID NOSORT (sortBy should be ignored)
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(SearchProtocol.SearchKeyword.SORTBY, iter.next());
      assertEquals(RawableFactory.from(2), iter.next());
      assertEquals(RawableFactory.from("@price"), iter.next());
      assertEquals(RawableFactory.from("ASC"), iter.next());
      assertFalse(iter.hasNext());
    }

    @Test
    public void sortByWithEmptyArrayThrowsException() {
      IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
        builder.sortBy((SortedField[]) null);
      });
      assertEquals("Sort by fields must not be null", exception.getMessage());
    }
  }

  @Nested
  class AddParamsTests {

    @Test
    public void addParamsWithLoadSpecificFields() {
      FTHybridPostProcessingParams params = builder.load("field1", "field2").build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LOAD 2 @field1 @field2
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(2), iter.next());
      assertEquals(RawableFactory.from("@field1"), iter.next());
      assertEquals(RawableFactory.from("@field2"), iter.next());
    }

    @Test
    public void addParamsWithLoadAll() {
      FTHybridPostProcessingParams params = builder.loadAll().build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LOAD *
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from("*"), iter.next());
    }

    @Test
    public void addParamsWithNoLoad() {
      FTHybridPostProcessingParams params = builder.limit(Limit.of(0, 10)).build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LIMIT 0 10 (no LOAD)
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(SearchProtocol.SearchKeyword.LIMIT, iter.next());
      assertEquals(RawableFactory.from(0), iter.next());
      assertEquals(RawableFactory.from(10), iter.next());
    }

    @Test
    public void addParamsWithLoadAndGroupBy() {
      FTHybridPostProcessingParams params = builder.load("brand", "price")
          .groupBy(new Group("@brand").reduce(Reducers.count().as("count"))).build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LOAD 2 @brand @price GROUPBY 1 @brand REDUCE COUNT 0 AS count
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(2), iter.next());
      assertEquals(RawableFactory.from("@brand"), iter.next());
      assertEquals(RawableFactory.from("@price"), iter.next());
      assertEquals(SearchProtocol.SearchKeyword.GROUPBY, iter.next());
      // Continue with GROUPBY assertions...
    }

    @Test
    public void addParamsWithLoadAndApply() {
      FTHybridPostProcessingParams params = builder.load("price")
          .apply(Apply.of("@price * 0.9", "discounted")).build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LOAD 1 @price APPLY @price * 0.9 AS discounted
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(1), iter.next());
      assertEquals(RawableFactory.from("@price"), iter.next());
      assertEquals(SearchProtocol.SearchKeyword.APPLY, iter.next());
      // Continue with APPLY assertions...
    }

    @Test
    public void addParamsWithLoadAndSortBy() {
      FTHybridPostProcessingParams params = builder.load("price", "rating")
          .sortBy(SortedField.asc("@price")).build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LOAD 2 @price @rating SORTBY 2 @price ASC
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(2), iter.next());
      assertEquals(RawableFactory.from("@price"), iter.next());
      assertEquals(RawableFactory.from("@rating"), iter.next());
      assertEquals(SearchProtocol.SearchKeyword.SORTBY, iter.next());
      // Continue with SORTBY assertions...
    }

    @Test
    public void addParamsWithLoadAndNoSort() {
      FTHybridPostProcessingParams params = builder.load("field1").noSort().build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LOAD 1 @field1 NOSORT
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(1), iter.next());
      assertEquals(RawableFactory.from("@field1"), iter.next());
      assertEquals(SearchProtocol.SearchKeyword.NOSORT, iter.next());
    }

    @Test
    public void addParamsWithLoadAndFilter() {
      FTHybridPostProcessingParams params = builder.load("price").filter(Filter.of("@price > 100"))
          .build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LOAD 1 @price FILTER @price > 100
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(1), iter.next());
      assertEquals(RawableFactory.from("@price"), iter.next());
      assertEquals(SearchProtocol.SearchKeyword.FILTER, iter.next());
      // Continue with FILTER assertions...
    }

    @Test
    public void addParamsWithLoadAndLimit() {
      FTHybridPostProcessingParams params = builder.load("field1", "field2").limit(Limit.of(10, 20))
          .build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Expected: FT.HYBRID LOAD 2 @field1 @field2 LIMIT 10 20
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(2), iter.next());
      assertEquals(RawableFactory.from("@field1"), iter.next());
      assertEquals(RawableFactory.from("@field2"), iter.next());
      assertEquals(SearchProtocol.SearchKeyword.LIMIT, iter.next());
      assertEquals(RawableFactory.from(10), iter.next());
      assertEquals(RawableFactory.from(20), iter.next());
    }

    @Test
    public void addParamsFieldWithoutAtPrefixGetsPrefix() {
      FTHybridPostProcessingParams params = builder.load("field1").build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Field without @ should get @ prefix
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(1), iter.next());
      assertEquals(RawableFactory.from("@field1"), iter.next());
    }

    @Test
    public void addParamsFieldWithAtPrefixNotDuplicated() {
      FTHybridPostProcessingParams params = builder.load("@field1").build();

      CommandArguments args = new CommandArguments(SearchProtocol.SearchCommand.HYBRID);
      params.addParams(args);

      // Field with @ should not get another @ prefix
      Iterator<Rawable> iter = args.iterator();
      assertEquals(SearchProtocol.SearchCommand.HYBRID, iter.next());
      assertEquals(LOAD, iter.next());
      assertEquals(RawableFactory.from(1), iter.next());
      assertEquals(RawableFactory.from("@field1"), iter.next());
    }
  }
}