api-surface #1
2 changed files with 141 additions and 0 deletions
feat(cache): fill/1 hydration for envelope structs
commit
c3c483017d
|
|
@ -566,6 +566,44 @@ defmodule Dexcord.Cache do
|
|||
@spec voice_states(id()) :: [entity()]
|
||||
def voice_states(guild_id), do: prefix_values(@voice_states, guild_id)
|
||||
|
||||
# --- hydration ----------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Best-effort hydration of an envelope struct's declared `hydrate` slots from
|
||||
the cache. Each slot is filled from ETS by the id in its `from` field; a cache
|
||||
miss (or a nil source id) leaves that slot `nil`. A struct with no `hydrate`
|
||||
declarations is returned unchanged.
|
||||
|
||||
This function reads ETS **only** - it never issues HTTP and never blocks on the
|
||||
network, so it is safe to call from any process on the hot path
|
||||
(api-surface.AC3.8). It is meant to be called by **user handler code** when a
|
||||
handler wants the related objects inline; the `Dexcord.Dispatcher` never calls
|
||||
it (hydration is opt-in, not a cost paid on every event).
|
||||
|
||||
Already-populated slots are left alone, so `fill/1` is idempotent.
|
||||
"""
|
||||
@spec fill(struct()) :: struct()
|
||||
def fill(%module{} = event) do
|
||||
if function_exported?(module, :__hydrations__, 0) do
|
||||
Enum.reduce(module.__hydrations__(), event, fn h, acc ->
|
||||
with nil <- Map.fetch!(acc, h.name),
|
||||
id when not is_nil(id) <- Map.fetch!(acc, h.from),
|
||||
{:ok, value} <- fill_lookup(h.type, id) do
|
||||
Map.put(acc, h.name, value)
|
||||
else
|
||||
_ -> acc
|
||||
end
|
||||
end)
|
||||
else
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
defp fill_lookup(Dexcord.User, id), do: user(id)
|
||||
defp fill_lookup(:channel, id), do: channel(id)
|
||||
defp fill_lookup(Dexcord.Guild, id), do: guild(id)
|
||||
defp fill_lookup(_type, _id), do: :error
|
||||
|
||||
# --- read helpers -------------------------------------------------------
|
||||
|
||||
defp cache_presences?(config), do: Map.get(config, :cache_presences, false)
|
||||
|
|
|
|||
103
test/dexcord/cache_fill_test.exs
Normal file
103
test/dexcord/cache_fill_test.exs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
defmodule Dexcord.CacheFillTest do
|
||||
@moduledoc """
|
||||
Unit tests for `Dexcord.Cache.fill/1` (api-surface.AC3.8): best-effort
|
||||
hydration of an envelope's declared `hydrate` slots from ETS only.
|
||||
|
||||
These tests start ONLY `Dexcord.Cache` - no FakeRest, no Finch pool. `fill/1`
|
||||
reads ETS and nothing else, so any accidental HTTP round-trip would crash on
|
||||
the missing connection pool; the suite passing is the proof that `fill/1`
|
||||
never touches the network.
|
||||
"""
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Dexcord.Cache
|
||||
alias Dexcord.Events
|
||||
|
||||
setup do
|
||||
Dexcord.EnvSandbox.sandbox_env()
|
||||
start_supervised!(Dexcord.Cache)
|
||||
:ok
|
||||
end
|
||||
|
||||
# Seed the cache exactly as the dispatcher would: decode once, hand the cache
|
||||
# both the decoded struct and the raw partial map.
|
||||
defp feed(name, raw) do
|
||||
Cache.handle_dispatch(name, Events.decode(name, raw), raw, %{cache_presences: false})
|
||||
end
|
||||
|
||||
# The golden guild's own ids (see test/fixtures/guild_create.json).
|
||||
@guild_id 900_000_000_000_000_000
|
||||
# channel 100000000000000000 is a type-0 text channel.
|
||||
@channel_id 100_000_000_000_000_000
|
||||
# member/user 800000000000000000 is Nelly.
|
||||
@user_id 800_000_000_000_000_000
|
||||
|
||||
defp reaction(overrides) do
|
||||
raw =
|
||||
Map.merge(
|
||||
%{
|
||||
"user_id" => to_string(@user_id),
|
||||
"channel_id" => to_string(@channel_id),
|
||||
"message_id" => "100000000000000099",
|
||||
"guild_id" => to_string(@guild_id),
|
||||
"emoji" => %{"id" => nil, "name" => "👍"}
|
||||
},
|
||||
overrides
|
||||
)
|
||||
|
||||
Events.decode(:MESSAGE_REACTION_ADD, raw)
|
||||
end
|
||||
|
||||
describe "fill/1 hydration (AC3.8)" do
|
||||
test "populates every declared slot from the cache on a full hit" do
|
||||
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
|
||||
|
||||
event = reaction(%{})
|
||||
# After decode the hydrate slots are always nil.
|
||||
assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = event
|
||||
|
||||
filled = Cache.fill(event)
|
||||
|
||||
assert %Dexcord.Events.ReactionAdd{
|
||||
user: %Dexcord.User{id: @user_id},
|
||||
channel: %Dexcord.TextChannel{id: @channel_id},
|
||||
guild: %Dexcord.Guild{id: @guild_id}
|
||||
} = filled
|
||||
end
|
||||
|
||||
test "a cache miss leaves that slot nil while the others still fill" do
|
||||
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
|
||||
|
||||
# Unknown user id: not in the cache.
|
||||
event = reaction(%{"user_id" => "111111111111111111"})
|
||||
|
||||
filled = Cache.fill(event)
|
||||
|
||||
assert filled.user == nil
|
||||
assert %Dexcord.TextChannel{id: @channel_id} = filled.channel
|
||||
assert %Dexcord.Guild{id: @guild_id} = filled.guild
|
||||
end
|
||||
|
||||
test "an unseeded cache leaves every slot nil (never blocks on the network)" do
|
||||
# No GUILD_CREATE seeded and, deliberately, no FakeRest/Finch started: if
|
||||
# fill/1 ever reached for HTTP it would crash here instead of returning nils.
|
||||
filled = Cache.fill(reaction(%{}))
|
||||
|
||||
assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = filled
|
||||
end
|
||||
|
||||
test "fill/1 is idempotent" do
|
||||
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
|
||||
|
||||
event = reaction(%{})
|
||||
once = Cache.fill(event)
|
||||
|
||||
assert Cache.fill(once) == once
|
||||
end
|
||||
|
||||
test "a struct with no hydration slots passes through unchanged" do
|
||||
user = %Dexcord.User{id: @user_id, username: "nelly"}
|
||||
assert Cache.fill(user) == user
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue