feat: issue #25 implemented - cli
This commit is contained in:
401
test/bds/cli_test.exs
Normal file
401
test/bds/cli_test.exs
Normal file
@@ -0,0 +1,401 @@
|
||||
defmodule BDS.CLITest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Ecto.Query
|
||||
import ExUnit.CaptureIO
|
||||
|
||||
alias BDS.CLI
|
||||
alias BDS.CLI.Commands
|
||||
alias BDS.CliSync.Notification
|
||||
alias BDS.Repo
|
||||
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
Repo.delete_all(Notification)
|
||||
|
||||
temp_dir = Path.join(System.tmp_dir!(), "bds-cli-test-#{System.unique_integer([:positive])}")
|
||||
File.mkdir_p!(temp_dir)
|
||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||
|
||||
{:ok, temp_dir: temp_dir}
|
||||
end
|
||||
|
||||
defp create_active_project(temp_dir, name \\ "CLI Test") do
|
||||
{:ok, project} = BDS.Projects.create_project(%{name: name, data_path: temp_dir})
|
||||
{:ok, project} = BDS.Projects.set_active_project(project.id)
|
||||
project
|
||||
end
|
||||
|
||||
defp notifications do
|
||||
Repo.all(Notification)
|
||||
end
|
||||
|
||||
describe "argv decoding" do
|
||||
test "splits on the unit separator and drops empties" do
|
||||
assert CLI.env_argv("post" <> <<0x1F>> <> "--title" <> <<0x1F>> <> "Hello" <> <<0x1F>>) ==
|
||||
["post", "--title", "Hello"]
|
||||
|
||||
assert CLI.env_argv(nil) == []
|
||||
assert CLI.env_argv("") == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "parser" do
|
||||
test "parses every subcommand" do
|
||||
optimus = CLI.optimus()
|
||||
|
||||
assert {:ok, [:rebuild], %{flags: %{incremental: true}}} =
|
||||
Optimus.parse(optimus, ["rebuild", "--incremental"])
|
||||
|
||||
assert {:ok, [:repair], %{args: %{part: "post-links"}}} =
|
||||
Optimus.parse(optimus, ["repair", "post-links"])
|
||||
|
||||
assert {:error, [:repair], _errors} = Optimus.parse(optimus, ["repair", "nonsense"])
|
||||
|
||||
assert {:ok, [:render], %{flags: %{force: true, incremental: false}}} =
|
||||
Optimus.parse(optimus, ["render", "--force"])
|
||||
|
||||
assert {:ok, [:upload], _result} = Optimus.parse(optimus, ["upload"])
|
||||
assert {:ok, [:push], _result} = Optimus.parse(optimus, ["push"])
|
||||
assert {:ok, [:pull], _result} = Optimus.parse(optimus, ["pull"])
|
||||
|
||||
assert {:ok, [:post], %{options: %{title: "Hi", tags: "a,b"}}} =
|
||||
Optimus.parse(optimus, ["post", "--title", "Hi", "--tags", "a,b"])
|
||||
|
||||
assert {:ok, [:media], %{args: %{file: "img.jpg"}, options: %{language: "de"}}} =
|
||||
Optimus.parse(optimus, ["media", "img.jpg", "--language", "de"])
|
||||
|
||||
assert {:ok, [:gallery], %{options: %{title: "Trip"}, unknown: ["a.jpg", "b.jpg"]}} =
|
||||
Optimus.parse(optimus, ["gallery", "--title", "Trip", "a.jpg", "b.jpg"])
|
||||
|
||||
assert {:ok, [:config, :get], %{args: %{key: "ui.theme"}}} =
|
||||
Optimus.parse(optimus, ["config", "get", "ui.theme"])
|
||||
|
||||
assert {:ok, [:config, :set], %{args: %{key: "k", value: "v"}}} =
|
||||
Optimus.parse(optimus, ["config", "set", "k", "v"])
|
||||
|
||||
assert {:ok, [:config, :list], _result} = Optimus.parse(optimus, ["config", "list"])
|
||||
assert {:ok, [:project, :list], _result} = Optimus.parse(optimus, ["project", "list"])
|
||||
|
||||
assert {:ok, [:project, :add], %{args: %{path: "/tmp/x"}, options: %{name: "X"}}} =
|
||||
Optimus.parse(optimus, ["project", "add", "/tmp/x", "--name", "X"])
|
||||
|
||||
assert {:ok, [:project, :switch], %{args: %{project: "blog"}}} =
|
||||
Optimus.parse(optimus, ["project", "switch", "blog"])
|
||||
|
||||
assert {:ok, [:tui], _result} = Optimus.parse(optimus, ["tui"])
|
||||
|
||||
assert {:ok, [:lua], %{args: %{script: "cleanup"}, unknown: ["arg1"]}} =
|
||||
Optimus.parse(optimus, ["lua", "cleanup", "arg1"])
|
||||
end
|
||||
|
||||
test "help exits 0 and unknown commands exit 1" do
|
||||
{help_code, help_output} = with_io(fn -> CLI.run(["--help"]) end)
|
||||
assert help_code == 0
|
||||
assert help_output =~ "rebuild"
|
||||
assert help_output =~ "gallery"
|
||||
|
||||
{code, _output} = with_io(:stderr, fn -> CLI.run(["frobnicate"]) end)
|
||||
assert code == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "config commands" do
|
||||
test "set, get, and list operate on the global settings store", %{temp_dir: _temp_dir} do
|
||||
assert {:ok, "cli.test.key = hello"} =
|
||||
Commands.dispatch([:config, :set], %{args: %{key: "cli.test.key", value: "hello"}})
|
||||
|
||||
assert {:ok, "hello"} =
|
||||
Commands.dispatch([:config, :get], %{args: %{key: "cli.test.key"}})
|
||||
|
||||
{:ok, output} = with_io(fn -> Commands.dispatch([:config, :list], %{}) end)
|
||||
assert output =~ "cli.test.key=hello"
|
||||
|
||||
assert {:error, message} =
|
||||
Commands.dispatch([:config, :get], %{args: %{key: "cli.test.missing"}})
|
||||
|
||||
assert message =~ "not set"
|
||||
|
||||
assert Enum.any?(
|
||||
notifications(),
|
||||
&(&1.entity_type == "setting" and &1.entity_id == "cli.test.key" and
|
||||
&1.from_cli == true)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "project commands" do
|
||||
test "add, switch, and list manage the project registry", %{temp_dir: temp_dir} do
|
||||
assert {:ok, added_message} =
|
||||
Commands.dispatch([:project, :add], %{
|
||||
args: %{path: temp_dir},
|
||||
options: %{name: "CLI Added"}
|
||||
})
|
||||
|
||||
assert added_message =~ "Added project"
|
||||
|
||||
project =
|
||||
Enum.find(BDS.Projects.list_projects(), &(&1.name == "CLI Added"))
|
||||
|
||||
assert project
|
||||
|
||||
assert {:ok, "Active project: CLI Added"} =
|
||||
Commands.dispatch([:project, :switch], %{args: %{project: project.slug}})
|
||||
|
||||
{:ok, output} = with_io(fn -> Commands.dispatch([:project, :list], %{}) end)
|
||||
assert output =~ "CLI Added"
|
||||
assert output =~ "* " <> project.id
|
||||
|
||||
assert {:error, message} =
|
||||
Commands.dispatch([:project, :switch], %{args: %{project: "no-such-project"}})
|
||||
|
||||
assert message =~ "No project matches"
|
||||
|
||||
entity_types = Enum.map(notifications(), & &1.entity_type)
|
||||
assert "project" in entity_types
|
||||
end
|
||||
|
||||
test "add rejects a missing directory" do
|
||||
assert {:error, message} =
|
||||
Commands.dispatch([:project, :add], %{
|
||||
args: %{path: "/nonexistent/bds-cli"},
|
||||
options: %{}
|
||||
})
|
||||
|
||||
assert message =~ "not a directory"
|
||||
end
|
||||
end
|
||||
|
||||
describe "post command" do
|
||||
test "creates a post from options and writes a cli notification", %{temp_dir: temp_dir} do
|
||||
create_active_project(temp_dir)
|
||||
|
||||
{{:ok, message}, output} =
|
||||
with_io(fn ->
|
||||
Commands.dispatch([:post], %{
|
||||
flags: %{no_translate: true},
|
||||
options: %{
|
||||
title: "From the CLI",
|
||||
content: "Hello from the command line",
|
||||
language: "en",
|
||||
tags: "cli,test",
|
||||
categories: "notes"
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
assert message =~ "Created post"
|
||||
assert output == "" or output =~ "translation"
|
||||
|
||||
post =
|
||||
Repo.one!(
|
||||
from post in BDS.Posts.Post, where: post.title == "From the CLI"
|
||||
)
|
||||
|
||||
assert post.tags == ["cli", "test"]
|
||||
assert post.categories == ["notes"]
|
||||
assert post.language == "en"
|
||||
assert post.status == :draft
|
||||
|
||||
assert Enum.any?(
|
||||
notifications(),
|
||||
&(&1.entity_type == "post" and &1.entity_id == post.id and &1.action == :created)
|
||||
)
|
||||
end
|
||||
|
||||
test "creates a post from JSON on stdin", %{temp_dir: temp_dir} do
|
||||
create_active_project(temp_dir)
|
||||
|
||||
json =
|
||||
Jason.encode!(%{
|
||||
"title" => "Stdin Post",
|
||||
"content" => "Body from stdin",
|
||||
"language" => "en",
|
||||
"tags" => ["json", "cli"]
|
||||
})
|
||||
|
||||
{{:ok, message}, _output} =
|
||||
with_io([input: json], fn ->
|
||||
Commands.dispatch([:post], %{flags: %{stdin: true, no_translate: true}, options: %{}})
|
||||
end)
|
||||
|
||||
assert message =~ "Created post"
|
||||
|
||||
post = Repo.one!(from post in BDS.Posts.Post, where: post.title == "Stdin Post")
|
||||
assert post.tags == ["json", "cli"]
|
||||
end
|
||||
|
||||
test "requires a title", %{temp_dir: temp_dir} do
|
||||
create_active_project(temp_dir)
|
||||
|
||||
assert {:error, message} = Commands.dispatch([:post], %{flags: %{}, options: %{}})
|
||||
assert message =~ "--title is required"
|
||||
end
|
||||
end
|
||||
|
||||
describe "media command" do
|
||||
test "imports a file and reports enrichment best-effort", %{temp_dir: temp_dir} do
|
||||
create_active_project(temp_dir)
|
||||
|
||||
source = Path.join(temp_dir, "cli-image.png")
|
||||
File.write!(source, "fake png bytes")
|
||||
|
||||
{result, _output} =
|
||||
with_io(fn ->
|
||||
Commands.dispatch([:media], %{args: %{file: source}, options: %{}})
|
||||
end)
|
||||
|
||||
assert {:ok, message} = result
|
||||
assert message =~ "Imported media"
|
||||
|
||||
assert Enum.any?(notifications(), &(&1.entity_type == "media" and &1.action == :created))
|
||||
end
|
||||
|
||||
test "rejects a missing file", %{temp_dir: temp_dir} do
|
||||
create_active_project(temp_dir)
|
||||
|
||||
assert {:error, message} =
|
||||
Commands.dispatch([:media], %{
|
||||
args: %{file: Path.join(temp_dir, "missing.png")},
|
||||
options: %{}
|
||||
})
|
||||
|
||||
assert message =~ "No such file"
|
||||
end
|
||||
end
|
||||
|
||||
describe "rebuild command" do
|
||||
test "incremental rebuild diffs and applies from the filesystem", %{temp_dir: temp_dir} do
|
||||
create_active_project(temp_dir)
|
||||
|
||||
{result, _output} =
|
||||
with_io(fn ->
|
||||
Commands.dispatch([:rebuild], %{flags: %{incremental: true}})
|
||||
end)
|
||||
|
||||
assert {:ok, message} = result
|
||||
assert message =~ "differences"
|
||||
end
|
||||
end
|
||||
|
||||
describe "gallery and tui guards" do
|
||||
test "gallery without images errors", %{temp_dir: temp_dir} do
|
||||
create_active_project(temp_dir)
|
||||
|
||||
assert {:error, message} =
|
||||
Commands.dispatch([:gallery], %{
|
||||
flags: %{},
|
||||
options: %{title: "Trip"},
|
||||
unknown: []
|
||||
})
|
||||
|
||||
assert message =~ "image files"
|
||||
end
|
||||
|
||||
test "tui is delegated to the launcher script" do
|
||||
assert {:error, message} = Commands.dispatch([:tui], %{})
|
||||
assert message =~ "launcher"
|
||||
end
|
||||
end
|
||||
|
||||
describe "lua command" do
|
||||
test "rejects unknown and non-utility scripts", %{temp_dir: temp_dir} do
|
||||
project = create_active_project(temp_dir)
|
||||
|
||||
assert {:error, message} =
|
||||
Commands.dispatch([:lua], %{args: %{script: "missing"}, unknown: []})
|
||||
|
||||
assert message =~ "No script"
|
||||
|
||||
{:ok, script} =
|
||||
BDS.Scripts.create_script(%{
|
||||
project_id: project.id,
|
||||
title: "Macro Script",
|
||||
kind: :macro,
|
||||
content: "function render() return \"x\" end",
|
||||
entrypoint: "render"
|
||||
})
|
||||
|
||||
assert {:error, macro_message} =
|
||||
Commands.dispatch([:lua], %{args: %{script: script.slug}, unknown: []})
|
||||
|
||||
assert macro_message =~ "macro"
|
||||
end
|
||||
|
||||
test "runs a utility script synchronously", %{temp_dir: temp_dir} do
|
||||
project = create_active_project(temp_dir)
|
||||
|
||||
{:ok, script} =
|
||||
BDS.Scripts.create_script(%{
|
||||
project_id: project.id,
|
||||
title: "Task Script",
|
||||
kind: :utility,
|
||||
content: "function main() return \"done-42\" end",
|
||||
entrypoint: "main"
|
||||
})
|
||||
|
||||
assert {:ok, message} =
|
||||
Commands.dispatch([:lua], %{args: %{script: script.slug}, unknown: []})
|
||||
|
||||
assert message =~ "done-42"
|
||||
end
|
||||
end
|
||||
|
||||
describe "full rebuild step sharing" do
|
||||
test "the CLI and GUI share the canonical full-rebuild sequence" do
|
||||
names = Enum.map(BDS.Maintenance.full_rebuild_steps("project-id"), & &1.name)
|
||||
|
||||
assert names == [
|
||||
"Rebuild Posts From Files",
|
||||
"Rebuild Media From Files",
|
||||
"Rebuild Scripts From Files",
|
||||
"Rebuild Templates From Files",
|
||||
"Rebuild Post Links",
|
||||
"Regenerate Missing Thumbnails",
|
||||
"Rebuild Embedding Index"
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
describe "installer" do
|
||||
test "installs a shim pointing at the release launcher", %{temp_dir: temp_dir} do
|
||||
release_root = Path.join(temp_dir, "release")
|
||||
launcher = Path.join([release_root, "cli", "bin", "bds-cli"])
|
||||
File.mkdir_p!(Path.dirname(launcher))
|
||||
File.write!(launcher, "#!/bin/sh\n")
|
||||
|
||||
bin_dir = Path.join(temp_dir, "local-bin")
|
||||
|
||||
assert {:ok, installed} =
|
||||
BDS.CLI.Install.install(bin_dir: bin_dir, release_root: release_root)
|
||||
|
||||
assert installed == Path.join(bin_dir, "bds-cli")
|
||||
content = File.read!(installed)
|
||||
assert content =~ launcher
|
||||
assert content =~ ~s(exec ")
|
||||
assert Bitwise.band(File.stat!(installed).mode, 0o111) != 0
|
||||
end
|
||||
|
||||
test "reports no_release outside a packaged release", %{temp_dir: temp_dir} do
|
||||
assert {:error, :no_release} =
|
||||
BDS.CLI.Install.install(
|
||||
bin_dir: Path.join(temp_dir, "bin"),
|
||||
release_root: Path.join(temp_dir, "not-a-release")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "settings form action" do
|
||||
test "the data section exposes the install action and run_action handles it" do
|
||||
fields = BDS.UI.SettingsForm.load("data", "nonexistent-project").fields
|
||||
action = Enum.find(fields, &(&1.type == :action))
|
||||
assert action.key == "install_cli"
|
||||
|
||||
# Outside a packaged release the action reports the error message.
|
||||
assert {:error, message} = BDS.UI.SettingsForm.run_action("install_cli")
|
||||
assert message =~ "packaged application"
|
||||
|
||||
assert {:error, _unknown} = BDS.UI.SettingsForm.run_action("nope")
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user