Skip to content

Commit 444b587

Browse files
committed
test: add unit tests for TypeMapper, ChangesetExtractor, and has_one through override
Cover all TypeMapper branches: association types, embed types, parameterized type variants, enum value extraction paths, custom type override, and edge cases. Add ChangesetExtractor unit tests including error path. Add has_one through with type override test. Brings coverage from 86.13% to 91.18%.
1 parent 1fecfbb commit 444b587

3 files changed

Lines changed: 353 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
defmodule EctoTypedSchema.ChangesetExtractorTest do
2+
use ExUnit.Case, async: true
3+
alias EctoTypedSchema.ChangesetExtractor
4+
5+
describe "extract/1" do
6+
test "extracts primitive field types" do
7+
body = [do: {:%{}, [], [name: :string, age: :integer]}]
8+
assert ChangesetExtractor.extract(body) == [name: :string, age: :integer]
9+
end
10+
11+
test "extracts parameterized types" do
12+
body = [do: {:%{}, [], [status: {:parameterized, {Ecto.Enum, %{values: [:a]}}}]}]
13+
14+
assert [{:status, {:parameterized, {Ecto.Enum, %{values: [:a]}}}}] =
15+
ChangesetExtractor.extract(body)
16+
end
17+
18+
test "extracts association types" do
19+
assoc_args = [
20+
field: :user,
21+
owner: MyApp.Post,
22+
related: MyApp.User,
23+
cardinality: :one
24+
]
25+
26+
body = [do: {:%{}, [], [user: {:assoc, {:%{}, [], assoc_args}}]}]
27+
[{:user, {:assoc, result}}] = ChangesetExtractor.extract(body)
28+
assert is_map(result)
29+
assert result.related == MyApp.User
30+
end
31+
32+
test "extracts embed types" do
33+
embed_args = [related: MyApp.Address, cardinality: :one]
34+
body = [do: {:%{}, [], [address: {:embed, {:%{}, [], embed_args}}]}]
35+
[{:address, {:embed, result}}] = ChangesetExtractor.extract(body)
36+
assert is_map(result)
37+
assert result.related == MyApp.Address
38+
end
39+
40+
test "raises on unexpected body shape" do
41+
assert_raise ArgumentError, ~r/unexpected __changeset__\/0 body shape/, fn ->
42+
ChangesetExtractor.extract(do: :unexpected)
43+
end
44+
end
45+
end
46+
end

test/ecto_typed_schema/type_mapper_test.exs

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,276 @@ defmodule EctoTypedSchema.TypeMapperTest do
105105
end
106106
end
107107

108+
describe "to_elixir_type/2 - custom type override" do
109+
test "returns custom type when :type option is provided" do
110+
custom = quote(do: MyApp.Status.t())
111+
assert TypeMapper.to_elixir_type(:string, type: custom) == custom
112+
end
113+
end
114+
115+
describe "to_elixir_type/1 - :any type" do
116+
test "maps :any to term()" do
117+
assert infer_type_string(:any) == "term()"
118+
end
119+
end
120+
121+
describe "to_elixir_type/1 - bare :array type" do
122+
test "maps :array to list()" do
123+
assert infer_type_string(:array) == "list()"
124+
end
125+
end
126+
127+
describe "to_elixir_type/1 - association types" do
128+
test "belongs_to" do
129+
assoc = %Ecto.Association.BelongsTo{
130+
field: :user,
131+
owner: MyApp.Post,
132+
related: MyApp.User,
133+
owner_key: :user_id,
134+
related_key: :id,
135+
queryable: MyApp.User,
136+
cardinality: :one,
137+
relationship: :parent,
138+
on_replace: :raise,
139+
defaults: [],
140+
on_cast: nil,
141+
where: [],
142+
unique: true,
143+
ordered: false
144+
}
145+
146+
assert infer_type_string({:assoc, assoc}) ==
147+
"Ecto.Schema.belongs_to(MyApp.User.t())"
148+
end
149+
150+
test "has_one" do
151+
assoc = %Ecto.Association.Has{
152+
field: :profile,
153+
owner: MyApp.User,
154+
related: MyApp.Profile,
155+
owner_key: :id,
156+
related_key: :user_id,
157+
queryable: MyApp.Profile,
158+
cardinality: :one,
159+
relationship: :child,
160+
on_delete: :nothing,
161+
on_replace: :raise,
162+
defaults: [],
163+
where: [],
164+
unique: true,
165+
ordered: false,
166+
preload_order: []
167+
}
168+
169+
assert infer_type_string({:assoc, assoc}) ==
170+
"Ecto.Schema.has_one(MyApp.Profile.t())"
171+
end
172+
173+
test "has_many" do
174+
assoc = %Ecto.Association.Has{
175+
field: :posts,
176+
owner: MyApp.User,
177+
related: MyApp.Post,
178+
owner_key: :id,
179+
related_key: :user_id,
180+
queryable: MyApp.Post,
181+
cardinality: :many,
182+
relationship: :child,
183+
on_delete: :nothing,
184+
on_replace: :raise,
185+
defaults: [],
186+
where: [],
187+
unique: true,
188+
ordered: false,
189+
preload_order: []
190+
}
191+
192+
assert infer_type_string({:assoc, assoc}) ==
193+
"Ecto.Schema.has_many(MyApp.Post.t())"
194+
end
195+
196+
test "many_to_many" do
197+
assoc = %Ecto.Association.ManyToMany{
198+
field: :tags,
199+
owner: MyApp.Post,
200+
related: MyApp.Tag,
201+
owner_key: :id,
202+
queryable: MyApp.Tag,
203+
cardinality: :many,
204+
relationship: :child,
205+
on_delete: :nothing,
206+
on_replace: :raise,
207+
defaults: [],
208+
join_keys: [{:post_id, :id}, {:tag_id, :id}],
209+
join_through: "posts_tags",
210+
join_where: [],
211+
join_defaults: [],
212+
where: [],
213+
unique: false,
214+
ordered: false,
215+
preload_order: []
216+
}
217+
218+
assert infer_type_string({:assoc, assoc}) ==
219+
"Ecto.Schema.many_to_many(MyApp.Tag.t())"
220+
end
221+
end
222+
223+
describe "to_elixir_type/1 - embed types" do
224+
test "embeds_one" do
225+
embed = %{related: MyApp.Address, cardinality: :one}
226+
227+
assert infer_type_string({:embed, embed}) ==
228+
"Ecto.Schema.embeds_one(MyApp.Address.t())"
229+
end
230+
231+
test "embeds_many" do
232+
embed = %{related: MyApp.Address, cardinality: :many}
233+
234+
assert infer_type_string({:embed, embed}) ==
235+
"Ecto.Schema.embeds_many(MyApp.Address.t())"
236+
end
237+
end
238+
239+
describe "to_elixir_type/1 - parameterized types" do
240+
test "{:parameterized, {Ecto.Enum, params}} with enum_values typed opt" do
241+
params = %{values: [:active, :inactive]}
242+
243+
result =
244+
{:parameterized, {Ecto.Enum, params}}
245+
|> TypeMapper.to_elixir_type(enum_values: [:on, :off])
246+
|> Macro.to_string()
247+
248+
assert result == ":on | :off"
249+
end
250+
251+
test "{:parameterized, Ecto.Enum, params} 3-tuple form" do
252+
params = %{values: [:active, :inactive]}
253+
254+
result =
255+
{:parameterized, Ecto.Enum, params}
256+
|> TypeMapper.to_elixir_type([])
257+
|> Macro.to_string()
258+
259+
assert result == ":active | :inactive"
260+
end
261+
262+
test "{:parameterized, Ecto.Enum} bare form falls back to atom()" do
263+
result =
264+
{:parameterized, Ecto.Enum}
265+
|> TypeMapper.to_elixir_type([])
266+
|> Macro.to_string()
267+
268+
assert result == "atom()"
269+
end
270+
271+
test "{:parameterized, {CustomModule, _}} non-Enum" do
272+
result =
273+
{:parameterized, {MyApp.Money, %{}}}
274+
|> TypeMapper.to_elixir_type([])
275+
|> Macro.to_string()
276+
277+
assert result == "MyApp.Money.t()"
278+
end
279+
280+
test "{:parameterized, CustomModule} bare module" do
281+
result =
282+
{:parameterized, MyApp.Money}
283+
|> TypeMapper.to_elixir_type([])
284+
|> Macro.to_string()
285+
286+
assert result == "MyApp.Money.t()"
287+
end
288+
289+
test "{:parameterized, CustomModule, _} 3-tuple non-Enum" do
290+
result =
291+
{:parameterized, MyApp.Money, %{}}
292+
|> TypeMapper.to_elixir_type([])
293+
|> Macro.to_string()
294+
295+
assert result == "MyApp.Money.t()"
296+
end
297+
end
298+
299+
describe "to_elixir_type/1 - enum value extraction" do
300+
test "extracts values from %{values: [...]}" do
301+
params = %{values: [:draft, :published]}
302+
303+
result =
304+
{:parameterized, {Ecto.Enum, params}}
305+
|> TypeMapper.to_elixir_type([])
306+
|> Macro.to_string()
307+
308+
assert result == ":draft | :published"
309+
end
310+
311+
test "extracts keys from %{mappings: [...]}" do
312+
params = %{mappings: [admin: "ADMIN", user: "USER"]}
313+
314+
result =
315+
{:parameterized, {Ecto.Enum, params}}
316+
|> TypeMapper.to_elixir_type([])
317+
|> Macro.to_string()
318+
319+
assert result == ":admin | :user"
320+
end
321+
322+
test "falls back to atom() when params have no values or mappings" do
323+
params = %{something_else: true}
324+
325+
result =
326+
{:parameterized, {Ecto.Enum, params}}
327+
|> TypeMapper.to_elixir_type([])
328+
|> Macro.to_string()
329+
330+
assert result == "atom()"
331+
end
332+
333+
test "single enum value" do
334+
params = %{values: [:only_one]}
335+
336+
result =
337+
{:parameterized, {Ecto.Enum, params}}
338+
|> TypeMapper.to_elixir_type([])
339+
|> Macro.to_string()
340+
341+
assert result == ":only_one"
342+
end
343+
344+
test "extracts values from AST map with :values key" do
345+
params = {:%{}, [], [values: [:a, :b]]}
346+
347+
result =
348+
{:parameterized, {Ecto.Enum, params}}
349+
|> TypeMapper.to_elixir_type([])
350+
|> Macro.to_string()
351+
352+
assert result == ":a | :b"
353+
end
354+
355+
test "extracts keys from AST map with :mappings key" do
356+
params = {:%{}, [], [mappings: [x: "X", y: "Y"]]}
357+
358+
result =
359+
{:parameterized, {Ecto.Enum, params}}
360+
|> TypeMapper.to_elixir_type([])
361+
|> Macro.to_string()
362+
363+
assert result == ":x | :y"
364+
end
365+
366+
test "AST map with neither values nor mappings falls back to atom()" do
367+
params = {:%{}, [], [other: true]}
368+
369+
result =
370+
{:parameterized, {Ecto.Enum, params}}
371+
|> TypeMapper.to_elixir_type([])
372+
|> Macro.to_string()
373+
374+
assert result == "atom()"
375+
end
376+
end
377+
108378
defp infer_type_string(ecto_type) do
109379
ecto_type
110380
|> TypeMapper.to_elixir_type()

test/ecto_typed_schema/types/has_one_test.exs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,43 @@ defmodule EctoTypedSchema.Types.HasOneTest do
280280
end
281281
end
282282

283+
describe "through association with type override" do
284+
test "uses custom type for has_one through", ctx do
285+
expected_types =
286+
with_tmpmodule Schema, ctx do
287+
use Ecto.Schema
288+
289+
schema "users" do
290+
has_one :account, Account, foreign_key: :user_id
291+
has_one :account_profile, through: [:account, :profile]
292+
end
293+
294+
@type t() :: %__MODULE__{
295+
__meta__: Ecto.Schema.Metadata.t(__MODULE__),
296+
id: integer(),
297+
account: Ecto.Schema.has_one(Account.t()) | nil,
298+
account_profile: Profile.t() | nil
299+
}
300+
after
301+
fetch_types!(Schema)
302+
end
303+
304+
generated_types =
305+
with_tmpmodule Schema, ctx do
306+
use EctoTypedSchema
307+
308+
typed_schema "users" do
309+
has_one :account, Account, foreign_key: :user_id
310+
has_one :account_profile, through: [:account, :profile], typed: [type: Profile.t()]
311+
end
312+
after
313+
fetch_types!(Schema)
314+
end
315+
316+
assert_type(expected_types, generated_types)
317+
end
318+
end
319+
283320
describe "through association fallback warning" do
284321
test "emits warning when through chain cannot be resolved", ctx do
285322
warnings =

0 commit comments

Comments
 (0)