defmodule BDS.SearchTest do use ExUnit.Case, async: false alias BDS.Repo setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) temp_dir = Path.join(System.tmp_dir!(), "bds-search-#{System.unique_integer([:positive])}") File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) {:ok, project} = BDS.Projects.create_project(%{name: "Search", data_path: temp_dir}) %{project: project, temp_dir: temp_dir} end test "search_posts indexes writes, supports filters and pagination, and removes deleted posts", %{project: project} do assert {:ok, draft_post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Galaxy Draft", content: "alpha nebula body", tags: ["space", "draft"], categories: ["astronomy"], language: "en" }) assert {:ok, published_post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Galaxy Published", content: "alpha nebula published", tags: ["space", "published"], categories: ["astronomy"], language: "de" }) assert {:ok, published_post} = BDS.Posts.publish_post(published_post.id) assert {:ok, archived_post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Galaxy Archived", content: "alpha nebula archive", tags: ["space", "archived"], categories: ["history"], language: "en" }) assert {:ok, archived_post} = BDS.Posts.archive_post(archived_post.id) assert {:ok, results} = BDS.Search.search_posts(project.id, "nebula", %{status: :draft}) assert results.total == 1 assert results.offset == 0 assert results.limit == 50 assert Enum.map(results.posts, & &1.id) == [draft_post.id] assert {:ok, tag_results} = BDS.Search.search_posts(project.id, "galaxy", %{ tags: ["space"], categories: ["astronomy"] }) assert tag_results.total == 2 assert Enum.sort(Enum.map(tag_results.posts, & &1.id)) == Enum.sort([draft_post.id, published_post.id]) assert {:ok, language_results} = BDS.Search.search_posts(project.id, "galaxy", %{language: "de"}) assert Enum.map(language_results.posts, & &1.id) == [published_post.id] assert {:ok, paged_results} = BDS.Search.search_posts(project.id, "galaxy", %{limit: 1, offset: 1}) assert paged_results.total == 3 assert paged_results.offset == 1 assert paged_results.limit == 1 assert length(paged_results.posts) == 1 assert {:ok, updated_post} = BDS.Posts.update_post(draft_post.id, %{title: "Comet Draft"}) assert {:ok, empty_results} = BDS.Search.search_posts(project.id, "Galaxy Draft", %{}) assert empty_results.total == 0 assert {:ok, updated_results} = BDS.Search.search_posts(project.id, "Comet Draft", %{}) assert Enum.map(updated_results.posts, & &1.id) == [updated_post.id] assert {:ok, :deleted} = BDS.Posts.delete_post(archived_post.id) assert {:ok, deleted_results} = BDS.Search.search_posts(project.id, "Galaxy Archived", %{}) assert deleted_results.total == 0 end test "search_posts includes translation text after reindexing", %{project: project} do assert {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Canonical", content: "root body", language: "en" }) now = System.system_time(:second) Repo.query!( """ INSERT INTO post_translations ( id, project_id, translation_for, language, title, excerpt, content, status, created_at, updated_at, published_at, file_path, checksum ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ Ecto.UUID.generate(), project.id, post.id, "fr", "Bonjour galaxie", "Resume", "contenu lunaire", "draft", now, now, nil, "", nil ] ) assert :ok = BDS.Search.reindex_project(project.id) assert {:ok, results} = BDS.Search.search_posts(project.id, "lunaire", %{}) assert Enum.map(results.posts, & &1.id) == [post.id] assert {:ok, missing_translation_results} = BDS.Search.search_posts(project.id, "Canonical", %{ missing_translation_language: "de" }) assert Enum.map(missing_translation_results.posts, & &1.id) == [post.id] end test "search_media indexes metadata, includes translation text, and removes deleted media", %{ project: project, temp_dir: temp_dir } do source_path = Path.join(temp_dir, "hero.txt") File.write!(source_path, "hero") assert {:ok, media} = BDS.Media.import_media(%{ project_id: project.id, source_path: source_path, title: "Aurora asset", alt: "Orbit illustration", caption: "Captioned item", tags: ["space"] }) assert {:ok, _translation} = BDS.Media.upsert_media_translation(media.id, "de", %{ title: "Weltraum Titel", alt: "Orbit auf Deutsch", caption: "Beschriftung" }) assert {:ok, results} = BDS.Search.search_media(project.id, "Orbit", %{}) assert Enum.map(results.media, & &1.id) == [media.id] assert {:ok, translated_results} = BDS.Search.search_media(project.id, "Weltraum", %{}) assert Enum.map(translated_results.media, & &1.id) == [media.id] assert {:ok, updated_media} = BDS.Media.update_media(media.id, %{title: "Renamed asset"}) assert {:ok, old_results} = BDS.Search.search_media(project.id, "Aurora", %{}) assert old_results.total == 0 assert {:ok, new_results} = BDS.Search.search_media(project.id, "Renamed", %{}) assert Enum.map(new_results.media, & &1.id) == [updated_media.id] assert {:ok, :deleted} = BDS.Media.delete_media(media.id) assert {:ok, deleted_results} = BDS.Search.search_media(project.id, "Renamed", %{}) assert deleted_results.total == 0 end test "rebuild operations repopulate the search index from filesystem truth", %{ project: project, temp_dir: temp_dir } do posts_dir = Path.join([temp_dir, "posts", "2026", "04"]) File.mkdir_p!(posts_dir) File.write!( Path.join(posts_dir, "filesystem-post.md"), [ "---", "id: search-post-from-file", "title: File Search Post", "slug: filesystem-post", "status: published", "language: en", "created_at: 1711843200", "updated_at: 1711929600", "published_at: 1712016000", "tags:", " - filesystem", "categories:", " - imports", "---", "starlight filesystem body", "" ] |> Enum.join("\n") ) media_dir = Path.join([temp_dir, "media", "2026", "04"]) File.mkdir_p!(media_dir) File.write!(Path.join(media_dir, "filesystem.txt"), "media body") File.write!( Path.join(media_dir, "filesystem.txt.meta"), [ "id: search-media-from-file", "original_name: filesystem.txt", "mime_type: text/plain", "size: 10", "title: File Search Media", "alt: imported alt", "caption: imported caption", "language: en", "created_at: 1711843200", "updated_at: 1711929600", "tags:", " - filesystem", "" ] |> Enum.join("\n") ) assert {:ok, _posts} = BDS.Posts.rebuild_posts_from_files(project.id) assert {:ok, _media} = BDS.Media.rebuild_media_from_files(project.id) assert {:ok, post_results} = BDS.Search.search_posts(project.id, "starlight", %{}) assert Enum.map(post_results.posts, & &1.id) == ["search-post-from-file"] assert {:ok, media_results} = BDS.Search.search_media(project.id, "imported", %{}) assert Enum.map(media_results.media, & &1.id) == ["search-media-from-file"] end test "search_posts applies language-aware stemming to indexed and query text", %{ project: project } do assert {:ok, german_post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Morgenroutine", content: "Die Katzen schlafen am Fenster.", language: "de" }) assert {:ok, french_post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Routine matinale", content: "Je cours chaque matin avant le travail.", language: "fr" }) assert {:ok, german_results} = BDS.Search.search_posts(project.id, "katze", %{}) assert Enum.map(german_results.posts, & &1.id) == [german_post.id] assert {:ok, french_results} = BDS.Search.search_posts(project.id, "courir", %{}) assert Enum.map(french_results.posts, & &1.id) == [french_post.id] end test "lists supported stemmer languages using normalized ISO codes" do languages = BDS.Search.list_stemmer_languages() assert is_list(languages) assert "en" in languages assert "de" in languages assert "fr" in languages assert "it" in languages assert "es" in languages assert Enum.uniq(languages) == languages end end