From b051e06454e87de8b72094d0a4e14c5b1f6d945f Mon Sep 17 00:00:00 2001 From: sloane <git@sloanelybutsurely.com> Date: Tue, 25 Mar 2025 13:46:04 -0400 Subject: [PATCH] create posts table --- .credo.exs | 56 ++--- .formatter.exs | 2 +- config/config.exs | 7 +- lib/core.ex | 2 +- lib/core/posts.ex | 55 +++++ lib/core/posts/post.ex | 100 +++++++++ lib/schema.ex | 13 +- lib/schema/post.ex | 21 ++ lib/schema/user.ex | 4 +- lib/schema/user_token.ex | 4 +- lib/web/controllers/post_controller.ex | 21 ++ lib/web/controllers/post_html.ex | 5 + .../controllers/post_html/show_blog.html.heex | 9 + lib/web/router.ex | 2 + mix.exs | 4 +- mix.lock | 1 + .../20250325174616_create_posts_table.exs | 35 ++++ test/core/posts_test.exs | 194 ++++++++++++++++++ test/support/test/data_case.ex | 4 + test/support/test/fixtures/posts.ex | 31 +++ test/support/test/shared_setup.ex | 48 +++++ 21 files changed, 577 insertions(+), 41 deletions(-) create mode 100644 lib/core/posts.ex create mode 100644 lib/core/posts/post.ex create mode 100644 lib/schema/post.ex create mode 100644 lib/web/controllers/post_controller.ex create mode 100644 lib/web/controllers/post_html.ex create mode 100644 lib/web/controllers/post_html/show_blog.html.heex create mode 100644 priv/repo/migrations/20250325174616_create_posts_table.exs create mode 100644 test/core/posts_test.exs create mode 100644 test/support/test/fixtures/posts.ex create mode 100644 test/support/test/shared_setup.ex diff --git a/.credo.exs b/.credo.exs index d5f7c3d..c74a78f 100644 --- a/.credo.exs +++ b/.credo.exs @@ -100,34 +100,34 @@ # Some of the rules have `priority: :high`, meaning Credo runs them unless we explicitly disable them # (removing them from this file wouldn't be enough, the `false` is required) # - {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, - {Credo.Check.Consistency.ParameterPatternMatching, false}, - {Credo.Check.Design.AliasUsage, false}, - {Credo.Check.Readability.AliasOrder, false}, - {Credo.Check.Readability.BlockPipe, false}, - {Credo.Check.Readability.LargeNumbers, false}, - {Credo.Check.Readability.ModuleDoc, false}, - {Credo.Check.Readability.MultiAlias, false}, - {Credo.Check.Readability.OneArityFunctionInPipe, false}, - {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false}, - {Credo.Check.Readability.PipeIntoAnonymousFunctions, false}, - {Credo.Check.Readability.PreferImplicitTry, false}, - {Credo.Check.Readability.SinglePipe, false}, - {Credo.Check.Readability.StrictModuleLayout, false}, - {Credo.Check.Readability.StringSigils, false}, - {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, - {Credo.Check.Readability.WithSingleClause, false}, - {Credo.Check.Refactor.CaseTrivialMatches, false}, - {Credo.Check.Refactor.CondStatements, false}, - {Credo.Check.Refactor.FilterCount, false}, - {Credo.Check.Refactor.MapInto, false}, - {Credo.Check.Refactor.MapJoin, false}, - {Credo.Check.Refactor.NegatedConditionsInUnless, false}, - {Credo.Check.Refactor.NegatedConditionsWithElse, false}, - {Credo.Check.Refactor.PipeChainStart, false}, - {Credo.Check.Refactor.RedundantWithClauseResult, false}, - {Credo.Check.Refactor.UnlessWithElse, false}, - {Credo.Check.Refactor.WithClauses, false}, + # {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, + # {Credo.Check.Consistency.ParameterPatternMatching, false}, + # {Credo.Check.Design.AliasUsage, false}, + # {Credo.Check.Readability.AliasOrder, false}, + # {Credo.Check.Readability.BlockPipe, false}, + # {Credo.Check.Readability.LargeNumbers, false}, + # {Credo.Check.Readability.ModuleDoc, false}, + # {Credo.Check.Readability.MultiAlias, false}, + # {Credo.Check.Readability.OneArityFunctionInPipe, false}, + # {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false}, + # {Credo.Check.Readability.PipeIntoAnonymousFunctions, false}, + # {Credo.Check.Readability.PreferImplicitTry, false}, + # {Credo.Check.Readability.SinglePipe, false}, + # {Credo.Check.Readability.StrictModuleLayout, false}, + # {Credo.Check.Readability.StringSigils, false}, + # {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, + # {Credo.Check.Readability.WithSingleClause, false}, + # {Credo.Check.Refactor.CaseTrivialMatches, false}, + # {Credo.Check.Refactor.CondStatements, false}, + # {Credo.Check.Refactor.FilterCount, false}, + # {Credo.Check.Refactor.MapInto, false}, + # {Credo.Check.Refactor.MapJoin, false}, + # {Credo.Check.Refactor.NegatedConditionsInUnless, false}, + # {Credo.Check.Refactor.NegatedConditionsWithElse, false}, + # {Credo.Check.Refactor.PipeChainStart, false}, + # {Credo.Check.Refactor.RedundantWithClauseResult, false}, + # {Credo.Check.Refactor.UnlessWithElse, false}, + # {Credo.Check.Refactor.WithClauses, false}, # # Checks scheduled for next check update (opt-in for now) diff --git a/.formatter.exs b/.formatter.exs index 18b26c5..ef8840c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,6 +1,6 @@ [ import_deps: [:ecto, :ecto_sql, :phoenix], subdirectories: ["priv/*/migrations"], - plugins: [Styler, Phoenix.LiveView.HTMLFormatter], + plugins: [Phoenix.LiveView.HTMLFormatter], inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] ] diff --git a/config/config.exs b/config/config.exs index ee5b709..9d9cf50 100644 --- a/config/config.exs +++ b/config/config.exs @@ -3,7 +3,8 @@ import Config config :esbuild, version: "0.17.11", sloanely_but_surely: [ - args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ] @@ -27,7 +28,9 @@ config :sloanely_but_surely, Web.Endpoint, config :sloanely_but_surely, namespace: Core, ecto_repos: [Core.Repo], - generators: [timestamp_type: :utc_datetime, binary_id: true] + generators: [timestamp_type: :utc_datetime_usec, binary_id: true] + +config :sloanely_but_surely, Core.Repo, migration_timestamps: [type: :utc_datetime_usec] config :tailwind, version: "3.4.3", diff --git a/lib/core.ex b/lib/core.ex index 8834e7a..cdd9c0e 100644 --- a/lib/core.ex +++ b/lib/core.ex @@ -1,4 +1,4 @@ defmodule Core do @moduledoc false - use Boundary, deps: [Schema], exports: [Accounts] + use Boundary, deps: [Schema], exports: [Accounts, Posts] end diff --git a/lib/core/posts.ex b/lib/core/posts.ex new file mode 100644 index 0000000..533d817 --- /dev/null +++ b/lib/core/posts.ex @@ -0,0 +1,55 @@ +defmodule Core.Posts do + @moduledoc false + + alias Core.Posts.Post + + def get!(id) do + Core.Repo.get!(Schema.Post, id) + end + + def get_by_publish_date_and_slug!(%Date{} = publish_date, slug) when is_binary(slug) do + publish_date + |> Post.Query.where_publish_date_and_slug(slug) + |> Core.Repo.one!() + end + + def change_post(%Schema.Post{} = post \\ %Schema.Post{}, attrs) do + Post.content_changeset(post, attrs) + end + + def create_post(attrs) do + attrs + |> change_post() + |> Core.Repo.insert() + end + + def update_post(%Schema.Post{} = post, attrs) do + post + |> change_post(attrs) + |> Core.Repo.update() + end + + def publish_post(%Schema.Post{} = post, published_at \\ DateTime.utc_now()) do + post + |> Post.publish_changeset(published_at) + |> Core.Repo.update() + end + + def delete_post(%Schema.Post{} = post, deleted_at \\ DateTime.utc_now()) do + post + |> Post.delete_changeset(deleted_at) + |> Core.Repo.update() + end + + def unpublish_post(%Schema.Post{} = post) do + post + |> Post.unpublish_changeset() + |> Core.Repo.update() + end + + def undelete_post(%Schema.Post{} = post) do + post + |> Post.undelete_changeset() + |> Core.Repo.update() + end +end diff --git a/lib/core/posts/post.ex b/lib/core/posts/post.ex new file mode 100644 index 0000000..b8adb17 --- /dev/null +++ b/lib/core/posts/post.ex @@ -0,0 +1,100 @@ +defmodule Core.Posts.Post do + @moduledoc false + + import Ecto.Changeset + + def content_changeset(%Schema.Post{} = post, attrs) do + changeset = + post + |> cast(attrs, [:tid, :kind, :slug, :title, :body]) + |> validate_required([:kind], message: "must have a kind") + |> validate_required([:body], message: "must have a body") + + changeset = + case get_field(changeset, :kind) do + :blog -> + validate_required(changeset, [:title, :slug]) + + :status -> + changeset + |> put_change(:slug, nil) + |> put_change(:title, nil) + + _ -> + changeset + end + + changeset + |> validate_format(:slug, ~r/^[a-z](?:[a-z-]*[a-z])?$/) + |> unique_constraint([:slug]) + end + + def publish_changeset(%Schema.Post{} = post, published_at) do + changeset = change(post) + + if is_nil(get_field(changeset, :published_at)) do + put_change(changeset, :published_at, published_at) + else + changeset + end + end + + def delete_changeset(%Schema.Post{} = post, deleted_at) do + changeset = change(post) + + if is_nil(get_field(changeset, :deleted_at)) do + put_change(changeset, :deleted_at, deleted_at) + else + changeset + end + end + + def unpublish_changeset(%Schema.Post{} = post) do + post + |> change() + |> put_change(:published_at, nil) + end + + def undelete_changeset(%Schema.Post{} = post) do + post + |> change() + |> put_change(:deleted_at, nil) + end + + defmodule Query do + @moduledoc false + import Ecto.Query + + def base do + from _ in Schema.Post, as: :posts + end + + def current(query \\ base()) do + where(query, [posts: p], is_nil(p.deleted_at)) + end + + def default_order(query \\ base()) do + order_by(query, [posts: p], desc: :inserted_at, desc: :updated_at) + end + + def where_publish_date_and_slug(query \\ current(), publish_date, slug) do + where( + query, + [posts: p], + p.slug == ^slug and + fragment("(? at time zone 'UTC' at time zone 'America/New_York')::date", p.published_at) == + ^publish_date + ) + end + + def published(query \\ base()) do + query + |> current() + |> where([posts: p], not is_nil(p.published_at)) + end + + def deleted(query \\ base()) do + where(query, [posts: p], not is_nil(p.deleted_at)) + end + end +end diff --git a/lib/schema.ex b/lib/schema.ex index 96e4ef7..a886b6d 100644 --- a/lib/schema.ex +++ b/lib/schema.ex @@ -1,4 +1,15 @@ defmodule Schema do @moduledoc false - use Boundary, deps: [], exports: [User, UserToken] + use Boundary, deps: [], exports: [Post, User, UserToken] + + defmacro __using__(_) do + quote do + use Ecto.Schema + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @type t() :: %__MODULE__{} + end + end end diff --git a/lib/schema/post.ex b/lib/schema/post.ex new file mode 100644 index 0000000..4e54648 --- /dev/null +++ b/lib/schema/post.ex @@ -0,0 +1,21 @@ +defmodule Schema.Post do + @moduledoc false + use Schema + + @post_kinds ~w[status blog]a + + schema "posts" do + field :tid, :string + field :kind, Ecto.Enum, values: @post_kinds + field :slug, :string + field :title, :string + field :body, :string + + field :published_at, :utc_datetime_usec + field :deleted_at, :utc_datetime_usec + + timestamps() + end + + def kinds, do: @post_kinds +end diff --git a/lib/schema/user.ex b/lib/schema/user.ex index 9c1b4b3..900262a 100644 --- a/lib/schema/user.ex +++ b/lib/schema/user.ex @@ -1,9 +1,7 @@ defmodule Schema.User do @moduledoc false - use Ecto.Schema + use Schema - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id schema "users" do field :username, :string field :password, :string, virtual: true, redact: true diff --git a/lib/schema/user_token.ex b/lib/schema/user_token.ex index f53d30f..19352ad 100644 --- a/lib/schema/user_token.ex +++ b/lib/schema/user_token.ex @@ -1,9 +1,7 @@ defmodule Schema.UserToken do @moduledoc false - use Ecto.Schema + use Schema - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id schema "users_tokens" do field :token, :binary field :context, :string diff --git a/lib/web/controllers/post_controller.ex b/lib/web/controllers/post_controller.ex new file mode 100644 index 0000000..e6ecd32 --- /dev/null +++ b/lib/web/controllers/post_controller.ex @@ -0,0 +1,21 @@ +defmodule Web.PostController do + use Web, :controller + + def show(conn, %{"year" => year, "month" => month, "day" => day, "slug" => slug}) do + with {year, ""} <- Integer.parse(year), + {month, ""} <- Integer.parse(month), + {day, ""} <- Integer.parse(day), + {:ok, publish_date} <- Date.new(year, month, day) do + post = Core.Posts.get_by_publish_date_and_slug!(publish_date, slug) + + conn + |> render_post(post) + else + _ -> raise Ecto.NoResultsError + end + end + + defp render_post(conn, %Schema.Post{kind: :blog} = blog) do + render(conn, :show_blog, blog: blog) + end +end diff --git a/lib/web/controllers/post_html.ex b/lib/web/controllers/post_html.ex new file mode 100644 index 0000000..0fcc3f9 --- /dev/null +++ b/lib/web/controllers/post_html.ex @@ -0,0 +1,5 @@ +defmodule Web.PostHTML do + use Web, :html + + embed_templates "post_html/*" +end diff --git a/lib/web/controllers/post_html/show_blog.html.heex b/lib/web/controllers/post_html/show_blog.html.heex new file mode 100644 index 0000000..b796a6a --- /dev/null +++ b/lib/web/controllers/post_html/show_blog.html.heex @@ -0,0 +1,9 @@ +<article> + <header class="mb-2"> + <h1 class="text-2xl font-bold">{@blog.title}</h1> + </header> + + <p>{@blog.body}</p> + + <%!-- <footer class="mt-4 border-t border-gray-200"></footer> --%> +</article> diff --git a/lib/web/router.ex b/lib/web/router.ex index 8f096ae..1345e26 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -40,6 +40,8 @@ defmodule Web.Router do delete "/admin/users/log_out", UserSessionController, :delete + get "/:year/:month/:day/:slug", PostController, :show + # live_session :current_user, on_mount: [{Web.UserAuth, :mount_current_user}] do # end end diff --git a/mix.exs b/mix.exs index fd72445..9d0073c 100644 --- a/mix.exs +++ b/mix.exs @@ -55,8 +55,8 @@ defmodule SlaonelyButSurely.MixProject do {:boundary, "~> 0.10.4"}, # Added dev and/or test dependencies - {:styler, "~> 1.4", only: [:dev, :test], runtime: false}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:faker, "~> 0.18", only: :test} ] end diff --git a/mix.lock b/mix.lock index fe31221..94e7fc0 100644 --- a/mix.lock +++ b/mix.lock @@ -16,6 +16,7 @@ "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, + "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, diff --git a/priv/repo/migrations/20250325174616_create_posts_table.exs b/priv/repo/migrations/20250325174616_create_posts_table.exs new file mode 100644 index 0000000..993e17f --- /dev/null +++ b/priv/repo/migrations/20250325174616_create_posts_table.exs @@ -0,0 +1,35 @@ +defmodule Core.Repo.Migrations.CreatePostsTable do + use Ecto.Migration + + def change do + create table(:posts, primary_key: false) do + add :id, :uuid, primary_key: true + add :tid, :text + + add :kind, :text, null: false + add :body, :text, null: false + + # blog only fields + add :slug, :text + add :title, :text + + add :published_at, :utc_datetime_usec + add :deleted_at, :utc_datetime_usec + timestamps() + end + + create index(:posts, [:kind]) + create unique_index(:posts, [:slug]) + create index(:posts, [:published_at]) + + create index( + :posts, + ["((published_at at time zone 'UTC' at time zone 'America/New_York')::date)"], + name: :posts_published_at_date_index + ) + + create index(:posts, [:deleted_at]) + create index(:posts, [:inserted_at]) + create index(:posts, [:updated_at]) + end +end diff --git a/test/core/posts_test.exs b/test/core/posts_test.exs new file mode 100644 index 0000000..a48b191 --- /dev/null +++ b/test/core/posts_test.exs @@ -0,0 +1,194 @@ +defmodule Test.Core.PostsTest do + use Test.DataCase, async: true + + describe "get!/1" do + @tag post: :post + test "returns the post with the given id", %{posts: %{post: post}} do + assert result = Core.Posts.get!(post.id) + assert %Schema.Post{} = result + assert result.id == post.id + end + + test "raises an Ecto.NoResultsError if there is no matching post" do + assert_raise Ecto.NoResultsError, fn -> + Core.Posts.get!(Faker.UUID.v4()) + end + end + end + + describe "get_by_publish_date_and_slug!/2" do + @tag blog: :blog + test "returns the post with the given publish date and slug", %{blogs: %{blog: blog}} do + assert {:ok, blog} = Core.Posts.publish_post(blog) + publish_date = DateTime.to_date(blog.published_at) + assert result = Core.Posts.get_by_publish_date_and_slug!(publish_date, blog.slug) + assert %Schema.Post{} = result + assert result.id == blog.id + end + + @tag blog: :blog + test "raises an Ecto.NoResultsError if there is no matching post", %{blogs: %{blog: blog}} do + assert_raise Ecto.NoResultsError, fn -> + Core.Posts.get_by_publish_date_and_slug!(~D[1993-11-28], "first-day-on-earth") + end + + assert {:ok, blog} = Core.Posts.publish_post(blog) + + assert_raise Ecto.NoResultsError, fn -> + Core.Posts.get_by_publish_date_and_slug!(~D[1993-11-28], blog.slug) + end + end + end + + describe "change_post/1/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = Core.Posts.change_post(%{}) + assert %Ecto.Changeset{} = Core.Posts.change_post(%Schema.Post{}, %{}) + + assert %Ecto.Changeset{} = + Test.Fixtures.Posts.blog_post(:blog) + |> Core.Posts.change_post() + + assert %Ecto.Changeset{} = + %Schema.Post{} + |> Core.Posts.change_post(Test.Fixtures.Posts.blog_post(:blog)) + + assert %Ecto.Changeset{} = + Test.Fixtures.Posts.status_post(:status) + |> Core.Posts.change_post() + + assert %Ecto.Changeset{} = + %Schema.Post{} + |> Core.Posts.change_post(Test.Fixtures.Posts.status_post(:status)) + end + end + + describe "create_post/1" do + test "creates a status post" do + assert {:ok, %Schema.Post{}} = + Test.Fixtures.Posts.status_post(:status) + |> Core.Posts.create_post() + end + + test "creates a blug post" do + assert {:ok, %Schema.Post{}} = + Test.Fixtures.Posts.blog_post(:blog) + |> Core.Posts.create_post() + end + + test "returns an error changeset for invalid attrs" do + assert {:error, %Ecto.Changeset{} = changeset} = Core.Posts.create_post(%{}) + refute changeset.valid? + assert "must have a kind" in errors_on(changeset).kind + assert "must have a body" in errors_on(changeset).body + + assert {:error, %Ecto.Changeset{} = changeset} = Core.Posts.create_post(%{kind: :blog}) + refute changeset.valid? + assert "must have a body" in errors_on(changeset).body + assert "can't be blank" in errors_on(changeset).title + assert "can't be blank" in errors_on(changeset).slug + + assert {:error, %Ecto.Changeset{} = changeset} = + Core.Posts.create_post(%{ + kind: :blog, + title: "Hello, World!", + slug: "hello-world!", + body: "This is a sample blog posts." + }) + + refute changeset.valid? + assert "has invalid format" in errors_on(changeset).slug + end + end + + describe "update_post/2" do + @tag blog: :to_update + test "updates a blog post", %{blogs: %{to_update: post}} do + assert {:ok, %Schema.Post{} = post} = Core.Posts.update_post(post, %{title: "A new title"}) + assert post.tid == "to_update" + assert post.title == "A new title" + end + + @tag status: :to_update + test "updates a status post", %{statuses: %{to_update: post}} do + assert {:ok, %Schema.Post{} = post} = Core.Posts.update_post(post, %{body: "Hello, World!"}) + assert post.tid == "to_update" + assert post.body == "Hello, World!" + end + end + + describe "publish_post/1/2" do + @describetag post: :to_publish + + test "publishes a post", %{posts: %{to_publish: to_publish}} do + refute to_publish.published_at + assert {:ok, published} = Core.Posts.publish_post(to_publish) + assert published.tid == "to_publish" + assert published.published_at + end + + test "does not update an already published post", %{posts: %{to_publish: to_publish}} do + assert {:ok, published} = Core.Posts.publish_post(to_publish) + assert {:ok, published_again} = Core.Posts.publish_post(published) + assert published.updated_at == published_again.updated_at + end + end + + describe "delete_post/1/2" do + @describetag post: :to_delete + + test "soft deletes a post", %{posts: %{to_delete: post}} do + refute post.deleted_at + assert {:ok, post} = Core.Posts.delete_post(post) + assert post.deleted_at + end + + test "does not update an already deleted post", %{posts: %{to_delete: to_delete}} do + assert {:ok, deleted} = Core.Posts.delete_post(to_delete) + assert {:ok, deleted_again} = Core.Posts.delete_post(deleted) + assert deleted.updated_at == deleted_again.updated_at + end + end + + describe "unpublish_post/1" do + @describetag posts: [:published, :not_published] + setup %{posts: %{published: published, not_published: not_published}} do + {:ok, published} = Core.Posts.publish_post(published) + + [posts: %{published: published, not_published: not_published}] + end + + test "unpublishes a post", %{posts: %{published: published}} do + assert published.published_at + assert {:ok, unpublished} = Core.Posts.unpublish_post(published) + refute unpublished.published_at + end + + test "does not update a post that isn't published", %{posts: %{not_published: not_published}} do + refute not_published.published_at + assert {:ok, not_updated} = Core.Posts.unpublish_post(not_published) + assert not_published.updated_at == not_updated.updated_at + end + end + + describe "undelete_post/1" do + @describetag posts: [:deleted, :not_deleted] + setup %{posts: %{deleted: deleted, not_deleted: not_deleted}} do + {:ok, deleted} = Core.Posts.delete_post(deleted) + + [posts: %{deleted: deleted, not_deleted: not_deleted}] + end + + test "undeletees a post", %{posts: %{deleted: deleted}} do + assert deleted.deleted_at + assert {:ok, undeleted} = Core.Posts.undelete_post(deleted) + refute undeleted.deleted_at + end + + test "does not update a post that isn't deleted", %{posts: %{not_deleted: not_deleted}} do + refute not_deleted.deleted_at + assert {:ok, not_updated} = Core.Posts.undelete_post(not_deleted) + assert not_deleted.updated_at == not_updated.updated_at + end + end +end diff --git a/test/support/test/data_case.ex b/test/support/test/data_case.ex index ff9c165..c31e41e 100644 --- a/test/support/test/data_case.ex +++ b/test/support/test/data_case.ex @@ -16,6 +16,8 @@ defmodule Test.DataCase do use ExUnit.CaseTemplate + import Test.SharedSetup + alias Ecto.Adapters.SQL.Sandbox using do @@ -34,6 +36,8 @@ defmodule Test.DataCase do :ok end + setup [:handle_post_tags, :handle_blog_tags, :handle_status_tags] + @doc """ Sets up the sandbox based on the test tags. """ diff --git a/test/support/test/fixtures/posts.ex b/test/support/test/fixtures/posts.ex new file mode 100644 index 0000000..526b9e7 --- /dev/null +++ b/test/support/test/fixtures/posts.ex @@ -0,0 +1,31 @@ +defmodule Test.Fixtures.Posts do + @moduledoc false + + def status_post(tid, attrs \\ %{}) do + Enum.into(attrs, %{ + tid: "#{tid}", + kind: :status, + body: Faker.Lorem.paragraph(), + title: nil, + status: nil + }) + end + + def blog_post(tid, attrs \\ %{}) do + title = Faker.Lorem.sentence() + + slug = + title + |> String.downcase() + |> String.replace(~r/[^\w]/, "-") + |> String.trim("-") + + Enum.into(attrs, %{ + tid: "#{tid}", + kind: :blog, + title: title, + slug: slug, + body: Enum.join(Faker.Lorem.paragraphs(), "\n\n") + }) + end +end diff --git a/test/support/test/shared_setup.ex b/test/support/test/shared_setup.ex new file mode 100644 index 0000000..99eafbd --- /dev/null +++ b/test/support/test/shared_setup.ex @@ -0,0 +1,48 @@ +defmodule Test.SharedSetup do + def handle_post_tags(tags) do + posts = + for tid <- get_test_ids(tags, [:post, :posts]), into: %{} do + fun = Enum.random([&Test.Fixtures.Posts.blog_post/1, &Test.Fixtures.Posts.status_post/1]) + {:ok, post} = Core.Posts.create_post(fun.(tid)) + {tid, post} + end + + [posts: posts] + end + + def handle_blog_tags(tags) do + blogs = + for tid <- get_test_ids(tags, [:blog, :blogs]), into: %{} do + {:ok, blog} = + Test.Fixtures.Posts.blog_post(tid) + |> Core.Posts.create_post() + + {tid, blog} + end + + [blogs: blogs] + end + + def handle_status_tags(tags) do + statuses = + for tid <- get_test_ids(tags, [:status, :statuses]), into: %{} do + {:ok, status} = + Test.Fixtures.Posts.status_post(tid) + |> Core.Posts.create_post() + + {tid, status} + end + + [statuses: statuses] + end + + defp get_test_ids(tags, keys) do + keys + |> Enum.flat_map(fn key -> + tags + |> Map.get(key) + |> List.wrap() + end) + |> Enum.uniq() + end +end