updated receive message for this new structure

defmodule Blockytalky.DSL do
  alias Blockytalky.UserState, as: US
  alias Blockytalky.BrickPi, as: BP
  alias Blockytalky.Music, as: Music
  alias Blockytalky.SonicPi, as: SP
  alias Blockytalky.CommsModule, as: CM
  #alias Blockytalky.CommsChannel, as: CC

  require Logger
  @moduledoc """
  The function-version of the Blockytalky.DSL as an intermediate representation
  (or library if you prefer)
  can be many lines to one "instruction"

  Mainly interoperates with the other APIs: Hardware, Music, Comms, and UserState
  We ``should`` do type checking / correctness checking here!
  """
  ## Macros

  defmacro __using__(_) do
    #expand when student code does `use Blockytalky.DSL`
    #register all the @attributes that will accumulate the macro function definitions
    quote do
      import Blockytalky.DSL
      require Blockytalky.DSL
      require Logger
      def init(_) do
        set(:sys_timer,[])
        GenServer.call(Blockytalky.UserState, {:get_funs,:init})
        |> Enum.map(fn x -> x.() end)
      end
      def loop() do
        #loop over timer stack global var and see if it is time to do those lambdas
        process_time_events
        set(:sys_message, get_message()) #get the latest message for all of the receive if blocks, msg is :nomsg if nothing in queue
        GenServer.call(Blockytalky.UserState, {:get_funs,:loop})
        |> Enum.map(fn x -> x.() end)
      end
    end
  end

  ####
  # The entry points of the user program. These get stored as asts in an @attribute (:once or :everytime)
  # and then run as lambas (anonymous callbacks) either in the init function or the loop function.
  defmacro start(do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :init, fn -> unquote(body) end})
    end
  end
  defmacro repeatedly(do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn -> unquote(body) end})
    end
  end
  defmacro in_time(seconds, do: body) do
    quote do
      time = (:calendar.universal_time |> :calendar.datetime_to_gregorian_seconds) + round(unquote(seconds))
      push_time_event({:at_time,time, fn -> unquote(body) end})
      time = nil
    end
  end
  defmacro for_time(seconds,clauses) do
    do_body = Keyword.get(clauses, :do, nil)
    after_body = Keyword.get(clauses, :after, :ok)
    quote do
      time = (:calendar.universal_time |> :calendar.datetime_to_gregorian_seconds) + round(unquote(seconds))
      push_time_event({:until_time, time, fn -> unquote(do_body) end, fn -> unquote(after_body) end})
      time = nil
    end
  end
  @doc """
  ## Example
      iex> when_sensor "PORT_1" > 100, do: set("item",10)
  """
  defmacro when_sensor({op,_m,[port_id,value]}, do: body) do
    #need to swap varable calls for when in case the variable changes between loops,
    #but the sensor port doesn't change
    value = case value do
      {:get, meta, opts} -> {:get_var_history, meta, opts}
      _ -> value
    end
    compare = case op do
      :> -> quote do fn x,y -> x > y end end
      :>= -> quote do fn x,y -> x >= y end end
      :< -> quote do fn x,y -> x < y end end
      :<= -> quote do fn x,y -> x <= y end end
      :== -> quote do fn x,y -> x == y end end
      :!= -> quote do fn x,y -> x != y end end
    end
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        when_sensor_value_compare(unquote(port_id),
          unquote(compare),
          unquote(value), #could be a constant or a var/var history [{iteration,value}...]
          fn -> unquote(body) end
          )
        end})
    end
  end
  @doc """
  ## Example
      iex> when_sensor "PORT_1" in 1..get("my_var"), do: 1 + 1
  """
  defmacro when_sensor({:in, _m, [port_id, range={:.., _m2, [_left,_right]}]}, do: body) do
  quote do
    GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
      when_sensor_value_range(unquote(port_id),
        unquote(range), #could be a constant or a var
        fn -> unquote(body) end)
        end}
      )
    end
  end
  defmacro when_sensor({:not_in, _m, [port_id, range={:.., _m2, [_left,_right]}]}, do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        when_sensor_value_range(unquote(port_id),
          unquote(range), #could be a constant or a var
          fn -> unquote(body) end,
          true) #not in flag
        end}
      )
    end
  end
  defmacro while_sensor({op,_,[port_id,value]}, do: body) do
    compare = case op do
      :> -> quote do fn x,y -> x > y end end
      :>= -> quote do fn x,y -> x >= y end end
      :< -> quote do fn x,y -> x < y end end
      :<= -> quote do fn x,y -> x <= y end end
      :== -> quote do fn x,y -> x == y end end
      :!= -> quote do fn x,y -> x != y end end
    end
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        while_sensor_value_compare(unquote(port_id),
          unquote(compare),
          unquote(value), #could be a constant or a var
          fn -> unquote(body) end
          )
        end}
      )
    end
  end
  defmacro while_sensor({:in, _m, [port_id, range={:.., _m2, [_left,_right]}]}, do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        while_sensor_value_range(unquote(port_id),
          unquote(range), #could be a constant or a var
          fn -> unquote(body) end)
          end}
        )
      end
  end
  defmacro while_sensor({:not_in, _m, [port_id, range={:.., _m2, [_left,_right]}]}, do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        while_sensor_value_range(unquote(port_id),
          unquote(range), #could be a constant or a var
          fn -> unquote(body) end,
          true) #not in flag
        end}
      )
    end
  end
  defmacro when_receive(msg, do: body) do
    #msg could be get("var")
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        if(unquote(msg) == get(:sys_message)) do #dequeue message is set at the beginning of the loop
          unquote(body)
        end
      end})
    end
  end
  ## Events
  ## Variables
  def set(var_name, value), do: US.set_var(var_name, value)
  def get(var_name) do
    case US.get_var(var_name) do #runtime error message
    nil ->
      Blockytalky.Endpoint.broadcast! "uc:command", "error", %{body: "#{var_name} has not been set!"}
      nil
    v -> v
    end
  end
  @doc """
  [{iteration, var_value}, ...]
  """
  def get_var_history(var_name) do
    case US.get_var_history(var_name) do #runtime error message
      nil ->
        Blockytalky.Endpoint.broadcast! "uc:command", "error", %{"body" => "#{var_name} has not been set!"}
        nil
      v -> v
    end
 end
  ## HW

  #get sensor value
  def get_sensor_value(port_num) do
     US.get_value(port_num)
  end

  ####
  # Helper functions called by macros
  # timer Events
  def process_time_events() do
    stack = get_sys_timer
      |> Enum.filter(fn x -> do_timer_action(x) end)
    set(:sys_timer, stack)
  end
  def push_time_event(event) do
    stack = get_sys_timer
    new_stack  = [event | stack]
    set(:sys_timer, new_stack)
  end
  defp get_sys_timer do
    case get(:sys_timer) do
      nil -> []
      v -> v
    end
  end
  @doc """
  In both of the following cases, if they return false, pop off of the stack
  """
  def do_timer_action({:at_time, time, fun}) do
    now = :calendar.universal_time |> :calendar.datetime_to_gregorian_seconds
    if now > time do
      fun.()
      false
    else
      true
    end
  end
  def do_timer_action({:until_time, time, do_fun}) do
    now = :calendar.universal_time |> :calendar.datetime_to_gregorian_seconds
    if now < time do
      do_fun.()
      true
    else
      false
    end

  end
  def do_timer_action({:until_time, time, do_fun, then_fun}) do
    now = :calendar.universal_time |> :calendar.datetime_to_gregorian_seconds
    if now < time do
      do_fun.()
      true
    else
      then_fun.()
      false
    end

  end
  #when sensor value is <comp> <value>, do: <body>
  def when_sensor_value_compare(port_id, comp_fun, value, body_fun) do
    #this is confusing, refactor this to be less awful.
    #Purpose: compare the change in the sensor value to a variable or constant, and if true, apply some body function
    #handles the edge case that the sensor stayed the same, but the value being compared against changed thus making the "when" trigger.
    {apply_fun, value} = case value do
      [{i, latest_value},{j, previous_value} | _ ] ->
        {fn(x,y,z) -> x != nil and comp_fun.(x,z) and (not comp_fun.(y,z) or not comp_fun.(y,previous_value)) end, latest_value}
      [{_, only_value}] ->
        {fn(x,y,z) -> x != nil and comp_fun.(x,z) and not comp_fun.(y,z) end, only_value}
      v ->
        {fn(x,y,z) -> x != nil and comp_fun.(x,z) and not comp_fun.(y,z) end, v}
    end
    is_valid = US.apply(
                apply_fun,
                port_id,
                value
                )
    if is_valid, do: body_fun.()
  end
  def while_sensor_value_compare(port_num, comp_fun, value, fun) do
    is_valid = US.apply(
                (fn(x,_y,z) -> x != nil and comp_fun.(x,z) end ),
                port_num,
                value
                )
    if is_valid, do: fun.()
  end
  #when sensor value is <within | out of> range, do: <body>
  def when_sensor_value_range(port_num, range, fun, not_in \\ false) do
    check_function = if not_in do
       fn(x,y,z) -> x != nil and not x in z and  y in z end
     else
       fn(x,y,z) -> x != nil and x in z and not y in z end
     end
    is_valid = US.apply(
                check_function,
                port_num,
                range
                )
    if is_valid, do: fun.()
  end
  def while_sensor_value_range(port_num, range, fun, not_in \\ false) do
    check_function = if not_in do
       fn(x,_y,z) -> x != nil and not x in z end
     else
       fn(x,_y,z) -> x != nil and x in z end
     end
    is_valid = US.apply(
                (fn(x,_y,z) -> x != nil and x in z end),
                port_num,
                range
                )
    if is_valid, do: fun.()
  end
  #set motor speed
  def bp_set_motor_speed(port_num, value) do
    if value in -100..100 do
      BP.set_motor_value(port_num, value)
    else
      Blockytalky.Endpoint.broadcast "uc:command", "error", %{"body" => "Tried to set motor speed to value not in -100 to 100: #{inspect value}"}
    end
  end
  ## Grove Pi
  ## Comms
  # message <msg> to <unit>
  def send_message(msg, to), do: CM.send_message(msg,to)
  def get_message() do
    {_sender, msg} = US.dequeue_message
    msg
  end
  def say(msg) do
    Blockytalky.Endpoint.broadcast "comms:message", "say", %{"body" => msg}
  end
  ## Music
  # motif <motif name> do: <music program>
  # cue <motif> in <num> beats
  # (optional) cue <signal>
  # (optional) sync <signal>
end

defmodule Blockytalky.DSL do
  alias Blockytalky.UserState, as: US
  alias Blockytalky.BrickPi, as: BP
  alias Blockytalky.Music, as: Music
  alias Blockytalky.SonicPi, as: SP
  alias Blockytalky.CommsModule, as: CM
  #alias Blockytalky.CommsChannel, as: CC

  require Logger
  @moduledoc """
  The function-version of the Blockytalky.DSL as an intermediate representation
  (or library if you prefer)
  can be many lines to one "instruction"

  Mainly interoperates with the other APIs: Hardware, Music, Comms, and UserState
  We ``should`` do type checking / correctness checking here!
  """
  ## Macros

  defmacro __using__(_) do
    #expand when student code does `use Blockytalky.DSL`
    #register all the @attributes that will accumulate the macro function definitions
    quote do
      import Blockytalky.DSL
      require Blockytalky.DSL
      require Logger
      def init(_) do
        set(:sys_timer,[])
        GenServer.call(Blockytalky.UserState, {:get_funs,:init})
        |> Enum.map(fn x -> x.() end)
      end
      def loop() do
        #loop over timer stack global var and see if it is time to do those lambdas
        process_time_events
        dequeued_message = get_message() #get the latest message for all of the receive if blocks
        GenServer.call(Blockytalky.UserState, {:get_funs,:loop})
        |> Enum.map(fn x -> x.() end)
      end
    end
  end

  ####
  # The entry points of the user program. These get stored as asts in an @attribute (:once or :everytime)
  # and then run as lambas (anonymous callbacks) either in the init function or the loop function.
  defmacro start(do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :init, fn -> unquote(body) end})
    end
  end
  defmacro repeatedly(do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn -> unquote(body) end})
    end
  end
  defmacro in_time(seconds, do: body) do
    quote do
      time = (:calendar.universal_time |> :calendar.datetime_to_gregorian_seconds) + round(unquote(seconds))
      push_time_event({:at_time,time, fn -> unquote(body) end})
      time = nil
    end
  end
  defmacro for_time(seconds,clauses) do
    do_body = Keyword.get(clauses, :do, nil)
    after_body = Keyword.get(clauses, :after, :ok)
    quote do
      time = (:calendar.universal_time |> :calendar.datetime_to_gregorian_seconds) + round(unquote(seconds))
      push_time_event({:until_time, time, fn -> unquote(do_body) end, fn -> unquote(after_body) end})
      time = nil
    end
  end
  @doc """
  ## Example
      iex> when_sensor "PORT_1" > 100, do: set("item",10)
  """
  defmacro when_sensor({op,_m,[port_id,value]}, do: body) do
    #need to swap varable calls for when in case the variable changes between loops,
    #but the sensor port doesn't change
    value = case value do
      {:get, meta, opts} -> {:get_var_history, meta, opts}
      _ -> value
    end
    compare = case op do
      :> -> quote do fn x,y -> x > y end end
      :>= -> quote do fn x,y -> x >= y end end
      :< -> quote do fn x,y -> x < y end end
      :<= -> quote do fn x,y -> x <= y end end
      :== -> quote do fn x,y -> x == y end end
      :!= -> quote do fn x,y -> x != y end end
    end
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        when_sensor_value_compare(unquote(port_id),
          unquote(compare),
          unquote(value), #could be a constant or a var/var history [{iteration,value}...]
          fn -> unquote(body) end
          )
        end})
    end
  end
  @doc """
  ## Example
      iex> when_sensor "PORT_1" in 1..get("my_var"), do: 1 + 1
  """
  defmacro when_sensor({:in, _m, [port_id, range={:.., _m2, [_left,_right]}]}, do: body) do
  quote do
    GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
      when_sensor_value_range(unquote(port_id),
        unquote(range), #could be a constant or a var
        fn -> unquote(body) end)
        end}
      )
    end
  end
  defmacro when_sensor({:not_in, _m, [port_id, range={:.., _m2, [_left,_right]}]}, do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        when_sensor_value_range(unquote(port_id),
          unquote(range), #could be a constant or a var
          fn -> unquote(body) end,
          true) #not in flag
        end}
      )
    end
  end
  defmacro while_sensor({op,_,[port_id,value]}, do: body) do
    compare = case op do
      :> -> quote do fn x,y -> x > y end end
      :>= -> quote do fn x,y -> x >= y end end
      :< -> quote do fn x,y -> x < y end end
      :<= -> quote do fn x,y -> x <= y end end
      :== -> quote do fn x,y -> x == y end end
      :!= -> quote do fn x,y -> x != y end end
    end
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        while_sensor_value_compare(unquote(port_id),
          unquote(compare),
          unquote(value), #could be a constant or a var
          fn -> unquote(body) end
          )
        end}
      )
    end
  end
  defmacro while_sensor({:in, _m, [port_id, range={:.., _m2, [_left,_right]}]}, do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        while_sensor_value_range(unquote(port_id),
          unquote(range), #could be a constant or a var
          fn -> unquote(body) end)
          end}
        )
      end
  end
  defmacro while_sensor({:not_in, _m, [port_id, range={:.., _m2, [_left,_right]}]}, do: body) do
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        while_sensor_value_range(unquote(port_id),
          unquote(range), #could be a constant or a var
          fn -> unquote(body) end,
          true) #not in flag
        end}
      )
    end
  end
  defmacro when_receive(msg, do: body) do
    #msg could be get("var")
    quote do
      GenServer.cast(Blockytalky.UserState, {:push_fun, :loop, fn ->
        if(unquote(msg) == dequeued_message) do #dequeue message is set at the beginning of the loop
          unquote(body)
        end
      end})
    end
  end
  ## Events
  ## Variables
  def set(var_name, value), do: US.set_var(var_name, value)
  def get(var_name) do
    case US.get_var(var_name) do #runtime error message
    nil ->
      Blockytalky.Endpoint.broadcast! "uc:command", "error", %{body: "#{var_name} has not been set!"}
      nil
    v -> v
    end
  end
  @doc """
  [{iteration, var_value}, ...]
  """
  def get_var_history(var_name) do
    case US.get_var_history(var_name) do #runtime error message
      nil ->
        Blockytalky.Endpoint.broadcast! "uc:command", "error", %{"body" => "#{var_name} has not been set!"}
        nil
      v -> v
    end
 end
  ## HW

  #get sensor value
  def get_sensor_value(port_num) do
     US.get_value(port_num)
  end

  ####
  # Helper functions called by macros
  # timer Events
  def process_time_events() do
    stack = get_sys_timer
      |> Enum.filter(fn x -> do_timer_action(x) end)
    set(:sys_timer, stack)
  end
  def push_time_event(event) do
    stack = get_sys_timer
    new_stack  = [event | stack]
    set(:sys_timer, new_stack)
  end
  defp get_sys_timer do
    case get(:sys_timer) do
      nil -> []
      v -> v
    end
  end
  @doc """
  In both of the following cases, if they return false, pop off of the stack
  """
  def do_timer_action({:at_time, time, fun}) do
    now = :calendar.universal_time |> :calendar.datetime_to_gregorian_seconds
    if now > time do
      fun.()
      false
    else
      true
    end
  end
  def do_timer_action({:until_time, time, do_fun}) do
    now = :calendar.universal_time |> :calendar.datetime_to_gregorian_seconds
    if now < time do
      do_fun.()
      true
    else
      false
    end

  end
  def do_timer_action({:until_time, time, do_fun, then_fun}) do
    now = :calendar.universal_time |> :calendar.datetime_to_gregorian_seconds
    if now < time do
      do_fun.()
      true
    else
      then_fun.()
      false
    end

  end
  #when sensor value is <comp> <value>, do: <body>
  def when_sensor_value_compare(port_id, comp_fun, value, body_fun) do
    #this is confusing, refactor this to be less awful.
    #Purpose: compare the change in the sensor value to a variable or constant, and if true, apply some body function
    #handles the edge case that the sensor stayed the same, but the value being compared against changed thus making the "when" trigger.
    {apply_fun, value} = case value do
      [{i, latest_value},{j, previous_value} | _ ] ->
        {fn(x,y,z) -> x != nil and comp_fun.(x,z) and (not comp_fun.(y,z) or not comp_fun.(y,previous_value)) end, latest_value}
      [{_, only_value}] ->
        {fn(x,y,z) -> x != nil and comp_fun.(x,z) and not comp_fun.(y,z) end, only_value}
      v ->
        {fn(x,y,z) -> x != nil and comp_fun.(x,z) and not comp_fun.(y,z) end, v}
    end
    is_valid = US.apply(
                apply_fun,
                port_id,
                value
                )
    if is_valid, do: body_fun.()
  end
  def while_sensor_value_compare(port_num, comp_fun, value, fun) do
    is_valid = US.apply(
                (fn(x,_y,z) -> x != nil and comp_fun.(x,z) end ),
                port_num,
                value
                )
    if is_valid, do: fun.()
  end
  #when sensor value is <within | out of> range, do: <body>
  def when_sensor_value_range(port_num, range, fun, not_in \\ false) do
    check_function = if not_in do
       fn(x,y,z) -> x != nil and not x in z and  y in z end
     else
       fn(x,y,z) -> x != nil and x in z and not y in z end
     end
    is_valid = US.apply(
                check_function,
                port_num,
                range
                )
    if is_valid, do: fun.()
  end
  def while_sensor_value_range(port_num, range, fun, not_in \\ false) do
    check_function = if not_in do
       fn(x,_y,z) -> x != nil and not x in z end
     else
       fn(x,_y,z) -> x != nil and x in z end
     end
    is_valid = US.apply(
                (fn(x,_y,z) -> x != nil and x in z end),
                port_num,
                range
                )
    if is_valid, do: fun.()
  end
  #set motor speed
  def bp_set_motor_speed(port_num, value) do
    if value in -100..100 do
      BP.set_motor_value(port_num, value)
    else
      Blockytalky.Endpoint.broadcast "uc:command", "error", %{"body" => "Tried to set motor speed to value not in -100 to 100: #{inspect value}"}
    end
  end
  ## Grove Pi
  ## Comms
  # message <msg> to <unit>
  def send_message(msg, to), do: CM.send_message(msg,to)
  def get_message() do
    {_sender, msg} = US.dequeue_message
    msg
  end
  def say(msg) do
    Blockytalky.Endpoint.broadcast "comms:message", "say", %{"body" => msg}
  end
  ## Music
  # motif <motif name> do: <music program>
  # cue <motif> in <num> beats
  # (optional) cue <signal>
  # (optional) sync <signal>
end


(WIP)

(WIP)

defmodule Serum.Build.Pass2.IndexBuilder do
  @moduledoc """
  During pass 2, IndexBuilder does the following:

  1. Renders a list of all blog posts and saves as an HTML document to the
    output directory.
  2. Loops through the tag map generated in the first pass, rendering a list of
    blog posts filtered by each tag.
  """

  import Serum.Util
  alias Serum.Error
  alias Serum.Build
  alias Serum.PostInfo
  alias Serum.Renderer

  @type state :: Build.state

  @async_opt [max_concurrency: System.schedulers_online * 10]

  @doc "Starts the IndexBuilder."
  @spec run(Build.mode, state) :: Error.result

  def run(mode, state) do
    IO.puts "Generating posts index..."

    postdir = Path.join state.dest, "posts"
    File.mkdir_p! postdir
    msg_mkdir postdir
    all_posts = state.site_ctx[:posts]
    title = state.project_info.list_title_all
    save_list Path.join(postdir, "index.html"), title, all_posts, state

    #test {nil, state.site_ctx[:posts]}, state

    tags = state.tag_map
    result = launch_tag mode, tags, state
    Error.filter_results result, :index_builder
  end

  defp test({tag, posts}, state) do
    pagination? = state.project_info.pagination
    posts_per_page = state.project_info.posts_per_page

    list_path = tag && "tags/" <> tag.name || "posts"
    list_dir = Path.join state.dest, list_path
    list_title =
      if is_nil(tag) do
        state.project_info.list_title_all
      else
        state.project_info.list_title_tag
        |> :io_lib.format([tag.name])
        |> IO.iodata_to_binary
      end
    File.mkdir_p! list_dir
    msg_mkdir list_dir

    posts = pagination? && Enum.chunk_every(posts, posts_per_page) || [posts]
    new_state =
      state
      |> Map.put(:list_path, list_path)
      |> Map.put(:list_title, list_title)
      |> Map.put(:max_page, length(posts))

    case render_lists posts, 1, [], new_state do
      {:ok, htmls} -> save_lists htmls, list_dir
      {:error, _} = error -> error
    end
  end

  @spec save_lists([binary], binary) :: :ok

  defp save_lists([first|_] = htmls, list_dir) do
    path_index = Path.join list_dir, "index.html"
    fwrite path_index, first
    msg_gen path_index
    htmls
    |> Enum.with_index(1)
    |> Enum.each(fn {html, page_num} ->
      path = Path.join list_dir, "page-#{page_num}.html"
      fwrite path, html
      msg_gen path
    end)
  end

  @spec launch_tag(Build.mode, map, state) :: [Task.t]

  defp launch_tag(:parallel, tagmap, state) do
    %{dest: dir} = state
    tagmap
    |> Task.async_stream(__MODULE__, :tag_task, [dir, state], @async_opt)
    |> Enum.map(&elem(&1, 1))
  end

  defp launch_tag(:sequential, tagmap, state) do
    %{dest: dir} = state
    tagmap
    |> Enum.map(&tag_task(&1, dir, state))
  end

  @doc false
  @spec tag_task({Serum.Tag.t, [PostInfo.t]}, binary, state) :: :ok

  def tag_task({tag, posts}, dest, state) do
    tagdir = Path.join [dest, "tags", tag.name]
    fmt = state.project_info.list_title_tag
    title = fmt |> :io_lib.format([tag.name]) |> IO.iodata_to_binary
    File.mkdir_p! tagdir
    msg_mkdir tagdir
    save_list Path.join(tagdir, "index.html"), title, posts, state
  end

  @spec save_list(binary, binary, [PostInfo.t], state) :: :ok

  defp save_list(path, title, posts, state) do
    list_ctx = [header: title, posts: posts]
    case Renderer.render "list", list_ctx, [page_title: title], state do
      {:ok, html} ->
        fwrite path, html
        msg_gen path
      error -> error
    end
  end

  """
  # for the first page
  path_index = Path.join state.list_dir, "index.html"
  fwrite path_index, html
  msg_gen path_index

  path = Path.join state.list_dir, "page-1.html"
  fwrite path, html
  msg_gen path

  # for other pages
  path = Path.join state.list_dir, "page-# {page_num}.html"
  fwrite path, html
  msg_gen path
  """

  @spec render_lists([[PostInfo.t]], integer, [Error.result(binary)], state)
    :: Error.result([binary])

  defp render_lists(paginated_posts, page_num, acc, state)

  defp render_lists([page], 1, _acc, state) do
    # The only page
    rendered = render_list page, 1, true, true, state
    [rendered]
    |> Error.filter_results_with_values(:render_lists)
  end

  defp render_lists([page], page_num, acc, state) do
    # The last page
    rendered = render_list page, page_num, false, true, state
    [rendered|acc]
    |> Enum.reverse()
    |> Error.filter_results_with_values(:render_lists)
  end

  defp render_lists([page|rest], 1, acc, state) do
    # The first page
    rendered = render_list page, 1, true, false, state
    render_lists rest, 2, [rendered|acc], state
  end

  defp render_lists([page|rest], page_num, acc, state) do
    # Other pages
    rendered = render_list page, page_num, false, false, state
    render_lists rest, page_num + 1, [rendered|acc], state
  end

  @spec render_list([PostInfo.t], integer, boolean, boolean, state)
    :: Error.result(binary)

  defp render_list(posts, page_num, first?, last?, state) do
    base = state.project_info.base_url
    path = state.list_path
    prev = if first?, do: Path.join([base, path, "page-#{page_num - 1}.html"])
    next = if last?, do: Path.join([base, path, "page-#{page_num + 1}.html"])
    list_ctx = [
      header: state.list_title,
      posts: posts,
      current_page: page_num,
      max_page: state.max_page,
      prev_page: prev,
      next_page: next
    ]
    Renderer.render "list", list_ctx, [page_title: state.list_title], state
  end
end

defmodule Serum.Build.Pass2.IndexBuilder do
  @moduledoc """
  During pass 2, IndexBuilder does the following:

  1. Renders a list of all blog posts and saves as an HTML document to the
    output directory.
  2. Loops through the tag map generated in the first pass, rendering a list of
    blog posts filtered by each tag.
  """

  import Serum.Util
  alias Serum.Error
  alias Serum.Build
  alias Serum.Renderer

  @type state :: Build.state

  @async_opt [max_concurrency: System.schedulers_online * 10]

  @doc "Starts the IndexBuilder."
  @spec run(Build.mode, state) :: Error.result

  def run(mode, state) do
    IO.puts "Generating posts index..."

    postdir = Path.join state.dest, "posts"
    File.mkdir_p! postdir
    msg_mkdir postdir
    all_posts = state.site_ctx[:posts]
    title = state.project_info.list_title_all
    save_list Path.join(postdir, "index.html"), title, all_posts, state

    tags = state.tag_map
    result = launch_tag mode, tags, state
    Error.filter_results result, :index_builder
  end

  @spec launch_tag(Build.mode, map, state) :: [Task.t]

  defp launch_tag(:parallel, tagmap, state) do
    %{dest: dir} = state
    tagmap
    |> Task.async_stream(__MODULE__, :tag_task, [dir, state], @async_opt)
    |> Enum.map(&elem(&1, 1))
  end

  defp launch_tag(:sequential, tagmap, state) do
    %{dest: dir} = state
    tagmap
    |> Enum.map(&tag_task(&1, dir, state))
  end

  @doc false
  @spec tag_task({Serum.Tag.t, [Serum.PostInfo.t]}, binary, state) :: :ok

  def tag_task({tag, posts}, dest, state) do
    tagdir = Path.join [dest, "tags", tag.name]
    fmt = state.project_info.list_title_tag
    title = fmt |> :io_lib.format([tag.name]) |> IO.iodata_to_binary
    File.mkdir_p! tagdir
    msg_mkdir tagdir
    save_list Path.join(tagdir, "index.html"), title, posts, state
  end

  @spec save_list(binary, binary, [Serum.PostInfo.t], state) :: :ok

  defp save_list(path, title, posts, state) do
    list_ctx = [header: title, posts: posts]
    case Renderer.render "list", list_ctx, [page_title: title], state do
      {:ok, html} ->
        fwrite path, html
        msg_gen path
      error -> error
    end
  end
end


Tests for GitHub.Sync.installation_event/1 (#1380)

Add tests for Github.Sync.pull_request_event/1

defmodule CodeCorps.GitHub.SyncTest do
  @moduledoc false

  use CodeCorps.DbAccessCase

  import CodeCorps.GitHub.TestHelpers
  import Ecto.Query, only: [where: 3]

  alias CodeCorps.{
    Comment,
    GitHub.Adapters,
    GitHub.Sync,
    GithubAppInstallation,
    GithubComment,
    GithubIssue,
    GithubPullRequest,
    GithubRepo,
    GithubUser,
    Repo,
    Task,
    TaskList,
    User
  }

  alias Ecto.Changeset

  describe "pull_request_event" do
    ["pull_request_opened","pull_request_closed","pull_request_edited", "pull_request_opened_by_bot","pull_request_reopened"]
    |> Enum.each(fn payload_name ->
    @event payload_name
      test  "runs succesfully when " <> @event do
        payload = load_event_fixture(@event)
        project = insert(:project)
        insert(:github_repo, github_id: payload["repository"]["id"], project: project)
        insert(:task_list, project: project, done: true)
        insert(:task_list, project: project, inbox: true)
        insert(:task_list, project: project, pull_requests: true)
        {:ok, _map} = Sync.pull_request_event(payload)
      end

      test "fails if repo not found when " <> @event do
        payload = load_event_fixture(@event)
        {:error, :repo_not_found} = Sync.pull_request_event(payload)
      end

      test "fails if api errors out when " <> @event do
        payload = load_event_fixture(@event)
        project = insert(:project)
        insert(:github_repo, github_id: payload["repository"]["id"], project: project)
        insert(:task_list, project: project, done: true)
        insert(:task_list, project: project, inbox: true)
        insert(:task_list, project: project, pull_requests: true)

        with_mock_api(CodeCorps.GitHub.FailureAPI) do
          assert {:error, :fetching_issue, _error} = Sync.pull_request_event(payload)
        end
      end

      test "fails with validation error if pull request is invalid when " <> @event do
        payload = load_event_fixture(@event)
        project = insert(:project)
        insert(:github_repo, github_id: payload["repository"]["id"], project: project)
        insert(:task_list, project: project, done: true)
        insert(:task_list, project: project, inbox: true)
        insert(:task_list, project: project, pull_requests: true)

        %{"pull_request" => pull} = payload
        corrupt_pull =  %{pull | "created_at" => nil,  "updated_at" => nil, "html_url" => nil, "locked" => nil,
        "number" => nil, "state" =>  nil, "title" => nil }
        corrupt_pull_request = Map.put(payload, "pull_request", corrupt_pull)
        {:error, :validating_github_pull_request, _changeset} = Sync.pull_request_event(corrupt_pull_request)
      end

      test "fails with validation error if task_list isn't found " <> @event do
        payload = load_event_fixture(@event)
        project = insert(:project)
        insert(:github_repo, github_id: payload["repository"]["id"], project: project)
        {:error, :validating_task, _changeset} = Sync.pull_request_event(payload)
      end

    end)
  end

  # Some clauses defined seem difficult or impossible to reach so their tests were omitted
  # - {:error, :validation_error_on_syncing_installation, Changeset.t()}
  # - {:error, :validation_error_on_marking_installation_processed, Changeset.t()}
  # - {:error, :unexpected_transaction_outcome, any}
  # However, if these clauses can be caused by some updates upstream we should cover them with tests

  describe "installation_event" do

    @payload load_event_fixture("installation_created")

    test "syncs_correctly_with valid data" do
      %{"installation" => %{"id" => installation_id}} = @payload

      assert Repo.aggregate(GithubAppInstallation, :count, :id) == 0

      {:ok, installation} = Sync.installation_event(@payload)

      assert Repo.aggregate(GithubAppInstallation, :count, :id) == 1
      assert installation.github_id == installation_id
    end

    test "fails if multiple installations are unprocessed" do
      user = insert(:user, github_id: @payload["sender"]["id"])
      project = insert(:project)
      attrs = %{project: project, user: user, sender_github_id: user.id, github_id: nil}
      insert(:github_app_installation, attrs)
      insert(:github_app_installation, attrs)

      {:error, :multiple_unprocessed_installations_found} = Sync.installation_event(@payload)
    end

    test "fails on syncing api error" do
      with_mock_api(CodeCorps.GitHub.FailureAPI) do
        assert {:error, :github_api_error_on_syncing_repos, _error} = Sync.installation_event(@payload)
      end
    end
  end

  describe "installation_repositories_event/1 added" do
    @payload load_event_fixture("installation_repositories_added")

    test "syncs_correctly when adding" do
      %{"installation" => %{
        "id" => installation_id
        },
        "repositories_added" => added_repos,
        "sender" => %{"id" => _user_id}
      } = @payload

      project = insert(:project)
      user =  insert(:user)

      insert(:github_app_installation, github_id: installation_id, project: project, user: user)

      {:ok, _repos} = Sync.installation_repositories_event(@payload)

      repo_ids = Enum.map(added_repos, &Map.get(&1, "id"))

      for repo <- Repo.all(GithubRepo) do
        assert repo.github_id in repo_ids
      end

      assert Repo.aggregate(GithubRepo, :count, :id) == 2
      assert Repo.aggregate(GithubAppInstallation, :count, :id) == 1
    end

    test "can fail when installation not found" do
      assert {:error, :unmatched_installation} == @payload |> Sync.installation_repositories_event()
    end

    test "fails with validation errors when syncing repos" do
      %{"installation" => %{
        "id" => installation_id
        },
        "repositories_added" => repos,
        "sender" => %{"id" => _user_id}
      } = @payload

      project = insert(:project)
      user =  insert(:user)
      insert(:github_app_installation, github_id: installation_id, project: project, user: user)

      corrupt_repos = Enum.map(repos, &(Map.put(&1,"name", "")))

      corrupted_payload = Map.put(@payload, "repositories_added", corrupt_repos)

      assert {:error, :validation_error_on_syncing_repos, %{}} == corrupted_payload |> Sync.installation_repositories_event()
    end
  end

  describe "installation_repositories_event/1 removed" do
    @payload load_event_fixture("installation_repositories_removed")

    test "syncs_correctly when removing" do
      %{"installation" => %{
        "id" => installation_id
        },
        "repositories_removed" => removed_repos
        } = @payload

      project = insert(:project)
      user =  insert(:user)
      installation = insert(:github_app_installation, github_id: installation_id, project: project, user: user)

      for repo <- removed_repos do
        insert(:github_repo, github_id: repo["id"], github_app_installation: installation)
      end

      assert Repo.aggregate(GithubRepo, :count, :id) == 2
      assert Repo.aggregate(GithubAppInstallation, :count, :id) == 1

      {:ok, _repos} = Sync.installation_repositories_event(@payload)

      assert Repo.aggregate(GithubRepo, :count, :id) == 0
    end
  end

  describe "issue_comment_event/1 on comment created for pull request" do
    @issue_comment_preloads [
      :user,
      [task: :user],
      [github_comment: [github_issue: [:github_pull_request, :github_repo]]]
    ]

    @payload load_event_fixture("issue_comment_created_on_pull_request")

    test "syncs correctly" do
      %{
        "issue" => %{
          "body" => issue_body,
          "id" => issue_github_id,
          "number" => issue_number,
          "user" => %{
            "id" => issue_user_github_id
          }
        },
        "comment" => %{
          "body" => comment_body,
          "id" => comment_github_id,
          "user" => %{
            "id" => comment_user_github_id
          }
        },
        "repository" => %{
          "id" => repo_github_id
        }
      } = @payload

      project = insert(:project)
      github_repo = insert(:github_repo, github_id: repo_github_id, project: project)
      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)
      insert(:task_list, project: project, pull_requests: true)

      {:ok, comment} = Sync.issue_comment_event(@payload)

      assert Repo.aggregate(GithubComment, :count, :id) == 1
      assert Repo.aggregate(GithubIssue, :count, :id) == 1
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 1
      assert Repo.aggregate(Comment, :count, :id) == 1
      assert Repo.aggregate(Task, :count, :id) == 1

      issue_user = Repo.get_by(User, github_id: issue_user_github_id)
      assert issue_user

      comment_user = Repo.get_by(User, github_id: comment_user_github_id)
      assert comment_user

      %{
        github_comment: %{
          github_issue: %{
            github_pull_request: github_pull_request
          } = github_issue
        } = github_comment,
        task: task
      } = comment = comment |> Repo.preload(@issue_comment_preloads)

      assert github_comment.github_id == comment_github_id

      assert github_issue.github_id == issue_github_id
      assert github_issue.body == issue_body
      assert github_issue.number == issue_number

      assert github_pull_request.number == issue_number
      assert github_pull_request.github_repo_id == github_repo.id

      assert task.markdown == issue_body
      assert task.project_id == project.id
      assert task.user.github_id == issue_user_github_id
      assert task.user_id == issue_user.id

      assert comment.markdown == comment_body
      assert comment.user_id == comment_user.id
      assert comment.user.github_id == comment_user_github_id
    end

    test "can fail when finding repo" do
      assert {:error, :repo_not_found} == @payload |> Sync.issue_comment_event()
    end

    test "can fail when fetching pull request" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      with_mock_api(CodeCorps.GitHub.FailureAPI) do
        assert {:error, :fetching_pull_request, %CodeCorps.GitHub.APIError{}} =
          @payload |> Sync.issue_comment_event()
      end
    end

    test "can fail on github pull request validation" do
      defmodule InvalidPullRequestAPI do
        @moduledoc false

        def request(:get, "https://api.github.com/repos/baxterthehacker/public-repo/pulls/1", _, _, _) do
          {:ok, body} =
            "pull_request"
            |> load_endpoint_fixture()
            |> Map.put("number", nil)
            |> Poison.encode
          {:ok, %HTTPoison.Response{status_code: 200, body: body}}
        end
        def request(method, endpoint, body, headers, options) do
          CodeCorps.GitHub.SuccessAPI.request(method, endpoint, body, headers, options)
        end
      end

      insert(:github_repo, github_id: @payload["repository"]["id"])

      with_mock_api(InvalidPullRequestAPI) do
        assert {:error, :validating_github_pull_request, %Changeset{} = changeset} =
          @payload |> Sync.issue_comment_event()
          refute changeset.valid?
      end
    end

    test "can fail on github user validation for github pull request" do
      defmodule InvalidUserAPI do
        @moduledoc false

        def request(:get, "https://api.github.com/repos/baxterthehacker/public-repo/pulls/1", _, _, _) do
          {:ok, body} =
            "pull_request"
            |> load_endpoint_fixture()
            |> Kernel.put_in(["user", "login"], nil)
            |> Poison.encode
          {:ok, %HTTPoison.Response{status_code: 200, body: body}}
        end
        def request(method, endpoint, body, headers, options) do
          CodeCorps.GitHub.SuccessAPI.request(method, endpoint, body, headers, options)
        end
      end

      insert(:github_repo, github_id: @payload["repository"]["id"])

      with_mock_api(InvalidUserAPI) do
        assert {
          :error,
          :validating_github_user_on_github_pull_request,
          %Changeset{} = changeset
        } = @payload |> Sync.issue_comment_event()

        refute changeset.valid?
      end
    end

    test "can fail on github issue validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {:error, :validating_github_issue, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["issue", "number"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github user validation for github issue" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {
        :error,
        :validating_github_user_on_github_issue,
        %Changeset{} = changeset
      } =
        @payload
        |> Kernel.put_in(["issue", "user", "login"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on task user validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)
      payload = @payload |> Kernel.put_in(["issue", "user", "email"], email)

      assert {:error, :validating_task_user, %Changeset{} = changeset} =
        payload |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail if task matched with multiple users" do
      github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      attrs =
        @payload["issue"]
        |> Adapters.Issue.to_issue()
        |> Map.put(:github_repo, github_repo)

      github_issue = insert(:github_issue, attrs)
      # creates a user for each task, which should never happen normally
      insert_pair(:task, github_issue: github_issue)

      assert {:error, :multiple_task_users_match} ==
        @payload |> Sync.issue_comment_event()
    end

    test "can fail on task validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # validation is triggered due to missing task list

      assert {:error, :validating_task, %Changeset{} = changeset} =
        @payload |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github comment validation" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, pull_requests: true)

      assert {:error, :validating_github_comment, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["comment", "url"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github user validation for github comment" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, pull_requests: true)

      assert {
        :error,
        :validating_github_user_on_github_comment,
        %Changeset{} = changeset
      } =
        @payload
        |> Kernel.put_in(["comment", "user", "login"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on comment user validation" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, pull_requests: true)

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)

      assert {:error, :validating_comment_user, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["comment", "user", "email"], email)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail if commment matched with multiple users" do
      %{project: project} = github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, pull_requests: true)

      attrs =
        @payload["comment"]
        |> Adapters.Comment.to_github_comment()
        |> Map.put(:github_repo, github_repo)

      github_comment = insert(:github_comment, attrs)
      # creates a user for each comment, which should never happen normally
      insert_pair(:comment, github_comment: github_comment)

      assert {:error, :multiple_comment_users_match} ==
        @payload |> Sync.issue_comment_event()
    end
  end

  describe "issue_comment_event/1 on comment created for regular issue" do
    @payload load_event_fixture("issue_comment_created")

    test "syncs correctly" do
      %{
        "issue" => %{
          "body" => issue_body,
          "id" => issue_github_id,
          "number" => issue_number,
          "user" => %{
            "id" => issue_user_github_id
          }
        },
        "comment" => %{
          "body" => comment_body,
          "id" => comment_github_id,
          "user" => %{
            "id" => comment_user_github_id
          }
        },
        "repository" => %{
          "id" => repo_github_id
        }
      } = @payload

      project = insert(:project)
      insert(:github_repo, github_id: repo_github_id, project: project)
      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      {:ok, comment} = Sync.issue_comment_event(@payload)

      assert Repo.aggregate(GithubComment, :count, :id) == 1
      assert Repo.aggregate(GithubIssue, :count, :id) == 1
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 0
      assert Repo.aggregate(Comment, :count, :id) == 1
      assert Repo.aggregate(Task, :count, :id) == 1

      issue_user = Repo.get_by(User, github_id: issue_user_github_id)
      assert issue_user

      comment_user = Repo.get_by(User, github_id: comment_user_github_id)
      assert comment_user

      %{
        github_comment: %{
          github_issue: %{
            github_pull_request: github_pull_request
          } = github_issue
        } = github_comment,
        task: task
      } = comment = comment |> Repo.preload(@issue_comment_preloads)

      assert github_comment.github_id == comment_github_id

      assert github_issue.github_id == issue_github_id
      assert github_issue.body == issue_body
      assert github_issue.number == issue_number
      assert github_pull_request == nil

      assert task.markdown == issue_body
      assert task.project_id == project.id
      assert task.user.github_id == issue_user_github_id
      assert task.user_id == issue_user.id

      assert comment.markdown == comment_body
      assert comment.user_id == comment_user.id
      assert comment.user.github_id == comment_user_github_id
    end

    test "can fail when finding repo" do
      assert {:error, :repo_not_found} == @payload |> Sync.issue_comment_event()
    end

    test "can fail on github issue validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {:error, :validating_github_issue, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["issue", "number"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github user validation for github issue" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {
        :error,
        :validating_github_user_on_github_issue,
        %Changeset{} = changeset
      } =
        @payload
        |> Kernel.put_in(["issue", "user", "login"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on task user validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)
      payload = @payload |> Kernel.put_in(["issue", "user", "email"], email)

      assert {:error, :validating_task_user, %Changeset{} = changeset} =
        payload |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail if task matched with multiple users" do
      github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      attrs =
        @payload["issue"]
        |> Adapters.Issue.to_issue()
        |> Map.put(:github_repo, github_repo)

      github_issue = insert(:github_issue, attrs)
      # creates a user for each task, which should never happen normally
      insert_pair(:task, github_issue: github_issue)

      assert {:error, :multiple_task_users_match} ==
        @payload |> Sync.issue_comment_event()
    end

    test "can fail on task validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # validation is triggered due to missing task list

      assert {:error, :validating_task, %Changeset{} = changeset} =
        @payload |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github comment validation" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      assert {:error, :validating_github_comment, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["comment", "url"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github user validation for github comment" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      assert {
        :error,
        :validating_github_user_on_github_comment,
        %Changeset{} = changeset
      } =
        @payload
        |> Kernel.put_in(["comment", "user", "login"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on comment user validation" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)

      assert {:error, :validating_comment_user, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["comment", "user", "email"], email)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail if commment matched with multiple users" do
      %{project: project} = github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      attrs =
        @payload["comment"]
        |> Adapters.Comment.to_github_comment()
        |> Map.put(:github_repo, github_repo)

      github_comment = insert(:github_comment, attrs)
      # creates a user for each comment, which should never happen normally
      insert_pair(:comment, github_comment: github_comment)

      assert {:error, :multiple_comment_users_match} ==
        @payload |> Sync.issue_comment_event()
    end
  end

  describe "issue_comment_event/1 on comment deleted" do
    test "syncs correctly" do
      %{"comment" => %{"id" => github_id}} = payload =
        load_event_fixture("issue_comment_deleted")

      github_comment = insert(:github_comment, github_id: github_id)
      comment = insert(:comment, github_comment: github_comment)

      {:ok, %{deleted_comments: [deleted_comment], deleted_github_comment: deleted_github_comment}}
        = payload |> Sync.issue_comment_event()

      assert deleted_comment.id == comment.id
      assert deleted_github_comment.id == github_comment.id
      assert Repo.aggregate(Comment, :count, :id) == 0
      assert Repo.aggregate(GithubComment, :count, :id) == 0
    end
  end

  describe "issue_event/1" do
    @payload load_event_fixture("issues_opened")

    test "with unmatched user, creates user, creates task for project associated to github repo" do
      %{
        "issue" => %{
          "body" => markdown, "title" => title, "number" => number,
          "user" => %{"id" => user_github_id}
        },
        "repository" => %{"id" => repo_github_id}
      } = @payload

      project = insert(:project)
      github_repo = insert(:github_repo, github_id: repo_github_id, project: project)
      insert(:task_list, project: project, inbox: true)

      {:ok, %Task{} = task} = @payload |> Sync.issue_event()
      assert Repo.aggregate(Task, :count, :id) == 1

      user = Repo.get_by(User, github_id: user_github_id)
      assert user

      task = task |> Repo.preload(:github_issue)

      assert task.user_id == user.id
      assert task.github_issue_id
      assert task.github_repo_id == github_repo.id
      assert task.project_id == project.id
      assert task.markdown == markdown
      assert task.title == title
      assert task.github_issue.number == number
      assert task.status == "open"
      assert task.order
    end

    test "with matched user, creates or updates task for project associated to github repo" do
      %{
        "issue" => %{
          "id" => issue_github_id,
          "body" => markdown,
          "title" => title,
          "number" => number,
          "user" => %{"id" => user_github_id}
        } ,
        "repository" => %{"id" => repo_github_id}
      } = @payload

      user = insert(:user, github_id: user_github_id)

      project = insert(:project)
      github_repo = insert(:github_repo, github_id: repo_github_id, project: project)
      github_issue = insert(:github_issue, github_id: issue_github_id, number: number, github_repo: github_repo)

      insert(:task_list, project: project, inbox: true)

      existing_task = insert(:task, project: project, user: user, github_repo: github_repo, github_issue: github_issue)

      {:ok, %Task{} = task} = @payload |> Sync.issue_event()

      assert Repo.aggregate(Task, :count, :id) == 1

      task = task |> Repo.preload(:github_issue)
      assert task.github_issue_id == github_issue.id
      assert task.github_repo_id == github_repo.id
      assert task.project_id == project.id
      assert task.markdown == markdown
      assert task.title == title
      assert task.github_issue.number == number
      assert task.status == "open"
      assert task.order

      assert existing_task.id == task.id
    end

    test "can fail when finding repo" do
      assert {:error, :repo_not_found} == @payload |> Sync.issue_event()
    end

    test "can fail on github issue validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {:error, :validating_github_issue, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["issue", "number"], nil)
        |> Sync.issue_event()

      refute changeset.valid?
    end

    test "can fail on github user validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {:error, :validating_github_user, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["issue", "user", "login"], nil)
        |> Sync.issue_event()

      refute changeset.valid?
    end

    test "can fail on user validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)
      payload = @payload |> Kernel.put_in(["issue", "user", "email"], email)

      assert {:error, :validating_user, %Changeset{} = changeset} =
        payload |> Sync.issue_event()

      refute changeset.valid?
    end

    test "can fail if matched by multiple users" do
      github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      attrs =
        @payload["issue"]
        |> Adapters.Issue.to_issue()
        |> Map.put(:github_repo, github_repo)

      github_issue = insert(:github_issue, attrs)
      # creates a user for each task, which should never happen normally
      insert_pair(:task, github_issue: github_issue)

      assert {:error, :multiple_task_users_match} ==
        @payload |> Sync.issue_event()
    end

    test "can fail on task validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # validation is triggered due to missing task list

      assert {:error, :validating_task, %Changeset{} = changeset} =
        @payload |> Sync.issue_event()

      refute changeset.valid?
    end
  end

  describe "sync_repo/1" do
    defp setup_test_repo do
      project = insert(:project)
      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)
      insert(:task_list, project: project, pull_requests: true)

      owner = "baxterthehacker"
      repo = "public-repo"
      github_app_installation = insert(:github_app_installation, github_account_login: owner)

      insert(
        :github_repo,
        github_app_installation: github_app_installation,
        name: repo,
        github_account_id: 6_752_317,
        github_account_avatar_url: "https://avatars3.githubusercontent.com/u/6752317?v=4",
        github_account_type: "User",
        github_id: 35_129_377,
        project: project)
    end

    test "syncs and resyncs with the project repo" do
      github_repo = setup_test_repo()

      # Sync the first time

      Sync.sync_repo(github_repo)

      repo = Repo.one(GithubRepo)

      assert repo.syncing_pull_requests_count == 4
      assert repo.syncing_issues_count == 8
      assert repo.syncing_comments_count == 12

      assert Repo.aggregate(GithubComment, :count, :id) == 12
      assert Repo.aggregate(GithubIssue, :count, :id) == 8
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 4
      assert Repo.aggregate(GithubUser, :count, :id) == 10
      assert Repo.aggregate(Comment, :count, :id) == 12
      assert Repo.aggregate(Task, :count, :id) == 8
      assert Repo.aggregate(User, :count, :id) == 13

      # Sync a second time – should run without trouble

      Sync.sync_repo(github_repo)

      repo = Repo.one(GithubRepo)

      assert repo.syncing_pull_requests_count == 4
      assert repo.syncing_issues_count == 8
      assert repo.syncing_comments_count == 12

      assert Repo.aggregate(GithubComment, :count, :id) == 12
      assert Repo.aggregate(GithubIssue, :count, :id) == 8
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 4
      assert Repo.aggregate(GithubUser, :count, :id) == 10
      assert Repo.aggregate(Comment, :count, :id) == 12
      assert Repo.aggregate(Task, :count, :id) == 8
      assert Repo.aggregate(User, :count, :id) == 13
    end

    # coupled to fixtures. depends on
    # - fixtures/github/endpoints/issues.json on having at least 4 issues
    #   linked to pull requests
    # - fixtures/github/endpoints/pulls.json having payloads for those 4 pull
    #   requests (matched by "number")
    test "matches github issue with github pull request correctly" do
      {:ok, github_repo} = setup_test_repo() |> Sync.sync_repo

      %GithubRepo{github_issues: github_issues} =
        GithubRepo |> Repo.get(github_repo.id) |> Repo.preload(:github_issues)

      linked_issues =
        github_issues
        |> Enum.reject(fn i -> is_nil(i.github_pull_request_id) end)

      assert linked_issues |> Enum.count == 4
    end

    @tag acceptance: true
    test "syncs with the project repo with the real API" do
      github_repo = setup_coderly_repo()

      with_real_api do
        Sync.sync_repo(github_repo)
      end

      repo = Repo.one(GithubRepo)

      assert repo.syncing_pull_requests_count == 1
      assert repo.syncing_issues_count == 3
      assert repo.syncing_comments_count == 2

      assert Repo.aggregate(GithubComment, :count, :id) == 2
      assert Repo.aggregate(GithubIssue, :count, :id) == 3
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 1
      assert Repo.aggregate(GithubUser, :count, :id) == 2
      assert Repo.aggregate(Comment, :count, :id) == 2
      assert Repo.aggregate(Task, :count, :id) == 3
      assert Repo.aggregate(User, :count, :id) == 2

      # Tasks closed more than 30 days ago
      archived_tasks =
        Task
        |> where([object], is_nil(object.task_list_id))
        |> Repo.all()

      %TaskList{tasks: inbox_tasks} =
        TaskList |> Repo.get_by(inbox: true) |> Repo.preload(:tasks)
      %TaskList{tasks: pull_requests_tasks} =
        TaskList |> Repo.get_by(pull_requests: true) |> Repo.preload(:tasks)

      assert Enum.count(archived_tasks) == 1
      assert Enum.count(inbox_tasks) == 1
      assert Enum.count(pull_requests_tasks) == 1
    end
  end
end

defmodule CodeCorps.GitHub.SyncTest do
  @moduledoc false

  use CodeCorps.DbAccessCase

  import CodeCorps.GitHub.TestHelpers
  import Ecto.Query, only: [where: 3]

  alias CodeCorps.{
    Comment,
    GitHub.Adapters,
    GitHub.Sync,
    GithubAppInstallation,
    GithubComment,
    GithubIssue,
    GithubPullRequest,
    GithubRepo,
    GithubUser,
    Repo,
    Task,
    TaskList,
    User
  }

  alias Ecto.Changeset

  # Some clauses defined seem difficult or impossible to reach so their tests were omitted
  # - {:error, :validation_error_on_syncing_installation, Changeset.t()}
  # - {:error, :validation_error_on_marking_installation_processed, Changeset.t()}
  # - {:error, :unexpected_transaction_outcome, any}
  # However, if these clauses can be caused by some updates upstream we should cover them with tests

  describe "installation_event" do

    @payload load_event_fixture("installation_created")

    test "syncs_correctly_with valid data" do
      %{"installation" => %{"id" => installation_id}} = @payload

      assert Repo.aggregate(GithubAppInstallation, :count, :id) == 0

      {:ok, installation} = Sync.installation_event(@payload)

      assert Repo.aggregate(GithubAppInstallation, :count, :id) == 1
      assert installation.github_id == installation_id
    end

    test "fails if multiple installations are unprocessed" do
      user = insert(:user, github_id: @payload["sender"]["id"])
      project = insert(:project)
      attrs = %{project: project, user: user, sender_github_id: user.id, github_id: nil}
      insert(:github_app_installation, attrs)
      insert(:github_app_installation, attrs)

      {:error, :multiple_unprocessed_installations_found} = Sync.installation_event(@payload)
    end

    test "fails on syncing api error" do
      with_mock_api(CodeCorps.GitHub.FailureAPI) do
        assert {:error, :github_api_error_on_syncing_repos, _error} = Sync.installation_event(@payload)
      end
    end
  end

  describe "installation_repositories_event/1 added" do
    @payload load_event_fixture("installation_repositories_added")

    test "syncs_correctly when adding" do
      %{"installation" => %{
        "id" => installation_id
        },
        "repositories_added" => added_repos,
        "sender" => %{"id" => _user_id}
      } = @payload

      project = insert(:project)
      user =  insert(:user)

      insert(:github_app_installation, github_id: installation_id, project: project, user: user)

      {:ok, _repos} = Sync.installation_repositories_event(@payload)

      repo_ids = Enum.map(added_repos, &Map.get(&1, "id"))

      for repo <- Repo.all(GithubRepo) do
        assert repo.github_id in repo_ids
      end

      assert Repo.aggregate(GithubRepo, :count, :id) == 2
      assert Repo.aggregate(GithubAppInstallation, :count, :id) == 1
    end

    test "can fail when installation not found" do
      assert {:error, :unmatched_installation} == @payload |> Sync.installation_repositories_event()
    end

    test "fails with validation errors when syncing repos" do
      %{"installation" => %{
        "id" => installation_id
        },
        "repositories_added" => repos,
        "sender" => %{"id" => _user_id}
      } = @payload

      project = insert(:project)
      user =  insert(:user)
      insert(:github_app_installation, github_id: installation_id, project: project, user: user)

      corrupt_repos = Enum.map(repos, &(Map.put(&1,"name", "")))

      corrupted_payload = Map.put(@payload, "repositories_added", corrupt_repos)

      assert {:error, :validation_error_on_syncing_repos, %{}} == corrupted_payload |> Sync.installation_repositories_event()
    end
  end

  describe "installation_repositories_event/1 removed" do
    @payload load_event_fixture("installation_repositories_removed")

    test "syncs_correctly when removing" do
      %{"installation" => %{
        "id" => installation_id
        },
        "repositories_removed" => removed_repos
        } = @payload

      project = insert(:project)
      user =  insert(:user)
      installation = insert(:github_app_installation, github_id: installation_id, project: project, user: user)

      for repo <- removed_repos do
        insert(:github_repo, github_id: repo["id"], github_app_installation: installation)
      end

      assert Repo.aggregate(GithubRepo, :count, :id) == 2
      assert Repo.aggregate(GithubAppInstallation, :count, :id) == 1

      {:ok, _repos} = Sync.installation_repositories_event(@payload)

      assert Repo.aggregate(GithubRepo, :count, :id) == 0
    end
  end

  describe "issue_comment_event/1 on comment created for pull request" do
    @issue_comment_preloads [
      :user,
      [task: :user],
      [github_comment: [github_issue: [:github_pull_request, :github_repo]]]
    ]

    @payload load_event_fixture("issue_comment_created_on_pull_request")

    test "syncs correctly" do
      %{
        "issue" => %{
          "body" => issue_body,
          "id" => issue_github_id,
          "number" => issue_number,
          "user" => %{
            "id" => issue_user_github_id
          }
        },
        "comment" => %{
          "body" => comment_body,
          "id" => comment_github_id,
          "user" => %{
            "id" => comment_user_github_id
          }
        },
        "repository" => %{
          "id" => repo_github_id
        }
      } = @payload

      project = insert(:project)
      github_repo = insert(:github_repo, github_id: repo_github_id, project: project)
      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)
      insert(:task_list, project: project, pull_requests: true)

      {:ok, comment} = Sync.issue_comment_event(@payload)

      assert Repo.aggregate(GithubComment, :count, :id) == 1
      assert Repo.aggregate(GithubIssue, :count, :id) == 1
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 1
      assert Repo.aggregate(Comment, :count, :id) == 1
      assert Repo.aggregate(Task, :count, :id) == 1

      issue_user = Repo.get_by(User, github_id: issue_user_github_id)
      assert issue_user

      comment_user = Repo.get_by(User, github_id: comment_user_github_id)
      assert comment_user

      %{
        github_comment: %{
          github_issue: %{
            github_pull_request: github_pull_request
          } = github_issue
        } = github_comment,
        task: task
      } = comment = comment |> Repo.preload(@issue_comment_preloads)

      assert github_comment.github_id == comment_github_id

      assert github_issue.github_id == issue_github_id
      assert github_issue.body == issue_body
      assert github_issue.number == issue_number

      assert github_pull_request.number == issue_number
      assert github_pull_request.github_repo_id == github_repo.id

      assert task.markdown == issue_body
      assert task.project_id == project.id
      assert task.user.github_id == issue_user_github_id
      assert task.user_id == issue_user.id

      assert comment.markdown == comment_body
      assert comment.user_id == comment_user.id
      assert comment.user.github_id == comment_user_github_id
    end

    test "can fail when finding repo" do
      assert {:error, :repo_not_found} == @payload |> Sync.issue_comment_event()
    end

    test "can fail when fetching pull request" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      with_mock_api(CodeCorps.GitHub.FailureAPI) do
        assert {:error, :fetching_pull_request, %CodeCorps.GitHub.APIError{}} =
          @payload |> Sync.issue_comment_event()
      end
    end

    test "can fail on github pull request validation" do
      defmodule InvalidPullRequestAPI do
        @moduledoc false

        def request(:get, "https://api.github.com/repos/baxterthehacker/public-repo/pulls/1", _, _, _) do
          {:ok, body} =
            "pull_request"
            |> load_endpoint_fixture()
            |> Map.put("number", nil)
            |> Poison.encode
          {:ok, %HTTPoison.Response{status_code: 200, body: body}}
        end
        def request(method, endpoint, body, headers, options) do
          CodeCorps.GitHub.SuccessAPI.request(method, endpoint, body, headers, options)
        end
      end

      insert(:github_repo, github_id: @payload["repository"]["id"])

      with_mock_api(InvalidPullRequestAPI) do
        assert {:error, :validating_github_pull_request, %Changeset{} = changeset} =
          @payload |> Sync.issue_comment_event()
          refute changeset.valid?
      end
    end

    test "can fail on github user validation for github pull request" do
      defmodule InvalidUserAPI do
        @moduledoc false

        def request(:get, "https://api.github.com/repos/baxterthehacker/public-repo/pulls/1", _, _, _) do
          {:ok, body} =
            "pull_request"
            |> load_endpoint_fixture()
            |> Kernel.put_in(["user", "login"], nil)
            |> Poison.encode
          {:ok, %HTTPoison.Response{status_code: 200, body: body}}
        end
        def request(method, endpoint, body, headers, options) do
          CodeCorps.GitHub.SuccessAPI.request(method, endpoint, body, headers, options)
        end
      end

      insert(:github_repo, github_id: @payload["repository"]["id"])

      with_mock_api(InvalidUserAPI) do
        assert {
          :error,
          :validating_github_user_on_github_pull_request,
          %Changeset{} = changeset
        } = @payload |> Sync.issue_comment_event()

        refute changeset.valid?
      end
    end

    test "can fail on github issue validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {:error, :validating_github_issue, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["issue", "number"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github user validation for github issue" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {
        :error,
        :validating_github_user_on_github_issue,
        %Changeset{} = changeset
      } =
        @payload
        |> Kernel.put_in(["issue", "user", "login"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on task user validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)
      payload = @payload |> Kernel.put_in(["issue", "user", "email"], email)

      assert {:error, :validating_task_user, %Changeset{} = changeset} =
        payload |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail if task matched with multiple users" do
      github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      attrs =
        @payload["issue"]
        |> Adapters.Issue.to_issue()
        |> Map.put(:github_repo, github_repo)

      github_issue = insert(:github_issue, attrs)
      # creates a user for each task, which should never happen normally
      insert_pair(:task, github_issue: github_issue)

      assert {:error, :multiple_task_users_match} ==
        @payload |> Sync.issue_comment_event()
    end

    test "can fail on task validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # validation is triggered due to missing task list

      assert {:error, :validating_task, %Changeset{} = changeset} =
        @payload |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github comment validation" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, pull_requests: true)

      assert {:error, :validating_github_comment, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["comment", "url"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github user validation for github comment" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, pull_requests: true)

      assert {
        :error,
        :validating_github_user_on_github_comment,
        %Changeset{} = changeset
      } =
        @payload
        |> Kernel.put_in(["comment", "user", "login"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on comment user validation" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, pull_requests: true)

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)

      assert {:error, :validating_comment_user, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["comment", "user", "email"], email)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail if commment matched with multiple users" do
      %{project: project} = github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, pull_requests: true)

      attrs =
        @payload["comment"]
        |> Adapters.Comment.to_github_comment()
        |> Map.put(:github_repo, github_repo)

      github_comment = insert(:github_comment, attrs)
      # creates a user for each comment, which should never happen normally
      insert_pair(:comment, github_comment: github_comment)

      assert {:error, :multiple_comment_users_match} ==
        @payload |> Sync.issue_comment_event()
    end
  end

  describe "issue_comment_event/1 on comment created for regular issue" do
    @payload load_event_fixture("issue_comment_created")

    test "syncs correctly" do
      %{
        "issue" => %{
          "body" => issue_body,
          "id" => issue_github_id,
          "number" => issue_number,
          "user" => %{
            "id" => issue_user_github_id
          }
        },
        "comment" => %{
          "body" => comment_body,
          "id" => comment_github_id,
          "user" => %{
            "id" => comment_user_github_id
          }
        },
        "repository" => %{
          "id" => repo_github_id
        }
      } = @payload

      project = insert(:project)
      insert(:github_repo, github_id: repo_github_id, project: project)
      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      {:ok, comment} = Sync.issue_comment_event(@payload)

      assert Repo.aggregate(GithubComment, :count, :id) == 1
      assert Repo.aggregate(GithubIssue, :count, :id) == 1
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 0
      assert Repo.aggregate(Comment, :count, :id) == 1
      assert Repo.aggregate(Task, :count, :id) == 1

      issue_user = Repo.get_by(User, github_id: issue_user_github_id)
      assert issue_user

      comment_user = Repo.get_by(User, github_id: comment_user_github_id)
      assert comment_user

      %{
        github_comment: %{
          github_issue: %{
            github_pull_request: github_pull_request
          } = github_issue
        } = github_comment,
        task: task
      } = comment = comment |> Repo.preload(@issue_comment_preloads)

      assert github_comment.github_id == comment_github_id

      assert github_issue.github_id == issue_github_id
      assert github_issue.body == issue_body
      assert github_issue.number == issue_number
      assert github_pull_request == nil

      assert task.markdown == issue_body
      assert task.project_id == project.id
      assert task.user.github_id == issue_user_github_id
      assert task.user_id == issue_user.id

      assert comment.markdown == comment_body
      assert comment.user_id == comment_user.id
      assert comment.user.github_id == comment_user_github_id
    end

    test "can fail when finding repo" do
      assert {:error, :repo_not_found} == @payload |> Sync.issue_comment_event()
    end

    test "can fail on github issue validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {:error, :validating_github_issue, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["issue", "number"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github user validation for github issue" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {
        :error,
        :validating_github_user_on_github_issue,
        %Changeset{} = changeset
      } =
        @payload
        |> Kernel.put_in(["issue", "user", "login"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on task user validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)
      payload = @payload |> Kernel.put_in(["issue", "user", "email"], email)

      assert {:error, :validating_task_user, %Changeset{} = changeset} =
        payload |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail if task matched with multiple users" do
      github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      attrs =
        @payload["issue"]
        |> Adapters.Issue.to_issue()
        |> Map.put(:github_repo, github_repo)

      github_issue = insert(:github_issue, attrs)
      # creates a user for each task, which should never happen normally
      insert_pair(:task, github_issue: github_issue)

      assert {:error, :multiple_task_users_match} ==
        @payload |> Sync.issue_comment_event()
    end

    test "can fail on task validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # validation is triggered due to missing task list

      assert {:error, :validating_task, %Changeset{} = changeset} =
        @payload |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github comment validation" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      assert {:error, :validating_github_comment, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["comment", "url"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on github user validation for github comment" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      assert {
        :error,
        :validating_github_user_on_github_comment,
        %Changeset{} = changeset
      } =
        @payload
        |> Kernel.put_in(["comment", "user", "login"], nil)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail on comment user validation" do
      %{project: project} =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)

      assert {:error, :validating_comment_user, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["comment", "user", "email"], email)
        |> Sync.issue_comment_event()

      refute changeset.valid?
    end

    test "can fail if commment matched with multiple users" do
      %{project: project} = github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)

      attrs =
        @payload["comment"]
        |> Adapters.Comment.to_github_comment()
        |> Map.put(:github_repo, github_repo)

      github_comment = insert(:github_comment, attrs)
      # creates a user for each comment, which should never happen normally
      insert_pair(:comment, github_comment: github_comment)

      assert {:error, :multiple_comment_users_match} ==
        @payload |> Sync.issue_comment_event()
    end
  end

  describe "issue_comment_event/1 on comment deleted" do
    test "syncs correctly" do
      %{"comment" => %{"id" => github_id}} = payload =
        load_event_fixture("issue_comment_deleted")

      github_comment = insert(:github_comment, github_id: github_id)
      comment = insert(:comment, github_comment: github_comment)

      {:ok, %{deleted_comments: [deleted_comment], deleted_github_comment: deleted_github_comment}}
        = payload |> Sync.issue_comment_event()

      assert deleted_comment.id == comment.id
      assert deleted_github_comment.id == github_comment.id
      assert Repo.aggregate(Comment, :count, :id) == 0
      assert Repo.aggregate(GithubComment, :count, :id) == 0
    end
  end

  describe "issue_event/1" do
    @payload load_event_fixture("issues_opened")

    test "with unmatched user, creates user, creates task for project associated to github repo" do
      %{
        "issue" => %{
          "body" => markdown, "title" => title, "number" => number,
          "user" => %{"id" => user_github_id}
        },
        "repository" => %{"id" => repo_github_id}
      } = @payload

      project = insert(:project)
      github_repo = insert(:github_repo, github_id: repo_github_id, project: project)
      insert(:task_list, project: project, inbox: true)

      {:ok, %Task{} = task} = @payload |> Sync.issue_event()
      assert Repo.aggregate(Task, :count, :id) == 1

      user = Repo.get_by(User, github_id: user_github_id)
      assert user

      task = task |> Repo.preload(:github_issue)

      assert task.user_id == user.id
      assert task.github_issue_id
      assert task.github_repo_id == github_repo.id
      assert task.project_id == project.id
      assert task.markdown == markdown
      assert task.title == title
      assert task.github_issue.number == number
      assert task.status == "open"
      assert task.order
    end

    test "with matched user, creates or updates task for project associated to github repo" do
      %{
        "issue" => %{
          "id" => issue_github_id,
          "body" => markdown,
          "title" => title,
          "number" => number,
          "user" => %{"id" => user_github_id}
        } ,
        "repository" => %{"id" => repo_github_id}
      } = @payload

      user = insert(:user, github_id: user_github_id)

      project = insert(:project)
      github_repo = insert(:github_repo, github_id: repo_github_id, project: project)
      github_issue = insert(:github_issue, github_id: issue_github_id, number: number, github_repo: github_repo)

      insert(:task_list, project: project, inbox: true)

      existing_task = insert(:task, project: project, user: user, github_repo: github_repo, github_issue: github_issue)

      {:ok, %Task{} = task} = @payload |> Sync.issue_event()

      assert Repo.aggregate(Task, :count, :id) == 1

      task = task |> Repo.preload(:github_issue)
      assert task.github_issue_id == github_issue.id
      assert task.github_repo_id == github_repo.id
      assert task.project_id == project.id
      assert task.markdown == markdown
      assert task.title == title
      assert task.github_issue.number == number
      assert task.status == "open"
      assert task.order

      assert existing_task.id == task.id
    end

    test "can fail when finding repo" do
      assert {:error, :repo_not_found} == @payload |> Sync.issue_event()
    end

    test "can fail on github issue validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {:error, :validating_github_issue, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["issue", "number"], nil)
        |> Sync.issue_event()

      refute changeset.valid?
    end

    test "can fail on github user validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])
      assert {:error, :validating_github_user, %Changeset{} = changeset} =
        @payload
        |> Kernel.put_in(["issue", "user", "login"], nil)
        |> Sync.issue_event()

      refute changeset.valid?
    end

    test "can fail on user validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # setup data to trigger a unique constraint
      email = "taken@mail.com"
      insert(:user, email: email)
      payload = @payload |> Kernel.put_in(["issue", "user", "email"], email)

      assert {:error, :validating_user, %Changeset{} = changeset} =
        payload |> Sync.issue_event()

      refute changeset.valid?
    end

    test "can fail if matched by multiple users" do
      github_repo =
        insert(:github_repo, github_id: @payload["repository"]["id"])

      attrs =
        @payload["issue"]
        |> Adapters.Issue.to_issue()
        |> Map.put(:github_repo, github_repo)

      github_issue = insert(:github_issue, attrs)
      # creates a user for each task, which should never happen normally
      insert_pair(:task, github_issue: github_issue)

      assert {:error, :multiple_task_users_match} ==
        @payload |> Sync.issue_event()
    end

    test "can fail on task validation" do
      insert(:github_repo, github_id: @payload["repository"]["id"])

      # validation is triggered due to missing task list

      assert {:error, :validating_task, %Changeset{} = changeset} =
        @payload |> Sync.issue_event()

      refute changeset.valid?
    end
  end

  describe "sync_repo/1" do
    defp setup_test_repo do
      project = insert(:project)
      insert(:task_list, project: project, done: true)
      insert(:task_list, project: project, inbox: true)
      insert(:task_list, project: project, pull_requests: true)

      owner = "baxterthehacker"
      repo = "public-repo"
      github_app_installation = insert(:github_app_installation, github_account_login: owner)

      insert(
        :github_repo,
        github_app_installation: github_app_installation,
        name: repo,
        github_account_id: 6_752_317,
        github_account_avatar_url: "https://avatars3.githubusercontent.com/u/6752317?v=4",
        github_account_type: "User",
        github_id: 35_129_377,
        project: project)
    end

    test "syncs and resyncs with the project repo" do
      github_repo = setup_test_repo()

      # Sync the first time

      Sync.sync_repo(github_repo)

      repo = Repo.one(GithubRepo)

      assert repo.syncing_pull_requests_count == 4
      assert repo.syncing_issues_count == 8
      assert repo.syncing_comments_count == 12

      assert Repo.aggregate(GithubComment, :count, :id) == 12
      assert Repo.aggregate(GithubIssue, :count, :id) == 8
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 4
      assert Repo.aggregate(GithubUser, :count, :id) == 10
      assert Repo.aggregate(Comment, :count, :id) == 12
      assert Repo.aggregate(Task, :count, :id) == 8
      assert Repo.aggregate(User, :count, :id) == 13

      # Sync a second time – should run without trouble

      Sync.sync_repo(github_repo)

      repo = Repo.one(GithubRepo)

      assert repo.syncing_pull_requests_count == 4
      assert repo.syncing_issues_count == 8
      assert repo.syncing_comments_count == 12

      assert Repo.aggregate(GithubComment, :count, :id) == 12
      assert Repo.aggregate(GithubIssue, :count, :id) == 8
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 4
      assert Repo.aggregate(GithubUser, :count, :id) == 10
      assert Repo.aggregate(Comment, :count, :id) == 12
      assert Repo.aggregate(Task, :count, :id) == 8
      assert Repo.aggregate(User, :count, :id) == 13
    end

    # coupled to fixtures. depends on
    # - fixtures/github/endpoints/issues.json on having at least 4 issues
    #   linked to pull requests
    # - fixtures/github/endpoints/pulls.json having payloads for those 4 pull
    #   requests (matched by "number")
    test "matches github issue with github pull request correctly" do
      {:ok, github_repo} = setup_test_repo() |> Sync.sync_repo

      %GithubRepo{github_issues: github_issues} =
        GithubRepo |> Repo.get(github_repo.id) |> Repo.preload(:github_issues)

      linked_issues =
        github_issues
        |> Enum.reject(fn i -> is_nil(i.github_pull_request_id) end)

      assert linked_issues |> Enum.count == 4
    end

    @tag acceptance: true
    test "syncs with the project repo with the real API" do
      github_repo = setup_coderly_repo()

      with_real_api do
        Sync.sync_repo(github_repo)
      end

      repo = Repo.one(GithubRepo)

      assert repo.syncing_pull_requests_count == 1
      assert repo.syncing_issues_count == 3
      assert repo.syncing_comments_count == 2

      assert Repo.aggregate(GithubComment, :count, :id) == 2
      assert Repo.aggregate(GithubIssue, :count, :id) == 3
      assert Repo.aggregate(GithubPullRequest, :count, :id) == 1
      assert Repo.aggregate(GithubUser, :count, :id) == 2
      assert Repo.aggregate(Comment, :count, :id) == 2
      assert Repo.aggregate(Task, :count, :id) == 3
      assert Repo.aggregate(User, :count, :id) == 2

      # Tasks closed more than 30 days ago
      archived_tasks =
        Task
        |> where([object], is_nil(object.task_list_id))
        |> Repo.all()

      %TaskList{tasks: inbox_tasks} =
        TaskList |> Repo.get_by(inbox: true) |> Repo.preload(:tasks)
      %TaskList{tasks: pull_requests_tasks} =
        TaskList |> Repo.get_by(pull_requests: true) |> Repo.preload(:tasks)

      assert Enum.count(archived_tasks) == 1
      assert Enum.count(inbox_tasks) == 1
      assert Enum.count(pull_requests_tasks) == 1
    end
  end
end