">
55 | <%= label f, :password, class: "control-label" %>
56 | <%= text_input f, :password, class: "form-control" %>
57 | <%= error_tag f, :password %>
58 | ```
59 | 这样我们的错误提示界面就会变成:
60 |
61 | 
62 |
63 | 非常醒目。至于 Phoenix 生成的模板里为什么不带 `has-error`,可以看 [github 上的一个 issue](https://github.com/phoenixframework/phoenix/issues/1961)。
64 |
65 | 第 2 个问题就容易解决了,我们来看现有代码:
66 |
67 | ```eex
68 |
">
69 | <%= label f, :password, class: "control-label" %>
70 | <%= text_input f, :password, class: "form-control" %>
71 | <%= error_tag f, :password %>
72 |
73 | ```
74 | 生成的模板里现在用了 `text_input`,它本来就是明文显示的,改为 [`password_input`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#password_input/3) 后,界面上就会用 * 号代替我们的输入。
75 |
76 | 这样,我们结束了用户注册模块。接下来,我们将开始开发用户的登录/退出功能。
77 |
78 | 但是,且慢,还有一个差点被我们遗忘的。
79 |
80 | ## 控制器的测试
81 |
82 | 你可能对 `mix test test/models/user_test.exs` 命令已经烂熟于心。但 `mix test test/controllers/user_controller_test.exs` 呢?
83 |
84 | 我们在生成用户的样板文件时,曾经生成过一个 `user_controller_test.exs` 文件,让我们运行下 `mix test test/controllers/user_controller_test.exs` 看看结果:
85 |
86 | ```bash
87 | $ mix test test/controllers/user_controller_test.exs
88 | Compiling 1 file (.ex)
89 | ....
90 |
91 | 1) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest)
92 | test/controllers/user_controller_test.exs:47
93 | ** (RuntimeError) expected redirection with status 302, got: 200
94 | stacktrace:
95 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2
96 | test/controllers/user_controller_test.exs:50: (test)
97 |
98 | ....
99 |
100 | 2) test creates resource and redirects when data is valid (TvRecipe.UserControllerTest)
101 | test/controllers/user_controller_test.exs:18
102 | ** (RuntimeError) expected redirection with status 302, got: 200
103 | stacktrace:
104 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2
105 | test/controllers/user_controller_test.exs:20: (test)
106 |
107 |
108 |
109 | Finished in 0.3 seconds
110 | 10 tests, 2 failures
111 | ```
112 | 好消息是,10 个测试,有 8 个通过;坏消息是有 2 个未通过。
113 |
114 | 显然,从模板文件到现在,我们的代码已经变化,现在测试文件一样需要根据实际情况做调整:
115 |
116 | ```elixir
117 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
118 | index 2e08483..95d3108 100644
119 | --- a/test/controllers/user_controller_test.exs
120 | +++ b/test/controllers/user_controller_test.exs
121 | @@ -2,7 +2,7 @@ defmodule TvRecipe.UserControllerTest do
122 | use TvRecipe.ConnCase
123 |
124 | alias TvRecipe.User
125 | - @valid_attrs %{email: "some content", password: "some content", username: "some content"}
126 | + @valid_attrs %{email: "chenxsan@gmail.com", password: "some content", username: "chenxsan"}
127 | @invalid_attrs %{}
128 |
129 | test "lists all entries on index", %{conn: conn} do
130 | @@ -18,7 +18,7 @@ defmodule TvRecipe.UserControllerTest do
131 | test "creates resource and redirects when data is valid", %{conn: conn} do
132 | conn = post conn, user_path(conn, :create), user: @valid_attrs
133 | assert redirected_to(conn) == user_path(conn, :index)
134 | - assert Repo.get_by(User, @valid_attrs)
135 | + assert Repo.get_by(User, @valid_attrs |> Map.delete(:password))
136 | end
137 |
138 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do
139 | @@ -48,7 +48,7 @@ defmodule TvRecipe.UserControllerTest do
140 | user = Repo.insert! %User{}
141 | conn = put conn, user_path(conn, :update, user), user: @valid_attrs
142 | assert redirected_to(conn) == user_path(conn, :show, user)
143 | - assert Repo.get_by(User, @valid_attrs)
144 | + assert Repo.get_by(User, @valid_attrs |> Map.delete(:password))
145 | end
146 | ```
147 | 我们在代码中做了三处修改,一个是订正 `@valid_attrs`,另外两个是修改 `Repo.get`,因为我们的 `User` 不再有 `password` 字段,所以应该从 `@valid_attrs` 中移除它,否则就会报错。
148 |
149 | 再运行测试,全部通过。
150 |
151 | 至此,我们完成所有用户注册相关的开发,下一章,开始进入[用户登录环节](../05-session/01-login.md)。
--------------------------------------------------------------------------------
/05-session/01-login.md:
--------------------------------------------------------------------------------
1 | # 登录
2 |
3 | 这一次,我们没有 `mix phoenix.gen.html` 可以用,所以要一步一步写了。
4 |
5 | 它的过程,跟[添加帮助文件一章](../02-explore-phoenix.md)一样。
6 |
7 | 但这里,我们要从测试写起,运行它,看着它抛出错误,之后才填补代码,保证每个测试通过。
8 |
9 | Don't panic,错误是指引我们成功的路灯。
10 |
11 | ## 添加路由
12 |
13 | 首先在 `test/controllers` 目录下新建一个 `session_controller_test.exs` 文件:
14 |
15 | ```elixir
16 | defmodule TvRecipe.SessionControllerTest do
17 | use TvRecipe.ConnCase
18 | end
19 | ```
20 |
21 | 我们希望在用户访问 `/sessions/new` 网址时,返回一个登录页面。虽然目前我们还不清楚 Phoenix 下的测试代码究竟是什么原理,但没关系,我们可以参考 `user_controller_test.exs` 测试文件照猫画虎:
22 |
23 | ```elixir
24 | test "renders form for new sessions", %{conn: conn} do
25 | conn = get conn, session_path(conn, :new)
26 | # 200 响应,页面上带有“登录”
27 | assert html_response(conn, 200) =~ "登录"
28 | end
29 | ```
30 | 运行测试,结果如下:
31 |
32 | ```bash
33 | $ mix test test/controllers/session_controller_test.exs
34 | ** (CompileError) test/controllers/session_controller_test.exs:5: undefined function session_path/2
35 | (stdlib) lists.erl:1338: :lists.foreach/2
36 | (stdlib) erl_eval.erl:670: :erl_eval.do_apply/6
37 | (elixir) lib/code.ex:370: Code.require_file/2
38 | (elixir) lib/kernel/parallel_require.ex:57: anonymous fn/2 in Kernel.ParallelRequire.spawn_requires/5
39 | ```
40 |
41 | `session_path` 函数未定义。要怎么定义,在哪定义?
42 |
43 | 实际上,在前面的章节里,我们已经遭遇过 `user_path`,但还没有解释过它从哪里来。
44 |
45 | 我们来看 [Phoenix.Router](https://hexdocs.pm/phoenix/Phoenix.Router.html) 的文档,其中 **Helpers** 一节有说明如下:
46 |
47 | > Phoenix automatically generates a module Helpers inside your router which contains named helpers to help developers generate and keep their routes up to date.
48 |
49 | > Helpers are automatically generated based on the controller name.
50 |
51 | 我们在 `router.ex` 文件中定义 `TvRecipe.Router` 模块,而 Phoenix 会在该模块下生成一个 `TvRecipe.Router.Helpers` 模块,用于管理我们的路由。`Helpers` 下的内容,基于控制器的名称生成。
52 |
53 | 比如我们有一个路由:
54 |
55 | ```elixir
56 | get "/", PageController, :index
57 | ```
58 | 则 Phoenix 会自动生成 `TvRecipe.Router.Helpers.page_path`。
59 |
60 | 那么,前面章节里 `user_path` 出现时,是在控制器与模板中,并且是光秃秃的 `user_path`,而不是 `TvRecipe.Router.Helpers.user_path` 这样冗长写法,它们究竟是怎样引用的?
61 |
62 | 我们回头去看控制器的代码,会在开头处看到这么一行:
63 |
64 | ```elixir
65 | use TvRecipe.Web, :controller
66 | ```
67 |
68 | 而 `TvRecipe.Web` 是定义在 `web/web.ex` 文件,其中会有这样的内容:
69 |
70 | ```elixir
71 | def controller do
72 | quote do
73 | use Phoenix.Controller
74 |
75 | alias TvRecipe.Repo
76 | import Ecto
77 | import Ecto.Query
78 |
79 | import TvRecipe.Router.Helpers
80 | import TvRecipe.Gettext
81 | end
82 | end
83 | ```
84 | 我们看到了 `import TvRecipe.Router.Helpers` 一行,这正是我们在控制器中可以直接使用 `user_path` 等函数的原因 - `use TvRecipe.Web, :controller` 做了准备工作。
85 |
86 | 现在,我们知道要怎么定义 `session_path` 了。
87 |
88 | 打开 `router.ex` 文件,添加一个新路由:
89 |
90 | ```elixir
91 | diff --git a/web/router.ex b/web/router.ex
92 | index 4ddc1cc..aac327c 100644
93 | --- a/web/router.ex
94 | +++ b/web/router.ex
95 | @@ -18,6 +18,7 @@ defmodule TvRecipe.Router do
96 |
97 | get "/", PageController, :index
98 | resources "/users", UserController
99 | + get "/sessions/new", SessionController, :new
100 | end
101 | ```
102 | 运行测试:
103 |
104 | ```bash
105 | mix test test/controllers/session_controller_test.exs
106 | Compiling 8 files (.ex)
107 |
108 |
109 | 1) test renders form for new sessions (TvRecipe.SessionControllerTest)
110 | test/controllers/session_controller_test.exs:4
111 | ** (UndefinedFunctionError) function TvRecipe.SessionController.init/1 is undefined (module TvRecipe.SessionController
112 | is not available)
113 | stacktrace:
114 | TvRecipe.SessionController.init(:new)
115 | (tv_recipe) web/router.ex:1: anonymous fn/1 in TvRecipe.Router.match_route/4
116 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
117 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2
118 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
119 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
120 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
121 | test/controllers/session_controller_test.exs:5: (test)
122 |
123 |
124 |
125 | Finished in 0.08 seconds
126 | 1 test, 1 failure
127 | ```
128 |
129 | `SessionController` 未定义。
130 |
131 | ## 创建 `SessionController` 模块
132 |
133 | 在 `web/controllers` 目录下新建一个 `session_controller.ex` 文件,内容如下:
134 |
135 | ```elixir
136 | defmodule TvRecipe.SessionController do
137 | use TvRecipe.Web, :controller
138 |
139 | def new(conn, _params) do
140 | render conn, "new.html"
141 | end
142 | end
143 | ```
144 | 你可能在想,`_params` 是什么意思。在 Elixir 下,如果一个参数没被用到,编译时就会有提示,我们给这个未用到的参数加个 `_` 前缀,就能消除编译时的提示。
145 |
146 | 现在运行测试:
147 |
148 | ```bash
149 | mix test test/controllers/session_controller_test.exs
150 | Compiling 1 file (.ex)
151 | Generated tv_recipe app
152 |
153 |
154 | 1) test renders form for new sessions (TvRecipe.SessionControllerTest)
155 | test/controllers/session_controller_test.exs:4
156 | ** (UndefinedFunctionError) function TvRecipe.SessionView.render/2 is undefined (module TvRecipe.SessionView is not ava
157 | ilable)
158 | stacktrace:
159 | TvRecipe.SessionView.render("new.html", %{conn: %Plug.Conn{adapter: {Plug.Adapters.Test.Conn, :...}, assigns: %{layou
160 | t: {TvRecipe.LayoutView, "app.html"}}, before_send: [#Function<0.101282891/1 in Plug.CSRFProtection.call/2>, #Function<4.111
161 | 648917/1 in Phoenix.Controller.fetch_flash/2>, #Function<0.61377594/1 in Plug.Session.before_send/2>, #Function<1.115972179/
162 | 1 in Plug.Logger.call/2>], body_params: %{}, cookies: %{}, halted: false, host: "www.example.com", method: "GET", owner: #PI
163 | D<0.302.0>, params: %{}, path_info: ["sessions", "new"], path_params: %{}, peer: {{127, 0, 0, 1}, 111317}, port: 80, private
164 | : %{TvRecipe.Router => {[], %{}}, :phoenix_action => :new, :phoenix_controller => TvRecipe.SessionController, :phoenix_endpo
165 | int => TvRecipe.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_layout => {TvRecipe.LayoutView, :app},
166 | :phoenix_pipelines => [:browser], :phoenix_recycled => true, :phoenix_route => #Function<12.75217690/1 in TvRecipe.Router.ma
167 | tch_route/4>, :phoenix_router => TvRecipe.Router, :phoenix_template => "new.html", :phoenix_view => TvRecipe.SessionView, :p
168 | lug_session => %{}, :plug_session_fetch => :done, :plug_skip_csrf_protection => true}, query_params: %{}, query_string: "",
169 | remote_ip: {127, 0, 0, 1}, req_cookies: %{}, req_headers: [], request_path: "/sessions/new", resp_body: nil, resp_cookies: %
170 | {}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "eedn739jkdct1hr8r3nod6nst95b2
171 | qvu"}, {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}], sch
172 | eme: :http, script_name: [], secret_key_base: "XfacEiZ/QVO87L4qirM0thXcedgcx5zYhLPAsmVPnL8AVu6qB/Et84yvJ6712aSn", state: :un
173 | set, status: nil}, view_module: TvRecipe.SessionView, view_template: "new.html"})
174 | (tv_recipe) web/templates/layout/app.html.eex:29: TvRecipe.LayoutView."app.html"/1
175 | (phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3
176 | (phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4
177 | (tv_recipe) web/controllers/session_controller.ex:1: TvRecipe.SessionController.action/2
178 | (tv_recipe) web/controllers/session_controller.ex:1: TvRecipe.SessionController.phoenix_controller_pipeline/2
179 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4
180 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
181 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2
182 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
183 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
184 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
185 | test/controllers/session_controller_test.exs:5: (test)
186 |
187 |
188 |
189 | Finished in 0.1 seconds
190 | 1 test, 1 failure
191 | ```
192 | 测试失败,因为 `TvRecipe.SessionView` 未定义。
193 |
194 | ## 创建 `SessionView` 模块
195 |
196 | 在 `web/views` 目录下新建一个 `session_view.ex` 文件,内容如下:
197 |
198 | ```elixir
199 | defmodule TvRecipe.SessionView do
200 | use TvRecipe.Web, :view
201 | end
202 | ```
203 | 在 Phoenix 下,View 与 templates 是分开的,其中 View 是模块(module),而 templates 在编译后,会变成 View 模块中的函数。这也是为什么我们在定义模板之前,要先定义视图的原因。
204 |
205 | 此时运行测试:
206 |
207 | ```bash
208 | mix test test/controllers/session_controller_test.exs
209 | Compiling 1 file (.ex)
210 | Generated tv_recipe app
211 |
212 |
213 | 1) test renders form for new sessions (TvRecipe.SessionControllerTest)
214 | test/controllers/session_controller_test.exs:4
215 | ** (Phoenix.Template.UndefinedError) Could not render "new.html" for TvRecipe.SessionView, please define a matching cla
216 | use for render/2 or define a template at "web/templates/session". No templates were compiled for this module.
217 | Assigns:
218 |
219 | %{conn: %Plug.Conn{adapter: {Plug.Adapters.Test.Conn, :...}, assigns: %{layout: {TvRecipe.LayoutView, "app.html"}}, bef
220 | ore_send: [#Function<0.101282891/1 in Plug.CSRFProtection.call/2>, #Function<4.111648917/1 in Phoenix.Controller.fetch_flash
221 | /2>, #Function<0.61377594/1 in Plug.Session.before_send/2>, #Function<1.115972179/1 in Plug.Logger.call/2>], body_params: %{
222 | }, cookies: %{}, halted: false, host: "www.example.com", method: "GET", owner: #PID<0.300.0>, params: %{}, path_info: ["sess
223 | ions", "new"], path_params: %{}, peer: {{127, 0, 0, 1}, 111317}, port: 80, private: %{TvRecipe.Router => {[], %{}}, :phoenix
224 | _action => :new, :phoenix_controller => TvRecipe.SessionController, :phoenix_endpoint => TvRecipe.Endpoint, :phoenix_flash =
225 | > %{}, :phoenix_format => "html", :phoenix_layout => {TvRecipe.LayoutView, :app}, :phoenix_pipelines => [:browser], :phoenix
226 | _recycled => true, :phoenix_route => #Function<12.75217690/1 in TvRecipe.Router.match_route/4>, :phoenix_router => TvRecipe.
227 | Router, :phoenix_template => "new.html", :phoenix_view => TvRecipe.SessionView, :plug_session => %{}, :plug_session_fetch =>
228 | :done, :plug_skip_csrf_protection => true}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{
229 | }, req_headers: [], request_path: "/sessions/new", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-
230 | age=0, private, must-revalidate"}, {"x-request-id", "vi7asqkbb9153m6ku8btf8r50p38rsqn"}, {"x-frame-options", "SAMEORIGIN"},
231 | {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}], scheme: :http, script_name: [], secret_key_ba
232 | se: "XfacEiZ/QVO87L4qirM0thXcedgcx5zYhLPAsmVPnL8AVu6qB/Et84yvJ6712aSn", state: :unset, status: nil}, template_not_found: TvR
233 | ecipe.SessionView, view_module: TvRecipe.SessionView, view_template: "new.html"}
234 |
235 | stacktrace:
236 | (phoenix) lib/phoenix/template.ex:364: Phoenix.Template.raise_template_not_found/3
237 | (tv_recipe) web/templates/layout/app.html.eex:29: TvRecipe.LayoutView."app.html"/1
238 | (phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3
239 | (phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4
240 | (tv_recipe) web/controllers/session_controller.ex:1: TvRecipe.SessionController.action/2
241 | (tv_recipe) web/controllers/session_controller.ex:1: TvRecipe.SessionController.phoenix_controller_pipeline/2
242 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4
243 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
244 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2
245 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
246 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
247 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
248 | test/controllers/session_controller_test.exs:5: (test)
249 |
250 |
251 |
252 | Finished in 0.1 seconds
253 | 1 test, 1 failure
254 | ```
255 | 测试失败,因为 new.html 模板不存在。
256 |
257 | ## 创建 `new.html.eex` 模板文件
258 |
259 | 在 `web/templates/session` 目录中新建一个空白 `new.html.eex` 模板文件。
260 |
261 | 现在运行测试:
262 |
263 | ```bash
264 | mix test test/controllers/session_controller_test.exs
265 | Compiling 1 file (.ex)
266 |
267 |
268 | 1) test renders form for new sessions (TvRecipe.SessionControllerTest)
269 | test/controllers/session_controller_test.exs:4
270 | Assertion with =~ failed
271 | code: html_response(conn, 200) =~ "登录"
272 | left: "\n\n \n
\n
\n
\n
\n
\n\n
Hello TvRecipe!\n
\n \n\n \n
\n \n \n\n"
34 | right: "退出"
35 | stacktrace:
36 | test/controllers/session_controller_test.exs:30: (test)
37 |
38 | ....
39 |
40 | Finished in 0.4 seconds
41 | 35 tests, 1 failure
42 | ```
43 |
44 | 打开 `app.html.eex` 文件,做如下修改:
45 |
46 | ```elixir
47 | diff --git a/web/templates/layout/app.html.eex b/web/templates/layout/app.html.eex
48 | index 2d39904..6c87a08 100644
49 | --- a/web/templates/layout/app.html.eex
50 | +++ b/web/templates/layout/app.html.eex
51 | @@ -19,6 +19,7 @@
52 |
Get Started
53 | <%= if @current_user do %>
54 |
<%= link @current_user.username, to: user_path(@conn, :show, @current_user) %>
55 | +
<%= link "退出", to: session_path(@conn, :delete, @current_user), method: "delete" %>
56 | <% end %>
57 |
58 |
59 | ```
60 | 现在运行测试:
61 |
62 | ```bash
63 | $ mix test
64 | .............................
65 |
66 | 1) test creates resource and redirects when data is valid (TvRecipe.UserControllerTest)
67 | test/controllers/user_controller_test.exs:18
68 | ** (ArgumentError) No helper clause for TvRecipe.Router.Helpers.session_path/3 defined for action :delete.
69 | The following session_path actions are defined under your router:
70 |
71 | * :create
72 | * :new
73 | stacktrace:
74 | (phoenix) lib/phoenix/router/helpers.ex:269: Phoenix.Router.Helpers.raise_route_error/5
75 | (tv_recipe) web/templates/layout/app.html.eex:22: TvRecipe.LayoutView."app.html"/1
76 | (phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3
77 | (phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4
78 | (tv_recipe) web/controllers/page_controller.ex:1: TvRecipe.PageController.action/2
79 | (tv_recipe) web/controllers/page_controller.ex:1: TvRecipe.PageController.phoenix_controller_pipeline/2
80 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4
81 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
82 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2
83 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
84 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
85 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
86 | test/controllers/user_controller_test.exs:23: (test)
87 |
88 | ...
89 |
90 | 2) test login user and redirect to home page when data is valid (TvRecipe.SessionControllerTest)
91 | test/controllers/session_controller_test.exs:13
92 | ** (ArgumentError) No helper clause for TvRecipe.Router.Helpers.session_path/3 defined for action :delete.
93 | The following session_path actions are defined under your router:
94 |
95 | * :create
96 | * :new
97 | stacktrace:
98 | (phoenix) lib/phoenix/router/helpers.ex:269: Phoenix.Router.Helpers.raise_route_error/5
99 | (tv_recipe) web/templates/layout/app.html.eex:22: TvRecipe.LayoutView."app.html"/1
100 | (phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3
101 | (phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4
102 | (tv_recipe) web/controllers/page_controller.ex:1: TvRecipe.PageController.action/2
103 | (tv_recipe) web/controllers/page_controller.ex:1: TvRecipe.PageController.phoenix_controller_pipeline/2
104 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4
105 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
106 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2
107 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
108 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
109 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
110 | test/controllers/session_controller_test.exs:24: (test)
111 |
112 | .
113 |
114 | Finished in 0.5 seconds
115 | 35 tests, 2 failures
116 | ```
117 | 前一个测试中的错误消除了,却又多了两个错误。这是因为我们还没有定义 `session_controller.ex` 文件中的 `delete` 动作及相应路由。
118 |
119 | 我们希望用户登录后点击“退出”,页面跳转到主页,并显示“退出成功”。
120 |
121 | 我们的测试这么写:
122 |
123 | ```elixir
124 | diff --git a/test/controllers/session_controller_test.exs b/test/controllers/session_controller_test.exs
125 | index 511d0ab..969662a 100644
126 | --- a/test/controllers/session_controller_test.exs
127 | +++ b/test/controllers/session_controller_test.exs
128 | @@ -50,4 +50,18 @@ defmodule TvRecipe.SessionControllerTest do
129 | assert html_response(conn, 200) =~ "登录"
130 | end
131 |
132 | + test "logouts user when logout button clicked", %{conn: conn} do
133 | + # 在数据库中新建一个用户
134 | + changeset = User.changeset(%User{}, @valid_user_attrs)
135 | + user = Repo.insert!(changeset)
136 | +
137 | + # 登录该用户
138 | + conn = post conn, session_path(conn, :create), session: Map.delete(@valid_user_attrs, :username)
139 | +
140 | + # 点击退出
141 | + conn = delete conn, session_path(conn, :delete, user)
142 | + assert get_flash(conn, :info) == "退出成功"
143 | + assert redirected_to(conn) == page_path(conn, :index)
144 | + end
145 | +
146 | end
147 | ```
148 | 接着我们根据测试中的要求调整 `session_controller.ex` 文件及 `router.ex`:
149 |
150 | ```elixir
151 | diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex
152 | index b5218f2..2a887ee 100644
153 | --- a/web/controllers/session_controller.ex
154 | +++ b/web/controllers/session_controller.ex
155 | @@ -30,4 +30,11 @@ defmodule TvRecipe.SessionController do
156 | |> render("new.html")
157 | end
158 | end
159 | +
160 | + def delete(conn, _params) do
161 | + conn
162 | + |> delete_session(:user_id)
163 | + |> put_flash(:info, "退出成功")
164 | + |> redirect(to: page_path(conn, :index))
165 | + end
166 | end
167 |
168 | diff --git a/web/router.ex b/web/router.ex
169 | index 1265c86..4c12197 100644
170 | --- a/web/router.ex
171 | +++ b/web/router.ex
172 | @@ -21,6 +21,7 @@ defmodule TvRecipe.Router do
173 | resources "/users", UserController
174 | get "/sessions/new", SessionController, :new
175 | post "/sessions/new", SessionController, :create
176 | + delete "/sessions/:id", SessionController, :delete
177 | end
178 |
179 | # Other scopes may use custom stacks.
180 | ```
181 | 运行测试,全部通过。
182 |
183 | 我们还可以优化下 `router.ex` 文件:
184 |
185 | ```elixir
186 | diff --git a/web/router.ex b/web/router.ex
187 | index 4c12197..292aeb8 100644
188 | --- a/web/router.ex
189 | +++ b/web/router.ex
190 | @@ -19,9 +19,7 @@ defmodule TvRecipe.Router do
191 |
192 | get "/", PageController, :index
193 | resources "/users", UserController
194 | - get "/sessions/new", SessionController, :new
195 | - post "/sessions/new", SessionController, :create
196 | - delete "/sessions/:id", SessionController, :delete
197 | + resources "/sessions", SessionController, only: [:new, :create, :delete]
198 | end
199 |
200 | # Other scopes may use custom stacks.
201 | ```
202 |
203 | 下一章,我们给页面加上[登录/注册按钮](04-login-logout-buttons.md),方便用户操作。
--------------------------------------------------------------------------------
/05-session/04-login-logout-buttons.md:
--------------------------------------------------------------------------------
1 | # 登录/注册按钮
2 |
3 | 我们至今还没有在页面上添加登录/注册按钮,这对普通用户来说非常不友好。
4 |
5 | 老规则,先写测试,我们不再新增,而是加在旧测试中:
6 |
7 | ```elixir
8 | diff --git a/test/controllers/session_controller_test.exs b/test/controllers/session_controller_test.exs
9 | index 969662a..98fbb5a 100644
10 | --- a/test/controllers/session_controller_test.exs
11 | +++ b/test/controllers/session_controller_test.exs
12 | @@ -14,6 +14,10 @@ defmodule TvRecipe.SessionControllerTest do
13 | user_changeset = User.changeset(%User{}, @valid_user_attrs)
14 | # 插入新用户
15 | user = Repo.insert! user_changeset
16 | + # 未登录情况下访问首页,应带有登录/注册文字
17 | + conn = get conn, page_path(conn, :index)
18 | + assert html_response(conn, 200) =~ "登录"
19 | + assert html_response(conn, 200) =~ "注册"
20 | # 用户登录
21 | conn = post conn, session_path(conn, :create), session: @valid_user_attrs
22 | # 显示“欢迎你”的消息
23 | ```
24 |
25 | 打开 `app.html.eex` 文件,添加两个按钮:
26 |
27 | ```eex
28 | diff --git a/web/templates/layout/app.html.eex b/web/templates/layout/app.html.eex
29 | index 6c87a08..b13f370 100644
30 | --- a/web/templates/layout/app.html.eex
31 | +++ b/web/templates/layout/app.html.eex
32 | @@ -20,6 +20,9 @@
33 | <%= if @current_user do %>
34 |
<%= link @current_user.username, to: user_path(@conn, :show, @current_user) %>
35 |
<%= link "退出", to: session_path(@conn, :delete, @current_user), method: "delete" %>
36 | + <% else %>
37 | +
<%= link "登录", to: session_path(@conn, :new) %>
38 | +
<%= link "注册", to: user_path(@conn, :new) %>
39 | <% end %>
40 |
41 |
42 | ```
43 | 运行测试:
44 |
45 | ```bash
46 | mix test
47 | ....................................
48 |
49 | Finished in 0.8 seconds
50 | 36 tests, 0 failures
51 | ```
52 | 全部通过。
53 |
54 | 在进入下一章前,我们还有个安全相关的问题需要略作修改:
55 |
56 | ```elixir
57 | diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex
58 | index 6ac524c..0c2eb0a 100644
59 | --- a/web/controllers/session_controller.ex
60 | +++ b/web/controllers/session_controller.ex
61 | @@ -15,6 +15,7 @@ defmodule TvRecipe.SessionController do
62 | conn
63 | |> put_session(:user_id, user.id)
64 | |> put_flash(:info, "欢迎你")
65 | + |> configure_session(renew: true)
66 | |> redirect(to: page_path(conn, :index))
67 | # 用户存在,但密码错误
68 | user ->
69 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
70 | index 8d8a6f5..8b9b38b 100644
71 | --- a/web/controllers/user_controller.ex
72 | +++ b/web/controllers/user_controller.ex
73 | @@ -21,6 +21,7 @@ defmodule TvRecipe.UserController do
74 | conn
75 | |> put_flash(:info, "User created successfully.")
76 | |> put_session(:user_id, user.id)
77 | + |> configure_session(renew: true)
78 | |> redirect(to: page_path(conn, :index))
79 | {:error, changeset} ->
80 | render(conn, "new.html", changeset: changeset)
81 | ```
82 | 我们在 `session_controller.ex` 与 `user_controller.ex` 两个文件中加入了 `configure_session(renew: true)`,用于预防 [session fixation 攻击](https://www.owasp.org/index.php/Session_fixation)。
83 |
84 | 另外,本着 DRY 的原则,我们可以将登录的逻辑合并到 `auth.ex` 文件中:
85 |
86 | ```elixir
87 | diff --git a/web/controllers/auth.ex b/web/controllers/auth.ex
88 | index 994112d..e298b68 100644
89 | --- a/web/controllers/auth.ex
90 | +++ b/web/controllers/auth.ex
91 | @@ -15,4 +15,10 @@ defmodule TvRecipe.Auth do
92 | assign(conn, :current_user, user)
93 | end
94 |
95 | + def login(conn, user) do
96 | + conn
97 | + |> put_session(:user_id, user.id)
98 | + |> configure_session(renew: true)
99 | + end
100 | +
101 | end
102 |
103 | diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex
104 | index 0c2eb0a..6f29ce0 100644
105 | --- a/web/controllers/session_controller.ex
106 | +++ b/web/controllers/session_controller.ex
107 | @@ -13,9 +13,8 @@ defmodule TvRecipe.SessionController do
108 | # 用户存在,且密码正确
109 | user && Comeonin.Bcrypt.checkpw(password, user.password_hash) ->
110 | conn
111 | - |> put_session(:user_id, user.id)
112 | |> put_flash(:info, "欢迎你")
113 | - |> configure_session(renew: true)
114 | + |> TvRecipe.Auth.login(user)
115 | |> redirect(to: page_path(conn, :index))
116 | # 用户存在,但密码错误
117 | user ->
118 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
119 | index 8b9b38b..b9234b1 100644
120 | --- a/web/controllers/user_controller.ex
121 | +++ b/web/controllers/user_controller.ex
122 | @@ -20,8 +20,7 @@ defmodule TvRecipe.UserController do
123 | {:ok, user} ->
124 | conn
125 | |> put_flash(:info, "User created successfully.")
126 | - |> put_session(:user_id, user.id)
127 | - |> configure_session(renew: true)
128 | + |> TvRecipe.Auth.login(user)
129 | |> redirect(to: page_path(conn, :index))
130 | {:error, changeset} ->
131 | render(conn, "new.html", changeset: changeset)
132 | ```
133 |
134 | 下一章,我们要对[用户相关页面做些限制](../06-restrict-access.md),以保证数据的安全。
--------------------------------------------------------------------------------
/06-restrict-access.md:
--------------------------------------------------------------------------------
1 | # 安全限制
2 |
3 | ## 限制未登录用户访问用户页面
4 |
5 | 目前为止,所有未登录用户都可以访问、操作用户相关页面。我们要加以限制:**未登录用户只允许使用 `:new` 及 `:create` 两个动作,访问其余动作时,全部重定向到登录页。**
6 |
7 | 首先在 `user_controller_test.exs` 文件中新增一个测试,具体内容看注释:
8 |
9 | ```elixir
10 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
11 | index 26055e3..ac6894e 100644
12 | --- a/test/controllers/user_controller_test.exs
13 | +++ b/test/controllers/user_controller_test.exs
14 | @@ -66,4 +66,18 @@ defmodule TvRecipe.UserControllerTest do
15 | assert redirected_to(conn) == user_path(conn, :index)
16 | refute Repo.get(User, user.id)
17 | end
18 | +
19 | + test "guest access user action redirected to login page", %{conn: conn} do
20 | + user = Repo.insert! %User{}
21 | + Enum.each([
22 | + get(conn, user_path(conn, :index)),
23 | + get(conn, user_path(conn, :show, user)),
24 | + get(conn, user_path(conn, :edit, user)),
25 | + put(conn, user_path(conn, :update, user), user: %{}),
26 | + delete(conn, user_path(conn, :delete, user))
27 | + ], fn conn ->
28 | + assert redirected_to(conn) == session_path(conn, :new)
29 | + assert conn.halted
30 | + end)
31 | + end
32 | end
33 | ```
34 | 接下来修改 `user_controller.ex` 文件中的代码:
35 |
36 | ```elixir
37 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
38 | index b9234b1..7bb7dac 100644
39 | --- a/web/controllers/user_controller.ex
40 | +++ b/web/controllers/user_controller.ex
41 | @@ -1,5 +1,6 @@
42 | defmodule TvRecipe.UserController do
43 | use TvRecipe.Web, :controller
44 | + plug :login_require when action in [:index, :show, :edit, :update, :delete]
45 |
46 | alias TvRecipe.User
47 |
48 | @@ -63,4 +64,20 @@ defmodule TvRecipe.UserController do
49 | |> put_flash(:info, "User deleted successfully.")
50 | |> redirect(to: user_path(conn, :index))
51 | end
52 | +
53 | + @doc """
54 | + 检查用户登录状态
55 | +
56 | + Returns `conn`
57 | + """
58 | + def login_require(conn, _opts) do
59 | + if conn.assigns.current_user do
60 | + conn
61 | + else
62 | + conn
63 | + |> put_flash(:info, "请先登录")
64 | + |> redirect(to: session_path(conn, :new))
65 | + |> halt()
66 | + end
67 | + end
68 | end
69 | ```
70 | 我们增加了一个函数式 plug `login_require`,并且将它应用在控制器中的动作前。
71 |
72 | 还记得我们的一个流程图吗?
73 |
74 | ```elixir
75 | conn
76 | |> router
77 | |> pipelines
78 | |> controller
79 | |> view
80 | |> template
81 | ```
82 | 这里,我们还能再进一步完善它:
83 |
84 | ```elixir
85 | conn
86 | |> router
87 | |> pipelines
88 | |> controller
89 | |> plugs
90 | |> action
91 | |> view
92 | |> template
93 | ```
94 | 我们的控制器在执行动作前,会按指定顺序执行一系列 plug。
95 |
96 | 现在运行测试:
97 |
98 | ```bash
99 | $ mix test
100 | .....................
101 |
102 | 1) test renders form for editing chosen resource (TvRecipe.UserControllerTest)
103 | test/controllers/user_controller_test.exs:44
104 | ** (RuntimeError) expected response with status 200, got: 302, with body:
105 | You are being
redirected.
106 | stacktrace:
107 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
108 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
109 | test/controllers/user_controller_test.exs:47: (test)
110 |
111 |
112 |
113 | 2) test lists all entries on index (TvRecipe.UserControllerTest)
114 | test/controllers/user_controller_test.exs:8
115 | ** (RuntimeError) expected response with status 200, got: 302, with body:
116 | You are being
redirected.
117 | stacktrace:
118 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
119 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
120 | test/controllers/user_controller_test.exs:10: (test)
121 |
122 |
123 |
124 | 3) test renders page not found when id is nonexistent (TvRecipe.UserControllerTest)
125 | test/controllers/user_controller_test.exs:38
126 | expected error to be sent as 404 status, but response sent 302 without error
127 | stacktrace:
128 | (phoenix) lib/phoenix/test/conn_test.ex:570: Phoenix.ConnTest.assert_error_sent/2
129 | test/controllers/user_controller_test.exs:39: (test)
130 |
131 | ..
132 |
133 | 4) test deletes chosen resource (TvRecipe.UserControllerTest)
134 | test/controllers/user_controller_test.exs:63
135 | Assertion with == failed
136 | code: redirected_to(conn) == user_path(conn, :index)
137 | left: "/sessions/new"
138 | right: "/users"
139 | stacktrace:
140 | test/controllers/user_controller_test.exs:66: (test)
141 |
142 |
143 |
144 | 5) test shows chosen resource (TvRecipe.UserControllerTest)
145 | test/controllers/user_controller_test.exs:32
146 | ** (RuntimeError) expected response with status 200, got: 302, with body:
147 | You are being
redirected.
148 | stacktrace:
149 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
150 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
151 | test/controllers/user_controller_test.exs:35: (test)
152 |
153 |
154 |
155 | 6) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest)
156 | test/controllers/user_controller_test.exs:50
157 | Assertion with == failed
158 | code: redirected_to(conn) == user_path(conn, :show, user)
159 | left: "/sessions/new"
160 | right: "/users/1121"
161 | stacktrace:
162 | test/controllers/user_controller_test.exs:53: (test)
163 |
164 |
165 |
166 | 7) test does not update chosen resource and renders errors when data is invalid (TvRecipe.UserControllerTest)
167 | test/controllers/user_controller_test.exs:57
168 | ** (RuntimeError) expected response with status 200, got: 302, with body:
169 | You are being
redirected.
170 | stacktrace:
171 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
172 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
173 | test/controllers/user_controller_test.exs:60: (test)
174 |
175 | .......
176 |
177 | Finished in 0.4 seconds
178 | 37 tests, 7 failures
179 | ```
180 | 因为我们前面新增了 `login_require` 限制,导致旧的测试有 7 个失败,它们均需要用户登录。
181 |
182 | 怎么测试用户登录的情况?
183 |
184 | 我们有一种选择是,在每一个测试前登录用户,比如这样:
185 |
186 | ```elixir
187 | test "shows chosen resource", %{conn: conn} do
188 | user = Repo.insert! User.changeset(%User{}, @valid_attrs)
189 | conn = post conn, session_path(conn, :create), session: @valid_attrs # <= 这一行,登录用户
190 | conn = get conn, user_path(conn, :show, user)
191 | assert html_response(conn, 200) =~ "Show user"
192 | end
193 | ```
194 | 只是这样我们会重复很多代码。
195 |
196 | 我们还可以借助 [`setup`](https://hexdocs.pm/ex_unit/ExUnit.Callbacks.html#setup/2)。在 Elixir 的测试里,`setup` 块的代码会在每一个 `test` 执行以前执行,它们返回的内容合并进 `context`,然后我们就可以在 `test` 中获取到。
197 |
198 | 但我们在一个测试文件中涉及两种情况,登录与未登录,`setup` 要如何区分它们?
199 |
200 | 我们可以使用 [`tag`](https://hexdocs.pm/ex_unit/ExUnit.Case.html#module-tags)。通过 `tag`,我们在上下文 `context` 中存储变量,`setup` 读取 `context`,根据需求返回不同的数据。
201 |
202 | 我们的代码改造如下:
203 |
204 | ```elixir
205 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
206 | index ac6894e..e11df40 100644
207 | --- a/test/controllers/user_controller_test.exs
208 | +++ b/test/controllers/user_controller_test.exs
209 | @@ -1,10 +1,22 @@
210 | defmodule TvRecipe.UserControllerTest do
211 | use TvRecipe.ConnCase
212 |
213 | - alias TvRecipe.User
214 | + alias TvRecipe.{Repo, User}
215 | @valid_attrs %{email: "chenxsan@gmail.com", password: "some content", username: "chenxsan"}
216 | @invalid_attrs %{}
217 |
218 | + setup %{conn: conn} = context do
219 | + if context[:logged_in] == true do
220 | + # 如果上下文里 :logged_in 值为 true
221 | + user = Repo.insert! User.changeset(%User{}, @valid_attrs)
222 | + conn = post conn, session_path(conn, :create), session: @valid_attrs
223 | + {:ok, [conn: conn, user: user]}
224 | + else
225 | + :ok
226 | + end
227 | + end
228 | +
229 | + @tag logged_in: true
230 | test "lists all entries on index", %{conn: conn} do
231 | conn = get conn, user_path(conn, :index)
232 | assert html_response(conn, 200) =~ "Listing users"
233 | @@ -29,24 +41,28 @@ defmodule TvRecipe.UserControllerTest do
234 | assert html_response(conn, 200) =~ "New user"
235 | end
236 |
237 | + @tag logged_in: true
238 | test "shows chosen resource", %{conn: conn} do
239 | user = Repo.insert! %User{}
240 | conn = get conn, user_path(conn, :show, user)
241 | assert html_response(conn, 200) =~ "Show user"
242 | end
243 |
244 | + @tag logged_in: true
245 | test "renders page not found when id is nonexistent", %{conn: conn} do
246 | assert_error_sent 404, fn ->
247 | get conn, user_path(conn, :show, -1)
248 | end
249 | end
250 |
251 | + @tag logged_in: true
252 | test "renders form for editing chosen resource", %{conn: conn} do
253 | user = Repo.insert! %User{}
254 | conn = get conn, user_path(conn, :edit, user)
255 | assert html_response(conn, 200) =~ "Edit user"
256 | end
257 |
258 | + @tag logged_in: true
259 | test "updates chosen resource and redirects when data is valid", %{conn: conn} do
260 | user = Repo.insert! %User{}
261 | conn = put conn, user_path(conn, :update, user), user: @valid_attrs
262 | @@ -54,12 +70,14 @@ defmodule TvRecipe.UserControllerTest do
263 | assert Repo.get_by(User, @valid_attrs |> Map.delete(:password))
264 | end
265 |
266 | + @tag logged_in: true
267 | test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do
268 | user = Repo.insert! %User{}
269 | conn = put conn, user_path(conn, :update, user), user: @invalid_attrs
270 | assert html_response(conn, 200) =~ "Edit user"
271 | end
272 |
273 | + @tag logged_in: true
274 | test "deletes chosen resource", %{conn: conn} do
275 | user = Repo.insert! %User{}
276 | conn = delete conn, user_path(conn, :delete, user)
277 | ```
278 | 我们根据 `logged_in` 的值返回不同 `conn`:一个是用户登录的 conn,一个是未登录的 conn。
279 |
280 | 现在运行测试:
281 |
282 | ```bash
283 | $ mix test
284 | ...........................
285 |
286 | 1) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest)
287 | test/controllers/user_controller_test.exs:66
288 | ** (RuntimeError) expected redirection with status 302, got: 200
289 | stacktrace:
290 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2
291 | test/controllers/user_controller_test.exs:69: (test)
292 |
293 | .........
294 |
295 | Finished in 0.5 seconds
296 | 37 tests, 1 failure
297 | ```
298 | 我们修复了大部分的错误,但还有一个失败的。
299 |
300 | 检查测试代码我们可以发现,`setup` 块里创建了一个邮箱为 `chenxsan@gmail.com`、用户名为 `chenxsan` 的用户,而更新时邮箱与用户名重复了。
301 |
302 | 我们调整一下:
303 |
304 | ```elixir
305 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
306 | index e11df40..c8263c6 100644
307 | --- a/test/controllers/user_controller_test.exs
308 | +++ b/test/controllers/user_controller_test.exs
309 | @@ -65,7 +65,7 @@ defmodule TvRecipe.UserControllerTest do
310 | @tag logged_in: true
311 | test "updates chosen resource and redirects when data is valid", %{conn: conn} do
312 | user = Repo.insert! %User{}
313 | - conn = put conn, user_path(conn, :update, user), user: @valid_attrs
314 | + conn = put conn, user_path(conn, :update, user), user: %{@valid_attrs | username: "samchen", email: "chenxsan+1@gmail.com"}
315 | assert redirected_to(conn) == user_path(conn, :show, user)
316 | assert Repo.get_by(User, @valid_attrs |> Map.delete(:password))
317 | end
318 | ```
319 | 这样,我们就修正了所有测试。
320 |
321 | ## 限制用户访问管理动作
322 |
323 | `user_controller.ex` 文件中,`:index` 与 `:delete` 动作通常是管理员才允许使用的,对普通用户来说,它们应该不可见。
324 |
325 | 我们直接移除相应的路由与控制器动作:
326 |
327 | ```elixir
328 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
329 | index 7bb7dac..c0056fd 100644
330 | --- a/web/controllers/user_controller.ex
331 | --- a/web/controllers/user_controller.ex
332 | +++ b/web/controllers/user_controller.ex
333 | @@ -1,14 +1,9 @@
334 | defmodule TvRecipe.UserController do
335 | use TvRecipe.Web, :controller
336 | - plug :login_require when action in [:index, :show, :edit, :update, :delete]
337 | + plug :login_require when action in [:show, :edit, :update]
338 |
339 | alias TvRecipe.User
340 |
341 | - def index(conn, _params) do
342 | - users = Repo.all(User)
343 | - render(conn, "index.html", users: users)
344 | - end
345 | -
346 | def new(conn, _params) do
347 | changeset = User.changeset(%User{})
348 | render(conn, "new.html", changeset: changeset)
349 | @@ -53,18 +48,6 @@ defmodule TvRecipe.UserController do
350 | end
351 | end
352 |
353 | - def delete(conn, %{"id" => id}) do
354 | - user = Repo.get!(User, id)
355 | -
356 | - # Here we use delete! (with a bang) because we expect
357 | - # it to always work (and if it does not, it will raise).
358 | - Repo.delete!(user)
359 | -
360 | - conn
361 | - |> put_flash(:info, "User deleted successfully.")
362 | - |> redirect(to: user_path(conn, :index))
363 | - end
364 | -
365 | @doc """
366 | 检查用户登录状态
367 | ```
368 | 然后运行测试。测试会帮我们定位出所有需要移除或修正的代码,我们逐一修改如下:
369 |
370 | ```elixir
371 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
372 | index c8263c6..a2ccee0 100644
373 | --- a/test/controllers/user_controller_test.exs
374 | +++ b/test/controllers/user_controller_test.exs
375 | @@ -16,12 +16,6 @@ defmodule TvRecipe.UserControllerTest do
376 | end
377 | end
378 |
379 | - @tag logged_in: true
380 | - test "lists all entries on index", %{conn: conn} do
381 | - conn = get conn, user_path(conn, :index)
382 | - assert html_response(conn, 200) =~ "Listing users"
383 | - end
384 | end
385 | end
386 |
387 | - @tag logged_in: true
388 | - test "lists all entries on index", %{conn: conn} do
389 | - conn = get conn, user_path(conn, :index)
390 | - assert html_response(conn, 200) =~ "Listing users"
391 | - end
392 | -
393 | test "renders form for new resources", %{conn: conn} do
394 | conn = get conn, user_path(conn, :new)
395 | assert html_response(conn, 200) =~ "New user"
396 | @@ -77,22 +71,12 @@ defmodule TvRecipe.UserControllerTest do
397 | assert html_response(conn, 200) =~ "Edit user"
398 | end
399 |
400 | - @tag logged_in: true
401 | - test "deletes chosen resource", %{conn: conn} do
402 | - user = Repo.insert! %User{}
403 | - conn = delete conn, user_path(conn, :delete, user)
404 | - assert redirected_to(conn) == user_path(conn, :index)
405 | - refute Repo.get(User, user.id)
406 | - end
407 | -
408 | test "guest access user action redirected to login page", %{conn: conn} do
409 | user = Repo.insert! %User{}
410 | Enum.each([
411 | - get(conn, user_path(conn, :index)),
412 | get(conn, user_path(conn, :show, user)),
413 | get(conn, user_path(conn, :edit, user)),
414 | put(conn, user_path(conn, :update, user), user: %{}),
415 | - delete(conn, user_path(conn, :delete, user))
416 | ], fn conn ->
417 | assert redirected_to(conn) == session_path(conn, :new)
418 | assert conn.halted
419 | ], fn conn ->
420 | assert redirected_to(conn) == session_path(conn, :new)
421 | assert conn.halted
422 | diff --git a/web/templates/user/edit.html.eex b/web/templates/user/edit.html.eex
423 | index 7e08f2b..beae173 100644
424 | --- a/web/templates/user/edit.html.eex
425 | +++ b/web/templates/user/edit.html.eex
426 | @@ -2,5 +2,3 @@
427 |
428 | <%= render "form.html", changeset: @changeset,
429 | action: user_path(@conn, :update, @user) %>
430 | -
431 | -<%= link "Back", to: user_path(@conn, :index) %>
432 | diff --git a/web/templates/user/new.html.eex b/web/templates/user/new.html.eex
433 | index e0b494f..adf2399 100644
434 | --- a/web/templates/user/new.html.eex
435 | +++ b/web/templates/user/new.html.eex
436 | @@ -2,5 +2,3 @@
437 |
438 | <%= render "form.html", changeset: @changeset,
439 | action: user_path(@conn, :create) %>
440 | -
441 | -<%= link "Back", to: user_path(@conn, :index) %>
442 | diff --git a/web/templates/user/show.html.eex b/web/templates/user/show.html.eex
443 | index d05f88d..4c3f497 100644
444 | --- a/web/templates/user/show.html.eex
445 | +++ b/web/templates/user/show.html.eex
446 | @@ -20,4 +20,3 @@
447 |
448 |
449 | <%= link "Edit", to: user_path(@conn, :edit, @user) %>
450 | -<%= link "Back", to: user_path(@conn, :index) %>
451 | ```
452 | 再次运行测试,全部通过。
453 |
454 | ## 限制已登录用户访问他人页面
455 |
456 | 我们还有一个问题,就是登录后的用户,通过修改 url 地址,能够访问他人的用户页面,还可以修改他人的信息。
457 |
458 | 我们需要加以限制:只有用户自己才可以访问、修改自己的用户页面。
459 |
460 | 我们有几种解决办法:
461 |
462 | 1. 不再通过 id 获取用户,直接读取 `conn.assigns.current_user`。
463 | 2. 把 id 隐藏起来,改用 `/profile` 这样的路径,用户就无从修改 url 中的 id,不过我们也没办法从 url 中获取 id,只能读取 `conn.assigns.current_user`。
464 | 3. 定义一个 `plug`,检查用户访问的 id 与 `conn.assigns.current_user` 的 id 是否一致,不一致则跳转。
465 |
466 | 这里使用第三种办法。
467 |
468 | 先定义一个测试:
469 |
470 | ```elixir
471 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
472 | index a2ccee0..fd57531 100644
473 | --- a/test/controllers/user_controller_test.exs
474 | +++ b/test/controllers/user_controller_test.exs
475 | @@ -82,4 +82,19 @@ defmodule TvRecipe.UserControllerTest do
476 | assert conn.halted
477 | end)
478 | end
479 | +
480 | + @tag logged_in: true
481 | + test "does not allow access to other user path", %{conn: conn, user: user} do
482 | + another_user = Repo.insert! %User{}
483 | + Enum.each([
484 | + get(conn, user_path(conn, :show, another_user)),
485 | + get(conn, user_path(conn, :edit, another_user)),
486 | + put(conn, user_path(conn, :update, another_user), user: %{})
487 | + ], fn conn ->
488 | + assert get_flash(conn, :error) == "禁止访问未授权页面"
489 | + assert redirected_to(conn) == user_path(conn, :show, user)
490 | + assert conn.halted
491 | + end)
492 | + end
493 | +
494 | end
495 | ```
496 | 然后修改 `user_controller.ex` 文件:
497 |
498 | ```elixir
499 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
500 | index c0056fd..520d986 100644
501 | --- a/web/controllers/user_controller.ex
502 | +++ b/web/controllers/user_controller.ex
503 | @@ -1,6 +1,7 @@
504 | defmodule TvRecipe.UserController do
505 | use TvRecipe.Web, :controller
506 | plug :login_require when action in [:show, :edit, :update]
507 | + plug :self_require when action in [:show, :edit, :update]
508 |
509 | alias TvRecipe.User
510 |
511 | @@ -63,4 +64,21 @@ defmodule TvRecipe.UserController do
512 | |> halt()
513 | end
514 | end
515 | +
516 | + @doc """
517 | + 检查用户是否授权访问动作
518 | +
519 | + Returns `conn`
520 | + """
521 | + def self_require(conn, _opts) do
522 | + %{"id" => id} = conn.params
523 | + if String.to_integer(id) == conn.assigns.current_user.id do
524 | + conn
525 | + else
526 | + conn
527 | + |> put_flash(:error, "禁止访问未授权页面")
528 | + |> redirect(to: user_path(conn, :show, conn.assigns.current_user))
529 | + |> halt()
530 | + end
531 | + end
532 | end
533 | ```
534 | 我们增加了一个 `self_require` 的 plug,并应用到几个动作上。请注意两个 plug 的顺序,`self_require` 排在 `login_require` 后面。
535 |
536 | 执行测试:
537 |
538 | ```bash
539 | $ mix test
540 | ....................
541 |
542 | 1) test shows chosen resource (TvRecipe.UserControllerTest)
543 | test/controllers/user_controller_test.exs:39
544 | ** (RuntimeError) expected response with status 200, got: 302, with body:
545 | You are being
redirected.
546 | stacktrace:
547 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
548 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
549 | test/controllers/user_controller_test.exs:42: (test)
550 |
551 |
552 |
553 | 2) test renders form for editing chosen resource (TvRecipe.UserControllerTest)
554 | test/controllers/user_controller_test.exs:53
555 | ** (RuntimeError) expected response with status 200, got: 302, with body:
556 | You are being
redirected.
557 | stacktrace:
558 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
559 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
560 | test/controllers/user_controller_test.exs:56: (test)
561 |
562 | ....
563 |
564 | 3) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest)
565 | test/controllers/user_controller_test.exs:60
566 | Assertion with == failed
567 | code: redirected_to(conn) == user_path(conn, :show, user)
568 | left: "/users/2948"
569 | right: "/users/2949"
570 | stacktrace:
571 | test/controllers/user_controller_test.exs:63: (test)
572 |
573 |
574 |
575 | 4) test renders page not found when id is nonexistent (TvRecipe.UserControllerTest)
576 | test/controllers/user_controller_test.exs:46
577 | expected error to be sent as 404 status, but response sent 302 without error
578 | stacktrace:
579 | (phoenix) lib/phoenix/test/conn_test.ex:570: Phoenix.ConnTest.assert_error_sent/2
580 | test/controllers/user_controller_test.exs:47: (test)
581 |
582 | .
583 |
584 | 5) test does not update chosen resource and renders errors when data is invalid (TvRecipe.UserControllerTest)
585 | test/controllers/user_controller_test.exs:68
586 | ** (RuntimeError) expected response with status 200, got: 302, with body:
587 | You are being
redirected.
588 | stacktrace:
589 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
590 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
591 | test/controllers/user_controller_test.exs:71: (test)
592 |
593 | ......
594 |
595 | Finished in 0.5 seconds
596 | 36 tests, 5 failures
597 | ```
598 | 因为代码的改动,我们的测试又有失败的。让我们修正它们:
599 |
600 | ```elixir
601 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
602 | index fd57531..a1b75c6 100644
603 | --- a/test/controllers/user_controller_test.exs
604 | +++ b/test/controllers/user_controller_test.exs
605 | @@ -3,6 +3,7 @@ defmodule TvRecipe.UserControllerTest do
606 |
607 | alias TvRecipe.{Repo, User}
608 | @valid_attrs %{email: "chenxsan@gmail.com", password: "some content", username: "chenxsan"}
609 | + @another_valid_attrs %{email: "chenxsan+1@gmail.com", password: "some content", username: "samchen"}
610 | @invalid_attrs %{}
611 |
612 | setup %{conn: conn} = context do
613 | @@ -36,37 +37,26 @@ defmodule TvRecipe.UserControllerTest do
614 | end
615 |
616 | @tag logged_in: true
617 | - test "shows chosen resource", %{conn: conn} do
618 | - user = Repo.insert! %User{}
619 | + test "shows chosen resource", %{conn: conn, user: user} do
620 | conn = get conn, user_path(conn, :show, user)
621 | assert html_response(conn, 200) =~ "Show user"
622 | end
623 |
624 | @tag logged_in: true
625 | - test "renders page not found when id is nonexistent", %{conn: conn} do
626 | - assert_error_sent 404, fn ->
627 | - get conn, user_path(conn, :show, -1)
628 | @tag logged_in: true
629 | - test "renders page not found when id is nonexistent", %{conn: conn} do
630 | - assert_error_sent 404, fn ->
631 | - get conn, user_path(conn, :show, -1)
632 | - end
633 | - end
634 | -
635 | - @tag logged_in: true
636 | - test "renders form for editing chosen resource", %{conn: conn} do
637 | - user = Repo.insert! %User{}
638 | + test "renders form for editing chosen resource", %{conn: conn, user: user} do
639 | conn = get conn, user_path(conn, :edit, user)
640 | assert html_response(conn, 200) =~ "Edit user"
641 | end
642 |
643 | @tag logged_in: true
644 | - test "updates chosen resource and redirects when data is valid", %{conn: conn} do
645 | - user = Repo.insert! %User{}
646 | - conn = put conn, user_path(conn, :update, user), user: %{@valid_attrs | username: "samchen", email: "chenxsan+1@gmail.com"}
647 | + test "updates chosen resource and redirects when data is valid", %{conn: conn, user: user} do
648 | + conn = put conn, user_path(conn, :update, user), user: @another_valid_attrs
649 | assert redirected_to(conn) == user_path(conn, :show, user)
650 | - assert Repo.get_by(User, @valid_attrs |> Map.delete(:password))
651 | + assert Repo.get_by(User, @another_valid_attrs |> Map.delete(:password))
652 | end
653 |
654 | @tag logged_in: true
655 | - test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do
656 | - user = Repo.insert! %User{}
657 | + test "does not update chosen resource and renders errors when data is invalid", %{conn: conn, user: user} do
658 | conn = put conn, user_path(conn, :update, user), user: @invalid_attrs
659 | assert html_response(conn, 200) =~ "Edit user"
660 | end
661 | ```
662 | 运行测试:
663 |
664 | ```bash
665 | $ mix test
666 | ...................................
667 |
668 | Finished in 0.4 seconds
669 | 35 tests, 0 failures
670 | ```
671 | 全部通过。
--------------------------------------------------------------------------------
/07-recipe/01-gen-html.md:
--------------------------------------------------------------------------------
1 | # 生成菜谱样板文件
2 |
3 | 前面的章节里,我们完成了后端系统最重要的部分:用户。接下来我们要进入菜谱模块的开发。
4 |
5 | 我们先来确认下,菜谱有哪些属性需要保存:
6 |
7 | 属性名|类型|备注|是否必填|默认值
8 | ---|---|---|---|---
9 | name|string|菜谱名|必填|
10 | title|string|节目名|必填|
11 | season|integer|第几季|必填|1
12 | episode|integer|第几集|必填|1
13 | content|text|内容|必填|
14 | user_id|integer|关联用户 id|必填|
15 |
16 | 这里我们可以直接使用 `mix phoenix.gen.html` 命令来生成菜谱相关的所有文件:
17 |
18 | ```bash
19 | $ mix phoenix.gen.html Recipe recipes name title season:integer episode:integer content:text user_id:references:users
20 | * creating web/controllers/recipe_controller.ex
21 | * creating web/templates/recipe/edit.html.eex
22 | * creating web/templates/recipe/form.html.eex
23 | * creating web/templates/recipe/index.html.eex
24 | * creating web/templates/recipe/new.html.eex
25 | * creating web/templates/recipe/show.html.eex
26 | * creating web/views/recipe_view.ex
27 | * creating test/controllers/recipe_controller_test.exs
28 | * creating web/models/recipe.ex
29 | * creating test/models/recipe_test.exs
30 | * creating priv/repo/migrations/20170206013306_create_recipe.exs
31 |
32 | Add the resource to your browser scope in web/router.ex:
33 |
34 | resources "/recipes", RecipeController
35 |
36 | Remember to update your repository by running migrations:
37 |
38 | $ mix ecto.migrate
39 | ```
40 | 
41 |
42 | 我们先按照提示把 `resources "/recipes", RecipeController` 加入 `web/router.ex` 文件中:
43 |
44 | ```elixir
45 | diff --git a/web/router.ex b/web/router.ex
46 | index e0811dc..a6d7cd5 100644
47 | --- a/web/router.ex
48 | +++ b/web/router.ex
49 | @@ -20,6 +20,7 @@ defmodule TvRecipe.Router do
50 | get "/", PageController, :index
51 | resources "/users", UserController, except: [:index, :delete]
52 | resources "/sessions", SessionController, only: [:new, :create, :delete]
53 | + resources "/recipes", RecipeController
54 | end
55 | ```
56 |
57 | 但请不要着急执行 `mix ecto.migrate`,我们有几个需要调整的地方:
58 |
59 | 1. 新建的 `priv/repo/migrations/20170206013306_create_recipe.exs` 文件中,有如下一句代码:
60 |
61 | ```elixir
62 | add :user_id, references(:users, on_delete: :nothing)
63 | ```
64 | `on_delete` 决定 `recipe` 关联的 `user` 被删时,我们要如何处置 `recipe`。`:nothing` 表示不动 `recipe`,`:delete_all` 表示悉数删除,这里我们使用 `:delete_all`。
65 | 2. 新建的 `web/models/recipe.ex` 文件中,有一句代码:
66 |
67 | ```elixir
68 | belongs_to :user, TvRecipe.User
69 | ```
70 | 因为 `Recipe` 与 `User` 的关系是双向的,所以我们需要在 `user.ex` 文件中增加一句:
71 |
72 | ```elixir
73 | has_many :recipes, TvRecipe.Recipe
74 | ```
75 | 3. 我们需要在 `recipe.ex` 文件中给 `season` 与 `episode` 设置默认值:
76 |
77 | ```elixir
78 | field :season, :integer, default: 1
79 | field :episode, :integer, default: 1
80 | ```
81 | 现在,我们可以执行 `mix ecto.migrate` 了:
82 |
83 | ```bash
84 | $ mix ecto.migrate
85 |
86 | 12:45:54.141 [info] == Running TvRecipe.Repo.Migrations.CreateRecipe.change/0 forward
87 |
88 | 12:45:54.141 [info] create table recipes
89 |
90 | 12:45:54.146 [info] create index recipes_user_id_index
91 |
92 | 12:45:54.149 [info] == Migrated in 0.0s
93 | ```
94 | 我们运行下测试看看:
95 |
96 | ```bash
97 | $ mix test
98 | mix test
99 | Compiling 24 files (.ex)
100 | Generated tv_recipe app
101 | ...............................................
102 |
103 | Finished in 0.5 seconds
104 | 47 tests, 0 failures
105 | ```
106 | 新生成的测试目前悉数通过。
107 |
108 |
--------------------------------------------------------------------------------
/07-recipe/02-recipe-scheme.md:
--------------------------------------------------------------------------------
1 | # Recipe 属性开发
2 |
3 | 在开发用户时,我们曾经分章节完成各个属性。但这里不再细分。
4 |
5 | 我们来看下 `mix phoenix.gen.html` 命令生成的 `recipe_test.exs` 文件内容:
6 |
7 | ```elixir
8 | defmodule TvRecipe.RecipeTest do
9 | use TvRecipe.ModelCase
10 |
11 | alias TvRecipe.Recipe
12 |
13 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"}
14 | @invalid_attrs %{}
15 |
16 | test "changeset with valid attributes" do
17 | changeset = Recipe.changeset(%Recipe{}, @valid_attrs)
18 | assert changeset.valid?
19 | end
20 |
21 | test "changeset with invalid attributes" do
22 | changeset = Recipe.changeset(%Recipe{}, @invalid_attrs)
23 | refute changeset.valid?
24 | end
25 | end
26 | ```
27 | 很显然,默认生成的 `@valid_attrs` 是无效的,因为少了一个 `user_id`。
28 |
29 | 但我们先处理其它属性,因为比较简单。
30 |
31 | 我们先增加测试:
32 |
33 | ```elixir
34 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs
35 | index a974aad..27f02ea 100644
36 | --- a/test/models/recipe_test.exs
37 | +++ b/test/models/recipe_test.exs
38 | @@ -15,4 +15,29 @@ defmodule TvRecipe.RecipeTest do
39 | changeset = Recipe.changeset(%Recipe{}, @invalid_attrs)
40 | refute changeset.valid?
41 | end
42 | +
43 | + test "name is required" do
44 | + attrs = %{@valid_attrs | name: ""}
45 | + assert {:name, "请填写"} in errors_on(%Recipe{}, attrs)
46 | + end
47 | +
48 | + test "title is required" do
49 | + attrs = %{@valid_attrs | title: ""}
50 | + assert {:title, "请填写"} in errors_on(%Recipe{}, attrs)
51 | + end
52 | +
53 | + test "season is required" do
54 | + attrs = %{@valid_attrs | season: nil}
55 | + assert {:season, "请填写"} in errors_on(%Recipe{}, attrs)
56 | + end
57 | +
58 | + test "episode is required" do
59 | + attrs = %{@valid_attrs | episode: nil}
60 | + assert {:episode, "请填写"} in errors_on(%Recipe{}, attrs)
61 | + end
62 | +
63 | + test "season should greater than 0" do
64 | + attrs = %{@valid_attrs | season: 0}
65 | + assert {:season, "请输入大于 0 的数字"} in errors_on(%Recipe{}, attrs)
66 | + end
67 | +
68 | + test "episode should greater than 0" do
69 | + attrs = %{@valid_attrs | episode: 0}
70 | + assert {:episode, "请输入大于 0 的数字"} in errors_on(%Recipe{}, attrs)
71 | + end
72 | +
73 | + test "content is required" do
74 | + attrs = %{@valid_attrs | content: ""}
75 | + assert {:content, "请填写"} in errors_on(%Recipe{}, attrs)
76 | + end
77 | end
78 | ```
79 | 然后修改 `recipe.ex` 文件,自定义验证消息:
80 |
81 | ```elixir
82 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex
83 | index 946d45c..8d34ed2 100644
84 | --- a/web/models/recipe.ex
85 | +++ b/web/models/recipe.ex
86 | @@ -18,6 +18,6 @@ defmodule TvRecipe.Recipe do
87 | def changeset(struct, params \\ %{}) do
88 | struct
89 | |> cast(params, [:name, :title, :season, :episode, :content])
90 | - |> validate_required([:name, :title, :season, :episode, :content])
91 | + |> validate_required([:name, :title, :season, :episode, :content], message: "请填写")
92 | + |> validate_number(:season, greater_than: 0, message: "请输入大于 0 的数字")
93 | + |> validate_number(:episode, greater_than: 0, message: "请输入大于 0 的数字")
94 | end
95 | end
96 | ```
97 | ## `user_id`
98 |
99 | `user_id` 有两条规则:
100 |
101 | 1. `user_id` 必填
102 | 2. `user_id` 对应着 `users` 表中用户的 id,该用户在表中必须存在
103 |
104 | ### 必填
105 |
106 | 我们先处理 `user_id` 必填的规则,补充一个测试,如下:
107 |
108 | ```elixir
109 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs
110 | index 27f02ea..3a9630b 100644
111 | --- a/test/models/recipe_test.exs
112 | +++ b/test/models/recipe_test.exs
113 | @@ -3,7 +3,7 @@ defmodule TvRecipe.RecipeTest do
114 |
115 | alias TvRecipe.Recipe
116 |
117 | - @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"}
118 | + @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content", user_id: 1}
119 | @invalid_attrs %{}
120 |
121 | test "changeset with valid attributes" do
122 | @@ -40,4 +40,9 @@ defmodule TvRecipe.RecipeTest do
123 | attrs = %{@valid_attrs | content: ""}
124 | assert {:content, "请填写"} in errors_on(%Recipe{}, attrs)
125 | end
126 | +
127 | + test "user_id is required" do
128 | + attrs = %{@valid_attrs | user_id: nil}
129 | + assert {:user_id, "请填写"} in errors_on(%Recipe{}, attrs)
130 | + end
131 | end
132 | ```
133 | 然后修改 `recipe.ex` 文件:
134 |
135 | ```elixir
136 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex
137 | index 8d34ed2..0520582 100644
138 | --- a/web/models/recipe.ex
139 | +++ b/web/models/recipe.ex
140 | @@ -17,7 +17,7 @@ defmodule TvRecipe.Recipe do
141 | """
142 | def changeset(struct, params \\ %{}) do
143 | struct
144 | - |> cast(params, [:name, :title, :season, :episode, :content])
145 | - |> validate_required([:name, :title, :season, :episode, :content], message: "请填写")
146 | + |> cast(params, [:name, :title, :season, :episode, :content, :user_id])
147 | + |> validate_required([:name, :title, :season, :episode, :content, :user_id], message: "请填写")
148 | end
149 | end
150 | ```
151 | 运行新增的测试:
152 |
153 | ```bash
154 | $ mix test test/models/recipe_test.exs:54
155 | Including tags: [line: "54"]
156 | Excluding tags: [:test]
157 |
158 | .
159 |
160 | Finished in 0.1 seconds
161 | 10 tests, 0 failures, 9 skipped
162 | ```
163 | 注意,我们只测试前面新增的测试,`:54` 表示执行该文件中第 54 行开始的 `test` 块。
164 |
165 | ### `user_id` 所指向的用户应存在
166 |
167 | 我们在 `recipe_test.exs` 文件中再增加一个测试,确保 `user_id` 所指的用户存在:
168 |
169 | ```elixir
170 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs
171 | index 3a9630b..2e1191c 100644
172 | --- a/test/models/recipe_test.exs
173 | +++ b/test/models/recipe_test.exs
174 | @@ -1,7 +1,7 @@
175 | defmodule TvRecipe.RecipeTest do
176 | use TvRecipe.ModelCase
177 |
178 | - alias TvRecipe.Recipe
179 | + alias TvRecipe.{Repo, Recipe}
180 |
181 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content", user_id: 1}
182 | @invalid_attrs %{}
183 | @@ -45,4 +45,9 @@ defmodule TvRecipe.RecipeTest do
184 | attrs = %{@valid_attrs | user_id: nil}
185 | assert {:user_id, "请填写"} in errors_on(%Recipe{}, attrs)
186 | end
187 | +
188 | + test "user_id should exist in users table" do
189 | + {:error, changeset} = Repo.insert Recipe.changeset(%Recipe{}, @valid_attrs)
190 | + assert {:user_id, "用户不存在"} in errors_on(changeset)
191 | + end
192 | end
193 | ```
194 | 运行新增的测试:
195 |
196 | ```bash
197 | $ mix test test/models/recipe_test.exs:59
198 | Compiling 13 files (.ex)
199 | Including tags: [line: "59"]
200 | Excluding tags: [:test]
201 |
202 |
203 |
204 | 1) test user_id should exist in users table (TvRecipe.RecipeTest)
205 | test/models/recipe_test.exs:59
206 | ** (Ecto.ConstraintError) constraint error when attempting to insert struct:
207 |
208 | * foreign_key: recipes_user_id_fkey
209 |
210 | If you would like to convert this constraint into an error, please
211 | call foreign_key_constraint/3 in your changeset and define the proper
212 | constraint name. The changeset has not defined any constraint.
213 |
214 | stacktrace:
215 | (ecto) lib/ecto/repo/schema.ex:493: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
216 | (elixir) lib/enum.ex:1229: Enum."-map/2-lists^map/1-0-"/2
217 | (ecto) lib/ecto/repo/schema.ex:479: Ecto.Repo.Schema.constraints_to_errors/3
218 | (ecto) lib/ecto/repo/schema.ex:213: anonymous fn/13 in Ecto.Repo.Schema.do_insert/4
219 | (ecto) lib/ecto/repo/schema.ex:684: anonymous fn/3 in Ecto.Repo.Schema.wrap_in_transaction/6
220 | (ecto) lib/ecto/adapters/sql.ex:615: anonymous fn/3 in Ecto.Adapters.SQL.do_transaction/3
221 | (db_connection) lib/db_connection.ex:1274: DBConnection.transaction_run/4
222 | (db_connection) lib/db_connection.ex:1198: DBConnection.run_begin/3
223 | (db_connection) lib/db_connection.ex:789: DBConnection.transaction/3
224 | test/models/recipe_test.exs:60: (test)
225 |
226 |
227 |
228 | Finished in 0.2 seconds
229 | 11 tests, 1 failure, 10 skipped
230 | ```
231 | 测试失败,但 Phoenix 给了 [foreign_key_constraint](https://hexdocs.pm/ecto/Ecto.Changeset.html#foreign_key_constraint/3) 的提示:
232 |
233 | ```elixir
234 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex
235 | index 0520582..a0b42fd 100644
236 | --- a/web/models/recipe.ex
237 | +++ b/web/models/recipe.ex
238 | @@ -19,5 +19,6 @@ defmodule TvRecipe.Recipe do
239 | struct
240 | |> cast(params, [:name, :title, :season, :episode, :content, :user_id])
241 | |> validate_required([:name, :title, :season, :episode, :content, :user_id], message: "请填写")
242 | + |> foreign_key_constraint(:user_id, message: "用户不存在")
243 | end
244 | end
245 | ```
246 | 再次运行测试:
247 |
248 | ```bash
249 | $ mix test test/models/recipe_test.exs:59
250 | Compiling 13 files (.ex)
251 | Including tags: [line: "59"]
252 | Excluding tags: [:test]
253 |
254 | .
255 |
256 | Finished in 0.1 seconds
257 | 11 tests, 0 failures, 10 skipped
258 | ```
259 | 测试通过。
260 |
261 | 但我们运行所有测试的话:
262 |
263 | ```bash
264 | $ mix test
265 | ..........................................
266 |
267 | 1) test creates resource and redirects when data is valid (TvRecipe.RecipeControllerTest)
268 | test/controllers/recipe_controller_test.exs:18
269 | ** (RuntimeError) expected redirection with status 302, got: 200
270 | stacktrace:
271 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2
272 | test/controllers/recipe_controller_test.exs:20: (test)
273 |
274 |
275 |
276 | 2) test updates chosen resource and redirects when data is valid (TvRecipe.RecipeControllerTest)
277 | test/controllers/recipe_controller_test.exs:47
278 | ** (RuntimeError) expected redirection with status 302, got: 200
279 | stacktrace:
280 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2
281 | test/controllers/recipe_controller_test.exs:50: (test)
282 |
283 | ..........
284 |
285 | Finished in 0.6 seconds
286 | 56 tests, 2 failures
287 | ```
288 | `recipe_controller_test.exs` 文件中出现两个错误 - 不过我们留给下一章处理。
--------------------------------------------------------------------------------
/07-recipe/03-recipe-controller.md:
--------------------------------------------------------------------------------
1 | # Recipe 控制器
2 |
3 | 上一章结尾,我们运行 `mix test` 检查出 `recipe_controller_test.exs` 文件中的两个错误。
4 |
5 | 很显然,它们是因为 `@valid_attrs` 中缺少 `user_id` 导致的。
6 |
7 | 怎么办,在 `@valid_attrs` 中随意添加个 `user_id` 可不能解决问题 - 用户必须存在。
8 |
9 | 一个粗暴的解决办法,是在每个测试中新建一个用户,然后把用户 id 传给 `@valid_attrs`,但那样又要重复一堆代码,我们可以把新建用户部分抽取到 `setup` 中:
10 |
11 | ```elixir
12 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
13 | index 646ebf2..51fdeab 100644
14 | --- a/test/controllers/recipe_controller_test.exs
15 | +++ b/test/controllers/recipe_controller_test.exs
16 | @@ -1,10 +1,16 @@
17 | defmodule TvRecipe.RecipeControllerTest do
18 | use TvRecipe.ConnCase
19 |
20 | - alias TvRecipe.Recipe
21 | + alias TvRecipe.{Repo, User, Recipe}
22 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"}
23 | @invalid_attrs %{}
24 |
25 | + setup do
26 | + user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)})
27 | + attrs = Map.put(@valid_attrs, :user_id, user.id)
28 | + {:ok, [attrs: attrs]}
29 | + end
30 | +
31 | test "lists all entries on index", %{conn: conn} do
32 | conn = get conn, recipe_path(conn, :index)
33 | assert html_response(conn, 200) =~ "Listing recipes"
34 | @@ -15,10 +21,10 @@ defmodule TvRecipe.RecipeControllerTest do
35 | assert html_response(conn, 200) =~ "New recipe"
36 | end
37 | + user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)})
38 | + attrs = Map.put(@valid_attrs, :user_id, user.id)
39 | + {:ok, [attrs: attrs]}
40 | + end
41 | +
42 | test "lists all entries on index", %{conn: conn} do
43 | conn = get conn, recipe_path(conn, :index)
44 | assert html_response(conn, 200) =~ "Listing recipes"
45 | @@ -15,10 +21,10 @@ defmodule TvRecipe.RecipeControllerTest do
46 | assert html_response(conn, 200) =~ "New recipe"
47 | end
48 |
49 | - test "creates resource and redirects when data is valid", %{conn: conn} do
50 | - conn = post conn, recipe_path(conn, :create), recipe: @valid_attrs
51 | + test "creates resource and redirects when data is valid", %{conn: conn, attrs: attrs} do
52 | + conn = post conn, recipe_path(conn, :create), recipe: attrs
53 | assert redirected_to(conn) == recipe_path(conn, :index)
54 | - assert Repo.get_by(Recipe, @valid_attrs)
55 | + assert Repo.get_by(Recipe, attrs)
56 | end
57 |
58 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do
59 | @@ -44,11 +50,11 @@ defmodule TvRecipe.RecipeControllerTest do
60 | assert html_response(conn, 200) =~ "Edit recipe"
61 | end
62 |
63 | - test "updates chosen resource and redirects when data is valid", %{conn: conn} do
64 | + test "updates chosen resource and redirects when data is valid", %{conn: conn, attrs: attrs} do
65 | recipe = Repo.insert! %Recipe{}
66 | - conn = put conn, recipe_path(conn, :update, recipe), recipe: @valid_attrs
67 | + conn = put conn, recipe_path(conn, :update, recipe), recipe: attrs
68 | assert redirected_to(conn) == recipe_path(conn, :show, recipe)
69 | - assert Repo.get_by(Recipe, @valid_attrs)
70 | + assert Repo.get_by(Recipe, attrs)
71 | end
72 | ```
73 | 在 `setup` 块中,我们新建了一个用户,并且重新组合出真正有效的 recipe 属性 `attrs`,然后返回。
74 |
75 | 现在运行测试:
76 |
77 | ```bash
78 | $ mix test
79 | ......................................................
80 |
81 | Finished in 0.8 seconds
82 | 56 tests, 0 failures
83 | ```
84 | 非常好,全部通过了。
85 |
86 | 接下来,我们处理动作的权限问题。
87 |
88 | ## Recipe 动作的权限
89 |
90 | 我们先确认 `RecipeController` 模块中各个动作的权限要求:
91 |
92 | 动作名|是否需要登录
93 | ---|---
94 | index|需要
95 | new|需要
96 | create|需要
97 | show|需要
98 | edit|需要
99 | update|需要
100 | delete|需要
101 |
102 | 都要登录?难道未登录用户不能查看其它用户创建的菜谱?当然可以,但我们将新建路由来满足这些需求。这一节,我们开发的是 Recipe 相关的管理动作。
103 |
104 | 前面章节中我们已经尝试过使用 `tag` 来标注用户登录状态下的测试,现在根据上面罗列的需求来修改 `recipe_controller_test.exs` 文件中的测试:
105 |
106 | ```elixir
107 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
108 | index 51fdeab..5632f8c 100644
109 | --- a/test/controllers/recipe_controller_test.exs
110 | +++ b/test/controllers/recipe_controller_test.exs
111 | @@ -5,51 +5,65 @@ defmodule TvRecipe.RecipeControllerTest do
112 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"}
113 | @invalid_attrs %{}
114 |
115 | - setup do
116 | - user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)})
117 | - attrs = Map.put(@valid_attrs, :user_id, user.id)
118 | - {:ok, [attrs: attrs]}
119 | + setup %{conn: conn} = context do
120 | + user_attrs = %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)}
121 | + user = Repo.insert! User.changeset(%User{}, user_attrs)
122 | + attrs = Map.put(@valid_attrs, :user_id, user.id)
123 | + if context[:logged_in] == true do
124 | + conn = post conn, session_path(conn, :create), session: user_attrs
125 | + {:ok, [conn: conn, attrs: attrs]}
126 | + else
127 | + {:ok, [attrs: attrs]}
128 | + end
129 | end
130 | -
131 | +
132 | + @tag logged_in: true
133 | test "lists all entries on index", %{conn: conn} do
134 | conn = get conn, recipe_path(conn, :index)
135 | assert html_response(conn, 200) =~ "Listing recipes"
136 | end
137 |
138 | + @tag logged_in: true
139 | test "renders form for new resources", %{conn: conn} do
140 | conn = get conn, recipe_path(conn, :new)
141 | assert html_response(conn, 200) =~ "New recipe"
142 | end
143 |
144 | + @tag logged_in: true
145 | test "creates resource and redirects when data is valid", %{conn: conn, attrs: attrs} do
146 | conn = post conn, recipe_path(conn, :create), recipe: attrs
147 | assert redirected_to(conn) == recipe_path(conn, :index)
148 | assert Repo.get_by(Recipe, attrs)
149 | end
150 |
151 | + @tag logged_in: true
152 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do
153 | conn = post conn, recipe_path(conn, :create), recipe: @invalid_attrs
154 | assert html_response(conn, 200) =~ "New recipe"
155 | end
156 |
157 | + @tag logged_in: true
158 | test "shows chosen resource", %{conn: conn} do
159 | recipe = Repo.insert! %Recipe{}
160 | conn = get conn, recipe_path(conn, :show, recipe)
161 | assert html_response(conn, 200) =~ "Show recipe"
162 | end
163 |
164 | + @tag logged_in: true
165 | + @tag logged_in: true
166 | test "renders page not found when id is nonexistent", %{conn: conn} do
167 | assert_error_sent 404, fn ->
168 | get conn, recipe_path(conn, :show, -1)
169 | end
170 | end
171 |
172 | + @tag logged_in: true
173 | test "renders form for editing chosen resource", %{conn: conn} do
174 | recipe = Repo.insert! %Recipe{}
175 | conn = get conn, recipe_path(conn, :edit, recipe)
176 | assert html_response(conn, 200) =~ "Edit recipe"
177 | end
178 |
179 | + @tag logged_in: true
180 | test "updates chosen resource and redirects when data is valid", %{conn: conn, attrs: attrs} do
181 | recipe = Repo.insert! %Recipe{}
182 | conn = put conn, recipe_path(conn, :update, recipe), recipe: attrs
183 | @@ -57,12 +71,14 @@ defmodule TvRecipe.RecipeControllerTest do
184 | assert Repo.get_by(Recipe, attrs)
185 | end
186 |
187 | + @tag logged_in: true
188 | test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do
189 | recipe = Repo.insert! %Recipe{}
190 | conn = put conn, recipe_path(conn, :update, recipe), recipe: @invalid_attrs
191 | assert html_response(conn, 200) =~ "Edit recipe"
192 | end
193 |
194 | + @tag logged_in: true
195 | test "deletes chosen resource", %{conn: conn} do
196 | recipe = Repo.insert! %Recipe{}
197 | conn = delete conn, recipe_path(conn, :delete, recipe)
198 | ```
199 |
200 | 我们给所有测试代码都加上了 `@tag logged_in` 的标签。
201 |
202 | 接下来我们需要一个验证用户登录状态的 plug,不巧我们在 `user_controller.ex` 文件中已经定义了一个 `login_require` 的 plug,现在是其它地方也要用到它 - 再放在 `user_controller.ex` 中并不合适,我们将它移到 `auth.ex` 文件中:
203 |
204 | ```elixir
205 | diff --git a/web/controllers/auth.ex b/web/controllers/auth.ex
206 | index e298b68..3dd3e7f 100644
207 | --- a/web/controllers/auth.ex
208 | +++ b/web/controllers/auth.ex
209 | @@ -1,5 +1,7 @@
210 | defmodule TvRecipe.Auth do
211 | import Plug.Conn
212 | + import Phoenix.Controller
213 | + alias TvRecipe.Router.Helpers
214 |
215 | @doc """
216 | 初始化选项
217 | @@ -21,4 +23,37 @@ defmodule TvRecipe.Auth do
218 | |> configure_session(renew: true)
219 | end
220 |
221 | + @doc """
222 | + 检查用户登录状态
223 | +
224 | + Returns `conn`
225 | + """
226 | + def login_require(conn, _opts) do
227 | + if conn.assigns.current_user do
228 | + conn
229 | + else
230 | + conn
231 | + |> put_flash(:info, "请先登录")
232 | + |> redirect(to: Helpers.session_path(conn, :new))
233 | + |> halt()
234 | + end
235 | + end
236 | +
237 | + @doc """
238 | + 检查用户是否授权访问动作
239 | +
240 | + Returns `conn`
241 | + """
242 | + def self_require(conn, _opts) do
243 | + %{"id" => id} = conn.params
244 | + if String.to_integer(id) == conn.assigns.current_user.id do
245 | + conn
246 | + else
247 | + conn
248 | + |> put_flash(:error, "禁止访问未授权页面")
249 | + |> redirect(to: Helpers.user_path(conn, :show, conn.assigns.current_user))
250 | + |> halt()
251 | + end
252 | + end
253 | +
254 | end
255 | \ No newline at end of file
256 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
257 | index 520d986..0f023d3 100644
258 | --- a/web/controllers/user_controller.ex
259 | +++ b/web/controllers/user_controller.ex
260 | @@ -49,36 +49,4 @@ defmodule TvRecipe.UserController do
261 | end
262 | end
263 |
264 | - @doc """
265 | - 检查用户登录状态
266 | -
267 | - Returns `conn`
268 | - """
269 | - def login_require(conn, _opts) do
270 | - if conn.assigns.current_user do
271 | - conn
272 | - else
273 | - conn
274 | - |> put_flash(:info, "请先登录")
275 | - |> redirect(to: session_path(conn, :new))
276 | - |> halt()
277 | - end
278 | - end
279 | -
280 | - @doc """
281 | end
282 |
283 | - @doc """
284 | - 检查用户登录状态
285 | -
286 | - Returns `conn`
287 | - """
288 | - def login_require(conn, _opts) do
289 | - if conn.assigns.current_user do
290 | - conn
291 | - else
292 | - conn
293 | - |> put_flash(:info, "请先登录")
294 | - |> redirect(to: session_path(conn, :new))
295 | - |> halt()
296 | - end
297 | - end
298 | -
299 | - @doc """
300 | - 检查用户是否授权访问动作
301 | -
302 | - Returns `conn`
303 | - """
304 | - def self_require(conn, _opts) do
305 | - %{"id" => id} = conn.params
306 | - if String.to_integer(id) == conn.assigns.current_user.id do
307 | - conn
308 | - else
309 | - conn
310 | - |> put_flash(:error, "禁止访问未授权页面")
311 | - |> redirect(to: user_path(conn, :show, conn.assigns.current_user))
312 | - |> halt()
313 | - end
314 | - end
315 | end
316 | ```
317 | 注意,我们并非只是简单的移动文本到 `auth.ex` 文件中。在 `auth.ex` 头部,我们还引入了两行代码,并调整了两个 plug:
318 |
319 | ```elixir
320 | import Phoenix.Controller
321 | alias TvRecipe.Router.Helpers
322 | ```
323 | `import Phoenix.Controller` 导入 `put_flash` 等方法,而 `alias TvRecipe.Router.Helpers` 让我们在 `auth.ex` 中可以快速书写各种路径。
324 |
325 | 接着在 `web.ex` 文件中 `import` 它:
326 |
327 | ```elixir
328 | diff --git a/web/web.ex b/web/web.ex
329 | index 50fd62e..9990080 100644
330 | --- a/web/web.ex
331 | +++ b/web/web.ex
332 | @@ -36,6 +36,7 @@ defmodule TvRecipe.Web do
333 |
334 | import TvRecipe.Router.Helpers
335 | import TvRecipe.Gettext
336 | + import TvRecipe.Auth, only: [login_require: 2, self_require: 2]
337 | end
338 | end
339 | ```
340 | 注意,目前我们只在 `controller` 中 `import`,后面可能会需要在 `router` 中 `import`。
341 |
342 | 最后,将 plug 应用到 `recipe_controller.ex` 文件中:
343 |
344 | ```elixir
345 | diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex
346 | index 96a0276..c74b492 100644
347 | --- a/web/controllers/recipe_controller.ex
348 | +++ b/web/controllers/recipe_controller.ex
349 | @@ -1,6 +1,6 @@
350 | defmodule TvRecipe.RecipeController do
351 | use TvRecipe.Web, :controller
352 | -
353 | + plug :login_require
354 | alias TvRecipe.Recipe
355 |
356 | def index(conn, _params) do
357 | ```
358 | 我们这次并没有使用 `when action in`,plug 将应用到该文件中所有的动作。
359 |
360 | 运行测试:
361 |
362 | ```bash
363 | $ mix test
364 | ......................................................
365 |
366 | Finished in 0.7 seconds
367 | 56 tests, 0 failures
368 | ```
369 | 一切顺利。
370 |
371 | 但因为我们现在所有测试都针对登录状态,我们还需要补充下未登录状态的测试:
372 |
373 | ```elixir
374 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
375 | index 5632f8c..faf67ca 100644
376 | --- a/test/controllers/recipe_controller_test.exs
377 | +++ b/test/controllers/recipe_controller_test.exs
378 | @@ -16,7 +16,7 @@ defmodule TvRecipe.RecipeControllerTest do
379 | {:ok, [attrs: attrs]}
380 | end
381 | end
382 | -
383 | +
384 | @tag logged_in: true
385 | test "lists all entries on index", %{conn: conn} do
386 | conn = get conn, recipe_path(conn, :index)
387 | @@ -85,4 +85,20 @@ defmodule TvRecipe.RecipeControllerTest do
388 | assert redirected_to(conn) == recipe_path(conn, :index)
389 | refute Repo.get(Recipe, recipe.id)
390 | end
391 | +
392 | + test "guest access user action redirected to login page", %{conn: conn} do
393 | + recipe = Repo.insert! %Recipe{}
394 | + Enum.each([
395 | + get(conn, recipe_path(conn, :index)),
396 | + get(conn, recipe_path(conn, :new)),
397 | + get(conn, recipe_path(conn, :create), recipe: %{}),
398 | + get(conn, recipe_path(conn, :show, recipe)),
399 | + get(conn, recipe_path(conn, :edit, recipe)),
400 | + put(conn, recipe_path(conn, :update, recipe), recipe: %{}),
401 | + put(conn, recipe_path(conn, :delete, recipe)),
402 | + ], fn conn ->
403 | + assert redirected_to(conn) == session_path(conn, :new)
404 | + assert conn.halted
405 | + end)
406 | + end
407 | end
408 | ```
409 |
410 | ## `user_id` 与 `:current_user`
411 |
412 | 在前面的测试里,我们先创建一个新用户,然后登录新用户,新建 recipe,并将新用户的 id 与新建的 recipe 关联起来。
413 |
414 | 考虑另一种情况:
415 |
416 | 1. 新建用户 A
417 | 2. 新建用户 B
418 | 3. 登录用户 B
419 | 4. 关联用户 A 的 id 与 Recipe
420 |
421 | 这一种情况,我们的测试还没有覆盖到。
422 |
423 | 我们来新增一个测试,验证一下:
424 |
425 | ```elixir
426 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
427 | index faf67ca..d8157a2 100644
428 | --- a/test/controllers/recipe_controller_test.exs
429 | +++ b/test/controllers/recipe_controller_test.exs
430 | @@ -101,4 +101,17 @@ defmodule TvRecipe.RecipeControllerTest do
431 | assert conn.halted
432 | end)
433 | end
434 | +
435 | + @tag logged_in: true
436 | + test "creates resource and redirects when data is valid but with other user_id", %{conn: conn, attrs: attrs} do
437 | + # 新建一个用户
438 | + user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan+1@gmail.com", username: "samchen", password: String.duplicate("1", 6)})
439 | + # 将新用户的 id 更新入 attrs,尝试替 samchen 创建一个菜谱
440 | + new_attrs = %{attrs | user_id: user.id}
441 | + post conn, recipe_path(conn, :create), recipe: new_attrs
442 | + # 用户 chenxsan 只能创建自己的菜谱,无法替 samchen 创建菜谱
443 | + assert Repo.get_by(Recipe, attrs)
444 | + # samchen 不应该有菜谱
445 | + refute Repo.get_by(Recipe, new_attrs)
446 | + end
447 | end
448 | ```
449 | 运行测试:
450 |
451 | ```bash
452 | $ mix test
453 | ..........................
454 |
455 | 1) test creates resource and redirects when data is valid but with other user_id (TvRecipe.RecipeControllerTest)
456 | test/controllers/recipe_controller_test.exs:106
457 | Expected truthy, got nil
458 | code: Repo.get_by(Recipe, attrs)
459 | stacktrace:
460 | test/controllers/recipe_controller_test.exs:113: (test)
461 |
462 | .............................
463 |
464 | Finished in 0.7 seconds
465 | 58 tests, 1 failure
466 | ```
467 | 测试失败:登录状态下的用户 A 给用户 B 创建了一个菜谱。
468 |
469 | 那么要如何修改我们的控制器代码?
470 |
471 | 我们可以像测试中一样,把登录用户的 id 传递进去,比如:
472 |
473 | ```elixir
474 | def create(conn, %{"recipe" => recipe_params}) do
475 | changeset = Recipe.changeset(%Recipe{}, Map.put(recipe_params, "user_id", conn.assigns.current_user.id))
476 | ```
477 | 可是,我们为什么要提供给用户传递 `user_id` 的机会呢?
478 |
479 | 让我们从 `recipe.ex` 文件中把 `user_id` 相关的代码去掉:
480 |
481 | ```elixir
482 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex
483 | index a0b42fd..8d34ed2 100644
484 | --- a/web/models/recipe.ex
485 | +++ b/web/models/recipe.ex
486 | @@ -17,8 +17,7 @@ defmodule TvRecipe.Recipe do
487 | """
488 | def changeset(struct, params \\ %{}) do
489 | struct
490 | - |> cast(params, [:name, :title, :season, :episode, :content, :user_id])
491 | - |> validate_required([:name, :title, :season, :episode, :content, :user_id], message: "请填写")
492 | - |> foreign_key_constraint(:user_id, message: "用户不存在")
493 | + |> cast(params, [:name, :title, :season, :episode, :content])
494 | + |> validate_required([:name, :title, :season, :episode, :content], message: "请填写")
495 | end
496 | end
497 | ```
498 | `recipe_test.exs` 中 `user_id` 相关的代码也要去掉:
499 |
500 | ```elixir
501 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs
502 | index 2e1191c..4e59fb9 100644
503 | --- a/test/models/recipe_test.exs
504 | +++ b/test/models/recipe_test.exs
505 | @@ -3,7 +3,7 @@ defmodule TvRecipe.RecipeTest do
506 |
507 | alias TvRecipe.{Recipe}
508 |
509 | - @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content", user_id: 1}
510 | + @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"}
511 | @invalid_attrs %{}
512 |
513 | test "changeset with valid attributes" do
514 | @@ -40,14 +40,5 @@ defmodule TvRecipe.RecipeTest do
515 | attrs = %{@valid_attrs | content: ""}
516 | assert {:content, "请填写"} in errors_on(%Recipe{}, attrs)
517 | end
518 | -
519 | - test "user_id is required" do
520 | - attrs = %{@valid_attrs | user_id: nil}
521 | - assert {:user_id, "请填写"} in errors_on(%Recipe{}, attrs)
522 | - end
523 |
524 | - test "user_id should exist in users table" do
525 | - {:error, changeset} = Repo.insert Recipe.changeset(%Recipe{}, @valid_attrs)
526 | - assert {:user_id, "用户不存在"} in errors_on(changeset)
527 | - end
528 | end
529 | ```
530 | 不要忘了还有 `recipe_controller_test.exs` 文件中的代码:
531 |
532 | ```elixir
533 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
534 | index d8157a2..d953315 100644
535 | --- a/test/controllers/recipe_controller_test.exs
536 | +++ b/test/controllers/recipe_controller_test.exs
537 | @@ -7,13 +7,12 @@ defmodule TvRecipe.RecipeControllerTest do
538 |
539 | setup %{conn: conn} = context do
540 | user_attrs = %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)}
541 | - user = Repo.insert! User.changeset(%User{}, user_attrs)
542 | - attrs = Map.put(@valid_attrs, :user_id, user.id)
543 | + Repo.insert! User.changeset(%User{}, user_attrs)
544 | if context[:logged_in] == true do
545 | conn = post conn, session_path(conn, :create), session: user_attrs
546 | - {:ok, [conn: conn, attrs: attrs]}
547 | + {:ok, [conn: conn]}
548 | else
549 | - {:ok, [attrs: attrs]}
550 | + :ok
551 | end
552 | end
553 |
554 | @@ -30,10 +29,10 @@ defmodule TvRecipe.RecipeControllerTest do
555 | end
556 |
557 | @tag logged_in: true
558 | - test "creates resource and redirects when data is valid", %{conn: conn, attrs: attrs} do
559 | - conn = post conn, recipe_path(conn, :create), recipe: attrs
560 | + test "creates resource and redirects when data is valid", %{conn: conn} do
561 | + conn = post conn, recipe_path(conn, :create), recipe: @valid_attrs
562 | assert redirected_to(conn) == recipe_path(conn, :index)
563 | - assert Repo.get_by(Recipe, attrs)
564 | + assert Repo.get_by(Recipe, @valid_attrs)
565 | end
566 |
567 | @tag logged_in: true
568 | @@ -64,11 +63,11 @@ defmodule TvRecipe.RecipeControllerTest do
569 | @@ -64,11 +63,11 @@ defmodule TvRecipe.RecipeControllerTest do
570 | end
571 |
572 | @tag logged_in: true
573 | - test "updates chosen resource and redirects when data is valid", %{conn: conn, attrs: attrs} do
574 | + test "updates chosen resource and redirects when data is valid", %{conn: conn} do
575 | recipe = Repo.insert! %Recipe{}
576 | - conn = put conn, recipe_path(conn, :update, recipe), recipe: attrs
577 | + conn = put conn, recipe_path(conn, :update, recipe), recipe: @valid_attrs
578 | assert redirected_to(conn) == recipe_path(conn, :show, recipe)
579 | - assert Repo.get_by(Recipe, attrs)
580 | + assert Repo.get_by(Recipe, @valid_attrs)
581 | end
582 |
583 | @tag logged_in: true
584 | @@ -102,16 +101,4 @@ defmodule TvRecipe.RecipeControllerTest do
585 | end)
586 | end
587 |
588 | - @tag logged_in: true
589 | - test "creates resource and redirects when data is valid but with other user_id", %{conn: conn, attrs: attrs} do
590 | - # 新建一个用户
591 | - user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan+1@gmail.com", username: "samchen", password: String.duplicate("1", 6)})
592 | - # 将新用户的 id 更新入 attrs,尝试替 samchen 创建一个菜谱
593 | - new_attrs = %{attrs | user_id: user.id}
594 | - post conn, recipe_path(conn, :create), recipe: new_attrs
595 | - # 用户 chenxsan 只能创建自己的菜谱,无法替 samchen 创建菜谱
596 | - assert Repo.get_by(Recipe, attrs)
597 | - # samchen 不应该有菜谱
598 | - refute Repo.get_by(Recipe, new_attrs)
599 | - end
600 | end
601 | ```
602 | 是的,绕了一圈,我们把前面新增的那个测试给删除了,因为 `Recipe.changeset` 已经不再接收 `user_id`,那个测试已经失去意义。
603 |
604 | 那么,我们要怎样将当前登录的用户 id 置入 recipe 中?
605 |
606 | Ecto 提供了 [build_assoc](https://hexdocs.pm/ecto/Ecto.html#build_assoc/3) 方法,用于处理这类“关联”,我们来改造下 `recipe_controller.ex` 文件:
607 |
608 | ```elixir
609 | diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex
610 | index c74b492..967b7bc 100644
611 | --- a/web/controllers/recipe_controller.ex
612 | +++ b/web/controllers/recipe_controller.ex
613 | @@ -9,12 +9,18 @@ defmodule TvRecipe.RecipeController do
614 | end
615 |
616 | def new(conn, _params) do
617 | - changeset = Recipe.changeset(%Recipe{})
618 | + changeset =
619 | + conn.assigns.current_user
620 | + |> build_assoc(:recipes)
621 | + |> Recipe.changeset()
622 | render(conn, "new.html", changeset: changeset)
623 | end
624 |
625 | def create(conn, %{"recipe" => recipe_params}) do
626 | - changeset = Recipe.changeset(%Recipe{}, recipe_params)
627 | + changeset =
628 | + conn.assigns.current_user
629 | + |> build_assoc(:recipes)
630 | + |> Recipe.changeset(recipe_params)
631 |
632 | case Repo.insert(changeset) do
633 | {:ok, _recipe} ->
634 | ```
635 | 你可能会好奇此时的 `changeset`,我们可以使用 `IO.inspect(changeset)` 在终端窗口中打印出来,它大致是这个样子:
636 |
637 | ```bash
638 | #Ecto.Changeset
, valid?: true>
641 | ```
642 | 可是其中并没有看到 `user_id` - 那么 `build_assoc` 构建的数据存放在哪?在 `changeset.data` 下,大致是这样:
643 |
644 | ```bash
645 | %TvRecipe.Recipe{__meta__: #Ecto.Schema.Metadata<:built, "recipes">,
646 | content: nil, episode: nil, id: nil, inserted_at: nil, name: nil, season: nil,
647 | title: nil, updated_at: nil,
648 | user: #Ecto.Association.NotLoaded, user_id: 1}
649 | ```
650 | 咦,上面看起来有点像魔法。
651 |
652 | 但我们仔细阅读 `Repo.insert` 的[说明](https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert/2),可以看到如下一段:
653 |
654 | > In case a changeset is given, the changes in the changeset are merged with the struct fields, and all of them are sent to the database.
655 |
656 | 其中有个关键词 **merged**,是的,存放在 `changeset.data` 下的数据,会被合并进去,这解释了我们 `user_id` 不在 `changeset.changes` 下,却最终在数据库中出现的魔法。
657 |
658 | 最后,我们还要调整些代码,来检验新建的 recipe 中是否包含了当前登录用户的 id:
659 |
660 | ```elixir
661 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
662 | index d953315..b901b61 100644
663 | --- a/test/controllers/recipe_controller_test.exs
664 | +++ b/test/controllers/recipe_controller_test.exs
665 | @@ -7,10 +7,10 @@ defmodule TvRecipe.RecipeControllerTest do
666 |
667 | setup %{conn: conn} = context do
668 | user_attrs = %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)}
669 | - Repo.insert! User.changeset(%User{}, user_attrs)
670 | + user = Repo.insert! User.changeset(%User{}, user_attrs)
671 | if context[:logged_in] == true do
672 | conn = post conn, session_path(conn, :create), session: user_attrs
673 | - {:ok, [conn: conn]}
674 | + {:ok, [conn: conn, user: user]}
675 | else
676 | :ok
677 | end
678 | @@ -29,10 +29,10 @@ defmodule TvRecipe.RecipeControllerTest do
679 | end
680 |
681 | @tag logged_in: true
682 | - test "creates resource and redirects when data is valid", %{conn: conn} do
683 | + test "creates resource and redirects when data is valid", %{conn: conn, user: user} do
684 | conn = post conn, recipe_path(conn, :create), recipe: @valid_attrs
685 | assert redirected_to(conn) == recipe_path(conn, :index)
686 | - assert Repo.get_by(Recipe, @valid_attrs)
687 | + assert Repo.get_by(Recipe, Map.put(@valid_attrs, :user_id, user.id))
688 | end
689 |
690 | @tag logged_in: true
691 | ```
692 | 运行测试:
693 |
694 | ```bash
695 | mix test
696 | .....................................................
697 |
698 | Finished in 0.7 seconds
699 | 55 tests, 0 failures
700 | ```
701 | 全部通过。
702 |
703 | ## 我的 recipes
704 |
705 | 我们还有一个情况未处理,用户 A 登录后现在可以查看、编辑、更新用户 B 的菜谱的,我们要禁止这些动作。
706 |
707 | 写个测试验证一下:
708 |
709 | ```elixir
710 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
711 | index b901b61..cdbc420 100644
712 | --- a/test/controllers/recipe_controller_test.exs
713 | +++ b/test/controllers/recipe_controller_test.exs
714 | @@ -101,4 +101,19 @@ defmodule TvRecipe.RecipeControllerTest do
715 | end)
716 | end
717 |
718 | + @tag logged_in: true
719 | + test "user should not allowed to show recipe of other people", %{conn: conn, user: user} do
720 | + # 当前登录用户创建了一个菜谱
721 | + conn = post conn, recipe_path(conn, :create), recipe: @valid_attrs
722 | + recipe = Repo.get_by(Recipe, Map.put(@valid_attrs, :user_id, user.id))
723 | + # 新建一个用户
724 | + new_user_attrs = %{email: "chenxsan+1@gmail.com", "username": "samchen", password: String.duplicate("1", 6)}
725 | + Repo.insert! User.changeset(%User{}, new_user_attrs)
726 | + # 登录新建的用户
727 | + conn = post conn, session_path(conn, :create), session: new_user_attrs
728 | + # 读取前头的 recipe 失败,因为它不属于新用户所有
729 | + assert_error_sent 404, fn ->
730 | + get conn, recipe_path(conn, :show, recipe)
731 | + end
732 | + end
733 | end
734 | ```
735 | 运行测试:
736 |
737 | ```bash
738 | mix test
739 | Compiling 1 file (.ex)
740 | ................................
741 |
742 | 1) test user should not allowed to show recipe of other people (TvRecipe.RecipeControllerTest)
743 | test/controllers/recipe_controller_test.exs:105
744 | expected error to be sent as 404 status, but response sent 200 without error
745 | stacktrace:
746 | (phoenix) lib/phoenix/test/conn_test.ex:570: Phoenix.ConnTest.assert_error_sent/2
747 | test/controllers/recipe_controller_test.exs:115: (test)
748 |
749 | .....................
750 |
751 | Finished in 0.8 seconds
752 | 56 tests, 1 failure
753 | ```
754 | Oops,报错了,我们期望响应是 404,却得到 200。
755 |
756 | 那么该如何取得当前登录用户自有的菜谱?
757 |
758 | 既然我们前面已经定义过用户与菜谱的关联关系,那么一切应该很容易才是。
759 |
760 | 是的,Ecto 提供了一个 [`assoc` 方法](https://hexdocs.pm/ecto/Ecto.html#assoc/2),它能帮我们取得用户关联的所有菜谱。
761 |
762 | 我们调整下 `recipe_controller.ex` 文件:
763 |
764 | ```elixir
765 | diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex
766 | index 967b7bc..22554ea 100644
767 | --- a/web/controllers/recipe_controller.ex
768 | +++ b/web/controllers/recipe_controller.ex
769 | @@ -33,7 +33,7 @@ defmodule TvRecipe.RecipeController do
770 | end
771 |
772 | def show(conn, %{"id" => id}) do
773 | - recipe = Repo.get!(Recipe, id)
774 | + recipe = Repo.get!(assoc(conn.assigns.current_user, :recipes), id)
775 | render(conn, "show.html", recipe: recipe)
776 | end
777 | ```
778 | 再运行测试:
779 |
780 | ```bash
781 | $ mix test
782 | ..........................
783 |
784 | 1) test shows chosen resource (TvRecipe.RecipeControllerTest)
785 | test/controllers/recipe_controller_test.exs:45
786 | ** (Ecto.NoResultsError) expected at least one result but got none in query:
787 |
788 | from r in TvRecipe.Recipe,
789 | where: r.user_id == ^2469,
790 | where: r.id == ^"645"
791 |
792 | stacktrace:
793 | (ecto) lib/ecto/repo/queryable.ex:78: Ecto.Repo.Queryable.one!/4
794 | (tv_recipe) web/controllers/recipe_controller.ex:36: TvRecipe.RecipeController.show/2
795 | (tv_recipe) web/controllers/recipe_controller.ex:1: TvRecipe.RecipeController.action/2
796 | (tv_recipe) web/controllers/recipe_controller.ex:1: TvRecipe.RecipeController.phoenix_controller_pipeline/2
797 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4
798 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
799 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2
800 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
801 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
802 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
803 | test/controllers/recipe_controller_test.exs:47: (test)
804 |
805 | ...........................
806 |
807 | Finished in 0.8 seconds
808 | 56 tests, 1 failure
809 | ```
810 | 我们修复了前面一个错误,但因为我们的修复代码导致了另一个新错误。
811 |
812 | 检查测试代码,可以发现,旧的测试代码已经不适用了,因为它们新建的 recipe 不包含 `user_id`。我们调整一下:
813 |
814 | ```elixir
815 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
816 | index cdbc420..d93bbd1 100644
817 | --- a/test/controllers/recipe_controller_test.exs
818 | +++ b/test/controllers/recipe_controller_test.exs
819 | @@ -42,8 +42,8 @@ defmodule TvRecipe.RecipeControllerTest do
820 | end
821 |
822 | @tag logged_in: true
823 | - test "shows chosen resource", %{conn: conn} do
824 | - recipe = Repo.insert! %Recipe{}
825 | + test "shows chosen resource", %{conn: conn, user: user} do
826 | + recipe = Repo.insert! %Recipe{user_id: user.id}
827 | conn = get conn, recipe_path(conn, :show, recipe)
828 | assert html_response(conn, 200) =~ "Show recipe"
829 | end
830 | ```
831 | 再运行测试:
832 |
833 | ```elixir
834 | $ mix test
835 | ......................................................
836 |
837 | Finished in 0.8 seconds
838 | 56 tests, 0 failures
839 | ```
840 | 通过了。
841 |
842 | 但上面我们只修改了 `show` 这个动作,其它几个动作同样需要修改:
843 |
844 | ```elixir
845 | diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex
846 | index 22554ea..f317b59 100644
847 | --- a/web/controllers/recipe_controller.ex
848 | +++ b/web/controllers/recipe_controller.ex
849 | @@ -4,7 +4,7 @@ defmodule TvRecipe.RecipeController do
850 | alias TvRecipe.Recipe
851 |
852 | def index(conn, _params) do
853 | - recipes = Repo.all(Recipe)
854 | + recipes = Repo.all(assoc(conn.assigns.current_user, :recipes))
855 | render(conn, "index.html", recipes: recipes)
856 | end
857 |
858 | @@ -38,13 +38,13 @@ defmodule TvRecipe.RecipeController do
859 | end
860 |
861 | def edit(conn, %{"id" => id}) do
862 | - recipe = Repo.get!(Recipe, id)
863 | + recipe = Repo.get!(assoc(conn.assigns.current_user, :recipes), id)
864 | changeset = Recipe.changeset(recipe)
865 | render(conn, "edit.html", recipe: recipe, changeset: changeset)
866 | end
867 |
868 | def update(conn, %{"id" => id, "recipe" => recipe_params}) do
869 | - recipe = Repo.get!(Recipe, id)
870 | + recipe = Repo.get!(assoc(conn.assigns.current_user, :recipes), id)
871 | changeset = Recipe.changeset(recipe, recipe_params)
872 |
873 | case Repo.update(changeset) do
874 | @@ -58,7 +58,7 @@ defmodule TvRecipe.RecipeController do
875 | end
876 |
877 | def delete(conn, %{"id" => id}) do
878 | - recipe = Repo.get!(Recipe, id)
879 | + recipe = Repo.get!(assoc(conn.assigns.current_user, :recipes), id)
880 |
881 | # Here we use delete! (with a bang) because we expect
882 | # it to always work (and if it does not, it will raise).
883 | ```
884 | 别忘了修复测试:
885 |
886 | ```elixir
887 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
888 | index d93bbd1..190ede9 100644
889 | --- a/test/controllers/recipe_controller_test.exs
890 | +++ b/test/controllers/recipe_controller_test.exs
891 | @@ -56,30 +56,30 @@ defmodule TvRecipe.RecipeControllerTest do
892 | end
893 |
894 | @tag logged_in: true
895 | - test "renders form for editing chosen resource", %{conn: conn} do
896 | - recipe = Repo.insert! %Recipe{}
897 | end
898 |
899 | @tag logged_in: true
900 | - test "renders form for editing chosen resource", %{conn: conn} do
901 | - recipe = Repo.insert! %Recipe{}
902 | + test "renders form for editing chosen resource", %{conn: conn, user: user} do
903 | + recipe = Repo.insert! %Recipe{user_id: user.id}
904 | conn = get conn, recipe_path(conn, :edit, recipe)
905 | assert html_response(conn, 200) =~ "Edit recipe"
906 | end
907 |
908 | @tag logged_in: true
909 | - test "updates chosen resource and redirects when data is valid", %{conn: conn} do
910 | - recipe = Repo.insert! %Recipe{}
911 | + test "updates chosen resource and redirects when data is valid", %{conn: conn, user: user} do
912 | + recipe = Repo.insert! %Recipe{user_id: user.id}
913 | conn = put conn, recipe_path(conn, :update, recipe), recipe: @valid_attrs
914 | assert redirected_to(conn) == recipe_path(conn, :show, recipe)
915 | assert Repo.get_by(Recipe, @valid_attrs)
916 | end
917 |
918 | @tag logged_in: true
919 | - test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do
920 | - recipe = Repo.insert! %Recipe{}
921 | + test "does not update chosen resource and renders errors when data is invalid", %{conn: conn, user: user} do
922 | + recipe = Repo.insert! %Recipe{user_id: user.id}
923 | conn = put conn, recipe_path(conn, :update, recipe), recipe: @invalid_attrs
924 | assert html_response(conn, 200) =~ "Edit recipe"
925 | end
926 |
927 | @tag logged_in: true
928 | - test "deletes chosen resource", %{conn: conn} do
929 | - recipe = Repo.insert! %Recipe{}
930 | + test "deletes chosen resource", %{conn: conn, user: user} do
931 | + recipe = Repo.insert! %Recipe{user_id: user.id}
932 | conn = delete conn, recipe_path(conn, :delete, recipe)
933 | assert redirected_to(conn) == recipe_path(conn, :index)
934 | refute Repo.get(Recipe, recipe.id)
935 | ```
936 |
937 | ## 数据的完整性
938 |
939 | 前面我们在处理 `user_id` 时,顺手删除了 `foreign_key_constraint`,那么,我们要如何处理这样一种情况:用户提交创建菜谱的请求,但用户突然被管理员删除。这时我们的数据库里就会出现无主的菜谱。我们希望避免这种情况。
940 |
941 | 我们在 `recipe_test.exs` 文件中重新增加一个测试:
942 |
943 | ```elixir
944 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs
945 | index 4dbc961..2e2b518 100644
946 | --- a/test/models/recipe_test.exs
947 | +++ b/test/models/recipe_test.exs
948 | @@ -1,7 +1,7 @@
949 | defmodule TvRecipe.RecipeTest do
950 | use TvRecipe.ModelCase
951 |
952 | - alias TvRecipe.{Recipe}
953 | + alias TvRecipe.{Repo, User, Recipe}
954 |
955 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"}
956 | @invalid_attrs %{}
957 | @@ -41,4 +41,13 @@ defmodule TvRecipe.RecipeTest do
958 | assert {:content, "请填写"} in errors_on(%Recipe{}, attrs)
959 | end
960 |
961 | + test "user must exist" do
962 | + changeset =
963 | + %User{id: -1}
964 | + |> Ecto.build_assoc(:recipes)
965 | + |> Recipe.changeset(@valid_attrs)
966 | + {:error, changeset} = Repo.insert changeset
967 | + assert {:user_id, "does not exist"} in errors_on(changeset)
968 | + end
969 | +
970 | end
971 | ```
972 | 运行测试:
973 |
974 | ```elixir
975 | mix test
976 | Compiling 13 files (.ex)
977 | .............................................
978 |
979 | 1) test user must exist (TvRecipe.RecipeTest)
980 | test/models/recipe_test.exs:44
981 | ** (Ecto.ConstraintError) constraint error when attempting to insert struct:
982 |
983 | * foreign_key: recipes_user_id_fkey
984 |
985 | If you would like to convert this constraint into an error, please
986 | call foreign_key_constraint/3 in your changeset and define the proper
987 | constraint name. The changeset has not defined any constraint.
988 |
989 | stacktrace:
990 | (ecto) lib/ecto/repo/schema.ex:493: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
991 | (elixir) lib/enum.ex:1229: Enum."-map/2-lists^map/1-0-"/2
992 | (ecto) lib/ecto/repo/schema.ex:479: Ecto.Repo.Schema.constraints_to_errors/3
993 | (ecto) lib/ecto/repo/schema.ex:213: anonymous fn/13 in Ecto.Repo.Schema.do_insert/4
994 | (ecto) lib/ecto/repo/schema.ex:684: anonymous fn/3 in Ecto.Repo.Schema.wrap_in_transaction/6
995 | (ecto) lib/ecto/adapters/sql.ex:615: anonymous fn/3 in Ecto.Adapters.SQL.do_transaction/3
996 | (db_connection) lib/db_connection.ex:1274: DBConnection.transaction_run/4
997 | (db_connection) lib/db_connection.ex:1198: DBConnection.run_begin/3
998 | (db_connection) lib/db_connection.ex:789: DBConnection.transaction/3
999 | test/models/recipe_test.exs:49: (test)
1000 |
1001 | .........
1002 |
1003 | Finished in 0.7 seconds
1004 | 57 tests, 1 failure
1005 | ```
1006 | 很好,`foreign_key_constraint` 的提示又出来了。修改 `recipe.ex` 文件:
1007 |
1008 | ```elixr
1009 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex
1010 | index 8d34ed2..fcc97ad 100644
1011 | --- a/web/models/recipe.ex
1012 | +++ b/web/models/recipe.ex
1013 | @@ -19,5 +19,6 @@ defmodule TvRecipe.Recipe do
1014 | struct
1015 | |> cast(params, [:name, :title, :season, :episode, :content])
1016 | |> validate_required([:name, :title, :season, :episode, :content], message: "请填写")
1017 | + |> foreign_key_constraint(:user_id)
1018 | end
1019 | end
1020 | ```
1021 | 再次运行测试:
1022 |
1023 | ```bash
1024 | mix test
1025 | Compiling 13 files (.ex)
1026 | .......................................................
1027 |
1028 | Finished in 0.7 seconds
1029 | 57 tests, 0 failures
1030 | ```
1031 |
1032 | 悉数通过。至此,我们完成了 `RecipeController` 的测试。下一章,我们将接触 View 的测试。
--------------------------------------------------------------------------------
/07-recipe/04-recipe-view.md:
--------------------------------------------------------------------------------
1 | # 菜谱视图
2 |
3 | 我们执行 [`mix phoenix.gen.html` 命令](https://github.com/phoenixframework/phoenix/blob/master/lib/mix/tasks/phoenix.gen.html.ex#L14)时,它会生成如下文件:
4 |
5 | * a schema in web/models
6 | * a view in web/views
7 | * a controller in web/controllers
8 | * a migration file for the repository
9 | * default CRUD templates in web/templates
10 | * test files for generated model and controller
11 |
12 | 其中有两个测试文件,但是没有视图的测试文件 - 为什么?难道视图不重要?不不不,只要是代码,都会有测试的必要 - 有时没写,只是一个优先级或是投入产出比上的考虑。那如何入手?
13 |
14 | 查看 `test/views` 目录,现在已经有三个文件。我们可以借鉴其中的 `error_view_test.exs` 文件。
15 |
16 | 首先在 `test/views` 目录下新建一个 `recipe_view_test.exs` 文件,然后准备好如下内容:
17 |
18 | ```elixir
19 | defmodule TvRecipe.RecipeViewTest do
20 | use TvRecipe.ConnCase, async: true
21 |
22 | # Bring render/3 and render_to_string/3 for testing custom views
23 | import Phoenix.View
24 |
25 | end
26 | ```
27 | 前面章节里我们曾经提到过,templates 文件会被编译成 view 模块下的函数。比如我们的 `web/templates/recipe/index.html.eex` 文件,最终会变成 `TvRecipe.RecipeView` 模块中的一个函数:
28 |
29 | ```elixir
30 | def render("index.html", assigns) do
31 | # 返回编译后的 eex 模板
32 | end
33 | ```
34 | 换句话说,我们其实可以在 `TvRecipe.RecipeView` 中定义 `render("index.html", assigns)`,而不必再写一个 `index.html.eex` 模板。只是模板对我们的开发更为友好,所以才从 View 中分离出来。
35 |
36 | 因此,测试模板即测试 View 中的函数。
37 |
38 | 那么,测试什么?测试我们的期望与事实是否相符。
39 |
40 | 举 `recipe/index.html.eex` 模板说,我们想在模板页面上显示哪些数据?Phoenix 最后的输出是否确保显示了?
41 |
42 | 来加一个测试:
43 |
44 | ```elixir
45 | diff --git a/test/views/recipe_view_test.exs b/test/views/recipe_view_test.exs
46 | index be4148a..8174c14 100644
47 | --- a/test/views/recipe_view_test.exs
48 | +++ b/test/views/recipe_view_test.exs
49 | @@ -4,4 +4,28 @@ defmodule TvRecipe.RecipeViewTest do
50 | # Bring render/3 and render_to_string/3 for testing custom views
51 | import Phoenix.View
52 |
53 | + alias TvRecipe.Recipe
54 | +
55 | + test "render index.html", %{conn: conn} do
56 | + recipes = [%Recipe{id: "1", name: "淘米", title: "侠饭", season: "1", episode: "1", content: "洗掉米表面的淀粉", user_id: "999"},
57 | + %Recipe{id: "2", name: "煮饭", title: "侠饭", season: "1", episode: "1", content: "浸泡", user_id: "888"}]
58 | + content = render_to_string(TvRecipe.RecipeView, "index.html", conn: conn, recipes: recipes)
59 | + # 页面上包含标题 Listing recipes
60 | + assert String.contains?(content, "Listing recipes")
61 | + for recipe <- recipes do
62 | + # 页面上包含菜谱名
63 | + assert String.contains?(content, recipe.name)
64 | + # 页面上包含节目名
65 | + assert String.contains?(content, recipe.title)
66 | + # 包含 season
67 | + assert String.contains?(content, recipe.season)
68 | + # 包含 episode
69 | + assert String.contains?(content, recipe.episode)
70 | + # 不包含所有者 id
71 | + refute String.contains?(content, recipe.user_id)
72 | + # 因为 content 很长,我们不在 index.html 里显示
73 | + refute String.contains?(content, recipe.content)
74 | + end
75 | + end
76 | +
77 | end
78 | ```
79 | 你可能要问,测试里的 `season` 为什么是 "1" 而不是 1,要知道我们给它定义的类型是 `integer`。
80 |
81 | 是的,我们本应该把它写为 1,但那样的话,我们的测试代码里就要做类型转换:
82 |
83 | ```elixir
84 | assert String.contains?(content, Integer.to_string(recipe.season))
85 | ```
86 | 为了省事,我们就直接把 1 写成了 "1" - 这并不会有问题,因为 Phoenix 在编译模板时,也会做[类型转换](https://hexdocs.pm/eex/EEx.html#eval_string/3):
87 |
88 | ```bash
89 | iex> EEx.eval_string "foo <%= bar %>", [bar: 123]
90 | "foo 123"
91 | ```
92 | 运行测试:
93 |
94 | ```bash
95 | $ mix test
96 | Compiling 1 file (.ex)
97 | ...
98 |
99 | 1) test render index.html (TvRecipe.RecipeViewTest)
100 | test/views/recipe_view_test.exs:9
101 | Expected false or nil, got true
102 | code: String.contains?(content, recipe.user_id())
103 | stacktrace:
104 | test/views/recipe_view_test.exs:25: anonymous fn/3 in TvRecipe.RecipeViewTest.test render index.html/1
105 | (elixir) lib/enum.ex:1755: Enum."-reduce/3-lists^foldl/2-0-"/3
106 | test/views/recipe_view_test.exs:15: (test)
107 |
108 | ....................................................
109 |
110 | Finished in 0.8 seconds
111 | 58 tests, 1 failure
112 | ```
113 | 一个错误发生,因为目前的 `index.html.eex` 中包含了 `content` 与 `user_id` 的内容。
114 |
115 | 我们调整下 `index.html.eex` 文件:
116 |
117 | ```eex
118 | diff --git a/web/templates/recipe/index.html.eex b/web/templates/recipe/index.html.eex
119 | index b6ff40b..1dc8a3a 100644
120 | --- a/web/templates/recipe/index.html.eex
121 | +++ b/web/templates/recipe/index.html.eex
122 | @@ -7,8 +7,6 @@
123 | Title |
124 | Season |
125 | Episode |
126 | - Content |
127 | - User |
128 |
129 | |
130 |
131 | @@ -20,8 +18,6 @@
132 | <%= recipe.title %> |
133 | <%= recipe.season %> |
134 | <%= recipe.episode %> |
135 | - <%= recipe.content %> |
136 | - <%= recipe.user_id %> |
137 |
138 |
139 | <%= link "Show", to: recipe_path(@conn, :show, recipe), class: "btn btn-default btn-xs" %>
140 | ```
141 |
142 | 再运行测试:
143 |
144 | ```bash
145 | mix test
146 | ........................................................
147 |
148 | Finished in 0.8 seconds
149 | 58 tests, 0 failures
150 | ```
151 | 悉数通过。
152 |
153 | 我们知道,`index.html.eex` 页面上有一个 `New recipe` 的按钮,那我们的测试里是否需要体现?`Show`、`Edit`、`Delete` 这些按钮呢?要不要给它们写测试?
154 |
155 | 测试测什么,测到怎么的粒度,我觉得没有标准答案,更多时候要根据项目情况去权衡。但如果一定要有一个什么做为参考,我会选择设计稿。设计稿上定下来的元素,我们就尽量在测试里体现 - 否则设计人员很容易找上门来,说怎么少了这少了那。
156 |
157 | 在结束本节之前,别忘了在菜单栏上加上“菜谱”,不然我们就只能通过修改 url 访问菜谱相关页面了:
158 |
159 | ```elixir
160 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
161 | index a1b75c6..7bd839c 100644
162 | --- a/test/controllers/user_controller_test.exs
163 | +++ b/test/controllers/user_controller_test.exs
164 | @@ -29,6 +29,7 @@ defmodule TvRecipe.UserControllerTest do
165 | # 注册后自动登录,检查首页是否包含用户名
166 | conn = get conn, page_path(conn, :index)
167 | assert html_response(conn, 200) =~ Map.get(@valid_attrs, :username)
168 | + assert html_response(conn, 200) =~ "菜谱"
169 | end
170 |
171 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do
172 | diff --git a/web/templates/layout/app.html.eex b/web/templates/layout/app.html.eex
173 | index b13f370..49240c9 100644
174 | --- a/web/templates/layout/app.html.eex
175 | +++ b/web/templates/layout/app.html.eex
176 | @@ -19,6 +19,7 @@
177 | Get Started
178 | <%= if @current_user do %>
179 | <%= link @current_user.username, to: user_path(@conn, :show, @current_user) %>
180 | + <%= link "菜谱", to: recipe_path(@conn, :index) %>
181 | <%= link "退出", to: session_path(@conn, :delete, @current_user), method: "delete" %>
182 | <% else %>
183 | <%= link "登录", to: session_path(@conn, :new) %>
184 | ```
185 |
186 | 运行测试:
187 |
188 | ```bash
189 | $ mix test
190 | ..........................................................
191 |
192 | Finished in 0.8 seconds
193 | 58 tests, 0 failures
194 | ```
--------------------------------------------------------------------------------
/07-recipe/05-recipe-tv-url.md:
--------------------------------------------------------------------------------
1 | # 添加视频地址
2 |
3 | 我们的菜谱现在有对应的节目名、哪一季哪一集,如果能直接附上视频的链接,就更完美了。
4 |
5 | 我们需要一个数据库迁移(migration)文件:
6 |
7 | ```bash
8 | $ mix ecto.gen.migration add_url_to_recipe
9 | * creating priv/repo/migrations
10 | * creating priv/repo/migrations/20170211030550_add_url_to_recipe.exs
11 | ```
12 | 修改新建的迁移文件:
13 |
14 | ```elixir
15 | diff --git a/priv/repo/migrations/20170211030550_add_url_to_recipe.exs b/priv/repo/migrations/20170211030550_add_url_to_recipe.exs
16 | index 01e5f17..f0918c6 100644
17 | --- a/priv/repo/migrations/20170211030550_add_url_to_recipe.exs
18 | +++ b/priv/repo/migrations/20170211030550_add_url_to_recipe.exs
19 | @@ -2,6 +2,8 @@ defmodule TvRecipe.Repo.Migrations.AddUrlToRecipe do
20 | use Ecto.Migration
21 |
22 | def change do
23 | -
24 | + alter table(:recipes) do
25 | + add :url, :string
26 | + end
27 | end
28 | end
29 | ```
30 | 接着将 `:url` 加入 schema 中:
31 |
32 | ```elixir
33 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex
34 | index 230f290..104db50 100644
35 | --- a/web/models/recipe.ex
36 | +++ b/web/models/recipe.ex
37 | @@ -6,6 +6,7 @@ defmodule TvRecipe.Recipe do
38 | field :title, :string
39 | field :season, :integer, default: 1
40 | field :episode, :integer, default: 1
41 | + field :url, :string
42 | field :content, :string
43 | belongs_to :user, TvRecipe.User
44 |
45 | @@ -17,7 +18,7 @@ defmodule TvRecipe.Recipe do
46 | """
47 | def changeset(struct, params \\ %{}) do
48 | struct
49 | - |> cast(params, [:name, :title, :season, :episode, :content])
50 | + |> cast(params, [:name, :title, :season, :episode, :content, :url])
51 | |> validate_required([:name, :title, :season, :episode, :content], message: "请填写")
52 | |> validate_number(:season, greater_than: 0, message: "请输入大于 0 的数字")
53 | |> validate_number(:episode, greater_than: 0, message: "请输入大于 0 的数字")
54 | ```
55 |
56 | 最后,执行 `mix ecto.migrate`:
57 |
58 | ```bash
59 | $ mix ecto.migrate
60 | Compiling 13 files (.ex)
61 |
62 | 11:53:37.646 [info] == Running TvRecipe.Repo.Migrations.AddUrlToRecipe.change/0 forward
63 |
64 | 11:53:37.646 [info] alter table recipes
65 |
66 | 11:53:37.676 [info] == Migrated in 0.0s
67 | ```
68 | 接着新增一个测试,我们需要验证 url 的有效性:
69 |
70 | ```elixir
71 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs
72 | index 8b093ed..f1ba3f9 100644
73 | --- a/test/models/recipe_test.exs
74 | +++ b/test/models/recipe_test.exs
75 | @@ -60,4 +60,9 @@ defmodule TvRecipe.RecipeTest do
76 | assert {:user_id, "does not exist"} in errors_on(changeset)
77 | end
78 |
79 | + test "url should be valid" do
80 | + attrs = Map.put(@valid_attrs, :url, "fjsalfa")
81 | + assert {:url, "url 错误"} in errors_on(%Recipe{}, attrs)
82 | + end
83 | +
84 | end
85 | ```
86 | 运行测试:
87 |
88 | ```bash
89 | mix test
90 | ...............................
91 |
92 | 1) test url should be valid (TvRecipe.RecipeTest)
93 | test/models/recipe_test.exs:63
94 | Assertion with in failed
95 | code: {:url, "url 错误"} in errors_on(%Recipe{}, attrs)
96 | left: {:url, "url 错误"}
97 | right: []
98 | stacktrace:
99 | test/models/recipe_test.exs:65: (test)
100 |
101 | ...........................
102 |
103 | Finished in 1.0 seconds
104 | 59 tests, 1 failure
105 | ```
106 | 那么我们要如何在 `recipe.ex` 文件中验证 url 的有效性?
107 |
108 | 我们可以考虑用正则表达式配合 `validate_format`,但有个更好的办法,是直接引用 Erlang 的方法:
109 |
110 | ```elixir
111 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex
112 | index 104db50..3b849c8 100644
113 | --- a/web/models/recipe.ex
114 | +++ b/web/models/recipe.ex
115 | @@ -22,6 +22,16 @@ defmodule TvRecipe.Recipe do
116 | |> validate_required([:name, :title, :season, :episode, :content], message: "请填写")
117 | |> validate_number(:season, greater_than: 0, message: "请输入大于 0 的数字")
118 | |> validate_number(:episode, greater_than: 0, message: "请输入大于 0 的数字")
119 | + |> validate_url(:url)
120 | |> foreign_key_constraint(:user_id)
121 | end
122 | +
123 | + defp validate_url(changeset, field, _options \\ []) do
124 | + validate_change changeset, field, fn _, url ->
125 | + case url |> String.to_charlist |> :http_uri.parse do
126 | + {:ok, _} -> []
127 | + {:error, _} -> [url: "url 错误"]
128 | + end
129 | + end
130 | + end
131 | end
132 | ```
133 | 我们在 `recipe.ex` 文件中新增了一个 `validate_url` 私有方法,并调用 Ecto 提供的 [validate_change](https://hexdocs.pm/ecto/Ecto.Changeset.html#validate_change/3) 函数来验证属性是否有效。[http_uri](http://erlang.org/doc/man/http_uri.html) 是 Erlang 的模块,在 Elixir 中,我们能够以 `:http_uri` 的形式调用。
134 |
135 | 我们所有的 recipe 模板都需要做调整 - 此时,我想你可能已经意识到测试驱动的好处了,如果我们给各个模板添加过测试,那么有新特性加入时,我们先在测试中体现我们的目的,然后运行测试,就知道需要修改哪些文件来达到我们的目的。
136 |
137 | 参照前一节的代码,我们来进一步完善 `RecipeViewTest` 模块的代码:
138 |
139 | ```elixir
140 | diff --git a/test/views/recipe_view_test.exs b/test/views/recipe_view_test.exs
141 | index 8174c14..9695647 100644
142 | --- a/test/views/recipe_view_test.exs
143 | +++ b/test/views/recipe_view_test.exs
144 | @@ -28,4 +28,23 @@ defmodule TvRecipe.RecipeViewTest do
145 | end
146 | end
147 |
148 | + test "render new.html", %{conn: conn} do
149 | + changeset = Recipe.changeset(%Recipe{})
150 | + content = render_to_string(TvRecipe.RecipeView, "new.html", conn: conn, changeset: changeset)
151 | + assert String.contains?(content, "url")
152 | + end
153 | +
154 | + test "render show.html", %{conn: conn} do
155 | + recipe = %Recipe{id: "1", name: "淘米", title: "侠饭", season: "1", episode: "1", content: "洗掉米表面的淀粉", user_id: "999", url: "https://github.com/chenxsan/PhoenixFramework"}
156 | + content = render_to_string(TvRecipe.RecipeView, "show.html", conn: conn, recipe: recipe)
157 | + assert String.contains?(content, recipe.url)
158 | + end
159 | +
160 | + test "render edit.html", %{conn: conn} do
161 | + recipe = %Recipe{id: "1", name: "淘米", title: "侠饭", season: "1", episode: "1", content: "洗掉米表面的淀粉", user_id: "999", url: "https://github.com/chenxsan/PhoenixFramework"}
162 | + changeset = Recipe.changeset(recipe)
163 | + content = render_to_string(TvRecipe.RecipeView, "edit.html", conn: conn, changeset: changeset, recipe: recipe)
164 | + assert String.contains?(content, recipe.url)
165 | + end
166 | +
167 | end
168 | ```
169 | 然后运行测试:
170 |
171 | ```bash
172 | mix test
173 | Compiling 1 file (.ex)
174 | ...
175 |
176 | 1) test render new.html (TvRecipe.RecipeViewTest)
177 | test/views/recipe_view_test.exs:31
178 | Expected truthy, got false
179 | code: String.contains?(content, "url")
180 | stacktrace:
181 | test/views/recipe_view_test.exs:34: (test)
182 |
183 |
184 |
185 | 2) test render show.html (TvRecipe.RecipeViewTest)
186 | test/views/recipe_view_test.exs:37
187 | Expected truthy, got false
188 | code: String.contains?(content, recipe.url())
189 | stacktrace:
190 | test/views/recipe_view_test.exs:40: (test)
191 |
192 | .
193 |
194 | 3) test render edit.html (TvRecipe.RecipeViewTest)
195 | test/views/recipe_view_test.exs:43
196 | Expected truthy, got false
197 | code: String.contains?(content, recipe.url())
198 | stacktrace:
199 | test/views/recipe_view_test.exs:47: (test)
200 |
201 | .......................................................
202 |
203 | Finished in 0.9 seconds
204 | 62 tests, 3 failures
205 | ```
206 | 根据测试结果,我们修改文件:
207 |
208 | ```elixir
209 | diff --git a/web/templates/recipe/form.html.eex b/web/templates/recipe/form.html.eex
210 | index 3bf90ff..ab12be3 100644
211 | --- a/web/templates/recipe/form.html.eex
212 | +++ b/web/templates/recipe/form.html.eex
213 | @@ -30,6 +30,12 @@
214 |
215 |
216 |
217 | + <%= label f, :url, class: "control-label" %>
218 | + <%= text_input f, :url, class: "form-control" %>
219 | + <%= error_tag f, :url %>
220 | +
221 | +
222 | +
223 | <%= label f, :content, class: "control-label" %>
224 | <%= textarea f, :content, class: "form-control" %>
225 | <%= error_tag f, :content %>
226 | diff --git a/web/templates/recipe/show.html.eex b/web/templates/recipe/show.html.eex
227 | index 3ef437d..f4ea463 100644
228 | --- a/web/templates/recipe/show.html.eex
229 | +++ b/web/templates/recipe/show.html.eex
230 | @@ -23,6 +23,11 @@
231 |
232 |
233 |
234 | + Url:
235 | + <%= @recipe.url %>
236 | +
237 | +
238 | +
239 | Content:
240 | <%= @recipe.content %>
241 |
242 | ```
243 | 最后再运行一次测试:
244 |
245 | ```elixir
246 | mix test
247 | Compiling 1 file (.ex)
248 | ..............................................................
249 |
250 | Finished in 0.9 seconds
251 | 62 tests, 0 failures
252 | ```
253 | 测试全部通过。
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Creative Commons Legal Code
2 |
3 | Attribution-NonCommercial-NoDerivs 3.0 Unported
4 |
5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
6 | LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN
7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
9 | REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR
10 | DAMAGES RESULTING FROM ITS USE.
11 |
12 | License
13 |
14 | THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE
15 | COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY
16 | COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS
17 | AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
18 |
19 | BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE
20 | TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY
21 | BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS
22 | CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND
23 | CONDITIONS.
24 |
25 | 1. Definitions
26 |
27 | a. "Adaptation" means a work based upon the Work, or upon the Work and
28 | other pre-existing works, such as a translation, adaptation,
29 | derivative work, arrangement of music or other alterations of a
30 | literary or artistic work, or phonogram or performance and includes
31 | cinematographic adaptations or any other form in which the Work may be
32 | recast, transformed, or adapted including in any form recognizably
33 | derived from the original, except that a work that constitutes a
34 | Collection will not be considered an Adaptation for the purpose of
35 | this License. For the avoidance of doubt, where the Work is a musical
36 | work, performance or phonogram, the synchronization of the Work in
37 | timed-relation with a moving image ("synching") will be considered an
38 | Adaptation for the purpose of this License.
39 | b. "Collection" means a collection of literary or artistic works, such as
40 | encyclopedias and anthologies, or performances, phonograms or
41 | broadcasts, or other works or subject matter other than works listed
42 | in Section 1(f) below, which, by reason of the selection and
43 | arrangement of their contents, constitute intellectual creations, in
44 | which the Work is included in its entirety in unmodified form along
45 | with one or more other contributions, each constituting separate and
46 | independent works in themselves, which together are assembled into a
47 | collective whole. A work that constitutes a Collection will not be
48 | considered an Adaptation (as defined above) for the purposes of this
49 | License.
50 | c. "Distribute" means to make available to the public the original and
51 | copies of the Work through sale or other transfer of ownership.
52 | d. "Licensor" means the individual, individuals, entity or entities that
53 | offer(s) the Work under the terms of this License.
54 | e. "Original Author" means, in the case of a literary or artistic work,
55 | the individual, individuals, entity or entities who created the Work
56 | or if no individual or entity can be identified, the publisher; and in
57 | addition (i) in the case of a performance the actors, singers,
58 | musicians, dancers, and other persons who act, sing, deliver, declaim,
59 | play in, interpret or otherwise perform literary or artistic works or
60 | expressions of folklore; (ii) in the case of a phonogram the producer
61 | being the person or legal entity who first fixes the sounds of a
62 | performance or other sounds; and, (iii) in the case of broadcasts, the
63 | organization that transmits the broadcast.
64 | f. "Work" means the literary and/or artistic work offered under the terms
65 | of this License including without limitation any production in the
66 | literary, scientific and artistic domain, whatever may be the mode or
67 | form of its expression including digital form, such as a book,
68 | pamphlet and other writing; a lecture, address, sermon or other work
69 | of the same nature; a dramatic or dramatico-musical work; a
70 | choreographic work or entertainment in dumb show; a musical
71 | composition with or without words; a cinematographic work to which are
72 | assimilated works expressed by a process analogous to cinematography;
73 | a work of drawing, painting, architecture, sculpture, engraving or
74 | lithography; a photographic work to which are assimilated works
75 | expressed by a process analogous to photography; a work of applied
76 | art; an illustration, map, plan, sketch or three-dimensional work
77 | relative to geography, topography, architecture or science; a
78 | performance; a broadcast; a phonogram; a compilation of data to the
79 | extent it is protected as a copyrightable work; or a work performed by
80 | a variety or circus performer to the extent it is not otherwise
81 | considered a literary or artistic work.
82 | g. "You" means an individual or entity exercising rights under this
83 | License who has not previously violated the terms of this License with
84 | respect to the Work, or who has received express permission from the
85 | Licensor to exercise rights under this License despite a previous
86 | violation.
87 | h. "Publicly Perform" means to perform public recitations of the Work and
88 | to communicate to the public those public recitations, by any means or
89 | process, including by wire or wireless means or public digital
90 | performances; to make available to the public Works in such a way that
91 | members of the public may access these Works from a place and at a
92 | place individually chosen by them; to perform the Work to the public
93 | by any means or process and the communication to the public of the
94 | performances of the Work, including by public digital performance; to
95 | broadcast and rebroadcast the Work by any means including signs,
96 | sounds or images.
97 | i. "Reproduce" means to make copies of the Work by any means including
98 | without limitation by sound or visual recordings and the right of
99 | fixation and reproducing fixations of the Work, including storage of a
100 | protected performance or phonogram in digital form or other electronic
101 | medium.
102 |
103 | 2. Fair Dealing Rights. Nothing in this License is intended to reduce,
104 | limit, or restrict any uses free from copyright or rights arising from
105 | limitations or exceptions that are provided for in connection with the
106 | copyright protection under copyright law or other applicable laws.
107 |
108 | 3. License Grant. Subject to the terms and conditions of this License,
109 | Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
110 | perpetual (for the duration of the applicable copyright) license to
111 | exercise the rights in the Work as stated below:
112 |
113 | a. to Reproduce the Work, to incorporate the Work into one or more
114 | Collections, and to Reproduce the Work as incorporated in the
115 | Collections; and,
116 | b. to Distribute and Publicly Perform the Work including as incorporated
117 | in Collections.
118 |
119 | The above rights may be exercised in all media and formats whether now
120 | known or hereafter devised. The above rights include the right to make
121 | such modifications as are technically necessary to exercise the rights in
122 | other media and formats, but otherwise you have no rights to make
123 | Adaptations. Subject to 8(f), all rights not expressly granted by Licensor
124 | are hereby reserved, including but not limited to the rights set forth in
125 | Section 4(d).
126 |
127 | 4. Restrictions. The license granted in Section 3 above is expressly made
128 | subject to and limited by the following restrictions:
129 |
130 | a. You may Distribute or Publicly Perform the Work only under the terms
131 | of this License. You must include a copy of, or the Uniform Resource
132 | Identifier (URI) for, this License with every copy of the Work You
133 | Distribute or Publicly Perform. You may not offer or impose any terms
134 | on the Work that restrict the terms of this License or the ability of
135 | the recipient of the Work to exercise the rights granted to that
136 | recipient under the terms of the License. You may not sublicense the
137 | Work. You must keep intact all notices that refer to this License and
138 | to the disclaimer of warranties with every copy of the Work You
139 | Distribute or Publicly Perform. When You Distribute or Publicly
140 | Perform the Work, You may not impose any effective technological
141 | measures on the Work that restrict the ability of a recipient of the
142 | Work from You to exercise the rights granted to that recipient under
143 | the terms of the License. This Section 4(a) applies to the Work as
144 | incorporated in a Collection, but this does not require the Collection
145 | apart from the Work itself to be made subject to the terms of this
146 | License. If You create a Collection, upon notice from any Licensor You
147 | must, to the extent practicable, remove from the Collection any credit
148 | as required by Section 4(c), as requested.
149 | b. You may not exercise any of the rights granted to You in Section 3
150 | above in any manner that is primarily intended for or directed toward
151 | commercial advantage or private monetary compensation. The exchange of
152 | the Work for other copyrighted works by means of digital file-sharing
153 | or otherwise shall not be considered to be intended for or directed
154 | toward commercial advantage or private monetary compensation, provided
155 | there is no payment of any monetary compensation in connection with
156 | the exchange of copyrighted works.
157 | c. If You Distribute, or Publicly Perform the Work or Collections, You
158 | must, unless a request has been made pursuant to Section 4(a), keep
159 | intact all copyright notices for the Work and provide, reasonable to
160 | the medium or means You are utilizing: (i) the name of the Original
161 | Author (or pseudonym, if applicable) if supplied, and/or if the
162 | Original Author and/or Licensor designate another party or parties
163 | (e.g., a sponsor institute, publishing entity, journal) for
164 | attribution ("Attribution Parties") in Licensor's copyright notice,
165 | terms of service or by other reasonable means, the name of such party
166 | or parties; (ii) the title of the Work if supplied; (iii) to the
167 | extent reasonably practicable, the URI, if any, that Licensor
168 | specifies to be associated with the Work, unless such URI does not
169 | refer to the copyright notice or licensing information for the Work.
170 | The credit required by this Section 4(c) may be implemented in any
171 | reasonable manner; provided, however, that in the case of a
172 | Collection, at a minimum such credit will appear, if a credit for all
173 | contributing authors of Collection appears, then as part of these
174 | credits and in a manner at least as prominent as the credits for the
175 | other contributing authors. For the avoidance of doubt, You may only
176 | use the credit required by this Section for the purpose of attribution
177 | in the manner set out above and, by exercising Your rights under this
178 | License, You may not implicitly or explicitly assert or imply any
179 | connection with, sponsorship or endorsement by the Original Author,
180 | Licensor and/or Attribution Parties, as appropriate, of You or Your
181 | use of the Work, without the separate, express prior written
182 | permission of the Original Author, Licensor and/or Attribution
183 | Parties.
184 | d. For the avoidance of doubt:
185 |
186 | i. Non-waivable Compulsory License Schemes. In those jurisdictions in
187 | which the right to collect royalties through any statutory or
188 | compulsory licensing scheme cannot be waived, the Licensor
189 | reserves the exclusive right to collect such royalties for any
190 | exercise by You of the rights granted under this License;
191 | ii. Waivable Compulsory License Schemes. In those jurisdictions in
192 | which the right to collect royalties through any statutory or
193 | compulsory licensing scheme can be waived, the Licensor reserves
194 | the exclusive right to collect such royalties for any exercise by
195 | You of the rights granted under this License if Your exercise of
196 | such rights is for a purpose or use which is otherwise than
197 | noncommercial as permitted under Section 4(b) and otherwise waives
198 | the right to collect royalties through any statutory or compulsory
199 | licensing scheme; and,
200 | iii. Voluntary License Schemes. The Licensor reserves the right to
201 | collect royalties, whether individually or, in the event that the
202 | Licensor is a member of a collecting society that administers
203 | voluntary licensing schemes, via that society, from any exercise
204 | by You of the rights granted under this License that is for a
205 | purpose or use which is otherwise than noncommercial as permitted
206 | under Section 4(b).
207 | e. Except as otherwise agreed in writing by the Licensor or as may be
208 | otherwise permitted by applicable law, if You Reproduce, Distribute or
209 | Publicly Perform the Work either by itself or as part of any
210 | Collections, You must not distort, mutilate, modify or take other
211 | derogatory action in relation to the Work which would be prejudicial
212 | to the Original Author's honor or reputation.
213 |
214 | 5. Representations, Warranties and Disclaimer
215 |
216 | UNLESS OTHERWISE MUTUALLY AGREED BY THE PARTIES IN WRITING, LICENSOR
217 | OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
218 | KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
219 | INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
220 | FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
221 | LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS,
222 | WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION
223 | OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
224 |
225 | 6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE
226 | LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR
227 | ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES
228 | ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS
229 | BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
230 |
231 | 7. Termination
232 |
233 | a. This License and the rights granted hereunder will terminate
234 | automatically upon any breach by You of the terms of this License.
235 | Individuals or entities who have received Collections from You under
236 | this License, however, will not have their licenses terminated
237 | provided such individuals or entities remain in full compliance with
238 | those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any
239 | termination of this License.
240 | b. Subject to the above terms and conditions, the license granted here is
241 | perpetual (for the duration of the applicable copyright in the Work).
242 | Notwithstanding the above, Licensor reserves the right to release the
243 | Work under different license terms or to stop distributing the Work at
244 | any time; provided, however that any such election will not serve to
245 | withdraw this License (or any other license that has been, or is
246 | required to be, granted under the terms of this License), and this
247 | License will continue in full force and effect unless terminated as
248 | stated above.
249 |
250 | 8. Miscellaneous
251 |
252 | a. Each time You Distribute or Publicly Perform the Work or a Collection,
253 | the Licensor offers to the recipient a license to the Work on the same
254 | terms and conditions as the license granted to You under this License.
255 | b. If any provision of this License is invalid or unenforceable under
256 | applicable law, it shall not affect the validity or enforceability of
257 | the remainder of the terms of this License, and without further action
258 | by the parties to this agreement, such provision shall be reformed to
259 | the minimum extent necessary to make such provision valid and
260 | enforceable.
261 | c. No term or provision of this License shall be deemed waived and no
262 | breach consented to unless such waiver or consent shall be in writing
263 | and signed by the party to be charged with such waiver or consent.
264 | d. This License constitutes the entire agreement between the parties with
265 | respect to the Work licensed here. There are no understandings,
266 | agreements or representations with respect to the Work not specified
267 | here. Licensor shall not be bound by any additional provisions that
268 | may appear in any communication from You. This License may not be
269 | modified without the mutual written agreement of the Licensor and You.
270 | e. The rights granted under, and the subject matter referenced, in this
271 | License were drafted utilizing the terminology of the Berne Convention
272 | for the Protection of Literary and Artistic Works (as amended on
273 | September 28, 1979), the Rome Convention of 1961, the WIPO Copyright
274 | Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996
275 | and the Universal Copyright Convention (as revised on July 24, 1971).
276 | These rights and subject matter take effect in the relevant
277 | jurisdiction in which the License terms are sought to be enforced
278 | according to the corresponding provisions of the implementation of
279 | those treaty provisions in the applicable national law. If the
280 | standard suite of rights granted under applicable copyright law
281 | includes additional rights not granted under this License, such
282 | additional rights are deemed to be included in the License; this
283 | License is not intended to restrict the license of any rights under
284 | applicable law.
285 |
286 |
287 | Creative Commons Notice
288 |
289 | Creative Commons is not a party to this License, and makes no warranty
290 | whatsoever in connection with the Work. Creative Commons will not be
291 | liable to You or any party on any legal theory for any damages
292 | whatsoever, including without limitation any general, special,
293 | incidental or consequential damages arising in connection to this
294 | license. Notwithstanding the foregoing two (2) sentences, if Creative
295 | Commons has expressly identified itself as the Licensor hereunder, it
296 | shall have all rights and obligations of Licensor.
297 |
298 | Except for the limited purpose of indicating to the public that the
299 | Work is licensed under the CCPL, Creative Commons does not authorize
300 | the use by either party of the trademark "Creative Commons" or any
301 | related trademark or logo of Creative Commons without the prior
302 | written consent of Creative Commons. Any permitted use will be in
303 | compliance with Creative Commons' then-current trademark usage
304 | guidelines, as may be published on its website or otherwise made
305 | available upon request from time to time. For the avoidance of doubt,
306 | this trademark restriction does not form part of this License.
307 |
308 | Creative Commons may be contacted at https://creativecommons.org/.
309 |
--------------------------------------------------------------------------------
/img/02-help-page-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/02-help-page-screenshot.png
--------------------------------------------------------------------------------
/img/02-mix-phoenix.gen.html.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/02-mix-phoenix.gen.html.png
--------------------------------------------------------------------------------
/img/02-phoenixframework-page-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/02-phoenixframework-page-screenshot.png
--------------------------------------------------------------------------------
/img/04-iex-shell.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-iex-shell.png
--------------------------------------------------------------------------------
/img/04-password-input.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-password-input.png
--------------------------------------------------------------------------------
/img/04-user-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-user-list.png
--------------------------------------------------------------------------------
/img/04-username-has-error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-username-has-error.png
--------------------------------------------------------------------------------
/img/04-users-blank-username.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-users-blank-username.png
--------------------------------------------------------------------------------
/img/04-users-new-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-users-new-page.png
--------------------------------------------------------------------------------
/img/05-login-user-http.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/05-login-user-http.png
--------------------------------------------------------------------------------
/img/07-generate-recipe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/07-generate-recipe.png
--------------------------------------------------------------------------------
/img/alipay-qr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/alipay-qr.png
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Phoenix Framework 中文入门教程
2 |
3 | 前端开发一直都是我的主业。是的,我是在暗示你:**Phoenix Framework 非常容易上手**。不过也因为我一直在从事前端开发,后端的一些概念我可能理解得不够到位,如有错误,欢迎指正。
4 |
5 | ## 目录
6 |
7 | 0. [准备工作](00-prepare.md)
8 | 1. [创建项目](01-create-project.md)
9 | 2. [Phoenix 初体验](02-explore-phoenix.md)
10 | 3. [Menu 项目规划](03-project-menu.md)
11 |
12 | ## 捐款
13 |
14 | 如果教程对你有很大帮助,并且你对捐款没有心理阴影的话,欢迎扫描下方的支付宝二维码:
15 |
16 | 
17 |
18 | ## License & Copyright
19 |
20 | © 2017 陈三
21 |
22 |  本作品采用 知识共享署名-禁止演绎 4.0 国际许可协议进行许可。
--------------------------------------------------------------------------------
|