defmodule BDS.ProjectsTest do use ExUnit.Case, async: false import Ecto.Query alias BDS.Metadata alias BDS.Projects.Project alias BDS.Repo alias BDS.Templates.Template setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) temp_root = Path.join(System.tmp_dir!(), "bds-projects-#{System.unique_integer([:positive])}") File.mkdir_p!(temp_root) on_exit(fn -> File.rm_rf(temp_root) end) %{temp_root: temp_root} end test "create_project slugifies names, keeps new projects inactive, and deduplicates slugs", %{ temp_root: temp_root } do first_dir = Path.join(temp_root, "first") second_dir = Path.join(temp_root, "second") File.mkdir_p!(first_dir) File.mkdir_p!(second_dir) assert {:ok, first} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: first_dir}) assert first.name == "Föö Bär Blog" assert first.slug == "foo-bar-blog" assert first.data_path == first_dir assert first.is_active == false assert is_integer(first.created_at) assert is_integer(first.updated_at) assert {:ok, second} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: second_dir}) assert second.slug == "foo-bar-blog-2" assert second.is_active == false end test "create_project leaves the project templates directory empty by default", %{ temp_root: temp_root } do temp_dir = Path.join(temp_root, "starter") File.mkdir_p!(temp_dir) assert {:ok, project} = BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir}) refute File.exists?(Path.join([temp_dir, "templates", "single-post.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "post-list.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "not-found.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "partials", "head.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "partials", "menu-items.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "macros", "gallery.liquid"])) starter_slugs = Repo.all( from template in Template, where: template.project_id == ^project.id, select: {template.slug, template.kind} ) refute {"single-post", :post} in starter_slugs refute {"post-list", :list} in starter_slugs refute {"not-found", :not_found} in starter_slugs end test "set_active_project clears the previous active project and activates the target", %{ temp_root: temp_root } do first_dir = Path.join(temp_root, "active-first") second_dir = Path.join(temp_root, "active-second") File.mkdir_p!(first_dir) File.mkdir_p!(second_dir) assert {:ok, first} = BDS.Projects.create_project(%{name: "First", data_path: first_dir}) assert {:ok, second} = BDS.Projects.create_project(%{name: "Second", data_path: second_dir}) assert {:ok, active_first} = BDS.Projects.set_active_project(first.id) assert active_first.is_active == true assert {:ok, active_second} = BDS.Projects.set_active_project(second.id) assert active_second.is_active == true refetched_first = BDS.Projects.get_project!(first.id) refetched_second = BDS.Projects.get_project!(second.id) assert refetched_first.is_active == false assert refetched_second.is_active == true assert Enum.count(BDS.Projects.list_projects(), & &1.is_active) == 1 end test "ensure_default_project creates the default project once and keeps it active" do Repo.delete_all(Project) assert {:ok, default_project} = BDS.Projects.ensure_default_project() assert default_project.id == "default" assert default_project.name == "My Blog" assert default_project.slug == "my-blog" assert default_project.is_active == true assert {:ok, same_project} = BDS.Projects.ensure_default_project() assert same_project.id == default_project.id assert Repo.aggregate(Project, :count, :id) == 1 end test "delete_project rejects the default and active projects", %{temp_root: temp_root} do Repo.delete_all(Project) assert {:ok, default_project} = BDS.Projects.ensure_default_project() assert {:error, :cannot_delete_default_project} = BDS.Projects.delete_project(default_project.id) temp_dir = Path.join(temp_root, "active-delete") File.mkdir_p!(temp_dir) assert {:ok, project} = BDS.Projects.create_project(%{name: "Delete Me", data_path: temp_dir}) assert {:ok, _active_project} = BDS.Projects.set_active_project(project.id) assert {:error, :cannot_delete_active_project} = BDS.Projects.delete_project(project.id) project_id = project.id assert %Project{id: ^project_id} = BDS.Projects.get_project(project.id) end test "delete_project removes internal project data but preserves external data paths", %{temp_root: temp_root} do assert {:ok, internal_project} = BDS.Projects.create_project(%{name: "Internal Project"}) internal_dir = BDS.Projects.project_data_dir(internal_project) on_exit(fn -> _ = File.rm_rf(internal_dir) end) refute File.exists?(Path.join(internal_dir, "templates/single-post.liquid")) assert {:ok, deleted_internal_project} = BDS.Projects.delete_project(internal_project.id) assert deleted_internal_project.id == internal_project.id assert BDS.Projects.get_project(internal_project.id) == nil refute File.exists?(internal_dir) external_dir = Path.join(temp_root, "external-delete") File.mkdir_p!(external_dir) assert {:ok, external_project} = BDS.Projects.create_project(%{name: "External Project", data_path: external_dir}) marker_path = Path.join(external_dir, "keep.txt") File.write!(marker_path, "preserve me") assert {:ok, deleted_external_project} = BDS.Projects.delete_project(external_project.id) assert deleted_external_project.id == external_project.id assert BDS.Projects.get_project(external_project.id) == nil assert File.read!(marker_path) == "preserve me" end test "create_project loads project metadata from an existing filesystem-backed blog", %{temp_root: temp_root} do external_dir = Path.join(temp_root, "imported-blog") meta_dir = Path.join(external_dir, "meta") File.mkdir_p!(meta_dir) File.write!( Path.join(meta_dir, "project.json"), Jason.encode!(%{ "name" => "Imported Blog", "description" => "Filesystem metadata", "publicUrl" => "https://imported.example", "mainLanguage" => "de", "defaultAuthor" => "Importer", "maxPostsPerPage" => 17, "blogmarkCategory" => "notes", "picoTheme" => "slate", "semanticSimilarityEnabled" => true, "blogLanguages" => ["en", "fr"] }) ) File.write!( Path.join(meta_dir, "publishing.json"), Jason.encode!(%{ "sshHost" => "upload.example", "sshUser" => "deploy", "sshRemotePath" => "/srv/imported", "sshMode" => "rsync" }) ) assert {:ok, project} = BDS.Projects.create_project(%{name: "Placeholder", data_path: external_dir}) assert BDS.Projects.get_project!(project.id).name == "Imported Blog" assert {:ok, metadata} = Metadata.get_project_metadata(project.id) assert metadata.name == "Imported Blog" assert metadata.description == "Filesystem metadata" assert metadata.public_url == "https://imported.example" assert metadata.main_language == "de" assert metadata.default_author == "Importer" assert metadata.max_posts_per_page == 17 assert metadata.blogmark_category == "notes" assert metadata.pico_theme == "slate" assert metadata.semantic_similarity_enabled == true assert metadata.blog_languages == ["en", "fr"] assert metadata.publishing_preferences == %{ "ssh_host" => "upload.example", "ssh_user" => "deploy", "ssh_remote_path" => "/srv/imported", "ssh_mode" => "rsync" } end end