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