diff --git a/lib/bds/frontmatter.ex b/lib/bds/frontmatter.ex new file mode 100644 index 0000000..5c1e4b1 --- /dev/null +++ b/lib/bds/frontmatter.ex @@ -0,0 +1,29 @@ +defmodule BDS.Frontmatter do + @moduledoc false + + def serialize_document(fields, body) when is_list(fields) do + frontmatter = + fields + |> Enum.flat_map(&serialize_field/1) + |> Enum.join("\n") + + ["---", frontmatter, "---", body || "", ""] + |> Enum.join("\n") + end + + defp serialize_field({_key, nil}), do: [] + defp serialize_field({_key, ""}), do: [] + defp serialize_field({_key, false}), do: [] + + defp serialize_field({key, true}) do + ["#{key}: true"] + end + + defp serialize_field({key, values}) when is_list(values) do + ["#{key}:" | Enum.map(values, &" - #{&1}")] + end + + defp serialize_field({key, value}) do + ["#{key}: #{value}"] + end +end \ No newline at end of file diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index ba0e57a..54f1c44 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -3,6 +3,7 @@ defmodule BDS.Posts do import Ecto.Query + alias BDS.Frontmatter alias BDS.Posts.Post alias BDS.Projects alias BDS.Repo @@ -76,9 +77,10 @@ defmodule BDS.Posts do published_at = post.published_at || System.system_time(:second) relative_path = build_post_relative_path(post.slug, post.created_at) full_path = Path.join(Projects.project_data_dir(project), relative_path) + updated_at = System.system_time(:second) :ok = File.mkdir_p(Path.dirname(full_path)) - :ok = File.write(full_path, serialize_post_file(post, published_at)) + :ok = File.write(full_path, serialize_post_file(%{post | updated_at: updated_at}, published_at)) post |> Post.changeset(%{ @@ -86,12 +88,28 @@ defmodule BDS.Posts do published_at: published_at, file_path: relative_path, content: nil, - updated_at: System.system_time(:second) + updated_at: updated_at }) |> Repo.update() end end + def get_post!(post_id), do: Repo.get!(Post, post_id) + + def rewrite_published_post(post_id) do + post = Repo.get!(Post, post_id) + + if post.status == :published and post.file_path not in [nil, ""] do + project = Projects.get_project!(post.project_id) + full_path = Path.join(Projects.project_data_dir(project), post.file_path) + body = published_post_body(post, full_path) + :ok = File.mkdir_p(Path.dirname(full_path)) + :ok = File.write(full_path, serialize_post_file(%{post | content: body}, post.published_at || System.system_time(:second))) + end + + :ok + end + defp normalize_updates(attrs, _post) do %{} |> maybe_put(:title, normalize_optional_title(attr(attrs, :title), attrs)) @@ -197,41 +215,42 @@ defmodule BDS.Posts do end defp serialize_post_file(post, published_at) do - frontmatter_lines = [ - "id: #{post.id}", - "title: #{post.title}", - "slug: #{post.slug}", - maybe_line("excerpt", post.excerpt), - "status: published", - maybe_line("author", post.author), - maybe_line("language", post.language), - maybe_boolean_line("do_not_translate", post.do_not_translate), - maybe_line("template_slug", post.template_slug), - "created_at: #{post.created_at}", - "updated_at: #{post.updated_at}", - "published_at: #{published_at}", - list_lines("tags", post.tags), - list_lines("categories", post.categories) - ] - |> List.flatten() - |> Enum.reject(&is_nil/1) - |> Enum.join("\n") - - ["---", frontmatter_lines, "---", post.content || "", ""] - |> Enum.join("\n") + Frontmatter.serialize_document( + [ + {:id, post.id}, + {:title, post.title}, + {:slug, post.slug}, + {:excerpt, post.excerpt}, + {:status, :published}, + {:author, post.author}, + {:language, post.language}, + {:do_not_translate, post.do_not_translate}, + {:template_slug, post.template_slug}, + {:created_at, post.created_at}, + {:updated_at, post.updated_at}, + {:published_at, published_at}, + {:tags, post.tags || []}, + {:categories, post.categories || []} + ], + post.content || "" + ) end - defp list_lines(label, items) do - ["#{label}:" | Enum.map(items || [], &" - #{&1}")] + defp published_post_body(%Post{content: content}, _full_path) when is_binary(content), do: content + + defp published_post_body(_post, full_path) do + case File.read(full_path) do + {:ok, contents} -> + case String.split(contents, "\n---\n", parts: 2) do + [_frontmatter, body] -> String.trim_trailing(body, "\n") + _parts -> "" + end + + {:error, _reason} -> + "" + end end - defp maybe_line(_label, nil), do: nil - defp maybe_line(_label, ""), do: nil - defp maybe_line(label, value), do: "#{label}: #{value}" - - defp maybe_boolean_line(_label, false), do: nil - defp maybe_boolean_line(label, true), do: "#{label}: true" - defp has_attr?(attrs, key) do Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) end diff --git a/lib/bds/scripts.ex b/lib/bds/scripts.ex index f234109..39b105a 100644 --- a/lib/bds/scripts.ex +++ b/lib/bds/scripts.ex @@ -3,6 +3,8 @@ defmodule BDS.Scripts do import Ecto.Query + alias BDS.Frontmatter + alias BDS.Projects alias BDS.Repo alias BDS.Scripts.Script alias BDS.Slug @@ -32,31 +34,152 @@ defmodule BDS.Scripts do |> Repo.insert() end + def publish_script(script_id) do + case Repo.get(Script, script_id) do + nil -> + {:error, :not_found} + + script -> + file_path = script_file_path(script.slug) + full_path = full_file_path(script.project_id, file_path) + updated_at = System.system_time(:second) + content = script.content || "" + + :ok = File.mkdir_p(Path.dirname(full_path)) + + :ok = + File.write( + full_path, + serialize_script_file(%{script | status: :published, file_path: file_path, updated_at: updated_at}, content) + ) + + script + |> Script.changeset(%{status: :published, file_path: file_path, content: nil, updated_at: updated_at}) + |> Repo.update() + end + end + + def update_script(script_id, attrs) do + case Repo.get(Script, script_id) do + nil -> + {:error, :not_found} + + script -> + next_slug = + if has_attr?(attrs, :slug) do + unique_slug(script.project_id, Slug.slugify(attr(attrs, :slug)), "script", script.id) + else + script.slug + end + + content_changed? = has_attr?(attrs, :content) and attr(attrs, :content) != script.content + now = System.system_time(:second) + + updates = %{} + |> maybe_put(:title, attr(attrs, :title)) + |> maybe_put(:kind, attr(attrs, :kind)) + |> maybe_put(:entrypoint, attr(attrs, :entrypoint)) + |> maybe_put(:enabled, attr(attrs, :enabled)) + |> maybe_put(:content, attr(attrs, :content)) + |> Map.put(:slug, next_slug) + |> Map.put(:version, script.version + 1) + |> Map.put(:updated_at, now) + |> maybe_put(:status, if(script.status == :published and content_changed?, do: :draft, else: nil)) + + script + |> Script.changeset(updates) + |> Repo.update() + end + end + + def delete_script(script_id) do + case Repo.get(Script, script_id) do + nil -> + {:error, :not_found} + + script -> + delete_file_if_present(script.project_id, script.file_path) + Repo.delete!(script) + {:ok, :deleted} + end + end + defp default_entrypoint(:macro), do: "render" defp default_entrypoint(_kind), do: "main" - defp unique_slug(project_id, base_slug, fallback) do + defp unique_slug(project_id, base_slug, fallback, exclude_id \\ nil) do normalized = if base_slug in [nil, ""], do: fallback, else: base_slug - if slug_available?(project_id, normalized) do + if slug_available?(project_id, normalized, exclude_id) do normalized else - find_unique_slug(project_id, normalized, 2) + find_unique_slug(project_id, normalized, 2, exclude_id) end end - defp find_unique_slug(project_id, base_slug, suffix) do + defp find_unique_slug(project_id, base_slug, suffix, exclude_id) do candidate = "#{base_slug}-#{suffix}" - if slug_available?(project_id, candidate) do + if slug_available?(project_id, candidate, exclude_id) do candidate else - find_unique_slug(project_id, base_slug, suffix + 1) + find_unique_slug(project_id, base_slug, suffix + 1, exclude_id) end end - defp slug_available?(project_id, slug) do - not Repo.exists?(from script in Script, where: script.project_id == ^project_id and script.slug == ^slug) + defp slug_available?(project_id, slug, exclude_id) do + query = from script in Script, where: script.project_id == ^project_id and script.slug == ^slug + + scoped_query = + case exclude_id do + nil -> query + _id -> from script in query, where: script.id != ^exclude_id + end + + not Repo.exists?(scoped_query) + end + + defp script_file_path(slug), do: Path.join(["scripts", "#{slug}.lua"]) + + defp full_file_path(project_id, relative_path) do + project = Projects.get_project!(project_id) + Path.join(Projects.project_data_dir(project), relative_path) + end + + defp serialize_script_file(script, content) do + Frontmatter.serialize_document( + [ + {:id, script.id}, + {:slug, script.slug}, + {:title, script.title}, + {:kind, script.kind}, + {:entrypoint, script.entrypoint}, + {:enabled, script.enabled}, + {:version, script.version}, + {:created_at, script.created_at}, + {:updated_at, script.updated_at} + ], + content + ) + end + + defp delete_file_if_present(_project_id, file_path) when file_path in [nil, ""], do: :ok + + defp delete_file_if_present(project_id, file_path) do + full_path = full_file_path(project_id, file_path) + + case File.rm(full_path) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp has_attr?(attrs, key) do + Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) end defp attr(attrs, key) do diff --git a/lib/bds/tags.ex b/lib/bds/tags.ex index 0f2f6b0..60416f4 100644 --- a/lib/bds/tags.ex +++ b/lib/bds/tags.ex @@ -3,6 +3,8 @@ defmodule BDS.Tags do import Ecto.Query + alias BDS.Posts + alias BDS.Posts.Post alias BDS.Projects alias BDS.Repo alias BDS.Tags.Tag @@ -40,6 +42,100 @@ defmodule BDS.Tags do Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name]) end + def sync_tags_json(project_id) do + write_tags_json(project_id) + :ok + end + + def update_tag(tag_id, attrs) do + case Repo.get(Tag, tag_id) do + nil -> + {:error, :not_found} + + tag -> + updates = %{ + color: attr(attrs, :color), + post_template_slug: attr(attrs, :post_template_slug), + updated_at: System.system_time(:second) + } + + tag + |> Tag.changeset(updates) + |> Repo.update() + |> case do + {:ok, updated_tag} -> + write_tags_json(updated_tag.project_id) + {:ok, updated_tag} + + error -> + error + end + end + end + + def rename_tag(tag_id, new_name) do + case Repo.get(Tag, tag_id) do + nil -> + {:error, :not_found} + + tag -> + normalized_name = String.trim(new_name) + + with :ok <- validate_rename_target(tag.project_id, tag.id, normalized_name) do + old_name = tag.name + + Repo.transaction(fn -> + affected_posts = posts_with_tag(tag.project_id, old_name) + + Enum.each(affected_posts, fn post -> + updated_tags = replace_tag(post.tags || [], old_name, normalized_name) + update_post_tags(post, updated_tags) + end) + + updated_tag = + tag + |> Tag.changeset(%{name: normalized_name, updated_at: System.system_time(:second)}) + |> Repo.update!() + + write_tags_json(tag.project_id) + updated_tag + end) + |> case do + {:ok, updated_tag} -> {:ok, updated_tag} + {:error, reason} -> {:error, reason} + end + end + end + end + + def merge_tags(source_tag_ids, target_tag_id) do + case Repo.get(Tag, target_tag_id) do + nil -> + {:error, :not_found} + + target_tag -> + source_tags = + Repo.all(from tag in Tag, where: tag.id in ^source_tag_ids and tag.project_id == ^target_tag.project_id) + + Repo.transaction(fn -> + source_names = Enum.map(source_tags, & &1.name) + + posts_with_any_tag(target_tag.project_id, source_names) + |> Enum.each(fn post -> + updated_tags = merge_post_tags(post.tags || [], source_names, target_tag.name) + update_post_tags(post, updated_tags) + end) + + Enum.each(source_tags, &Repo.delete!/1) + write_tags_json(target_tag.project_id) + end) + |> case do + {:ok, _} -> {:ok, :merged} + {:error, reason} -> {:error, reason} + end + end + end + defp write_tags_json(project_id) do project = Projects.get_project!(project_id) path = Path.join([Projects.project_data_dir(project), "meta", "tags.json"]) @@ -71,6 +167,51 @@ defmodule BDS.Tags do end end + defp validate_rename_target(project_id, tag_id, name) do + if Repo.exists?(from tag in Tag, where: tag.project_id == ^project_id and tag.id != ^tag_id and fragment("lower(?)", tag.name) == ^String.downcase(name)) do + {:error, + %Tag{} + |> Tag.changeset(%{project_id: project_id, name: name, id: tag_id, created_at: 0, updated_at: 0}) + |> Ecto.Changeset.add_error(:name, "has already been taken")} + else + :ok + end + end + + defp posts_with_tag(project_id, tag_name) do + Repo.all(from post in Post, where: post.project_id == ^project_id) + |> Enum.filter(fn post -> tag_name in (post.tags || []) end) + end + + defp posts_with_any_tag(project_id, tag_names) do + Repo.all(from post in Post, where: post.project_id == ^project_id) + |> Enum.filter(fn post -> Enum.any?(post.tags || [], &(&1 in tag_names)) end) + end + + defp replace_tag(tags, old_name, new_name) do + tags + |> Enum.map(fn tag -> if tag == old_name, do: new_name, else: tag end) + |> Enum.uniq() + end + + defp merge_post_tags(tags, source_names, target_name) do + retained_tags = Enum.reject(tags, &(&1 in source_names)) + + if target_name in retained_tags do + retained_tags + else + retained_tags ++ [target_name] + end + end + + defp update_post_tags(post, updated_tags) do + post + |> Post.changeset(%{tags: updated_tags, updated_at: System.system_time(:second)}) + |> Repo.update!() + + Posts.rewrite_published_post(post.id) + end + defp maybe_put(map, _key, nil), do: map defp maybe_put(map, _key, ""), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index c9b1f77..b8250be 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -3,6 +3,8 @@ defmodule BDS.Templates do import Ecto.Query + alias BDS.Frontmatter + alias BDS.Projects alias BDS.Repo alias BDS.Slug alias BDS.Templates.Template @@ -30,28 +32,194 @@ defmodule BDS.Templates do |> Repo.insert() end - defp unique_slug(project_id, base_slug, fallback) do + def publish_template(template_id) do + case Repo.get(Template, template_id) do + nil -> + {:error, :not_found} + + template -> + file_path = template_file_path(template.slug) + full_path = full_file_path(template.project_id, file_path) + updated_at = System.system_time(:second) + content = template.content || "" + + :ok = File.mkdir_p(Path.dirname(full_path)) + + :ok = + File.write( + full_path, + serialize_template_file(%{template | status: :published, file_path: file_path, updated_at: updated_at}, content) + ) + + template + |> Template.changeset(%{status: :published, file_path: file_path, content: nil, updated_at: updated_at}) + |> Repo.update() + end + end + + def update_template(template_id, attrs) do + case Repo.get(Template, template_id) do + nil -> + {:error, :not_found} + + template -> + next_slug = + if has_attr?(attrs, :slug) do + unique_slug(template.project_id, Slug.slugify(attr(attrs, :slug)), "template", template.id) + else + template.slug + end + + content_changed? = has_attr?(attrs, :content) and attr(attrs, :content) != template.content + now = System.system_time(:second) + + updates = %{} + |> maybe_put(:title, attr(attrs, :title)) + |> maybe_put(:kind, attr(attrs, :kind)) + |> maybe_put(:enabled, attr(attrs, :enabled)) + |> maybe_put(:content, attr(attrs, :content)) + |> Map.put(:slug, next_slug) + |> Map.put(:version, template.version + 1) + |> Map.put(:updated_at, now) + |> maybe_put(:status, if(template.status == :published and content_changed?, do: :draft, else: nil)) + + template + |> Template.changeset(updates) + |> Repo.update() + end + end + + def delete_template(template_id, opts \\ []) do + case Repo.get(Template, template_id) do + nil -> + {:error, :not_found} + + template -> + post_count = count_referencing_posts(template) + tag_count = count_referencing_tags(template) + force? = Keyword.get(opts, :force, false) + + cond do + not force? and (post_count > 0 or tag_count > 0) -> + {:error, {:has_references, %{posts: post_count, tags: tag_count}}} + + true -> + if force? do + clear_template_references(template) + end + + delete_file_if_present(template.project_id, template.file_path) + Repo.delete!(template) + {:ok, :deleted} + end + end + end + + defp unique_slug(project_id, base_slug, fallback, exclude_id \\ nil) do normalized = if base_slug in [nil, ""], do: fallback, else: base_slug - if slug_available?(project_id, normalized) do + if slug_available?(project_id, normalized, exclude_id) do normalized else - find_unique_slug(project_id, normalized, 2) + find_unique_slug(project_id, normalized, 2, exclude_id) end end - defp find_unique_slug(project_id, base_slug, suffix) do + defp find_unique_slug(project_id, base_slug, suffix, exclude_id) do candidate = "#{base_slug}-#{suffix}" - if slug_available?(project_id, candidate) do + if slug_available?(project_id, candidate, exclude_id) do candidate else - find_unique_slug(project_id, base_slug, suffix + 1) + find_unique_slug(project_id, base_slug, suffix + 1, exclude_id) end end - defp slug_available?(project_id, slug) do - not Repo.exists?(from template in Template, where: template.project_id == ^project_id and template.slug == ^slug) + defp slug_available?(project_id, slug, exclude_id) do + query = from template in Template, where: template.project_id == ^project_id and template.slug == ^slug + + scoped_query = + case exclude_id do + nil -> query + _id -> from template in query, where: template.id != ^exclude_id + end + + not Repo.exists?(scoped_query) + end + + defp template_file_path(slug), do: Path.join(["templates", "#{slug}.liquid"]) + + defp full_file_path(project_id, relative_path) do + project = Projects.get_project!(project_id) + Path.join(Projects.project_data_dir(project), relative_path) + end + + defp serialize_template_file(template, content) do + Frontmatter.serialize_document( + [ + {:id, template.id}, + {:slug, template.slug}, + {:title, template.title}, + {:kind, template.kind}, + {:enabled, template.enabled}, + {:version, template.version}, + {:created_at, template.created_at}, + {:updated_at, template.updated_at} + ], + content + ) + end + + defp count_referencing_posts(template) do + Repo.aggregate(from(post in BDS.Posts.Post, where: post.project_id == ^template.project_id and post.template_slug == ^template.slug), :count, :id) + end + + defp count_referencing_tags(template) do + Repo.aggregate(from(tag in BDS.Tags.Tag, where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug), :count, :id) + end + + defp clear_template_references(template) do + now = System.system_time(:second) + + affected_posts = + Repo.all( + from(post in BDS.Posts.Post, + where: post.project_id == ^template.project_id and post.template_slug == ^template.slug + ) + ) + + from(post in BDS.Posts.Post, where: post.project_id == ^template.project_id and post.template_slug == ^template.slug) + |> Repo.update_all(set: [template_slug: nil, updated_at: now]) + + from(tag in BDS.Tags.Tag, where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug) + |> Repo.update_all(set: [post_template_slug: nil, updated_at: now]) + + Enum.each(affected_posts, fn post -> + BDS.Posts.rewrite_published_post(post.id) + end) + + BDS.Tags.sync_tags_json(template.project_id) + + :ok + end + + defp delete_file_if_present(_project_id, file_path) when file_path in [nil, ""], do: :ok + + defp delete_file_if_present(project_id, file_path) do + full_path = full_file_path(project_id, file_path) + + case File.rm(full_path) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp has_attr?(attrs, key) do + Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) end defp attr(attrs, key) do diff --git a/test/bds/scripts_test.exs b/test/bds/scripts_test.exs index a228e50..7c11372 100644 --- a/test/bds/scripts_test.exs +++ b/test/bds/scripts_test.exs @@ -1,10 +1,17 @@ defmodule BDS.ScriptsTest do use ExUnit.Case, async: false + alias BDS.Repo + alias BDS.Scripts.Script + setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) - {:ok, project} = BDS.Projects.create_project(%{name: "Scripts"}) - %{project: project} + temp_dir = Path.join(System.tmp_dir!(), "bds-scripts-#{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: "Scripts", data_path: temp_dir}) + %{project: project, temp_dir: temp_dir} end test "create_script creates draft scripts with default entrypoints by kind", %{project: project} do @@ -34,4 +41,78 @@ defmodule BDS.ScriptsTest do assert macro_script.entrypoint == "render" assert macro_script.slug == "render-card" end + + test "publish_script writes a lua file with frontmatter and clears draft content", %{project: project, temp_dir: temp_dir} do + assert {:ok, script} = + BDS.Scripts.create_script(%{ + project_id: project.id, + title: "Process Feed", + kind: :utility, + content: "function main() return 'ok' end" + }) + + assert {:ok, published} = BDS.Scripts.publish_script(script.id) + + assert published.status == :published + assert published.content == nil + assert published.file_path == "scripts/process-feed.lua" + + full_path = Path.join(temp_dir, published.file_path) + assert File.exists?(full_path) + + contents = File.read!(full_path) + assert contents =~ "---\nid: #{published.id}\n" + assert contents =~ "slug: process-feed\n" + assert contents =~ "title: Process Feed\n" + assert contents =~ "kind: utility\n" + assert contents =~ "entrypoint: main\n" + assert contents =~ "enabled: true\n" + assert contents =~ "version: 1\n" + assert contents =~ "created_at: #{published.created_at}\n" + assert contents =~ "updated_at: #{published.updated_at}\n" + assert contents =~ "\n---\nfunction main() return 'ok' end\n" + end + + test "update_script bumps version and reopens a published script when content changes", %{project: project} do + assert {:ok, script} = + BDS.Scripts.create_script(%{ + project_id: project.id, + title: "Render Card", + kind: :macro, + content: "function render() return 'v1' end" + }) + + assert {:ok, published} = BDS.Scripts.publish_script(script.id) + assert published.status == :published + + assert {:ok, updated} = + BDS.Scripts.update_script(script.id, %{ + content: "function render() return 'v2' end", + enabled: false + }) + + assert updated.version == 2 + assert updated.status == :draft + assert updated.enabled == false + assert updated.file_path == "scripts/render-card.lua" + assert updated.content == "function render() return 'v2' end" + assert updated.updated_at >= published.updated_at + end + + test "delete_script removes the published file and database row", %{project: project, temp_dir: temp_dir} do + assert {:ok, script} = + BDS.Scripts.create_script(%{ + project_id: project.id, + title: "Cleanup", + kind: :utility, + content: "function main() return true end" + }) + + assert {:ok, published} = BDS.Scripts.publish_script(script.id) + assert File.exists?(Path.join(temp_dir, published.file_path)) + + assert {:ok, :deleted} = BDS.Scripts.delete_script(published.id) + assert Repo.get(Script, published.id) == nil + refute File.exists?(Path.join(temp_dir, published.file_path)) + end end diff --git a/test/bds/tags_test.exs b/test/bds/tags_test.exs index 142d9bc..961dd4b 100644 --- a/test/bds/tags_test.exs +++ b/test/bds/tags_test.exs @@ -1,6 +1,9 @@ defmodule BDS.TagsTest do use ExUnit.Case, async: false + alias BDS.Posts.Post + alias BDS.Repo + setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) temp_dir = Path.join(System.tmp_dir!(), "bds-tags-#{System.unique_integer([:positive])}") @@ -32,6 +35,86 @@ defmodule BDS.TagsTest do assert "has already been taken" in errors_on(changeset).name end + test "update_tag rewrites the tag row and meta/tags.json", %{project: project, temp_dir: temp_dir} do + assert {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) + + assert {:ok, updated} = + BDS.Tags.update_tag(tag.id, %{ + color: "#112233", + post_template_slug: "article" + }) + + assert updated.name == "Alpha" + assert updated.color == "#112233" + assert updated.post_template_slug == "article" + assert updated.updated_at >= tag.updated_at + + tags_path = Path.join([temp_dir, "meta", "tags.json"]) + + assert %{"tags" => [%{"name" => "Alpha", "color" => "#112233", "post_template_slug" => "article"}]} = + Jason.decode!(File.read!(tags_path)) + end + + test "rename_tag updates post tag arrays, rewrites published post files, and refreshes tags.json", %{project: project, temp_dir: temp_dir} do + assert {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) + + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Tagged Post", + content: "Body", + tags: ["Alpha", "Other"] + }) + + assert {:ok, published_post} = BDS.Posts.publish_post(post.id) + + assert {:ok, renamed} = BDS.Tags.rename_tag(tag.id, "Beta") + assert renamed.name == "Beta" + + reloaded_post = Repo.get!(Post, published_post.id) + assert reloaded_post.tags == ["Beta", "Other"] + + post_path = Path.join(temp_dir, reloaded_post.file_path) + contents = File.read!(post_path) + assert contents =~ "tags:\n - Beta\n - Other\n" + assert contents =~ "\n---\nBody\n" + + tags_path = Path.join([temp_dir, "meta", "tags.json"]) + assert %{"tags" => [%{"name" => "Beta"}]} = Jason.decode!(File.read!(tags_path)) + end + + test "merge_tags moves source tags onto the target, deduplicates post tags, deletes sources, and refreshes tags.json", %{project: project, temp_dir: temp_dir} do + assert {:ok, source_a} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) + assert {:ok, source_b} = BDS.Tags.create_tag(%{project_id: project.id, name: "Beta"}) + assert {:ok, target} = BDS.Tags.create_tag(%{project_id: project.id, name: "Gamma"}) + + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Merge Me", + content: "Body", + tags: ["Alpha", "Beta", "Gamma"] + }) + + assert {:ok, published_post} = BDS.Posts.publish_post(post.id) + + assert {:ok, :merged} = BDS.Tags.merge_tags([source_a.id, source_b.id], target.id) + + reloaded_post = Repo.get!(Post, published_post.id) + assert reloaded_post.tags == ["Gamma"] + assert Repo.get(BDS.Tags.Tag, source_a.id) == nil + assert Repo.get(BDS.Tags.Tag, source_b.id) == nil + assert Repo.get(BDS.Tags.Tag, target.id) != nil + + post_path = Path.join(temp_dir, reloaded_post.file_path) + contents = File.read!(post_path) + assert contents =~ "tags:\n - Gamma\n" + assert contents =~ "\n---\nBody\n" + + tags_path = Path.join([temp_dir, "meta", "tags.json"]) + assert %{"tags" => [%{"name" => "Gamma"}]} = Jason.decode!(File.read!(tags_path)) + end + defp errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key -> diff --git a/test/bds/templates_test.exs b/test/bds/templates_test.exs index 0b826e3..824a8f3 100644 --- a/test/bds/templates_test.exs +++ b/test/bds/templates_test.exs @@ -1,10 +1,19 @@ defmodule BDS.TemplatesTest do use ExUnit.Case, async: false + import Ecto.Query + + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.Tags.Tag setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) - {:ok, project} = BDS.Projects.create_project(%{name: "Templates"}) - %{project: project} + temp_dir = Path.join(System.tmp_dir!(), "bds-templates-#{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: "Templates", data_path: temp_dir}) + %{project: project, temp_dir: temp_dir} end test "create_template creates a draft template with slug deduplication", %{project: project} do @@ -28,4 +37,109 @@ defmodule BDS.TemplatesTest do assert duplicate.slug == "article-view-2" end + + test "publish_template writes a liquid file with frontmatter and clears draft content", %{project: project, temp_dir: temp_dir} do + assert {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Landing Page", + kind: :list, + content: "
{{ page_title }}
" + }) + + assert {:ok, published} = BDS.Templates.publish_template(template.id) + + assert published.status == :published + assert published.content == nil + assert published.file_path == "templates/landing-page.liquid" + + full_path = Path.join(temp_dir, published.file_path) + assert File.exists?(full_path) + + contents = File.read!(full_path) + assert contents =~ "---\nid: #{published.id}\n" + assert contents =~ "slug: landing-page\n" + assert contents =~ "title: Landing Page\n" + assert contents =~ "kind: list\n" + assert contents =~ "enabled: true\n" + assert contents =~ "version: 1\n" + assert contents =~ "created_at: #{published.created_at}\n" + assert contents =~ "updated_at: #{published.updated_at}\n" + assert contents =~ "\n---\n
{{ page_title }}
\n" + end + + test "update_template bumps version and reopens a published template when content changes", %{project: project} do + assert {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Snippet", + kind: :partial, + content: "v1" + }) + + assert {:ok, published} = BDS.Templates.publish_template(template.id) + assert published.status == :published + + assert {:ok, updated} = + BDS.Templates.update_template(template.id, %{ + content: "v2", + enabled: false + }) + + assert updated.version == 2 + assert updated.status == :draft + assert updated.enabled == false + assert updated.file_path == "templates/snippet.liquid" + assert updated.content == "v2" + assert updated.updated_at >= published.updated_at + end + + test "delete_template refuses referenced templates unless forced, then clears references and deletes the file", %{project: project, temp_dir: temp_dir} do + assert {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Article View", + kind: :post, + content: "
{{ content }}
" + }) + + assert {:ok, published} = BDS.Templates.publish_template(template.id) + + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Uses Template", + content: "Body", + template_slug: published.slug + }) + + assert {:ok, _published_post} = BDS.Posts.publish_post(post.id) + + assert {:ok, _tag} = + BDS.Tags.create_tag(%{ + project_id: project.id, + name: "Feature", + post_template_slug: published.slug + }) + + assert {:error, {:has_references, %{posts: 1, tags: 1}}} = BDS.Templates.delete_template(published.id) + + assert {:ok, :deleted} = BDS.Templates.delete_template(published.id, force: true) + + reloaded_post = Repo.get(Post, hd(Repo.all(from p in Post, select: p.id))) + reloaded_tag = Repo.get(Tag, hd(Repo.all(from t in Tag, select: t.id))) + + assert reloaded_post.template_slug == nil + assert reloaded_tag.post_template_slug == nil + + refute File.exists?(Path.join(temp_dir, published.file_path)) + + post_path = Path.join(temp_dir, reloaded_post.file_path) + post_contents = File.read!(post_path) + refute post_contents =~ "template_slug:" + assert post_contents =~ "\n---\nBody\n" + + tags_path = Path.join([temp_dir, "meta", "tags.json"]) + assert %{"tags" => [%{"name" => "Feature"}]} = Jason.decode!(File.read!(tags_path)) + end end