240 lines
7.6 KiB
Elixir
240 lines
7.6 KiB
Elixir
defmodule BDS.CSM006NPlusOneTest do
|
|
@moduledoc """
|
|
Tests for CSM-006: N+1 Queries in Reindexing & Rendering.
|
|
|
|
Verifies that reindex_posts/1, reindex_media/1, and normalize_list_posts
|
|
use batched queries instead of per-record queries.
|
|
"""
|
|
use ExUnit.Case, async: false
|
|
|
|
alias BDS.Media.Media
|
|
alias BDS.Repo
|
|
|
|
setup do
|
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
|
temp_dir = Path.join(System.tmp_dir!(), "bds-csm006-#{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: "CSM006", data_path: temp_dir})
|
|
%{project: project, temp_dir: temp_dir}
|
|
end
|
|
|
|
describe "reindex_posts/1 correctness with batched translations" do
|
|
test "reindexes posts with translations correctly", %{project: project} do
|
|
# Create posts, some with translations
|
|
posts =
|
|
for i <- 1..5 do
|
|
{:ok, post} =
|
|
BDS.Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Post #{i}",
|
|
content: "Content for post #{i}",
|
|
tags: ["tag#{i}"],
|
|
categories: ["cat#{i}"],
|
|
language: "en"
|
|
})
|
|
|
|
if rem(i, 2) == 0 do
|
|
BDS.Posts.Translations.upsert_post_translation(post.id, "de", %{
|
|
title: "Beitrag #{i}",
|
|
content: "Inhalt für Beitrag #{i}"
|
|
})
|
|
|
|
BDS.Posts.Translations.upsert_post_translation(post.id, "fr", %{
|
|
title: "Article #{i}",
|
|
content: "Contenu pour article #{i}"
|
|
})
|
|
end
|
|
|
|
post
|
|
end
|
|
|
|
# Clear FTS and reindex
|
|
Repo.query!("DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", [project.id])
|
|
|
|
# Reindex should succeed and produce correct FTS entries
|
|
assert :ok = BDS.Search.reindex_posts(project.id)
|
|
|
|
# Verify all posts are indexed
|
|
%{rows: rows} = Repo.query!("SELECT post_id FROM posts_fts ORDER BY post_id")
|
|
indexed_ids = Enum.map(rows, fn [id] -> id end) |> Enum.sort()
|
|
expected_ids = Enum.map(posts, & &1.id) |> Enum.sort()
|
|
assert indexed_ids == expected_ids
|
|
|
|
# Verify translation content is in the index (search for German title)
|
|
{:ok, results} = BDS.Search.search_posts(project.id, "Beitrag")
|
|
assert results.total >= 1
|
|
|
|
# Verify search for French content works
|
|
{:ok, results} = BDS.Search.search_posts(project.id, "Contenu")
|
|
assert results.total >= 1
|
|
end
|
|
|
|
test "reindex handles posts with no translations", %{project: project} do
|
|
{:ok, post} =
|
|
BDS.Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Solo Post",
|
|
content: "Solo content unique-keyword-xyz",
|
|
tags: ["solo"],
|
|
categories: [],
|
|
language: "en"
|
|
})
|
|
|
|
Repo.query!("DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", [project.id])
|
|
assert :ok = BDS.Search.reindex_posts(project.id)
|
|
|
|
{:ok, results} = BDS.Search.search_posts(project.id, "unique-keyword-xyz")
|
|
assert results.total == 1
|
|
assert hd(results.posts).id == post.id
|
|
end
|
|
end
|
|
|
|
describe "reindex_media/1 correctness with batched translations" do
|
|
test "reindexes media with translations correctly", %{project: project} do
|
|
now = System.os_time(:second)
|
|
|
|
media_items =
|
|
for i <- 1..5 do
|
|
{:ok, media} =
|
|
%Media{}
|
|
|> Media.changeset(%{
|
|
id: Ecto.UUID.generate(),
|
|
project_id: project.id,
|
|
title: "Media #{i}",
|
|
alt: "Alt text #{i}",
|
|
caption: "Caption #{i}",
|
|
original_name: "file#{i}.jpg",
|
|
filename: "file#{i}.jpg",
|
|
mime_type: "image/jpeg",
|
|
size: 1024,
|
|
file_path: "media/file#{i}.jpg",
|
|
sidecar_path: "media/file#{i}.json",
|
|
tags: ["media_tag#{i}"],
|
|
language: "en",
|
|
created_at: now,
|
|
updated_at: now
|
|
})
|
|
|> Repo.insert()
|
|
|
|
if rem(i, 2) == 0 do
|
|
%BDS.Media.Translation{}
|
|
|> BDS.Media.Translation.changeset(%{
|
|
id: Ecto.UUID.generate(),
|
|
project_id: project.id,
|
|
translation_for: media.id,
|
|
language: "de",
|
|
title: "Medien #{i}",
|
|
alt: "Alt DE #{i}",
|
|
caption: "Beschriftung #{i}",
|
|
created_at: now,
|
|
updated_at: now
|
|
})
|
|
|> Repo.insert!()
|
|
end
|
|
|
|
media
|
|
end
|
|
|
|
# Reindex
|
|
assert :ok = BDS.Search.reindex_media(project.id)
|
|
|
|
# Verify all media are indexed
|
|
%{rows: rows} = Repo.query!("SELECT media_id FROM media_fts ORDER BY media_id")
|
|
indexed_ids = Enum.map(rows, fn [id] -> id end) |> Enum.sort()
|
|
expected_ids = Enum.map(media_items, & &1.id) |> Enum.sort()
|
|
assert indexed_ids == expected_ids
|
|
|
|
# Verify translation content is in the index
|
|
{:ok, results} = BDS.Search.search_media(project.id, "Medien")
|
|
assert results.total >= 1
|
|
|
|
{:ok, results} = BDS.Search.search_media(project.id, "Beschriftung")
|
|
assert results.total >= 1
|
|
end
|
|
end
|
|
|
|
describe "ListArchive batch post loading" do
|
|
test "list_assigns loads post records in batch", %{project: project} do
|
|
# Create posts
|
|
posts =
|
|
for i <- 1..5 do
|
|
{:ok, post} =
|
|
BDS.Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "List Post #{i}",
|
|
content: "List content #{i}",
|
|
tags: ["list_tag"],
|
|
categories: ["list_cat"],
|
|
language: "en"
|
|
})
|
|
|
|
post
|
|
end
|
|
|
|
# Pass post assigns with only IDs (forces load_post_record)
|
|
post_assigns =
|
|
Enum.map(posts, fn post ->
|
|
%{id: post.id, slug: post.slug, title: post.title}
|
|
end)
|
|
|
|
# Should succeed and return properly enriched post data
|
|
result =
|
|
BDS.Rendering.ListArchive.list_assigns(project.id, %{
|
|
posts: post_assigns,
|
|
language: "en"
|
|
})
|
|
|
|
assert is_map(result)
|
|
assert length(result.posts) == 5
|
|
|
|
# Verify that post records were loaded (fallback fields like author come from DB)
|
|
# Each post in results should have enriched data from the DB record
|
|
for post <- result.posts do
|
|
assert is_binary(post.id)
|
|
assert is_binary(post.slug)
|
|
assert is_binary(post.title)
|
|
end
|
|
end
|
|
|
|
test "PostRendering.load_post_records_batch/1 returns map of id to record", %{
|
|
project: project
|
|
} do
|
|
posts =
|
|
for i <- 1..3 do
|
|
{:ok, post} =
|
|
BDS.Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Batch #{i}",
|
|
content: "Batch content #{i}",
|
|
tags: [],
|
|
categories: [],
|
|
language: "en"
|
|
})
|
|
|
|
post
|
|
end
|
|
|
|
ids = Enum.map(posts, & &1.id)
|
|
records_map = BDS.Rendering.PostRendering.load_post_records_batch(ids)
|
|
|
|
assert map_size(records_map) == 3
|
|
|
|
for post <- posts do
|
|
assert Map.has_key?(records_map, post.id)
|
|
assert records_map[post.id].title == post.title
|
|
end
|
|
end
|
|
|
|
test "PostRendering.load_post_records_batch/1 handles empty list" do
|
|
assert BDS.Rendering.PostRendering.load_post_records_batch([]) == %{}
|
|
end
|
|
|
|
test "PostRendering.load_post_records_batch/1 handles nonexistent IDs" do
|
|
records_map = BDS.Rendering.PostRendering.load_post_records_batch(["nonexistent-id"])
|
|
assert records_map == %{"nonexistent-id" => nil}
|
|
end
|
|
end
|
|
end
|