Skip to main content

Command Palette

Search for a command to run...

동호회 관리 플랫폼 개발기 #4 - Domain Rules

Published
•3 min read•View as Markdown

현재 Hoing 에는 3가지 리소스가 있다.

  1. User

  2. Club

  3. Member

이 중 Club 리소스에 대한 도메인 규칙을 정하고, 이를 테스트로 표현해본다.

Club

  1. Club 은 로그인 된 상태에서만 생성이 가능하다.

  2. Club 을 생성한 생성자는 자동으로 Club 의 owner member 가 된다.

  3. Club 의 owner 만 Club 정보를 수정할 수 있다.

generator

각 리소스의 생성자를 정의한다.

별도로 인자가 없는 경우 리소스를 생성하는데 필요한 다른 관계들은 자동으로 생성하도록 처리한다.

defmodule Hoing.Generator do
  @moduledoc "Data generation for tests"
  use Ash.Generator

  def member(opts \\ []) do
    user = opts[:user] || generate(user())
    club = opts[:club] || generate(club())

    seed_generator(
      %Hoing.Clubs.Member{
        user_id: user.id,
        club_id: club.id,
        role: :member
      },
      overrides: opts
    )
  end

  def club(opts \\ []) do
    actor = opts[:actor] || once(:default_user, fn -> generate(user()) end)

    changeset_generator(
      Hoing.Clubs.Club,
      :create,
      defaults: [
        name: sequence(:club_name, &"Club #{&1}", &((&1 || 0) + 1)),
        description: sequence(:club_description, &"Description of Club #{&1}")
      ],
      overrides: opts,
      actor: actor
    )
  end

  def user(opts \\ []) do
    seed_generator(
      %Hoing.Accounts.User{
        email: sequence(:user_email, &"user#{&1}@example.com")
      },
      overrides: opts
    )
  end
end

club_test.exs

위에서 정의한 generator 를 활용해서 테스트를 구현하자.

defmodule Hoing.Clubs.ClubTest do
  use Hoing.DataCase, async: true

  alias Hoing.Clubs, warn: false

  describe "Hoing.Clubs.create_club!" do
    test "can not create club without actor" do
      assert_raise Ash.Error.Forbidden, fn ->
        Clubs.create_club!(%{name: "New Club"})
      end
    end

    test "can create club with actor" do
      actor = generate(user())

      club = Clubs.create_club!(%{name: "New Club"}, actor: actor)

      assert club.name == "New Club"
    end

    test "set actor as owner member of the created club" do
      actor = generate(user())

      club = Clubs.create_club!(%{name: "New Club"}, actor: actor) |> Ash.load!(:members)
      member = List.first(club.members)

      assert length(club.members) == 1
      assert member.user_id == actor.id
      assert member.club_id == club.id
      assert member.role == :owner
    end
  end

  describe "Hoing.Clubs.update_club!" do
    test "can not update club without actor" do
      club = generate(club())

      assert_raise Ash.Error.Forbidden, fn ->
        Clubs.update_club!(club, %{name: "Updated Club"})
      end
    end

    test "can update club if actor is owner of the club" do
      user = generate(user())
      club = generate(club(actor: user)) |> Ash.load!(:members)

      member =
        club.members
        |> Enum.find(fn member -> member.user_id == user.id && member.club_id == club.id end)

      updated_club =
        Clubs.update_club!(club, %{name: "Updated Club", description: "No more description."},
          actor: user,
          context: %{member: member}
        )

      assert updated_club.id == club.id
      assert updated_club.name == "Updated Club"
      assert updated_club.description == "No more description."
    end

    test "can not update club if actor is not owner of the club" do
      club = generate(club())

      manager_user = generate(user())
      manager_member = generate(member(user: manager_user, club: club, role: :manager))

      member_user = generate(user())
      member_member = generate(member(user: member_user, club: club, role: :member))

      assert_raise Ash.Error.Forbidden,
                   fn ->
                     Clubs.update_club!(
                       club,
                       %{name: "Updated Club", description: "No more description."},
                       actor: manager_user,
                       context: %{member: manager_member}
                     )
                   end

      assert_raise Ash.Error.Forbidden,
                   fn ->
                     Clubs.update_club!(
                       club,
                       %{name: "Updated Club", description: "No more description."},
                       actor: member_user,
                       context: %{member: member_member}
                     )
                   end
    end
  end
end

위 test 를 실행해보자.

mix test test/hoing/clubs/club_test.exs

test 가 정상작동하는 것을 확인할 수 있다.

More from this blog

Ash Typescript

TL;DRElixir의 Ash Framework로 정의한 리소스를 TypeScript와 바로 연결해 주는 툴.백엔드 스펙을 따로 문서화하지 않아도 타입 안정성과 동기화를 자동으로 보장한다. 왜 사용하는가? Elixir로 백엔드를, TypeScript로 프런트엔드를 작성하다 보면 변경되는 API 스펙을 항상 동기화된 상태로 관리하기 쉽지 않다.결국 동기화를 위해서는 Graphql 을 사용하거나, 별도의 툴이 필요하다.AshTypescript ...

Sep 28, 20253 min read

Ash Reactor

TLDR; Reactor는 복잡한 워크플로우를 단계별로 정의하고 실행하는 라이브러리다. 각 단계가 독립적으로 돌아가며, 병렬 처리와 에러 핸들링도 쉽게 할 수 있다. 왜 쓸까? 프로젝트에서 "데이터 조회 → 가공 → 외부 API 호출 → 저장" 같은 복잡한 로직을 처리할 때가 있다.이런걸 그냥 함수로 쭉 연결하면 코드가 복잡해지고 에러 처리도 어려워진다. Reactor를 쓰면 각 단계를 명확히 분리하고 선언적으로 워크플로우를 관리할 수 있다. ...

Sep 21, 20254 min read

Ash StateMachine

TLDR; Ash StateMachine 은 상태 변경이 가능한 케이스를 명시하고, 미리 선언된 상태 변경만 가능하도록 한다. 왜 쓸까? 예를 들면, “새로 생성 → 진행 중 → 완료” 처럼 정해진 workflow 가 있다면, 로직으로 if/else 를 사용하는 것보다 상태머신으로 선언해 두는 게 안전하고 읽기 쉽다. 예제: Task 요구사항: 기본 상태는 :new :start 액션이 :new → :in_progress :finish ...

Sep 14, 20252 min read

Ash Oban

최근 작업중에 이메일 발송을 처리해야 했다.after_action 에 이마일 발송을 고려했는데 최종적으로는 AshOban 을 사용했다. AshOban이 뭔가? Ash Framework와 Oban을 이어주는 라이브러리다. 백그라운드 job을 Ash 스타일로 쓸 수 있게 해준다. 멤버 초대 이메일 보내기 클럽에 새 멤버를 초대하면 이메일을 보내야 하는 상황이다. 이걸 어떻게 처리할까? Action Hook으로 하면: 사용자가 서버에서 이메일 발송...

Aug 31, 20252 min read

kkbz.hop

14 posts