312 lines
11 KiB
Elixir
312 lines
11 KiB
Elixir
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 "create_project keeps committed project when template rebuild fails", %{
|
|
temp_root: temp_root
|
|
} do
|
|
temp_dir = Path.join(temp_root, "broken-template-blog")
|
|
templates_dir = Path.join(temp_dir, "templates")
|
|
File.mkdir_p!(templates_dir)
|
|
|
|
File.write!(
|
|
Path.join(templates_dir, "broken.liquid"),
|
|
"---\ntitle: Broken Template\nkind: post\n---\nBody"
|
|
)
|
|
|
|
assert_raise KeyError, fn ->
|
|
BDS.Projects.create_project(%{name: "Broken Templates", data_path: temp_dir})
|
|
end
|
|
|
|
assert %Project{name: "Broken Templates"} =
|
|
Repo.get_by(Project, data_path: temp_dir)
|
|
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 "project_cache_dir never falls back into the project data directory" do
|
|
# Private app-internal artifacts (the embeddings index) must live under the
|
|
# OS private app directory (macOS: ~/Library/Application Support/bds), never
|
|
# inside priv/data/projects/<id> — leaving them in the project tree pollutes
|
|
# the repository.
|
|
saved = Application.get_env(:bds, :project_cache_root)
|
|
Application.delete_env(:bds, :project_cache_root)
|
|
on_exit(fn -> Application.put_env(:bds, :project_cache_root, saved) end)
|
|
|
|
project_id = "fallback-#{System.unique_integer([:positive])}"
|
|
cache_dir = BDS.Projects.project_cache_dir(project_id)
|
|
|
|
data_dir = Path.expand("../../priv/data/projects/#{project_id}", __DIR__)
|
|
refute cache_dir == data_dir
|
|
refute String.starts_with?(cache_dir, Path.expand("../../priv/data", __DIR__))
|
|
|
|
private_app_dir =
|
|
case :filename.basedir(:user_config, "bds") do
|
|
path when is_list(path) -> List.to_string(path)
|
|
path -> path
|
|
end
|
|
|> Path.expand()
|
|
|
|
assert String.starts_with?(cache_dir, private_app_dir)
|
|
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 "the default project's public content folder lives outside the repo and private dir" do
|
|
Repo.delete_all(Project)
|
|
|
|
assert {:ok, default_project} = BDS.Projects.ensure_default_project()
|
|
|
|
data_dir = BDS.Projects.project_data_dir(default_project)
|
|
|
|
# Public content must live under the per-user default content location,
|
|
# never in the application repo (priv/data) nor the private app dir.
|
|
refute String.starts_with?(data_dir, Path.expand("../../priv/data", __DIR__))
|
|
refute String.starts_with?(data_dir, BDS.Projects.private_dir())
|
|
assert String.starts_with?(data_dir, Application.fetch_env!(:bds, :default_content_root))
|
|
end
|
|
|
|
test "project_data_dir never falls back into the application repo" do
|
|
# A project without an explicit data_path resolves to the per-user default
|
|
# content location, not priv/data/projects/<id> inside the repo.
|
|
project = %Project{id: "no-path-#{System.unique_integer([:positive])}", data_path: nil}
|
|
|
|
data_dir = BDS.Projects.project_data_dir(project)
|
|
|
|
refute String.starts_with?(data_dir, Path.expand("../../priv/data", __DIR__))
|
|
assert String.starts_with?(data_dir, Application.fetch_env!(:bds, :default_content_root))
|
|
end
|
|
|
|
test "project locations are recorded in a machine-local registry under private_dir", %{
|
|
temp_root: temp_root
|
|
} do
|
|
external_dir = Path.join(temp_root, "registry-blog")
|
|
File.mkdir_p!(external_dir)
|
|
|
|
assert {:ok, project} =
|
|
BDS.Projects.create_project(%{name: "Registry Blog", data_path: external_dir})
|
|
|
|
registry = BDS.Projects.project_registry()
|
|
assert registry[project.id] == external_dir
|
|
assert String.starts_with?(BDS.Projects.registry_path(), BDS.Projects.private_dir())
|
|
|
|
assert {:ok, _deleted} = BDS.Projects.delete_project(project.id)
|
|
refute Map.has_key?(BDS.Projects.project_registry(), project.id)
|
|
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
|