├── .formatter.exs ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── changelist.md ├── config └── config.exs ├── lib ├── phoenix_integration.ex └── phoenix_integration │ ├── assertions.ex │ ├── form │ ├── DESIGN.md │ ├── change.ex │ ├── common.ex │ ├── messages.ex │ ├── tag.ex │ ├── tree_creation.ex │ ├── tree_edit.ex │ └── tree_finish.ex │ └── requests.ex ├── mix.exs ├── mix.lock └── test ├── assertions_test.exs ├── checkbox_test.exs ├── details ├── change_test.exs ├── input_types_test.exs ├── messages_test.exs ├── tag_test.exs ├── tree_creation_test.exs ├── tree_edit_test.exs └── tree_finish_test.exs ├── fixtures └── templates │ ├── checkbox.html │ ├── input_types.html │ ├── sample.html │ └── second.html ├── forms_test.exs ├── links_test.exs ├── request_test.exs ├── support ├── endpoint.ex └── form_support.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | /docs 6 | erl_crash.dump 7 | *.ez 8 | 9 | Guardfile 10 | tmp 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | sudo: false 3 | 4 | elixir: 5 | - 1.10 6 | otp_release: 7 | - 21.0 8 | 9 | before_script: 10 | - mix local.hex --force 11 | - mix deps.get --only test 12 | 13 | script: 14 | - mix test 15 | # - mix credo 16 | 17 | after_script: 18 | - MIX_ENV=docs mix deps.get 19 | - MIX_ENV=docs mix inch.report 20 | 21 | after_success: 22 | - bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, and 12 | distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 15 | owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all other entities 18 | that control, are controlled by, or are under common control with that entity. 19 | For the purposes of this definition, "control" means (i) the power, direct or 20 | indirect, to cause the direction or management of such entity, whether by 21 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, including 28 | but not limited to software source code, documentation source, and configuration 29 | files. 30 | 31 | "Object" form shall mean any form resulting from mechanical transformation or 32 | translation of a Source form, including but not limited to compiled object code, 33 | generated documentation, and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or Object form, made 36 | available under the License, as indicated by a copyright notice that is included 37 | in or attached to the work (an example is provided in the Appendix below). 38 | 39 | "Derivative Works" shall mean any work, whether in Source or Object form, that 40 | is based on (or derived from) the Work and for which the editorial revisions, 41 | annotations, elaborations, or other modifications represent, as a whole, an 42 | original work of authorship. For the purposes of this License, Derivative Works 43 | shall not include works that remain separable from, or merely link (or bind by 44 | name) to the interfaces of, the Work and Derivative Works thereof. 45 | 46 | "Contribution" shall mean any work of authorship, including the original version 47 | of the Work and any modifications or additions to that Work or Derivative Works 48 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 49 | by the copyright owner or by an individual or Legal Entity authorized to submit 50 | on behalf of the copyright owner. For the purposes of this definition, 51 | "submitted" means any form of electronic, verbal, or written communication sent 52 | to the Licensor or its representatives, including but not limited to 53 | communication on electronic mailing lists, source code control systems, and 54 | issue tracking systems that are managed by, or on behalf of, the Licensor for 55 | the purpose of discussing and improving the Work, but excluding communication 56 | that is conspicuously marked or otherwise designated in writing by the copyright 57 | owner as "Not a Contribution." 58 | 59 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 60 | of whom a Contribution has been received by Licensor and subsequently 61 | incorporated within the Work. 62 | 63 | 2. Grant of Copyright License. Subject to the terms and conditions of this 64 | License, each Contributor hereby grants to You a perpetual, worldwide, 65 | non-exclusive, no-charge, royalty-free, irrevocable copyright license to 66 | reproduce, prepare Derivative Works of, publicly display, publicly perform, 67 | sublicense, and distribute the Work and such Derivative Works in Source or 68 | Object form. 69 | 70 | 3. Grant of Patent License. Subject to the terms and conditions of this License, 71 | each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, 72 | no-charge, royalty-free, irrevocable (except as stated in this section) patent 73 | license to make, have made, use, offer to sell, sell, import, and otherwise 74 | transfer the Work, where such license applies only to those patent claims 75 | licensable by such Contributor that are necessarily infringed by their 76 | Contribution(s) alone or by combination of their Contribution(s) with the Work 77 | to which such Contribution(s) was submitted. If You institute patent litigation 78 | against any entity (including a cross-claim or counterclaim in a lawsuit) 79 | alleging that the Work or a Contribution incorporated within the Work 80 | constitutes direct or contributory patent infringement, then any patent licenses 81 | granted to You under this License for that Work shall terminate as of the date 82 | such litigation is filed. 83 | 84 | 4. Redistribution. You may reproduce and distribute copies of the Work or 85 | Derivative Works thereof in any medium, with or without modifications, and in 86 | Source or Object form, provided that You meet the following conditions: 87 | 88 | You must give any other recipients of the Work or Derivative Works a copy of 89 | this License; and You must cause any modified files to carry prominent notices 90 | stating that You changed the files; and You must retain, in the Source form of 91 | any Derivative Works that You distribute, all copyright, patent, trademark, and 92 | attribution notices from the Source form of the Work, excluding those notices 93 | that do not pertain to any part of the Derivative Works; and If the Work 94 | includes a "NOTICE" text file as part of its distribution, then any Derivative 95 | Works that You distribute must include a readable copy of the attribution 96 | notices contained within such NOTICE file, excluding those notices that do not 97 | pertain to any part of the Derivative Works, in at least one of the following 98 | places: within a NOTICE text file distributed as part of the Derivative Works; 99 | within the Source form or documentation, if provided along with the Derivative 100 | Works; or, within a display generated by the Derivative Works, if and wherever 101 | such third-party notices normally appear. The contents of the NOTICE file are 102 | for informational purposes only and do not modify the License. You may add Your 103 | own attribution notices within Derivative Works that You distribute, alongside 104 | or as an addendum to the NOTICE text from the Work, provided that such 105 | additional attribution notices cannot be construed as modifying the License. 106 | 107 | You may add Your own copyright statement to Your modifications and may provide 108 | additional or different license terms and conditions for use, reproduction, or 109 | distribution of Your modifications, or for any such Derivative Works as a whole, 110 | provided Your use, reproduction, and distribution of the Work otherwise complies 111 | with the conditions stated in this License. 5. Submission of Contributions. 112 | Unless You explicitly state otherwise, any Contribution intentionally submitted 113 | for inclusion in the Work by You to the Licensor shall be under the terms and 114 | conditions of this License, without any additional terms or conditions. 115 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 116 | any separate license agreement you may have executed with Licensor regarding 117 | such Contributions. 118 | 119 | 6. Trademarks. This License does not grant permission to use the trade names, 120 | trademarks, service marks, or product names of the Licensor, except as required 121 | for reasonable and customary use in describing the origin of the Work and 122 | reproducing the content of the NOTICE file. 123 | 124 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in 125 | writing, Licensor provides the Work (and each Contributor provides its 126 | Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 127 | KIND, either express or implied, including, without limitation, any warranties 128 | or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 129 | PARTICULAR PURPOSE. You are solely responsible for determining the 130 | appropriateness of using or redistributing the Work and assume any risks 131 | associated with Your exercise of permissions under this License. 132 | 133 | 8. Limitation of Liability. In no event and under no legal theory, whether in 134 | tort (including negligence), contract, or otherwise, unless required by 135 | applicable law (such as deliberate and grossly negligent acts) or agreed to in 136 | writing, shall any Contributor be liable to You for damages, including any 137 | direct, indirect, special, incidental, or consequential damages of any character 138 | arising as a result of this License or out of the use or inability to use the 139 | Work (including but not limited to damages for loss of goodwill, work stoppage, 140 | computer failure or malfunction, or any and all other commercial damages or 141 | losses), even if such Contributor has been advised of the possibility of such 142 | damages. 143 | 144 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or 145 | Derivative Works thereof, You may choose to offer, and charge a fee for, 146 | acceptance of support, warranty, indemnity, or other liability obligations 147 | and/or rights consistent with this License. However, in accepting such 148 | obligations, You may act only on Your own behalf and on Your sole 149 | responsibility, not on behalf of any other Contributor, and only if You agree to 150 | indemnify, defend, and hold each Contributor harmless for any liability incurred 151 | by, or claims asserted against, such Contributor by reason of your accepting any 152 | such warranty or additional liability. 153 | 154 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | phoenix_integration 2 | ======== 3 | 4 | [](https://travis-ci.org/boydm/phoenix_integration) 5 | [](https://hex.pm/packages/phoenix_integration) 6 | [](https://hex.pm/packages/phoenix_integration) 7 | [](https://hex.pm/packages/phoenix_integration) 8 | [](http://inch-ci.org/github/boydm/phoenix_integration) 9 | 10 | 11 | ## Overview 12 | 13 | PhoenixIntegration is set of lightweight, server-side integration test functions for Phoenix. 14 | Works within the existing `Phoenix.ConnTest` framework and emphasizes both speed and readability. 15 | 16 | The goal is to chain together a string of requests and assertions that thoroughly 17 | exercise your application in as lightweight and readable manner as possible. 18 | 19 | I love the pipe `|>` command in Elixir. By using the pipe to chain together calls in an integration test, phoenix_integration is able to be very readable. Tight integration with Phoenix.ConnTest means the calls all use the fast-path to your application for speed. 20 | 21 | Version 0.6 moves from Poison to Jason for Phoenix 1.4 compatibility. 22 | 23 | Version 0.7 requires Floki 0.24.0 or higher. Otherwise it is a patch-like update. 24 | 25 | ## Documentation 26 | 27 | You can read [the full documentation here](https://hexdocs.pm/phoenix_integration). 28 | 29 | ## Configuration 30 | 31 | ### Step 1 32 | 33 | You need to tell phoenix_integration which endpoint to use. Add the following to your phoenix application's `config/test.exs` file. 34 | 35 | ```elixir 36 | config :phoenix_integration, 37 | endpoint: MyApp.Endpoint 38 | ``` 39 | 40 | Where MyApp is the name of your application. 41 | 42 | Do this up before compiling phoenix_integration as part of step 2. If you change the endpoint in the config file, you will need to recompile the phoenix_integration dependency. 43 | 44 | Phoenix_integration will produce warnings if your HTML likely doesn't do what you meant. (For example, it will warn you if two text fields have the same name.) You can turn those off by adding `warnings: false` to the config. 45 | 46 | 47 | ### Step 2 48 | 49 | Add PhoenixIntegration to the deps section of your application's `mix.exs` file 50 | 51 | ```elixir 52 | defp deps do 53 | [ 54 | # ... 55 | {:phoenix_integration, "~> 0.9", only: :test} 56 | # ... 57 | ] 58 | end 59 | ``` 60 | 61 | Don't forget to run `mix deps.get` 62 | 63 | ### Step 3 64 | 65 | Create a test/support/integration_case.ex file. Mine simply looks like this: 66 | 67 | ```elixir 68 | defmodule MyApp.IntegrationCase do 69 | use ExUnit.CaseTemplate 70 | 71 | using do 72 | quote do 73 | use MyApp.ConnCase 74 | use PhoenixIntegration 75 | end 76 | end 77 | 78 | end 79 | 80 | ``` 81 | 82 | Alternately you could place the call to `use PhoenixIntegration` in your conn_case.ex file. Just make sure it is after the definition of `@endpoint`. 83 | 84 | ### Step 4 85 | Start writing integration tests. They should use your integration_conn.ex file. Here is a full example (just the name of the app is changed). This is from the location test/integration/page_integration_test.exs 86 | 87 | ```elixir 88 | defmodule MyApp.AboutIntegrationTest do 89 | use MyApp.IntegrationCase, async: true 90 | 91 | test "Basic page flow", %{conn: conn} do 92 | # get the root index page 93 | get( conn, page_path(conn, :index) ) 94 | # click/follow through the various about pages 95 | |> follow_link( "About Us" ) 96 | |> follow_link( "Contact" ) 97 | |> follow_link( "Privacy" ) 98 | |> follow_link( "Terms of Service" ) 99 | |> follow_button( "Accept" ) 100 | |> follow_link( "Home" ) 101 | |> assert_response( status: 200, path: page_path(conn, :index) ) 102 | end 103 | 104 | end 105 | ``` 106 | 107 | Each function in phoenix_integration accepts a conn and some other data, and returns a conn. This conn is intended to be passed into the next function via a pipe to build up a clear, readable chain of events in your test. 108 | 109 | 110 | ## Making Requests 111 | 112 | The [`PhoenixIntegration.Requests`](https://hexdocs.pm/phoenix_integration/PhoenixIntegration.Requests.html) module contains a set functions that make requests to your application through the router. 113 | 114 | In general, these functions look for links or forms in the html returned by a previous request. Then they make a new request to application as specified by your test. If the link wasn’t found, then an appropriate error is raised. 115 | 116 | See [the full documentation](https://hexdocs.pm/phoenix_integration/PhoenixIntegration.Requests.html) for details. 117 | 118 | For example, a call such as `follow_link( conn, "About Us" )`, looks in conn.body_request (which should contain html from a previous request), for an anchor tag that contains the visible text ‘About Us’. Note that it uses =~ and not == to look for the text, so you only need to specify enough text to find the link. 119 | 120 | These functions are also pretty flexible. A call such as `follow_link( conn, "/about/us" )` recognizes that this is a path, so it looks for an anchor tag with an href equal to `“/about/us”`. Similarly, you could pass in a css-style id such as `“#about_us”` to find an anchor with the specified html id. 121 | 122 | ### Handling Redirects 123 | 124 | All functions of the form follow_* make a request to your app. Then if a redirect is returned, makes another request following the redirect. This will go on until max_redirects is reached. 125 | 126 | The goal is that (similar to Capybara), your integration test code looks like a set of actions that a user would actually do. To a user, redirects just happen. Clicking links and following forms are what is important. 127 | 128 | ### Submitting Forms 129 | 130 | The `follow_form` function finds a form in the body of the previously returned conn, fills in the fields you have specified (raising an appropriate error if the form or fields aren’t found), submits the form to your application, and follows any redirects. 131 | 132 | Used in a integration pipe chain, it looks like this: 133 | 134 | ```elixir 135 | test "Create new user", %{conn: conn} do 136 | # get the root index page 137 | get( conn, page_path(conn, :index) ) 138 | |> follow_link( "Sign Up" ) 139 | |> follow_form( %{ user: %{ 140 | name: "New User", 141 | email: "user@example.com", 142 | password: "test.password", 143 | confirm_password: "test.password" 144 | }} ) 145 | |> assert_response( 146 | status: 200, 147 | path: page_path(conn, :index), 148 | html: "New User" ) 149 | end 150 | ``` 151 | 152 | The `submit_form` function is very similar, except that you handle any redirects yourself. 153 | 154 | ### Tracking multiple users 155 | 156 | A very common scenario involves interactions between multiple users. The good news is that user state is returned in the conn from your controllers, so it is easy to track. 157 | 158 | Is this example, I use a test_sign_in_user function (not shown), which uses token authentication so that I don’t have to pay the BCrypt price every time I run a test… 159 | 160 | ```elixir 161 | test "admin can create a thing", %{conn: conn} do 162 | # create and sign in admin 163 | admin = test_insert_user permissions: @admin_perms 164 | admin_conn = test_sign_in_user(conn, admin) 165 | 166 | # create and sign in regular user 167 | user = test_insert_user 168 | user_conn = test_sign_in_user(conn, user) 169 | 170 | # admin create a new thing 171 | get( admin_conn, admin_path(conn, :index) ) 172 | |> follow_link( "Create Thing" ) 173 | |> follow_form( %{ thing: %{ 174 | name: "New Thing" 175 | }} ) 176 | |> assert_response( 177 | status: 200, 178 | path: admin_path(conn, :index), 179 | html: "New Thing" ) 180 | 181 | # load the thing 182 | thing = Repo.get_by(Thing, name: "New Thing") 183 | assert thing 184 | 185 | # the user should be able to view the thing 186 | get( user_conn, page_path(conn, :index) ) 187 | |> follow_link( thing.name ) 188 | |> assert_response( 189 | status: 200, 190 | path: thing_path(conn, :show, thing), 191 | html: "New Thing" 192 | ) 193 | end 194 | ``` 195 | 196 | ## Asserting Responses 197 | 198 | I really wanted to see unbroken chains of piped call to make it really clear that this was a chain of events/state being tested. 199 | 200 | The following line, which is very common in Phoenix.ConnTest controller tests works well, but doesn’t allow you to build that chain of commands. 201 | 202 | `assert html_response(conn, 200) =~ “Some text”` 203 | 204 | So, the `PhoenixIntegration.Assertions` module introduces two new functions, which can test multiple conditions in a single call, and always return the (unchanged) conn being tested. 205 | 206 | See [the full documentation](https://hexdocs.pm/phoenix_integration/PhoenixIntegration.Assertions.html) for details. 207 | 208 | I use assert_response at almost a 1:1 ratio with the various request calls, so my tests often look something like this: 209 | 210 | ```elixir 211 | test "Basic page flow", %{conn: conn} do 212 | # get the root index page 213 | get( conn, page_path(conn, :index) ) 214 | # click/follow through the various about pages 215 | |> follow_link( "About Us" ) 216 | |> assert_response( status: 200, path: about_path(conn, :index) ) 217 | |> follow_link( "Contact" ) 218 | |> assert_response( content_type: "text/html" ) 219 | |> follow_link( "Privacy" ) 220 | |> assert_response( html: "Privacy Policy" ) 221 | |> follow_button( "Accept" ) 222 | |> assert_response( html: "Privacy Policy" ) 223 | |> follow_link( "Home" ) 224 | |> assert_response( status: 200, path: page_path(conn, :index) ) 225 | end 226 | ``` 227 | 228 | To keep the chain clean and readable, each call to `assert_response` takes a conn, followed by a list of conditions to assert against. These conditions can appear multiple times in a single and will be called in the order specified. 229 | 230 | ```elixir 231 | |> assert_response( 232 | status: 200, 233 | path: page_path(conn, :index) 234 | html: "Good Content", 235 | html: "More Content" 236 | ) 237 | ``` 238 | 239 | The `refute_response` function is very similar in form to `assert_response`, except that it refutes the given conditions. I find that it is used much less frequently, and usually prove that a response doesn’t have a specific piece of content. 240 | 241 | ```elixir 242 | |> follow_link( "Show Thing" ) 243 | |> assert_response( 244 | status: 200, 245 | path: thing_path(conn, :show, thing) 246 | html: "Good Content" 247 | ) 248 | |> refute_response( 249 | body: "Bad Content" 250 | ) 251 | ``` 252 | 253 | ## Documentation 254 | 255 | You can read [the full documentation here](https://hexdocs.pm/phoenix_integration). 256 | -------------------------------------------------------------------------------- /changelist.md: -------------------------------------------------------------------------------- 1 | ## phoenix_integration Changelist 2 | 3 | ### 0.9.2 4 | * Supports phoenix_html 3.0. Thank you @hzeus and @ZombieHarvester 5 | * :jason and :floki warnings resolved by making them dependent applications @ZombieHarvester 6 | * Improve regex matching. @adz 7 | * Added the Apache 2.0 license 8 | 9 | ### 0.9.1 10 | * Loosen name identification to allow form field names with characters like ? in them. Thank you @arnodirlam 11 | 12 | ### 0.9.0 13 | * Fixs bug in refute_response. Thank you @StanisLove 14 | * update flow_assertions. Thank you @marick 15 | * now requires Elixir 1.10+ bumping to 0.9.0 accordingly 16 | 17 | ### 0.8.2 18 | * Fixes issue #39. Nice improvements to fetch_form. Thank you @marick 19 | * Fix issue #41. Support phoenix 1.5 which deprecated "use Phoenix.ConnTest" in favor of "import Phoenix.ConnTest" 20 | 21 | ### 0.8.1 22 | * Fixes issue #36, correctly handle other form input types. Thank you @marick for the fix. 23 | 24 | ### 0.8.0 25 | * Fairly large update to handle forms with hidden fields. This update treats forms as parsed 26 | trees and has more informative output, and is generally more flexible. This entire update 27 | is brought to you by the hard work of Brian Marick (@marick on GitHub). Thank you! 28 | 29 | ### 0.7.0 30 | * __Marking this as 0.7.0 because it is requires a minor version update to Floki. Otherwise 31 | the actual changes in phoenix_markdown are more patch-like...__ 32 | * Changed a private functions "is_struct" to "do_is_struct" to avoid a conflict with 33 | the new Kernel.is_struct function in Elixir v1.10 34 | * Change the minimum required version of Floki to 0.24.0 and then use the new 35 | Floki.parse_document pattern to get rid of the deprecation warnings. 36 | * add .formatter.exs and format the code 37 | 38 | ### 0.6.0 39 | * Moved from Poison to Jason for json parsing 40 | 41 | ### 0.5.3 42 | * Merged pull request #23 from jonasschmidt to support deeply nested forms. 43 | * Add Travis tests for elixir 1.7 44 | 45 | ### 0.5.2 46 | * Merged pull request #22 from wooga to allow single-character input names in forms 47 | 48 | ### 0.5.1 49 | * fixed bug (issue #20) where it didn't find radio input fields if none were intially checked 50 | * removed dependency on DeepMerge 51 | 52 | ### 0.5.0 53 | * added Request.click_button to find and click simple buttons on the page 54 | * added Request.follow_button to find, click, and follow simple buttons on the page 55 | * improved error message when usinga link, while asking for the wrong method. 56 | 57 | ### 0.4.1 58 | * run the code through the elixir 1.6 formatter 59 | * update travis tests 60 | 61 | ### 0.4.0 62 | * fix issue #11, was incorrectly reading the method of the form in the case of a get 63 | * add the fetch_form function to the Request module 64 | * support for nested forms. Thank you https://github.com/bitboxer 65 | * support follow_link for phoenix_html 2.10. Thank you https://github.com/andreapavoni 66 | * bump up to Elixir 1.4 67 | * update docs 68 | 69 | ### 0.3.0 70 | * added a new :value assertion type that checks the result of a callback for truthyness 71 | * relaxed requirement on floki version 72 | 73 | ### 0.2.0 74 | * Updated Dependencies to use version 0.13 of Floki (the html parsing enging) 75 | * Use updated Floki syntax when searching for links and forms by text content. 76 | * Errors while running a form test now display the path that was associated with the form. 77 | should aid in resolving issues on pages with more than one form. 78 | Thank you goes to https://github.com/Mbuckley0 for sorting this out. 79 | * Change the readme to suggest only loading phoenix_integration in test mode 80 | * Added support for DateTime fields in forms. Again thank you https://github.com/Mbuckley0 81 | for adding this feature in. 82 | 83 | ### 0.1.2 84 | * Add support for file upload fields in forms 85 | 86 | ### 0.1.1 87 | * Added `:assigns` option to both `Assertions.assert_response` and `Assertions.refute_response`. 88 | * Added `Requests.follow_fn` 89 | * cleaning up readme and docs 90 | 91 | ### 0.1.0 92 | First release -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | case Mix.env() do 4 | :test -> 5 | config :phoenix, :template_engines, md: PhoenixMarkdown.Engine 6 | 7 | config :phoenix_integration, 8 | endpoint: PhoenixIntegration.TestEndpoint 9 | 10 | config :phoenix, :json_library, Jason 11 | _ -> 12 | nil 13 | end 14 | -------------------------------------------------------------------------------- /lib/phoenix_integration.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration do 2 | @moduledoc """ 3 | Lightweight server-side integration test functions for Phoenix. Works within the existing 4 | Phoenix.ConnTest framework and emphasizes both speed and readability. 5 | 6 | ## Configuration 7 | 8 | ### Step 1 9 | 10 | You need to tell phoenix_integration which endpoint to use. Add the following to your phoenix application's `config/test.exs` file. 11 | 12 | ```elixir 13 | config :phoenix_integration, 14 | endpoint: MyApp.Endpoint 15 | ``` 16 | 17 | Where MyApp is the name of your application. 18 | 19 | Do this up before compiling phoenix_integration as part of step 2. If you change the endpoint in the config file, you will need to recompile the phoenix_integration dependency. 20 | 21 | Phoenix_integration will produce warnings if your HTML likely doesn't do what you meant. (For example, it will warn you if two text fields have the same name.) You can turn those off by adding `warnings: false` to the config. 22 | 23 | 24 | 25 | ### Step 2 26 | 27 | Add PhoenixIntegration to the deps section of your application's `mix.exs` file 28 | 29 | ```elixir 30 | defp deps do 31 | [ 32 | # ... 33 | {:phoenix_integration, "~> 0.8", only: :test} 34 | # ... 35 | ] 36 | end 37 | ``` 38 | 39 | Don't forget to run `mix deps.get` 40 | 41 | ### Step 3 42 | 43 | Create a test/support/integration_case.ex file. Mine simply looks like this: 44 | 45 | ```elixir 46 | defmodule MyApp.IntegrationCase do 47 | use ExUnit.CaseTemplate 48 | 49 | using do 50 | quote do 51 | use MyApp.ConnCase 52 | use PhoenixIntegration 53 | end 54 | end 55 | 56 | end 57 | 58 | ``` 59 | 60 | Alternately you could place the call to `use PhoenixIntegration` in your conn_case.ex file. Just make sure it is after the definition of `@endpoint`. 61 | 62 | 63 | 64 | 65 | ## Overview 66 | 67 | phoenix_integration provides two assertion and six request functions to be used 68 | alongside the existing `get`, `post`, `put`, `patch`, and `delete` utilities 69 | inside of a Phoenix.ConnTest test suite. 70 | 71 | The goal is to chain together a string of requests and assertions that thouroughly 72 | exercise your application in as lightweight and readable manner as possible. 73 | 74 | Each function accepts a conn and some other data, and returns a conn intended to be 75 | passed into the next function via a pipe. 76 | 77 | ### Examples 78 | test "Basic page flow", %{conn: conn} do 79 | # get the root index page 80 | get( conn, page_path(conn, :index) ) 81 | # click/follow through the various about pages 82 | |> follow_link( "About Us" ) 83 | |> follow_link( "Contact" ) 84 | |> follow_link( "Privacy" ) 85 | |> follow_link( "Terms of Service" ) 86 | |> follow_link( "Home" ) 87 | |> assert_response( status: 200, path: page_path(conn, :index) ) 88 | end 89 | 90 | test "Create new user", %{conn: conn} do 91 | # get the root index page 92 | get( conn, page_path(conn, :index) ) 93 | # click/follow through the various about pages 94 | |> follow_link( "Sign Up" ) 95 | |> follow_form( %{ user: %{ 96 | name: "New User", 97 | email: "user@example.com", 98 | password: "test.password", 99 | confirm_password: "test.password" 100 | }} ) 101 | |> assert_response( 102 | status: 200, 103 | path: page_path(conn, :index), 104 | html: "New User" ) 105 | end 106 | 107 | ### Simulate multiple users 108 | Since all user state is held in the conn that is being passed around (just like when 109 | a user is hitting your application in a browser), you can simulate multiple users 110 | simply by tracking separate conns for them. 111 | 112 | In the example below, I'm assuming an application-specific `test_sign_in` function, which 113 | itself uses the `follow_*` functions to sign a given user in. 114 | 115 | Notice how `user_conn` is tracked and reused. This keeps the state the user builds 116 | up as the various links are followed, just like it would be when a proper browser is used. 117 | 118 | ### Example 119 | test "admin grants a user permissions", %{conn: conn, user: user, admin: admin} do 120 | # sign in the user and admin 121 | user_conn = test_sign_in( conn, user ) 122 | admin_conn = test_sign_in( conn, admin ) 123 | 124 | # user can't see a restricted page 125 | user_conn = get( user_conn, page_path(conn, :index) ) 126 | |> follow_link( "Restricted" ) 127 | |> assert_response( status: 200, path: session_path(conn, :new) ) 128 | |> refute_response( body: "Restricted Content" ) 129 | 130 | # admin grants the user permission 131 | get( admin_conn, page_path(conn, :index) ) 132 | |> follow_link( "Admin Dashboard" ) 133 | |> follow_form( %{ user: %{ 134 | permissoin: "ok_to_do_thing" 135 | }} ) 136 | |> assert_response( 137 | status: 200, 138 | path: admin_path(conn, :index), 139 | html: "Permission Granted" ) 140 | 141 | # the user should now be able to see the restricted page 142 | get( user_conn, page_path(conn, :index) ) 143 | |> follow_link( "Restricted" ) 144 | |> assert_response( 145 | status: 200, 146 | path: restricted_path(conn, :index), 147 | html: "Restricted Content" 148 | ) 149 | end 150 | 151 | ### Tip 152 | 153 | You can intermix `IO.inspect` calls in the pipe chain to help with debugging. This 154 | will print the current state of the conn into the console. 155 | 156 | test "Basic page flow", %{conn: conn} do 157 | # get the root index page 158 | get( conn, page_path(conn, :index) ) 159 | |> follow_link( "About Us" ) 160 | |> IO.inspect 161 | |> follow_link( "Home" ) 162 | |> assert_response( status: 200, path: page_path(conn, :index) ) 163 | end 164 | 165 | I like to use `assert_response` pretty heavily to make sure the content I expect 166 | is really there and to make sure I am traveling to the right locations. 167 | 168 | test "Basic page flow", %{conn: conn} do 169 | get(conn, page_path(conn, :index) ) 170 | |> assert_response( 171 | status: 200, 172 | path: page_path(conn, :index), 173 | html: "Test App" 174 | ) 175 | |> follow_link( "About" ) 176 | |> assert_response( 177 | status: 200, 178 | path: about_path(conn, :index), 179 | html: "About Test App" 180 | ) 181 | |> follow_link( "Contact" ) 182 | |> assert_response( 183 | status: 200, 184 | path: about_path(conn, :contact), 185 | html: "Contact" 186 | ) 187 | |> follow_link( "Home" ) 188 | |> assert_response( 189 | status: 200, 190 | path: page_path(conn, :index), 191 | html: "Test App" 192 | ) 193 | end 194 | 195 | 196 | ### What phoenix_integration is NOT 197 | 198 | phoenix_integration is not a client-side acceptence test suite. It does not use 199 | a real browser and does not exercise javascript code that lives there. It's focus 200 | is on fast, readable, server-side integration. 201 | 202 | Try using a tool like [`Hound`](https://hex.pm/packages/hound) for full-stack 203 | integration tests. 204 | """ 205 | 206 | defmacro __using__(_opts) do 207 | quote do 208 | import PhoenixIntegration.Assertions 209 | import PhoenixIntegration.Requests 210 | end 211 | 212 | # quote 213 | end 214 | 215 | # defmacro 216 | end 217 | -------------------------------------------------------------------------------- /lib/phoenix_integration/assertions.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Assertions do 2 | @moduledoc """ 3 | Functions to assert/refute the response content of a conn without interrupting the 4 | chain of actions in an integration test. 5 | 6 | Each function takes a conn and a set of conditions to test. Each condition is tested 7 | and, if they all pass, the function returns the passed-in conn unchanged. If any 8 | condition fails, the function raises an appropriate error. 9 | 10 | This is intended to be used in a (possibly long) chain of piped functions that 11 | exercises a set of functionality in your application. 12 | 13 | ### Example 14 | test "Basic page flow", %{conn: conn} do 15 | # get the root index page 16 | get( conn, page_path(conn, :index) ) 17 | # click/follow through the various about pages 18 | |> follow_link( "About Us" ) 19 | |> assert_response( status: 200, path: about_path(conn, :index) ) 20 | |> follow_link( "Contact" ) 21 | |> assert_response( content_type: "text/html" ) 22 | |> follow_link( "Privacy" ) 23 | |> assert_response( html: "Privacy Policy" ) 24 | |> follow_link( "Home" ) 25 | |> assert_response( status: 200, path: page_path(conn, :index) ) 26 | end 27 | """ 28 | 29 | # import IEx 30 | defmodule ResponseError do 31 | @moduledoc false 32 | defexception message: "#{IO.ANSI.red()}The conn's response was not formed as expected\n" 33 | end 34 | 35 | @doc """ 36 | Asserts a set of conditions against the response fields of a conn. Returns the conn on success 37 | so that it can be used in the next integration call. 38 | 39 | ### Parameters 40 | * `conn` should be a conn returned from a previous request 41 | should point to the path being redirected to. 42 | * `conditions` a list of conditions to test against. Conditions can include: 43 | * `:status` checks that `conn.status` equals the given numeric value 44 | * `:content_type` the conn's content-type header should contain the given text. Typical 45 | values are `"text/html"` or `"application/json"` 46 | * `:body` conn.resp_body should contain the given text. Does not check the content_type. 47 | * `:html` checks that content_type is html, then looks for the given text in the body. 48 | * `:json` checks that content_type is json, then checks that the json data equals the given map. 49 | * `:path` the route rendered into the conn must equal the given path (or uri). 50 | * `:uri` same as `:path` 51 | * `:redirect` checks that `conn.status` is 302 and that the path in the "location" redirect 52 | header equals the given path. 53 | * `:to` same as `:redirect` 54 | * `:assigns` checks that conn.assigns contains the given values, which could be in the form of `%{key => value}` 55 | or `[{key, value}]` 56 | * `:value` checks that the value returned by a callback (in the form `fn(conn)`) is truthy 57 | 58 | Conditions can be used multiple times within a single call to `assert_response`. This can be useful 59 | to look for multiple text strings in the body. 60 | 61 | Example 62 | 63 | # test a rendered page 64 | assert_response( conn, 65 | status: 200, 66 | path: page_path(conn, :index), 67 | html: "Some Content", 68 | html: "More Content", 69 | assigns: %{current_user_id: user.id} 70 | ) 71 | 72 | # test a redirection 73 | assert_response( conn, to: page_path(conn, :index) ) 74 | 75 | # test a callback value 76 | assert_response( conn, value: fn(conn) -> 77 | Guardian.Plug.current_resource(conn) 78 | end) 79 | """ 80 | def assert_response(conn = %Plug.Conn{}, conditions) do 81 | Enum.each(conditions, fn {condition, value} -> 82 | case condition do 83 | :status -> assert_status(conn, value) 84 | :content_type -> assert_content_type(conn, value) 85 | :body -> assert_body(conn, value) 86 | :html -> assert_body_html(conn, value) 87 | :json -> assert_body_json(conn, value) 88 | :uri -> assert_uri(conn, value) 89 | :path -> assert_uri(conn, value, :path) 90 | :redirect -> assert_redirect(conn, value) 91 | :to -> assert_redirect(conn, value, :to) 92 | :assigns -> assert_assigns(conn, value) 93 | :value -> assert_value(conn, value) 94 | end 95 | end) 96 | 97 | conn 98 | end 99 | 100 | @doc """ 101 | Refutes a set of conditions for the response fields in a conn. Returns the conn on success 102 | so that it can be used in the next integration call. 103 | 104 | ### Parameters 105 | * `conn` should be a conn returned from a previous request 106 | should point to the path being redirected to. 107 | * `conditions` a list of conditions to test against. Conditions can include: 108 | * `:status` checks that `conn.status` is not the given numeric value 109 | * `:content_type` the conn's content-type header should not contain the given text. Typical 110 | values are `"text/html"` or `"applicaiton/json"` 111 | * `:body` conn.resp_body should not contain the given text. Does not check the content_type. 112 | * `:html` checks if content_type is html. If it is, it then checks that the given text is not in the body. 113 | * `:json` checks if content_type is json, then checks that the json data does not equal the given map. 114 | * `:path` the route rendered into the conn must not equal the given path (or uri). 115 | * `:uri` same as `:path` 116 | * `:redirect` checks if `conn.status` is 302. If it is, then checks that the path in the "location" redirect 117 | header is not the given path. 118 | * `:to` same as `:redirect` 119 | * `:assigns` checks that conn.assigns does not contain the given values, which could be in the form of `%{key: value}` 120 | or `[{:key, value}]` 121 | * `:value` checks that the value returned by a callback (in the form `fn(conn)`) is false or nil 122 | 123 | `refute_response` is often used in conjuntion with `assert_response` to form a complete condition check. 124 | 125 | Example 126 | 127 | # test a rendered page 128 | follow_path( conn, page_path(conn, :index) ) 129 | |> assert_response( 130 | status: 200, 131 | path: page_path(conn, :index) 132 | html: "Good Content" 133 | ) 134 | |> refute_response( body: "Invalid Content" ) 135 | """ 136 | def refute_response(conn = %Plug.Conn{}, conditions) do 137 | Enum.each(conditions, fn {condition, value} -> 138 | case condition do 139 | :status -> refute_status(conn, value) 140 | :content_type -> refute_content_type(conn, value) 141 | :body -> refute_body(conn, value) 142 | :html -> refute_body_html(conn, value) 143 | :json -> refute_body_json(conn, value) 144 | :uri -> refute_uri(conn, value) 145 | :path -> refute_uri(conn, value, :path) 146 | :redirect -> refute_redirect(conn, value) 147 | :to -> refute_redirect(conn, value, :to) 148 | :assigns -> refute_assigns(conn, value) 149 | :value -> refute_value(conn, value) 150 | end 151 | end) 152 | 153 | conn 154 | end 155 | 156 | # ---------------------------------------------------------------------------- 157 | defp assert_value(conn, callback, err_type \\ :value) 158 | 159 | defp assert_value(conn, callback, err_type) when is_function(callback, 1) do 160 | value = callback.(conn) 161 | 162 | if value do 163 | conn 164 | else 165 | # raise an appropriate error 166 | msg = 167 | error_msg_type(conn, err_type) <> 168 | error_msg_expected("callback response to be truthy") <> error_msg_found(inspect(value)) 169 | 170 | raise %ResponseError{message: msg} 171 | end 172 | end 173 | 174 | # ---------------------------------------------------------------------------- 175 | defp refute_value(conn, callback, err_type \\ :value) 176 | 177 | defp refute_value(conn, callback, err_type) when is_function(callback, 1) do 178 | value = callback.(conn) 179 | 180 | unless value do 181 | conn 182 | else 183 | # raise an appropriate error 184 | msg = 185 | error_msg_type(conn, err_type) <> 186 | error_msg_expected("callback response to be nil or false") <> 187 | error_msg_found(inspect(value)) 188 | 189 | raise %ResponseError{message: msg} 190 | end 191 | end 192 | 193 | # ---------------------------------------------------------------------------- 194 | defp assert_assigns(conn, expected, err_type \\ :assigns) 195 | 196 | defp assert_assigns(conn, expected, err_type) when is_map(expected) do 197 | Enum.each(expected, fn {key, value} -> 198 | if conn.assigns[key] != value do 199 | # raise an appropriate error 200 | msg = 201 | error_msg_type(conn, err_type) <> 202 | error_msg_expected("conn.assigns to contain: " <> inspect(expected)) <> 203 | error_msg_found(inspect(conn.assigns)) 204 | 205 | raise %ResponseError{message: msg} 206 | end 207 | end) 208 | end 209 | 210 | defp assert_assigns(conn, expected, err_type) when is_list(expected), 211 | do: assert_assigns(conn, Enum.into(expected, %{}), err_type) 212 | 213 | # ---------------------------------------------------------------------------- 214 | defp refute_assigns(conn, expected, err_type \\ :assigns) 215 | 216 | defp refute_assigns(conn, expected, err_type) when is_map(expected) do 217 | Enum.each(expected, fn {key, value} -> 218 | if conn.assigns[key] == value do 219 | # raise an appropriate error 220 | msg = 221 | error_msg_type(conn, err_type) <> 222 | error_msg_expected("conn.assigns to NOT contain: " <> inspect(expected)) <> 223 | error_msg_found(inspect(conn.assigns)) 224 | 225 | raise %ResponseError{message: msg} 226 | end 227 | end) 228 | end 229 | 230 | defp refute_assigns(conn, expected, err_type) when is_list(expected), 231 | do: assert_assigns(conn, Enum.into(expected, %{}), err_type) 232 | 233 | # ---------------------------------------------------------------------------- 234 | defp assert_uri(conn, expected, err_type \\ :uri) do 235 | # parse the expected uri 236 | uri = URI.parse(expected) 237 | 238 | # prepare the path and query data 239 | {uri_path, conn_path} = 240 | case uri.path do 241 | nil -> {nil, nil} 242 | _path -> {uri.path, conn.request_path} 243 | end 244 | 245 | {uri_query, conn_query} = 246 | case uri.query do 247 | nil -> 248 | {nil, nil} 249 | 250 | _query -> 251 | # decode the queries to get order independence 252 | {URI.decode_query(uri.query), URI.decode_query(conn.query_string)} 253 | end 254 | 255 | # The main test 256 | pass = 257 | cond do 258 | uri_path && uri_query -> uri_path == conn_path && uri_query == conn_query 259 | uri_path -> uri_path == conn_path 260 | uri_query -> uri_query == conn_query 261 | end 262 | 263 | # raise or not as appropriate 264 | if pass do 265 | conn 266 | else 267 | # raise an appropriate error 268 | msg = 269 | error_msg_type(conn, err_type) <> 270 | error_msg_expected(expected) <> error_msg_found(conn_request_path(conn)) 271 | 272 | raise %ResponseError{message: msg} 273 | end 274 | end 275 | 276 | # ---------------------------------------------------------------------------- 277 | defp refute_uri(conn, expected, err_type \\ :uri) do 278 | # parse the expected uri 279 | uri = URI.parse(expected) 280 | 281 | # prepare the path and query data 282 | {uri_path, conn_path} = 283 | case uri.path do 284 | nil -> {nil, nil} 285 | _path -> {uri.path, conn.request_path} 286 | end 287 | 288 | {uri_query, conn_query} = 289 | case uri.query do 290 | nil -> 291 | {nil, nil} 292 | 293 | _query -> 294 | # decode the queries to get order independence 295 | {URI.decode_query(uri.query), URI.decode_query(conn.query_string)} 296 | end 297 | 298 | # The main test 299 | pass = 300 | cond do 301 | uri_path && uri_query -> uri_path != conn_path || uri_query != conn_query 302 | uri_path -> uri_path != conn_path 303 | uri_query -> uri_query != conn_query 304 | end 305 | 306 | # raise or not as appropriate 307 | if pass do 308 | conn 309 | else 310 | # raise an appropriate error 311 | msg = 312 | error_msg_type(conn, err_type) <> 313 | error_msg_expected("path to NOT be:" <> conn_request_path(conn)) <> 314 | error_msg_found(conn_request_path(conn)) 315 | 316 | raise %ResponseError{message: msg} 317 | end 318 | end 319 | 320 | # ---------------------------------------------------------------------------- 321 | defp assert_redirect(conn, expected, err_type \\ :redirect) do 322 | assert_status(conn, 302) 323 | 324 | case Plug.Conn.get_resp_header(conn, "location") do 325 | [^expected] -> 326 | conn 327 | 328 | [to] -> 329 | msg = 330 | error_msg_type(conn, err_type) <> 331 | error_msg_expected(to_string(expected)) <> error_msg_found(to_string(to)) 332 | 333 | raise %ResponseError{message: msg} 334 | end 335 | end 336 | 337 | # ---------------------------------------------------------------------------- 338 | defp refute_redirect(conn, expected, err_type \\ :redirect) do 339 | case conn.status do 340 | 302 -> 341 | case Plug.Conn.get_resp_header(conn, "location") do 342 | [^expected] -> 343 | msg = 344 | error_msg_type(conn, err_type) <> 345 | error_msg_expected("to NOT redirect to: " <> to_string(expected)) <> 346 | error_msg_found("redirect to: " <> to_string(expected)) 347 | 348 | raise %ResponseError{message: msg} 349 | 350 | [_to] -> 351 | conn 352 | end 353 | 354 | _other -> 355 | conn 356 | end 357 | end 358 | 359 | # ---------------------------------------------------------------------------- 360 | defp assert_body_html(conn, expected, err_type \\ :html) do 361 | assert_content_type(conn, "text/html", err_type) 362 | |> assert_body(expected, err_type) 363 | end 364 | 365 | # ---------------------------------------------------------------------------- 366 | defp refute_body_html(conn, expected, err_type \\ :html) do 367 | # slightly different than asserting body_html 368 | # good if not html content 369 | case Plug.Conn.get_resp_header(conn, "content-type") do 370 | [] -> 371 | conn 372 | 373 | [header] -> 374 | cond do 375 | header =~ "text/html" -> 376 | refute_body(conn, expected, err_type) 377 | 378 | true -> 379 | conn 380 | end 381 | end 382 | end 383 | 384 | # ---------------------------------------------------------------------------- 385 | defp assert_body_json(conn, expected, err_type \\ :json) do 386 | assert_content_type(conn, "application/json", err_type) 387 | 388 | case Jason.decode!(conn.resp_body) do 389 | ^expected -> 390 | conn 391 | 392 | data -> 393 | msg = 394 | error_msg_type(conn, err_type) <> 395 | error_msg_expected(inspect(expected)) <> error_msg_found(inspect(data)) 396 | 397 | raise %ResponseError{message: msg} 398 | end 399 | end 400 | 401 | # ---------------------------------------------------------------------------- 402 | defp refute_body_json(conn, expected, err_type \\ :json) do 403 | # similar to refute body html, ok if content isn't json 404 | case Plug.Conn.get_resp_header(conn, "content-type") do 405 | [] -> 406 | conn 407 | 408 | [header] -> 409 | cond do 410 | header =~ "json" -> 411 | case Jason.decode!(conn.resp_body) do 412 | ^expected -> 413 | msg = 414 | error_msg_type(conn, err_type) <> 415 | error_msg_expected("to NOT find " <> inspect(expected)) <> 416 | error_msg_found(inspect(expected)) 417 | 418 | raise %ResponseError{message: msg} 419 | 420 | _data -> 421 | conn 422 | end 423 | 424 | true -> 425 | conn 426 | end 427 | end 428 | end 429 | 430 | # ---------------------------------------------------------------------------- 431 | defp assert_body(conn, expected, err_type \\ :body) do 432 | if conn.resp_body =~ expected do 433 | conn 434 | else 435 | msg = 436 | error_msg_type(conn, err_type) <> 437 | error_msg_expected("to find \"#{inspect(expected)}\"") <> 438 | error_msg_found("Not in the response body\n") <> IO.ANSI.yellow() <> conn.resp_body 439 | 440 | raise %ResponseError{message: msg} 441 | end 442 | end 443 | 444 | # ---------------------------------------------------------------------------- 445 | defp refute_body(conn, expected, err_type \\ :body) do 446 | if conn.resp_body =~ expected do 447 | msg = 448 | error_msg_type(conn, err_type) <> 449 | error_msg_expected("NOT to find \"#{inspect(expected)}\"") <> 450 | error_msg_found("in the response body\n") <> IO.ANSI.yellow() <> conn.resp_body 451 | 452 | raise %ResponseError{message: msg} 453 | else 454 | conn 455 | end 456 | end 457 | 458 | # ---------------------------------------------------------------------------- 459 | defp assert_status(conn, status, err_type \\ :status) do 460 | case conn.status do 461 | ^status -> 462 | conn 463 | 464 | other -> 465 | msg = 466 | error_msg_type(conn, err_type) <> 467 | error_msg_expected(to_string(status)) <> error_msg_found(to_string(other)) 468 | 469 | raise %ResponseError{message: msg} 470 | end 471 | end 472 | 473 | # ---------------------------------------------------------------------------- 474 | defp refute_status(conn, status, err_type \\ :status) do 475 | case conn.status do 476 | ^status -> 477 | msg = 478 | error_msg_type(conn, err_type) <> 479 | error_msg_expected("NOT " <> to_string(status)) <> error_msg_found(to_string(status)) 480 | 481 | raise %ResponseError{message: msg} 482 | 483 | _other -> 484 | conn 485 | end 486 | end 487 | 488 | # ---------------------------------------------------------------------------- 489 | defp assert_content_type(conn, expected_type, err_type \\ :content_type) do 490 | case Plug.Conn.get_resp_header(conn, "content-type") do 491 | [] -> 492 | # no content type header was found 493 | msg = 494 | error_msg_type(conn, err_type) <> 495 | error_msg_expected("content-type header of \"#{expected_type}\"") <> 496 | error_msg_found("No content-type header was found") 497 | 498 | raise %ResponseError{message: msg} 499 | 500 | [header] -> 501 | cond do 502 | header =~ expected_type -> 503 | # success case 504 | conn 505 | 506 | true -> 507 | # there was a content type header, but the wrong one 508 | msg = 509 | error_msg_type(conn, err_type) <> 510 | error_msg_expected("content-type including \"#{expected_type}\"") <> 511 | error_msg_found("\"#{header}\"") 512 | 513 | raise %ResponseError{message: msg} 514 | end 515 | end 516 | end 517 | 518 | # ---------------------------------------------------------------------------- 519 | defp refute_content_type(conn, expected_type, err_type \\ :content_type) do 520 | case Plug.Conn.get_resp_header(conn, "content-type") do 521 | [] -> 522 | conn 523 | 524 | [header] -> 525 | cond do 526 | header =~ expected_type -> 527 | # the refuted content_type header was found 528 | msg = 529 | error_msg_type(conn, err_type) <> 530 | error_msg_expected("content-type to NOT be \"#{expected_type}\"") <> 531 | error_msg_found("\"#{header}\"") 532 | 533 | raise %ResponseError{message: msg} 534 | 535 | true -> 536 | conn 537 | end 538 | end 539 | end 540 | 541 | # ---------------------------------------------------------------------------- 542 | defp error_msg_type(conn, type) do 543 | "#{IO.ANSI.red()}The conn's response was not formed as expected\n" <> 544 | "#{IO.ANSI.green()}Error verifying #{IO.ANSI.cyan()}:#{type}\n" <> 545 | "#{IO.ANSI.green()}Request path: #{IO.ANSI.yellow()}#{conn_request_path(conn)}\n" <> 546 | "#{IO.ANSI.green()}Request method: #{IO.ANSI.yellow()}#{conn.method}\n" <> 547 | "#{IO.ANSI.green()}Request params: #{IO.ANSI.yellow()}#{inspect(conn.params)}\n" 548 | end 549 | 550 | # ---------------------------------------------------------------------------- 551 | defp error_msg_expected(msg) do 552 | "#{IO.ANSI.green()}Expected: #{IO.ANSI.red()}#{msg}\n" 553 | end 554 | 555 | # ---------------------------------------------------------------------------- 556 | defp error_msg_found(msg) do 557 | "#{IO.ANSI.green()}Found: #{IO.ANSI.red()}#{msg}\n" 558 | end 559 | 560 | # ---------------------------------------------------------------------------- 561 | defp conn_request_path(conn) do 562 | conn.request_path <> 563 | case conn.query_string do 564 | nil -> "" 565 | "" -> "" 566 | query -> "?" <> query 567 | end 568 | end 569 | end 570 | -------------------------------------------------------------------------------- /lib/phoenix_integration/form/DESIGN.md: -------------------------------------------------------------------------------- 1 | This code is about the inner workings of this: 2 | 3 | submit_form(form, %{ animal: %{ 4 | name: "Bossie", 5 | species_id: 1 6 | }}) 7 | 8 | It converts a form produced by 9 | [Floki](https://hexdocs.pm/floki/Floki.html) into a map from atoms to 10 | values, one that can be submitted to a Phoenix controller via the usual 11 | ConnTest methods (like `post`): 12 | 13 | post(conn, Route.some_path, %{animal: %{name: "Bossie"}...}) 14 | 15 | There are three steps: 16 | 17 | #### Tree creation ([tree_creation.ex](./tree_creation.ex)) 18 | 19 | The Floki input contains a sequence of parsed HTML tags. Each of those 20 | is turned into a `Tag` struct ([tag.ex](./tag.ex)). That contains the 21 | relevant attributes of the tag, most importantly the `name` and 22 | `values`. (The `values` field is an empty list for a tag that has no 23 | value, a singleton list for an ordinary tag, or an arbitrary list for 24 | a `Tag` that can produce multiple values - see below for that.) 25 | 26 | A `Tag` also contains a `path` derived from the implicit nesting 27 | within the `name`. For example, `name="animal[species_id]"` has a path 28 | of `[:animal, :species_id]`. 29 | 30 | The tree creation step assembles a sequence of `Tag` values into a 31 | tree whose interior nodes are `:path` atoms and whose leaves are 32 | `Tags`. This involves some processing beyond just creating a tree 33 | because there can be relationships between HTML tags with the same 34 | `name`. Two are most important: 35 | 36 | * Tags whose shared name ends in `[]`, like this: 37 | 38 | 39 | 40 | 41 | 42 | ... are merged into a single `Tag` with the path 43 | `[:reservation, :chosen_ids]` and marked `has_list_value: true`. 44 | 45 | * HTML checkboxes do not by themselves ever return a "false" value. 46 | The appearance that they do is kludged with a `type="hidden"` tag: 47 | 48 | 49 | 50 | 51 | The two are merged into a single `Tag`. If the `"checkbox"` tag has 52 | a `checked` attribute, the `Tag`'s value is `"true"`; otherwise it's 53 | `"false"`. 54 | 55 | #### Tree editing ([tree_edit.ex](./tree_edit.ex)) 56 | 57 | This phase updates the `Tag` tree, directed by a tree of values like this: 58 | 59 | %{animal: %{ 60 | name: "Bossie", 61 | species_id: 1 62 | }} 63 | 64 | The tree is first converted into a sequence of `Change` 65 | ([change.ex](./change.ex)) structs that contain both a `path` and a 66 | `value`. Then each `Change` is processed in turn to (possibly) update 67 | the `values` field of a `Tag` with the same path. 68 | 69 | Note that this approach has worse "big-O" performance than a single 70 | pass that descends both trees at once[[fn1]](#fn1), but it means this 71 | module works the same way as the previous one, it makes error 72 | reporting more convenient, and it's not like we're talking about big 73 | trees here. 74 | 75 | Speaking of error handling, various errors are reported. Most common 76 | will be trying to `Change` a value with no corresponding `Tag`. The 77 | reporting is done by the `Messages` module 78 | ([messages.ex](./messages.ex)), which also handles warnings produced 79 | in the tree-creation step. The warnings are for HTML that almost 80 | certainly doesn't do what its author wanted, such as having two 81 | `"text"` inputs with the same `name` that does *not* end in `[]`. (In 82 | normal Phoenix use, the controller action will never see the first 83 | input's value.)[[fn2]](#fn2) 84 | 85 | When the tree contains a struct like `Date` or `Plug.Upload`, the 86 | `Change`-creation step descends into the struct just like it would a 87 | regular map. However, the resulting `Change` values are marked so that 88 | no error is reported if they don't correspond to a `path` in the `Tag` 89 | tree. That is, it's fine if a form has only a 90 | `name="top_level[date][day]"` tag and doesn't use the other fields of 91 | a `Date` that supplies its value. 92 | 93 | #### Finishing the tree ([tree_finish.ex](./tree_finish.ex)) 94 | 95 | The last step descends the tree and just replaces the `Tags` with list 96 | or non-list values, as appropriate. *Except*... 97 | 98 | * Non-list leaves with no value are deleted. For example, consider 99 | radio buttons like this: 100 | 101 | 102 | 103 | 104 | Were the real form to be submitted, it would contain nothing about 105 | the name `"user[role]"`. Therefore, it would be incorrect for the 106 | tree given to `ConnTest.post` to produce a HTTP post string like 107 | `"...&user[role]=&..."`. Deleting empty `values` prevents that. 108 | 109 | * List leaves with an empty (`[]`) value are also deleted. That's the 110 | behavior HTML has with, for example, a `select` tag where nothing is 111 | checked. Like this: 112 | 113 | 117 | 118 | Related to the above, we have to avoid cases where the finished tree 119 | looks like this: 120 | 121 | %{animals: 122 | %{name: "Bossie", 123 | subtree: %{}}} 124 | 125 | There's no form that could deliver values that would result in an 126 | empty map being given to the controller action. So such key-value 127 | pairs are pruned, leaving this: 128 | 129 | %{animals: 130 | %{name: "Bossie"}} 131 | 132 | 133 | ------------------- 134 | ##### [[fn1]](#fn1) 135 | 136 | `O(width-of-tree * depth-of-tree)` vs. something like `O(width-of-tree + depth-of-tree)`, though I haven't thought about it much. 137 | 138 | ##### [[fn2]](#fn2) 139 | 140 | Tree-creation warnings will throw away information 141 | from conflicting nodes. That might not be the same information Phoenix 142 | would lose if the original form were submitted. For example, if a form 143 | has both a `name="animal[traits]"` and a `name="animal[traits][]"`, 144 | Phoenix might give the second one precedence while this code gives the 145 | first. 146 | -------------------------------------------------------------------------------- /lib/phoenix_integration/form/change.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Form.Change do 2 | alias PhoenixIntegration.Form.Common 3 | @moduledoc false 4 | 5 | # A test asks that a form value be changed. This struct contains 6 | # the information required to make the change. 7 | 8 | defstruct path: [], value: nil, ignore_if_missing_from_form: false 9 | 10 | def changes(tree), do: changes(tree, %__MODULE__{}) 11 | 12 | defp changes(node, state) do 13 | case classify(node) do 14 | :descend_map -> 15 | Enum.flat_map(node, fn {key, value} -> 16 | changes(value, note_longer_path(state, key)) 17 | end) 18 | :descend_struct -> 19 | changes(Map.from_struct(node), note_struct(state)) 20 | :finish_descent -> 21 | finish_descent(node, state) 22 | end 23 | end 24 | 25 | def classify(node) when not is_map(node), 26 | do: :finish_descent 27 | 28 | def classify(node) when is_map(node) do 29 | case {do_is_struct(node), node} do 30 | {false, _} -> :descend_map 31 | {true, %Plug.Upload{}} -> :finish_descent 32 | {true, _} -> :descend_struct 33 | end 34 | end 35 | 36 | def finish_descent(leaf, state) do 37 | [%{state | 38 | path: Enum.reverse(state.path), 39 | value: leaf 40 | }] 41 | end 42 | 43 | 44 | # This is in the latest version of Elixir, but let's have 45 | # some backward compatibility. 46 | defp do_is_struct(v) do 47 | v |> Map.has_key?(:__struct__) 48 | end 49 | 50 | defp note_struct(%__MODULE__{} = state), 51 | do: %{state | ignore_if_missing_from_form: true} 52 | 53 | defp note_longer_path(%__MODULE__{} = state, key), 54 | do: %{state | path: [Common.symbolize(key) | state.path] } 55 | 56 | end 57 | -------------------------------------------------------------------------------- /lib/phoenix_integration/form/common.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Form.Common do 2 | @moduledoc false 3 | 4 | ### Code shared among different tree-traversal modules. 5 | alias PhoenixIntegration.Form.Tag 6 | 7 | def symbolize(anything), do: to_string(anything) |> String.to_atom 8 | 9 | 10 | # ---------------------------------------------------------------------------- 11 | @doc "In trees whose leaves are tags, find some arbitrary leaf." 12 | def any_leaf(%Tag{} = tag), do: tag 13 | def any_leaf(tree) do 14 | {_key, subtree} = Enum.at(tree, 0) 15 | any_leaf(subtree) 16 | end 17 | 18 | # ---------------------------------------------------------------------------- 19 | # Tree creation and editing follow the same basic code structure and 20 | # use struct definitions with a common "shape". These utilities work 21 | # with that. (Low-rent behaviours.) 22 | 23 | def put_tree(acc, tree), do: %{acc | tree: tree} 24 | 25 | def put_warning(acc, message_atom, message_context), 26 | do: put_message(acc, :warnings, message_atom, message_context) 27 | 28 | def put_error(acc, message_atom, message_context) do 29 | acc 30 | |> put_message(:errors, message_atom, message_context) 31 | |> Map.put(:valid?, false) 32 | end 33 | 34 | defp put_message(acc, kind, message_atom, message_context), 35 | do: Map.update!(acc, kind, &(&1 ++ [{message_atom, message_context}])) 36 | end 37 | -------------------------------------------------------------------------------- /lib/phoenix_integration/form/messages.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Form.Messages do 2 | 3 | @moduledoc false 4 | # The various messages - both warnings and errors - that can be given to the user. 5 | alias PhoenixIntegration.Form.Common 6 | 7 | @headlines %{ 8 | no_such_name_in_form: "You tried to set the value of a tag that isn't in the form.", 9 | arity_clash: "You are combining list and scalar values.", 10 | tag_has_no_name: "A form tag has no name.", 11 | empty_name: "A tag has an empty name.", 12 | form_conflicting_paths: "The form has two conflicting names." 13 | } 14 | 15 | # This is used for testing as well as within this module. 16 | def get(key), do: @headlines[key] 17 | 18 | # ---------------------------------------------------------------------------- 19 | # Entry point 20 | 21 | def emit(message_tuples, form) do 22 | Enum.map(message_tuples, fn {message_atom, data} -> 23 | emit_one(message_atom, form, data) 24 | end) 25 | end 26 | 27 | defp emit_one(message_atom, form, context) when is_list(context) do 28 | {severity, iodata} = 29 | apply(__MODULE__, message_atom, [get(message_atom), form] ++ context) 30 | warnings? = Application.get_env(:phoenix_integration, :warnings, true) 31 | 32 | case {severity, warnings?} do 33 | {:error, _} -> 34 | put_iodata(:red, "Error", iodata) 35 | {:warning, true} -> 36 | put_iodata(:yellow, "Warning", iodata) 37 | {:warning, false} -> 38 | :ignore 39 | end 40 | end 41 | 42 | defp emit_one(message_atom, form, context) do 43 | emit_one(message_atom, form, [context]) 44 | end 45 | 46 | # ---------------------------------------------------------------------------- 47 | # A function for each headline 48 | 49 | def no_such_name_in_form(headline, form, context) do 50 | hint = 51 | case context.why do 52 | :path_too_long -> [ 53 | "Your path is longer than the names it should match.", 54 | key_values([ 55 | "Here is your path", inspect(context.change.path), 56 | "Here is an available name", context.tree[context.last_tried].name]) 57 | ] 58 | :path_too_short -> [ 59 | "You provided only a prefix of all the available names.", 60 | key_values([ 61 | "Here is your path", inspect(context.change.path), 62 | "Here is an available name", Common.any_leaf(context.tree).name]) 63 | ] 64 | :possible_typo -> 65 | key_values([ 66 | "Path tried", inspect(context.change.path), 67 | "Is this a typo?", "#{inspect context.last_tried}", 68 | "Your value", inspect(context.change.value)]) 69 | end 70 | 71 | {:error, [headline, hint, form_description(form)]} 72 | end 73 | 74 | def arity_clash(headline, form, %{existing: existing, change: change}) do 75 | hint = 76 | case existing.has_list_value do 77 | true -> [ 78 | "Note that the name of the tag you're setting ends in `[]`:", 79 | " #{inspect existing.name}", 80 | "So your value should be a list, rather than this:", 81 | " #{inspect change.value}", 82 | ] 83 | false -> [ 84 | "The value you want to use is a list:", 85 | " #{inspect change.value}", 86 | "But the name of the tag doesn't end in `[]`:", 87 | " #{inspect existing.name}" 88 | ] 89 | end 90 | 91 | {:error, [headline, hint, form_description(form)]} 92 | end 93 | 94 | def tag_has_no_name(headline, form, floki_tag) do 95 | {:warning, [ 96 | headline, 97 | Floki.raw_html(floki_tag), 98 | "It can't be included in the params sent to the controller.", 99 | form_description(form), 100 | ]} 101 | end 102 | 103 | def empty_name(headline, form, floki_tag) do 104 | {:warning, [ 105 | headline, 106 | Floki.raw_html(floki_tag), 107 | form_description(form), 108 | ]} 109 | end 110 | 111 | def form_conflicting_paths(headline, form, %{old: old, new: new}) do 112 | {:warning, [ 113 | headline, 114 | "Phoenix will ignore one of them.", 115 | key_values([ 116 | "Earlier name", old.name, 117 | " Later name", new.name, 118 | ]), 119 | form_description(form), 120 | ]} 121 | end 122 | 123 | # ---------------------------------------------------------------------------- 124 | # This prints (to stdio) an iodata tree, but unlike IO.puts, it adds 125 | # a newline at the end of each element. It also handles color. 126 | 127 | defp put_iodata(color, word, [headline | rest]) do 128 | prefix = apply(IO.ANSI, color, []) 129 | 130 | IO.puts "#{prefix}#{word}: #{headline}" 131 | for iodata <- rest, do: put_iodata(iodata) 132 | IO.puts "#{IO.ANSI.reset}" 133 | end 134 | 135 | defp put_iodata(iodata) when is_list(iodata) do 136 | for line <- iodata, do: put_iodata(line) 137 | end 138 | 139 | defp put_iodata(string) when is_binary(string), do: IO.puts string 140 | 141 | # ---------------------------------------------------------------------------- 142 | 143 | defp form_description(form) do 144 | [action] = Floki.attribute(form, "action") 145 | 146 | [ key_value("Form action", inspect action), 147 | case Floki.attribute(form, "id") do 148 | [] -> [] 149 | [id] -> key_value("Form id", inspect id) 150 | end 151 | ] 152 | end 153 | 154 | defp key_values(list) do 155 | list 156 | |> Enum.chunk_every(2) 157 | |> Enum.map(fn [key, value] -> key_value(key, value) end) 158 | end 159 | 160 | defp key_value(key, value) do 161 | "#{key}: #{value}" 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/phoenix_integration/form/tag.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Form.Tag do 2 | alias PhoenixIntegration.Form.Common 3 | 4 | @moduledoc false 5 | # A `Tag` is a representation of a value-providing HTML tag within a 6 | # Phoenix-style HTML form. Tags live on the leaves of a tree (nested 7 | # `Map`) representing the whole form. See [DESIGN.md](./DESIGN.md) for 8 | # more. 9 | 10 | # There are two types of tags. 11 | # - some tags are associated with an list of values. Those tags 12 | # will have named ending in `[]`: `name="animal[nicknames][]`. 13 | # - others have one value, or occasionally zero values (such as an 14 | # unchecked checkbox). 15 | defstruct has_list_value: false, 16 | # To accommodate the different tags, values are always stored in a 17 | # list. The empty list represents a tag without a value. 18 | values: [], 19 | # The name is as given in the HTML tag. 20 | name: nil, 21 | # The path is the name split up into a list of symbols representing 22 | # the tree structure implied by the[bracketed[name]]. 23 | path: [], 24 | # The tag itself, like `"input"` or "textarea". 25 | tag: "", 26 | # Where relevant, the value of the "type=" attribute of the tag. 27 | # Otherwise should be unused. 28 | type: nil, 29 | # Whether the particular value is checked (checkboxes, selects). 30 | checked: false, 31 | # The original Floki tag. 32 | original: nil 33 | 34 | def new!(floki_tag) do 35 | {:ok, %__MODULE__{} = tag} = new(floki_tag) 36 | tag 37 | end 38 | 39 | def new(floki_tag) do 40 | with( 41 | [name] <- Floki.attribute(floki_tag, "name"), 42 | :ok <- check_name(name) 43 | ) do 44 | {:ok, safe_new(floki_tag, name)} 45 | else 46 | [] -> 47 | {:warning, :tag_has_no_name, floki_tag} 48 | :empty_name -> 49 | {:warning, :empty_name, floki_tag} 50 | end 51 | end 52 | 53 | defp safe_new(floki_tag, name) do 54 | type = 55 | case Floki.attribute(floki_tag, "type") do 56 | [] -> "`type` irrelevant for `#{name}`" 57 | [x] -> x 58 | end 59 | 60 | checked = Floki.attribute(floki_tag, "checked") != [] 61 | 62 | %__MODULE__{tag: tag_name(floki_tag), 63 | original: floki_tag, 64 | type: type, 65 | name: name, 66 | checked: checked 67 | } 68 | |> add_fields_that_depend_on_name 69 | |> add_values 70 | end 71 | 72 | # ---------------------------------------------------------------------------- 73 | defp add_fields_that_depend_on_name(incomplete_tag) do 74 | has_list_value = String.ends_with?(incomplete_tag.name, "[]") 75 | path = 76 | case has_list_value do 77 | false -> path_to(incomplete_tag.name) 78 | true -> path_to(String.trim_trailing(incomplete_tag.name, "[]")) 79 | end 80 | 81 | %{ incomplete_tag | 82 | path: path, 83 | has_list_value: has_list_value} 84 | end 85 | 86 | # ---------------------------------------------------------------------------- 87 | defp add_values(%{tag: "textarea"} = incomplete_tag) do 88 | raw_value = Floki.FlatText.get(incomplete_tag.original) 89 | %{incomplete_tag | values: [raw_value]} 90 | end 91 | 92 | defp add_values(%{tag: "select"} = incomplete_tag) do 93 | selected_values = fn selected_options -> 94 | case Floki.attribute(selected_options, "value") do 95 | [] -> 96 | # "if no value attribute is included, the value defaults to the 97 | # text contained inside the element" - 98 | # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select 99 | [Floki.FlatText.get(selected_options)] 100 | values -> 101 | values 102 | end 103 | end 104 | 105 | value_when_no_option_is_selected = fn select -> 106 | multiple? = Floki.attribute(select, "multiple") != [] 107 | options = Floki.find(select, "option") 108 | case {multiple?, options} do 109 | # I don't see it explicitly stated, but the value of a 110 | # non-multiple `select` with no selected option is the value 111 | # of the first option. 112 | {false, [first|_rest]} -> selected_values.(first) 113 | {true, _} -> [] 114 | # A `select` with no options is pretty silly. Nevertheless. 115 | {_, []} -> [] 116 | end 117 | end 118 | 119 | values = 120 | case Floki.find(incomplete_tag.original, "option[selected]") do 121 | [] -> 122 | value_when_no_option_is_selected.(incomplete_tag.original) 123 | selected_options -> 124 | selected_values.(selected_options) 125 | end 126 | %{incomplete_tag | values: values} 127 | end 128 | 129 | defp add_values(%{tag: "input"} = incomplete_tag) do 130 | raw_values = Floki.attribute(incomplete_tag.original, "value") 131 | %{incomplete_tag | values: apply_input_special_cases(incomplete_tag, raw_values)} 132 | end 133 | 134 | # ---------------------------------------------------------------------------- 135 | # Special cases for `input` tags as described in 136 | # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input/checkbox 137 | # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio 138 | defp apply_input_special_cases(%{type: "checkbox"} = incomplete_tag, values), 139 | do: tags_with_checked_attribute(incomplete_tag, values) 140 | 141 | defp apply_input_special_cases(%{type: "radio"} = incomplete_tag, values), 142 | do: tags_with_checked_attribute(incomplete_tag, values) 143 | 144 | # This catches the zillion variants of the type="text" tag. 145 | defp apply_input_special_cases(_incomplete_tag, []), do: [""] 146 | 147 | defp apply_input_special_cases(_incomplete_tag, values), do: values 148 | 149 | # ---------------------------------------------------------------------------- 150 | defp tags_with_checked_attribute(incomplete_tag, values) do 151 | case {incomplete_tag.checked, values} do 152 | {true,[]} -> ["on"] 153 | {true,values} -> values 154 | {false,_} -> [] 155 | end 156 | end 157 | 158 | # ---------------------------------------------------------------------------- 159 | defp path_to(name) do 160 | name 161 | |> separate_name_pieces 162 | |> Enum.map(&(List.first(&1) |> Common.symbolize)) 163 | end 164 | 165 | defp check_name(name) do 166 | case separate_name_pieces(name) do 167 | [] -> 168 | :empty_name 169 | _ -> 170 | :ok 171 | end 172 | end 173 | 174 | defp separate_name_pieces(name), do: Regex.scan(~r/[^\[\]]+/, name) 175 | 176 | # Floki allows tags to come in two forms 177 | defp tag_name([floki_tag]), do: tag_name(floki_tag) 178 | defp tag_name({name, _, _}), do: name 179 | end 180 | -------------------------------------------------------------------------------- /lib/phoenix_integration/form/tree_creation.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Form.TreeCreation do 2 | @moduledoc false 3 | 4 | # The code in this module converts a Floki representation of an HTML 5 | # form into a tree structure whose leaves are Tags: that is, descriptions 6 | # of a form tag that can provide values to POST-style parameters. 7 | # See [DESIGN.md](./DESIGN.md) for more. 8 | 9 | alias PhoenixIntegration.Form.Tag 10 | alias PhoenixIntegration.Form.Common 11 | 12 | defstruct valid?: :true, tree: %{}, warnings: [], errors: [] 13 | 14 | ### Main interface 15 | 16 | def build_tree(form) do 17 | # Currently no errors, only warnings. 18 | %{valid?: true} = form_to_floki_tags(form) |> build_tree_from_floki_tags 19 | end 20 | 21 | # ---------------------------------------------------------------------------- 22 | defp form_to_floki_tags(form) do 23 | ["input", "textarea", "select"] 24 | |> Enum.flat_map(fn tag_name -> floki_tags(form, tag_name) end) 25 | end 26 | 27 | defp floki_tags(form, "input") do 28 | form 29 | |> Floki.find("input") 30 | |> Enum.map(&force_explicit_type/1) 31 | |> reject_types(["button", "image", "reset", "submit"]) 32 | end 33 | 34 | defp floki_tags(form, "textarea"), do: Floki.find(form, "textarea") 35 | defp floki_tags(form, "select"), do: Floki.find(form, "select") 36 | 37 | # An omitted type counts as `text` 38 | defp force_explicit_type(floki_tag) do 39 | adjuster = fn {name, attributes, children} -> 40 | {name, [{"type", "text"} | attributes], children} 41 | end 42 | 43 | case Floki.attribute(floki_tag, "type") do 44 | [] -> Floki.traverse_and_update(floki_tag, adjuster) 45 | [_] -> floki_tag 46 | end 47 | end 48 | 49 | defp reject_types(floki_tags, disallowed) do 50 | reject_one = fn floki_tag -> 51 | [type] = Floki.attribute(floki_tag, "type") 52 | type in disallowed 53 | end 54 | 55 | Enum.reject(floki_tags, reject_one) 56 | end 57 | 58 | # ---------------------------------------------------------------------------- 59 | defp build_tree_from_floki_tags(tags) do 60 | reducer = fn floki_tag, acc -> 61 | with( 62 | {:ok, tag} <- Tag.new(floki_tag), 63 | {:ok, new_tree} <- add_tag(acc.tree, tag) 64 | ) do 65 | Common.put_tree(acc, new_tree) 66 | else 67 | {:warning, message_atom, message_context} -> 68 | Common.put_warning(acc, message_atom, message_context) 69 | end 70 | end 71 | 72 | Enum.reduce(tags, %__MODULE__{}, reducer) 73 | end 74 | 75 | # ---------------------------------------------------------------------------- 76 | def add_tag!(tree, %Tag{} = tag) do # Used in tests 77 | {:ok, new_tree} = add_tag(tree, tag) 78 | new_tree 79 | end 80 | 81 | def add_tag(tree, %Tag{} = tag) do 82 | try do 83 | {:ok, add_tag(tree, tag.path, tag)} 84 | catch 85 | {message_code, message_context} -> 86 | {:warning, message_code, message_context} 87 | end 88 | end 89 | 90 | defp add_tag(tree, [last], %Tag{} = tag) do 91 | case Map.get(tree, last) do 92 | nil -> 93 | Map.put_new(tree, last, tag) 94 | %Tag{} -> 95 | Map.update!(tree, last, &(combine_values &1, tag)) 96 | _ -> 97 | throw {:form_conflicting_paths, %{old: Common.any_leaf(tree), new: tag}} 98 | end 99 | end 100 | 101 | defp add_tag(tree, [next | rest], %Tag{} = tag) do 102 | case Map.get(tree, next) do 103 | %Tag{} = old -> # we've reached a leaf but new Tag has path left 104 | throw {:form_conflicting_paths, %{old: old, new: tag}} 105 | nil -> 106 | Map.put(tree, next, add_tag(%{}, rest, tag)) 107 | _ -> 108 | Map.update!(tree, next, &(add_tag &1, rest, tag)) 109 | end 110 | end 111 | 112 | # ---------------------------------------------------------------------------- 113 | defp combine_values(earlier_tag, later_tag) do 114 | case {earlier_tag.type, later_tag.type, earlier_tag.has_list_value} do 115 | {"hidden", "checkbox", _} -> 116 | implement_hidden_hack(earlier_tag, later_tag) 117 | {"radio", "radio", false} -> 118 | implement_radio(earlier_tag, later_tag) 119 | {_, _, false} -> 120 | later_tag 121 | {_, _, true} -> 122 | %{earlier_tag | values: earlier_tag.values ++ later_tag.values} 123 | end 124 | end 125 | 126 | defp implement_hidden_hack(hidden_tag, checkbox_tag) do 127 | case checkbox_tag.values == [] do 128 | true -> hidden_tag 129 | false -> checkbox_tag 130 | end 131 | end 132 | 133 | defp implement_radio(earlier_tag, current_tag) do 134 | case current_tag.values == [] do 135 | true -> earlier_tag 136 | false -> current_tag 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/phoenix_integration/form/tree_edit.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Form.TreeEdit do 2 | @moduledoc false 3 | # Once a tree of `Tag` structures has been created, the values contained 4 | # within it can be overridden by leaves of a different tree provided by 5 | # test. 6 | alias PhoenixIntegration.Form.{Change, Tag, Common} 7 | 8 | defstruct valid?: :true, tree: %{}, errors: [] 9 | 10 | def apply_edits(tree, edit_tree) do 11 | changes = Change.changes(edit_tree) 12 | 13 | reducer = fn change, acc -> 14 | case apply_change(acc.tree, change) do 15 | {:ok, new_tree} -> 16 | Common.put_tree(acc, new_tree) 17 | {:error, message_atom, message_context} -> 18 | Common.put_error(acc, message_atom, message_context) 19 | end 20 | end 21 | 22 | case Enum.reduce(changes, %__MODULE__{tree: tree}, reducer) do 23 | %{valid?: true, tree: tree} -> {:ok, tree} 24 | %{errors: errors} -> {:error, errors} 25 | end 26 | end 27 | 28 | def apply_change!(tree, %Change{} = change) do 29 | {:ok, new_tree} = apply_change(tree, change) 30 | new_tree 31 | end 32 | 33 | def apply_change(tree, %Change{} = change) do 34 | try do 35 | {:ok, apply_change(tree, change.path, change)} 36 | catch 37 | {description, context} -> 38 | handle_oddity(description, context, tree, change) 39 | end 40 | end 41 | 42 | def handle_oddity(:no_such_name_in_form, %{why: :possible_typo}, tree, 43 | %{ignore_if_missing_from_form: true}), 44 | do: {:ok, tree} 45 | 46 | def handle_oddity(description, context, _tree, _change), 47 | do: {:error, description, context} 48 | 49 | defp apply_change(tree, [last], %Change{} = change) do 50 | case Map.get(tree, last) do 51 | %Tag{} = tag -> 52 | Map.put(tree, last, combine(tag, change)) 53 | nil -> 54 | throw no_such_name_in_form(:possible_typo, tree, last, change) 55 | _ -> 56 | throw no_such_name_in_form(:path_too_short, tree, last, change) 57 | end 58 | end 59 | 60 | defp apply_change(tree, [next | rest], %Change{} = change) do 61 | case Map.get(tree, next) do 62 | %Tag{} -> 63 | throw no_such_name_in_form(:path_too_long, tree, next, change) 64 | nil -> 65 | throw no_such_name_in_form(:possible_typo, tree, next, change) 66 | _ -> 67 | Map.update!(tree, next, &(apply_change &1, rest, change)) 68 | end 69 | end 70 | 71 | defp no_such_name_in_form(why, tree, key, change) do 72 | {:no_such_name_in_form, 73 | %{why: why, tree: tree, last_tried: key, change: change} 74 | } 75 | end 76 | 77 | def combine(%Tag{} = tag, %Change{} = change) do 78 | case {is_list(change.value), tag.has_list_value} do 79 | {true, true} -> 80 | %Tag{ tag | values: change.value} 81 | {false, false} -> 82 | %Tag{ tag | values: [change.value]} 83 | _ -> 84 | throw {:arity_clash, %{existing: tag, change: change}} 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/phoenix_integration/form/tree_finish.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Form.TreeFinish do 2 | @moduledoc false 3 | # Once a tree of `Tag` structures has been created and perhaps edited, 4 | # nit is converted to a simple tree as delivered to a controller action. 5 | 6 | alias PhoenixIntegration.Form.Tag 7 | 8 | def to_action_params(tree) when is_map(tree) do 9 | ignore_empty_array_tag_like_HTTP_does = fn acc, _key -> 10 | acc 11 | end 12 | to_params_reducer(tree, ignore_empty_array_tag_like_HTTP_does) 13 | end 14 | 15 | # This is a function because it's referred to by tests. 16 | def no_value_msg, 17 | do: {:no_value, 18 | "Unless given a value, this param will not be sent to the server."} 19 | 20 | def to_form_params(tree) when is_map(tree) do 21 | retain_empty_array_tag = fn acc, key -> 22 | Map.put(acc, key, no_value_msg()) 23 | end 24 | to_params_reducer(tree, retain_empty_array_tag) 25 | end 26 | 27 | 28 | defp to_params_reducer(tree, empty_list_handler) when is_map(tree) do 29 | Enum.reduce(tree, %{}, &(to_params_step &1, &2, empty_list_handler)) 30 | |> Enum.reject(fn {_key, val} -> val == %{} end) 31 | |> Map.new 32 | end 33 | 34 | defp to_params_step({key, %Tag{} = tag}, acc, empty_list_handler) do 35 | case {tag.has_list_value, tag.values} do 36 | {_, []} -> 37 | empty_list_handler.(acc, key) 38 | {true, values} -> 39 | Map.put(acc, key, values) 40 | {false, [value]} -> 41 | Map.put(acc, key, value) 42 | _ -> 43 | throw "A user error that should have already been reported." 44 | end 45 | end 46 | 47 | defp to_params_step({key, submap}, acc, empty_list_handler) when is_map(submap) do 48 | Map.put(acc, key, to_params_reducer(submap, empty_list_handler)) 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.9.2" 5 | @url "https://github.com/boydm/phoenix_integration" 6 | 7 | def project do 8 | [ 9 | app: :phoenix_integration, 10 | version: @version, 11 | elixir: "~> 1.10", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | deps: deps(), 14 | package: [ 15 | contributors: ["Boyd Multerer"], 16 | maintainers: ["Boyd Multerer"], 17 | licenses: ["MIT"], 18 | links: %{ 19 | "GitHub" => @url, 20 | "Blog Post" => 21 | "https://medium.com/@boydm/integration-testing-phoenix-applications-b2a46acae9cb" 22 | } 23 | ], 24 | name: "phoenix_integration", 25 | source_url: @url, 26 | docs: docs(), 27 | description: """ 28 | Lightweight server-side integration test functions for Phoenix. 29 | Optimized for Elixir Pipes and the existing Phoenix.ConnTest 30 | framework to emphasize both speed and readability. 31 | """ 32 | ] 33 | end 34 | 35 | # Specifies which paths to compile per environment. 36 | defp elixirc_paths(:test), do: ["lib", "test/support"] 37 | defp elixirc_paths(_), do: ["lib"] 38 | 39 | def application do 40 | [applications: [:phoenix, :floki, :jason]] 41 | end 42 | 43 | defp deps do 44 | [ 45 | {:phoenix, "~> 1.3"}, 46 | {:phoenix_html, "~> 2.10 or ~> 3.0"}, 47 | {:floki, ">= 0.24.0"}, 48 | {:jason, "~> 1.1"}, 49 | {:flow_assertions, "~> 0.7", only: :test}, 50 | 51 | # Docs dependencies 52 | {:ex_doc, ">= 0.0.0", only: [:dev, :docs]}, 53 | {:inch_ex, ">= 0.0.0", only: :docs} 54 | # {:credo, "~> 1.0", only: [:dev, :test], runtime: false} 55 | ] 56 | end 57 | 58 | def docs do 59 | [ 60 | extras: ["README.md"], 61 | source_ref: "v#{@version}", 62 | main: "PhoenixIntegration" 63 | ] 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.15", "b29e8e729f4aa4a00436580dcc2c9c5c51890613457c193cc8525c388ccb2f06", [:mix], [], "hexpm", "044523d6438ea19c1b8ec877ec221b008661d3c27e3b848f4c879f500421ca5c"}, 5 | "ex_doc": {:hex, :ex_doc, "0.25.3", "3edf6a0d70a39d2eafde030b8895501b1c93692effcbd21347296c18e47618ce", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "9ebebc2169ec732a38e9e779fd0418c9189b3ca93f4a676c961be6c1527913f5"}, 6 | "floki": {:hex, :floki, "0.31.0", "f05ee8a8e6a3ced4e62beeb2c79a63bc8e12ab98fbaaf6e6a3d9b76b1278e23f", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "b05afa372f5c345a5bf240ac25ea1f0f3d5fcfd7490ac0beeb4a203f9444891e"}, 7 | "flow_assertions": {:hex, :flow_assertions, "0.7.1", "b175bffdc551b5ce3d0586aa4580f1708a2d98665e1d8b1f13f5dd9521f6d828", [:mix], [], "hexpm", "c83622f227bb6bf2b5c11f5515af1121884194023dbda424035c4dbbb0982b7c"}, 8 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 9 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, 10 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 11 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 14 | "mime": {:hex, :mime, "2.0.1", "0de4c81303fe07806ebc2494d5321ce8fb4df106e34dd5f9d787b637ebadc256", [:mix], [], "hexpm", "7a86b920d2aedce5fb6280ac8261ac1a739ae6c1a1ad38f5eadf910063008942"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 16 | "phoenix": {:hex, :phoenix, "1.6.0", "7b85023f7ddef9a5c70909a51cc37c8b868b474d853f90f4280efd26b0e7cce5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "52ffdd31f2daeb399b2e1eb57d468f99a1ad6eee5d8ea19d2353492f06c9fc96"}, 17 | "phoenix_html": {:hex, :phoenix_html, "3.0.4", "232d41884fe6a9c42d09f48397c175cd6f0d443aaa34c7424da47604201df2e1", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ce17fd3cf815b2ed874114073e743507704b1f5288bb03c304a77458485efc8b"}, 18 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 19 | "phoenix_view": {:hex, :phoenix_view, "1.0.0", "fea71ecaaed71178b26dd65c401607de5ec22e2e9ef141389c721b3f3d4d8011", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "82be3e2516f5633220246e2e58181282c71640dab7afc04f70ad94253025db0c"}, 20 | "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, 21 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 22 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 23 | } 24 | -------------------------------------------------------------------------------- /test/assertions_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.AssertionsTest do 2 | use ExUnit.Case, async: true 3 | import Plug.Conn 4 | import Phoenix.ConnTest 5 | @endpoint PhoenixIntegration.TestEndpoint 6 | 7 | # ============================================================================ 8 | # set up context 9 | setup do 10 | %{conn: build_conn(:get, "/")} 11 | end 12 | 13 | # ============================================================================ 14 | # known data 15 | 16 | @expected_json_data %{ 17 | "one" => 1, 18 | "two" => "two", 19 | "other" => "Sample" 20 | } 21 | @invalid_json_data %{ 22 | "one" => 1, 23 | "two" => 2, 24 | "other" => "Sample" 25 | } 26 | 27 | # ============================================================================ 28 | # assert_response - dives into the conn returned from a get/put/post/delete 29 | # call and asserts the given content. 30 | 31 | # ---------------------------------------------------------------------------- 32 | # assert status 33 | test "assert_response :status succeeds", %{conn: conn} do 34 | conn = get(conn, "/sample") 35 | PhoenixIntegration.Assertions.assert_response(conn, status: 200) 36 | end 37 | 38 | test "assert_response :status fails if wrong status", %{conn: conn} do 39 | conn = get(conn, "/sample") 40 | 41 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 42 | PhoenixIntegration.Assertions.assert_response(conn, status: 201) 43 | end 44 | end 45 | 46 | # ---------------------------------------------------------------------------- 47 | # assert value 48 | test "assert_response :value succeeds for truthy value", %{conn: conn} do 49 | conn = get(conn, "/sample") 50 | PhoenixIntegration.Assertions.assert_response(conn, value: fn _ -> 123 end) 51 | end 52 | 53 | test "assert_response :value fails for false value", %{conn: conn} do 54 | conn = get(conn, "/sample") 55 | 56 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 57 | PhoenixIntegration.Assertions.assert_response(conn, value: fn _ -> false end) 58 | end 59 | end 60 | 61 | test "assert_response :value fails for nil value", %{conn: conn} do 62 | conn = get(conn, "/sample") 63 | 64 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 65 | PhoenixIntegration.Assertions.assert_response(conn, value: fn _ -> nil end) 66 | end 67 | end 68 | 69 | # ---------------------------------------------------------------------------- 70 | # assert assigns 71 | test "assert_response :assigns succeeds", %{conn: conn} do 72 | conn = assign(conn, :some_key, "some_value") 73 | PhoenixIntegration.Assertions.assert_response(conn, assigns: %{some_key: "some_value"}) 74 | end 75 | 76 | test "assert_response :assigns fails if missing a key", %{conn: conn} do 77 | conn = assign(conn, :some_key, "some_value") 78 | 79 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 80 | PhoenixIntegration.Assertions.assert_response( 81 | conn, 82 | assigns: %{ 83 | some_key: "some_value", 84 | missing_key: "missing_value" 85 | } 86 | ) 87 | end 88 | end 89 | 90 | test "assert_response :assigns fails if wrong value", %{conn: conn} do 91 | conn = assign(conn, :some_key, "some_value") 92 | 93 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 94 | PhoenixIntegration.Assertions.assert_response( 95 | conn, 96 | assigns: %{ 97 | some_key: "wrong_value" 98 | } 99 | ) 100 | end 101 | end 102 | 103 | # ---------------------------------------------------------------------------- 104 | # assert uri / path 105 | test "assert_response :uri succeeds", %{conn: conn} do 106 | conn = get(conn, "/sample") 107 | PhoenixIntegration.Assertions.assert_response(conn, uri: "/sample") 108 | PhoenixIntegration.Assertions.assert_response(conn, path: "/sample") 109 | end 110 | 111 | test "assert_response :uri ignores the scheme/host and such", %{conn: conn} do 112 | conn = get(conn, "/sample") 113 | PhoenixIntegration.Assertions.assert_response(conn, uri: "http://www.example.com/sample") 114 | end 115 | 116 | test "assert_response :path is works independent of query order", %{conn: conn} do 117 | conn = get(conn, "/sample?a=1&b=2") 118 | PhoenixIntegration.Assertions.assert_response(conn, uri: "/sample?a=1&b=2") 119 | PhoenixIntegration.Assertions.assert_response(conn, uri: "/sample?b=2&a=1") 120 | PhoenixIntegration.Assertions.assert_response(conn, path: "/sample?a=1&b=2") 121 | PhoenixIntegration.Assertions.assert_response(conn, path: "/sample?b=2&a=1") 122 | end 123 | 124 | test "assert_response :path fails if wrong root path", %{conn: conn} do 125 | conn = get(conn, "/sample?a=1&b=2") 126 | 127 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 128 | PhoenixIntegration.Assertions.assert_response(conn, uri: "/other?a=1&b=2") 129 | end 130 | 131 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 132 | PhoenixIntegration.Assertions.assert_response(conn, path: "/other?a=1&b=2") 133 | end 134 | end 135 | 136 | test "assert_response :path fails if wrong query params", %{conn: conn} do 137 | conn = get(conn, "/sample?a=1&b=2") 138 | 139 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 140 | PhoenixIntegration.Assertions.assert_response(conn, uri: "/sample?a=2&b=2") 141 | end 142 | 143 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 144 | PhoenixIntegration.Assertions.assert_response(conn, path: "/sample?a=2&b=2") 145 | end 146 | end 147 | 148 | test "assert_response :path fails if missing query params", %{conn: conn} do 149 | conn = get(conn, "/sample?a=1&b=2") 150 | 151 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 152 | PhoenixIntegration.Assertions.assert_response(conn, uri: "/sample?a=1&b=2&missing=2") 153 | end 154 | 155 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 156 | PhoenixIntegration.Assertions.assert_response(conn, path: "/sample?a=1&b=2&missing=2") 157 | end 158 | end 159 | 160 | # ---------------------------------------------------------------------------- 161 | # assert body 162 | test "assert_response :body succeeds", %{conn: conn} do 163 | conn = get(conn, "/sample") 164 | PhoenixIntegration.Assertions.assert_response(conn, body: "Sample Page") 165 | end 166 | 167 | test "assert_response :body succeeds mith multiple", %{conn: conn} do 168 | conn = get(conn, "/sample") 169 | 170 | PhoenixIntegration.Assertions.assert_response( 171 | conn, 172 | body: "Sample", 173 | body: "Page" 174 | ) 175 | end 176 | 177 | test "assert_response :body fails if string not present", %{conn: conn} do 178 | conn = get(conn, "/sample") 179 | 180 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 181 | PhoenixIntegration.Assertions.assert_response(conn, body: "Invalid") 182 | end 183 | end 184 | 185 | test "assert_response :body multiple fails if any not present", %{conn: conn} do 186 | conn = get(conn, "/sample") 187 | 188 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 189 | PhoenixIntegration.Assertions.assert_response( 190 | conn, 191 | body: "Sample Page", 192 | body: "Invalid" 193 | ) 194 | end 195 | end 196 | 197 | # ---------------------------------------------------------------------------- 198 | # assert content_type 199 | test "assert_response :content_type succeeds", %{conn: conn} do 200 | conn = get(conn, "/sample") 201 | PhoenixIntegration.Assertions.assert_response(conn, content_type: "text/html") 202 | PhoenixIntegration.Assertions.assert_response(conn, content_type: "html") 203 | end 204 | 205 | test "assert_response :content_type fails if wrong type", %{conn: conn} do 206 | conn = get(conn, "/sample") 207 | 208 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 209 | PhoenixIntegration.Assertions.assert_response(conn, content_type: "json") 210 | end 211 | end 212 | 213 | # ---------------------------------------------------------------------------- 214 | # assert html 215 | test "assert_response :html succeeds", %{conn: conn} do 216 | conn = get(conn, "/sample") 217 | 218 | PhoenixIntegration.Assertions.assert_response( 219 | conn, 220 | body: "Sample", 221 | body: "Page" 222 | ) 223 | end 224 | 225 | test "assert_response :html fails if wrong type", %{conn: conn} do 226 | conn = get(conn, "/test_json") 227 | 228 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 229 | PhoenixIntegration.Assertions.assert_response(conn, html: "Sample Page") 230 | end 231 | end 232 | 233 | test "assert_response :html fails if missing content", %{conn: conn} do 234 | conn = get(conn, "/sample") 235 | 236 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 237 | PhoenixIntegration.Assertions.assert_response(conn, html: "invalid content") 238 | end 239 | end 240 | 241 | test "assert_response :html fails if missing content for regexp", %{conn: conn} do 242 | conn = get(conn, "/sample") 243 | 244 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 245 | PhoenixIntegration.Assertions.assert_response(conn, html: ~r/invalid content/) 246 | end 247 | end 248 | 249 | # ---------------------------------------------------------------------------- 250 | # assert json 251 | test "assert_response :json succeeds", %{conn: conn} do 252 | conn = get(conn, "/test_json") 253 | PhoenixIntegration.Assertions.assert_response(conn, json: @expected_json_data) 254 | end 255 | 256 | test "assert_response :json fails if wrong type", %{conn: conn} do 257 | conn = get(conn, "/sample") 258 | 259 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 260 | PhoenixIntegration.Assertions.assert_response(conn, json: "Sample") 261 | end 262 | end 263 | 264 | test "assert_response :json fails if content wrong", %{conn: conn} do 265 | conn = get(conn, "/test_json") 266 | 267 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 268 | PhoenixIntegration.Assertions.assert_response(conn, json: @invalid_json_data) 269 | end 270 | end 271 | 272 | # ---------------------------------------------------------------------------- 273 | # assert redirect / to 274 | test "assert_response :redirect succeeds", %{conn: conn} do 275 | conn = get(conn, "/test_redir") 276 | PhoenixIntegration.Assertions.assert_response(conn, redirect: "/sample") 277 | PhoenixIntegration.Assertions.assert_response(conn, to: "/sample") 278 | end 279 | 280 | test "assert_response :redirect fails if redirects to wrong path", %{conn: conn} do 281 | conn = get(conn, "/test_redir") 282 | 283 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 284 | PhoenixIntegration.Assertions.assert_response(conn, redirect: "/other/path") 285 | end 286 | 287 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 288 | PhoenixIntegration.Assertions.assert_response(conn, to: "/other/path") 289 | end 290 | end 291 | 292 | # ============================================================================ 293 | # refute_response - dives into the conn returned from a get/put/post/delete 294 | # call and refutes the given content. 295 | 296 | # ---------------------------------------------------------------------------- 297 | # refute body 298 | test "refute_response :body succeeds", %{conn: conn} do 299 | conn = get(conn, "/sample") 300 | PhoenixIntegration.Assertions.refute_response(conn, body: "not_in_body") 301 | end 302 | 303 | test "refute_response :body succeeds mith multiple", %{conn: conn} do 304 | conn = get(conn, "/sample") 305 | 306 | PhoenixIntegration.Assertions.refute_response( 307 | conn, 308 | body: "not_in_body", 309 | body: "this_isnt_either" 310 | ) 311 | end 312 | 313 | test "refute_response :body fails if string IS present", %{conn: conn} do 314 | conn = get(conn, "/sample") 315 | 316 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 317 | PhoenixIntegration.Assertions.refute_response(conn, body: "Sample Page") 318 | end 319 | end 320 | 321 | test "refute_response :body multiple fails if any is present", %{conn: conn} do 322 | conn = get(conn, "/sample") 323 | 324 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 325 | PhoenixIntegration.Assertions.refute_response( 326 | conn, 327 | body: "not_in_body", 328 | body: "Sample Page" 329 | ) 330 | end 331 | end 332 | 333 | # ---------------------------------------------------------------------------- 334 | # refute content_type 335 | test "refute_response :content_type succeeds", %{conn: conn} do 336 | conn = get(conn, "/sample") 337 | PhoenixIntegration.Assertions.refute_response(conn, content_type: "json") 338 | end 339 | 340 | test "refute_response :content_type fails if is the type", %{conn: conn} do 341 | conn = get(conn, "/sample") 342 | 343 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 344 | PhoenixIntegration.Assertions.refute_response(conn, content_type: "html") 345 | end 346 | end 347 | 348 | # ---------------------------------------------------------------------------- 349 | # refute status 350 | test "refute_response :status succeeds", %{conn: conn} do 351 | conn = get(conn, "/sample") 352 | PhoenixIntegration.Assertions.refute_response(conn, status: 201) 353 | end 354 | 355 | test "refute_response :status fails if is the status", %{conn: conn} do 356 | conn = get(conn, "/sample") 357 | 358 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 359 | PhoenixIntegration.Assertions.refute_response(conn, status: 200) 360 | end 361 | end 362 | 363 | # ---------------------------------------------------------------------------- 364 | # refute value 365 | test "refute_response :value succeeds for false value", %{conn: conn} do 366 | conn = get(conn, "/sample") 367 | PhoenixIntegration.Assertions.refute_response(conn, value: fn _ -> false end) 368 | end 369 | 370 | test "refute_response :value succeeds for nil value", %{conn: conn} do 371 | conn = get(conn, "/sample") 372 | PhoenixIntegration.Assertions.refute_response(conn, value: fn _ -> nil end) 373 | end 374 | 375 | test "refute_response :value fails for truthy value", %{conn: conn} do 376 | conn = get(conn, "/sample") 377 | 378 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 379 | PhoenixIntegration.Assertions.refute_response(conn, value: fn _ -> 123 end) 380 | end 381 | end 382 | 383 | # ---------------------------------------------------------------------------- 384 | # refute html 385 | test "refute_response :html succeeds with wrong content", %{conn: conn} do 386 | conn = get(conn, "/sample") 387 | PhoenixIntegration.Assertions.refute_response(conn, body: "not_in_body") 388 | end 389 | 390 | test "refute_response :html succeeds if wrong type", %{conn: conn} do 391 | conn = get(conn, "/test_json") 392 | PhoenixIntegration.Assertions.refute_response(conn, html: "Sample") 393 | end 394 | 395 | test "refute_response :html fails if contains content", %{conn: conn} do 396 | conn = get(conn, "/sample") 397 | 398 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 399 | PhoenixIntegration.Assertions.refute_response(conn, html: "Sample Page") 400 | end 401 | end 402 | 403 | # ---------------------------------------------------------------------------- 404 | # refute json 405 | test "refute_response :json succeeds", %{conn: conn} do 406 | conn = get(conn, "/test_json") 407 | PhoenixIntegration.Assertions.refute_response(conn, json: @invalid_json_data) 408 | end 409 | 410 | test "refute_response :json succeeds if wrong type", %{conn: conn} do 411 | conn = get(conn, "/sample") 412 | PhoenixIntegration.Assertions.refute_response(conn, json: "Sample") 413 | end 414 | 415 | test "refute_response :json fails if content found", %{conn: conn} do 416 | conn = get(conn, "/test_json") 417 | 418 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 419 | PhoenixIntegration.Assertions.refute_response(conn, json: @expected_json_data) 420 | end 421 | end 422 | 423 | # ---------------------------------------------------------------------------- 424 | # refute uri / path 425 | test "refute_response :uri succeeds with wrong path", %{conn: conn} do 426 | conn = get(conn, "/sample?a=1&b=2") 427 | PhoenixIntegration.Assertions.refute_response(conn, uri: "/sample/invalid?a=1&b=2") 428 | PhoenixIntegration.Assertions.refute_response(conn, path: "/sample/invalid?a=1&b=2") 429 | end 430 | 431 | test "refute_response :uri succeeds with wrong query", %{conn: conn} do 432 | conn = get(conn, "/sample?a=1&b=2") 433 | PhoenixIntegration.Assertions.refute_response(conn, uri: "/sample?a=2&b=2") 434 | PhoenixIntegration.Assertions.refute_response(conn, path: "/sample?a=2&b=2") 435 | end 436 | 437 | test "refute_response :uri succeeds with missing query item", %{conn: conn} do 438 | conn = get(conn, "/sample?a=1&b=2") 439 | PhoenixIntegration.Assertions.refute_response(conn, uri: "/sample?a=1&b=2&c=3") 440 | PhoenixIntegration.Assertions.refute_response(conn, path: "/sample?a=2&b=2&c=3") 441 | end 442 | 443 | test "refute_response :uri throws if found regardless of query order", %{conn: conn} do 444 | conn = get(conn, "/sample?a=1&b=2") 445 | 446 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 447 | PhoenixIntegration.Assertions.refute_response(conn, uri: "/sample?a=1&b=2") 448 | end 449 | 450 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 451 | PhoenixIntegration.Assertions.refute_response(conn, uri: "/sample?b=2&a=1") 452 | end 453 | 454 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 455 | PhoenixIntegration.Assertions.refute_response(conn, path: "/sample?a=1&b=2") 456 | end 457 | 458 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 459 | PhoenixIntegration.Assertions.refute_response(conn, path: "/sample?b=2&a=1") 460 | end 461 | end 462 | 463 | test "refute_response :uri ignores the scheme/host and such", %{conn: conn} do 464 | conn = get(conn, "/sample") 465 | 466 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 467 | PhoenixIntegration.Assertions.refute_response(conn, uri: "http://www.example.com/sample") 468 | end 469 | end 470 | 471 | # ---------------------------------------------------------------------------- 472 | # refute redirect / to 473 | test "refute_response :redirect succeeds if not redirecting", %{conn: conn} do 474 | conn = get(conn, "/sample") 475 | PhoenixIntegration.Assertions.refute_response(conn, redirect: "/sample") 476 | PhoenixIntegration.Assertions.refute_response(conn, to: "/sample") 477 | end 478 | 479 | test "refute_response :redirect succeeds if redirecting to the wrong place", %{conn: conn} do 480 | conn = get(conn, "/test_redir") 481 | PhoenixIntegration.Assertions.refute_response(conn, redirect: "/sample/invalid") 482 | PhoenixIntegration.Assertions.refute_response(conn, to: "/sample/invalid") 483 | end 484 | 485 | test "refute_response :redirect fails if redirects to given path", %{conn: conn} do 486 | conn = get(conn, "/test_redir") 487 | 488 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 489 | PhoenixIntegration.Assertions.refute_response(conn, redirect: "/sample") 490 | end 491 | 492 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 493 | PhoenixIntegration.Assertions.refute_response(conn, to: "/sample") 494 | end 495 | end 496 | 497 | # ---------------------------------------------------------------------------- 498 | # refute assigns 499 | test "refute_response :assigns succeeds if missing a key", %{conn: conn} do 500 | conn = assign(conn, :some_key, "some_value") 501 | PhoenixIntegration.Assertions.refute_response(conn, assigns: %{missing_key: "some_value"}) 502 | end 503 | 504 | test "refute_response :assigns succeeds if wrong value", %{conn: conn} do 505 | conn = assign(conn, :some_key, "some_value") 506 | PhoenixIntegration.Assertions.refute_response(conn, assigns: %{some_key: "wrong_value"}) 507 | end 508 | 509 | test "refute_response :assigns fails if key and value present", %{conn: conn} do 510 | conn = assign(conn, :some_key, "some_value") 511 | 512 | assert_raise PhoenixIntegration.Assertions.ResponseError, fn -> 513 | PhoenixIntegration.Assertions.refute_response( 514 | conn, 515 | assigns: %{ 516 | some_key: "some_value" 517 | } 518 | ) 519 | end 520 | end 521 | end 522 | -------------------------------------------------------------------------------- /test/checkbox_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.CheckboxTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.ConnTest 4 | import PhoenixIntegration.Requests, only: [test_build_form_data: 2] 5 | @endpoint PhoenixIntegration.TestEndpoint 6 | 7 | setup do 8 | [form_with_hidden: find_form("#with_hidden"), 9 | form_without_hidden: find_form("#without_hidden")] 10 | end 11 | 12 | 13 | test "diagnostic for issue 45" do 14 | result = 15 | find_form("#issue45") 16 | |> PhoenixIntegration.Form.TreeCreation.build_tree 17 | |> Map.get(:tree) 18 | |> PhoenixIntegration.Form.TreeEdit.apply_edits(%{ab_test: %{environments: ["ci"]}}) 19 | 20 | assert {:ok, _} = result 21 | end 22 | 23 | # The "hidden input hack" uses two `input` tags for each checkbox: 24 | # 25 | # 26 | # 27 | # 28 | # If the checkbox is not checked, the browser is not to include its 29 | # name/value pair in the form parameters. However, the `hidden` 30 | # input is included, so the backend receives "name" => "false". 31 | # 32 | # If the checkbox has been clicked, the browser includes both inputs. Since 33 | # they both have the same `name`, the second overrides the first, so 34 | # the backend receives "name" => "true" 35 | 36 | describe "the hidden input hack" do 37 | test "what a checkbox with a hidden 'false' input looks like", 38 | %{form_with_hidden: form} do 39 | 40 | {"form", _, 41 | [{"input", hidden1, _}, 42 | {"input", checkbox1, _}, 43 | {"input", hidden2, _}, 44 | {"input", checkbox2, _} 45 | ] 46 | } = form 47 | 48 | assert_name_type_value(hidden1, "animals[chosen][1]", "hidden", "false") 49 | assert_name_type_value(checkbox1, "animals[chosen][1]", "checkbox", "true") 50 | assert_name_type_value(hidden2, "animals[chosen][2]", "hidden", "false") 51 | assert_name_type_value(checkbox2, "animals[chosen][2]", "checkbox", "true") 52 | end 53 | 54 | test "if not 'checked' by the test, the default (hidden) values are used", 55 | %{form_with_hidden: form} do 56 | 57 | %{animals: %{chosen: checked}} = 58 | test_build_form_data(form, %{}) 59 | assert checked == %{"1": "false", "2": "false"} 60 | end 61 | 62 | test "set just one value, check that the other one is retained", 63 | %{form_with_hidden: form} do 64 | 65 | %{animals: %{chosen: checked}} = 66 | test_build_form_data(form, %{animals: %{chosen: 67 | %{"2" => "true"}}}) 68 | 69 | assert checked == %{"1": "false", "2": "true"} 70 | end 71 | 72 | test "setting all the values", %{form_with_hidden: form} do 73 | %{animals: %{chosen: checked}} = 74 | test_build_form_data(form, %{animals: %{chosen: 75 | %{"1" => "false", "2" => "true"}}}) 76 | 77 | assert checked == %{"1": "false", "2": "true"} 78 | end 79 | end 80 | 81 | # ---------------------------------------------------------------------------- 82 | describe "without the hidden input" do 83 | test "what a checkbox looks like", 84 | %{form_without_hidden: form} do 85 | 86 | {"form", _, 87 | [{"input", checkbox1, _}, 88 | {"input", checkbox2, _} 89 | ] 90 | } = form 91 | 92 | assert_name_type_value(checkbox1, "animals[chosen][1]", "checkbox", "true") 93 | assert_name_type_value(checkbox2, "animals[chosen][2]", "checkbox", "true") 94 | end 95 | 96 | test "if nothing is 'checked' in the test, no values are sent", 97 | %{form_without_hidden: form} do 98 | # It would be unusual for a form to *only* have non-values, but - 99 | # if it does - not even the top level key will be available to the 100 | # controller action. 101 | assert %{} == test_build_form_data(form, %{}) 102 | end 103 | 104 | test "if one value is checked in the test, only that value is sent", 105 | %{form_without_hidden: form} do 106 | 107 | %{animals: %{chosen: checked}} = 108 | test_build_form_data(form, %{animals: %{chosen: 109 | %{"1" => "true"}}}) 110 | 111 | assert checked == %{:"1" => "true"} 112 | end 113 | 114 | test "if all values are set, they're all sent", 115 | %{form_without_hidden: form} do 116 | 117 | %{animals: %{chosen: checked}} = 118 | test_build_form_data(form, %{animals: %{chosen: 119 | %{"1" => "true", "2" => "true"}}}) 120 | 121 | assert checked == %{:"1" => "true", :"2" => "true"} 122 | end 123 | end 124 | 125 | # ---------------------------------------------------------------------------- 126 | def find_form(id) do 127 | html = get(build_conn(:get, "/"), "/checkbox").resp_body 128 | 129 | {:ok, _action, _method, form} = 130 | PhoenixIntegration.Requests.test_find_html_form(html, id, nil, "form") 131 | 132 | form 133 | end 134 | 135 | def assert_name_type_value(source, name, type, value), 136 | do: assert source == [{"name", name}, {"type", type}, {"value", value}] 137 | end 138 | -------------------------------------------------------------------------------- /test/details/change_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Details.ChangeTest do 2 | use ExUnit.Case, async: true 3 | import FlowAssertions.MapA 4 | alias PhoenixIntegration.Form.Change 5 | 6 | describe "changes created from simple maps" do 7 | test "typical case" do 8 | input = %{top_level: 9 | %{lower: "lower", 10 | continue: %{continued: [1, 2, 3]} 11 | }} 12 | 13 | [continued, lower] = Change.changes(input) |> sort_by_value 14 | 15 | assert_fields(lower, 16 | path: [:top_level, :lower], 17 | value: "lower") 18 | assert_fields(continued, 19 | path: [:top_level, :continue, :continued], 20 | value: [1, 2, 3]) 21 | end 22 | 23 | test "empty case" do 24 | assert [] == Change.changes(%{}) 25 | end 26 | 27 | test "empty leaf case" do 28 | input = %{top_level: 29 | %{lower: "lower", 30 | continue: %{} 31 | }} 32 | 33 | [lower] = Change.changes(input) # empty map ignored 34 | assert_fields(lower, 35 | path: [:top_level, :lower], 36 | value: "lower") 37 | end 38 | end 39 | 40 | # ---------------------------------------------------------------------------- 41 | defstruct shallow: nil, deep: %{} 42 | 43 | describe "structs produce optional paths" do 44 | test "a simple case" do 45 | input = %{top_level: 46 | %{struct: %__MODULE__{shallow: "shallow"}}} 47 | 48 | [only] = Change.changes(input) 49 | 50 | assert_fields(only, 51 | path: [:top_level, :struct, :shallow], 52 | value: "shallow", 53 | ignore_if_missing_from_form: true) 54 | end 55 | 56 | test "a nested case" do 57 | input = %{top_level: 58 | %{struct: %__MODULE__{ 59 | shallow: "shallow", 60 | deep: %__MODULE__{shallow: "deeper shallow"}}, 61 | non_struct: "simple"}} 62 | 63 | [deeper, shallow, simple] = Change.changes(input) |> sort_by_value 64 | 65 | assert_fields(deeper, 66 | path: [:top_level, :struct, :deep, :shallow], 67 | value: "deeper shallow", 68 | ignore_if_missing_from_form: true) 69 | assert_fields(shallow, 70 | path: [:top_level, :struct, :shallow], 71 | value: "shallow", 72 | ignore_if_missing_from_form: true) 73 | assert_fields(simple, 74 | path: [:top_level, :non_struct], 75 | value: "simple", 76 | ignore_if_missing_from_form: false) 77 | end 78 | end 79 | 80 | 81 | # ---------------------------------------------------------------------------- 82 | describe "special handling of Plug.Upload" do 83 | test "it is treated as a single value, not descended into" do 84 | upload = %Plug.Upload{content_type: "image/jpg", 85 | path: "/var/mytests/photo.jpg", 86 | filename: "photo.jpg"} 87 | 88 | input = %{top_level: %{picture: upload}} 89 | 90 | [only] = Change.changes(input) 91 | 92 | assert_fields(only, 93 | path: [:top_level, :picture], 94 | value: upload, 95 | ignore_if_missing_from_form: false) 96 | end 97 | end 98 | 99 | # ---------------------------------------------------------------------------- 100 | defp sort_by_value(changes) do 101 | Enum.sort_by(changes, &(to_string &1.value)) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/details/input_types_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Details.InputTypeTest do 2 | use ExUnit.Case 3 | import Phoenix.ConnTest 4 | alias PhoenixIntegration.Form.{TreeCreation,TreeEdit,TreeFinish} 5 | 6 | @endpoint PhoenixIntegration.TestEndpoint 7 | use PhoenixIntegration 8 | 9 | # Note that input type="checkbox" and type="radio" are checked elsewhere 10 | setup do 11 | html = get(build_conn(:get, "/"), "/input_types").resp_body 12 | 13 | {:ok, _action, _method, form} = 14 | PhoenixIntegration.Requests.test_find_html_form(html, "#input_types", nil, "form") 15 | 16 | created = TreeCreation.build_tree(form) 17 | [created: created, tree: created.tree] 18 | end 19 | 20 | test "all text-like fields have a value initialized to the empty string", 21 | %{tree: tree} do 22 | uninitialized_fields = %{user: 23 | %{date: "", 24 | datetime_local: "", 25 | email: "", 26 | file: "", 27 | hidden: "", 28 | month: "", 29 | number: "", 30 | password: "", 31 | photo: "", 32 | range: "", 33 | search: "", 34 | tel: "", 35 | text: "", 36 | time: "", 37 | url: "", 38 | week: "", 39 | datetime: "" 40 | }} 41 | 42 | assert TreeFinish.to_action_params(tree) == uninitialized_fields 43 | end 44 | 45 | test "edits apply normally", %{tree: tree} do 46 | edits = %{user: 47 | %{date: "date", 48 | datetime_local: "datetime_local", 49 | email: "email", 50 | file: "file", 51 | hidden: "hidden", 52 | month: "month", 53 | number: "number", 54 | password: "password", 55 | photo: "photo", 56 | range: "range", 57 | search: "search", 58 | tel: "tel", 59 | text: "text", 60 | time: "time", 61 | url: "url", 62 | week: "week", 63 | datetime: "datetime" 64 | }} 65 | 66 | assert {:ok, edited} = TreeEdit.apply_edits(tree, edits) 67 | finished = TreeFinish.to_action_params(edited) 68 | assert finished == edits 69 | end 70 | 71 | test "explicitly that there are no warnings", %{created: created} do 72 | assert created.warnings == [] 73 | end 74 | 75 | test "explicitly that button-type inputs are not included", %{tree: tree} do 76 | refute Map.get(tree.user, :button) 77 | refute Map.get(tree.user, :image) 78 | refute Map.get(tree.user, :reset) 79 | refute Map.get(tree.user, :submit) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/details/messages_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Details.MessagesTest do 2 | use ExUnit.Case 3 | import ExUnit.CaptureIO 4 | import PhoenixIntegration.FormSupport 5 | alias PhoenixIntegration.Form.Messages 6 | 7 | ######################################### 8 | # 9 | # IMPORTANT 10 | # 11 | # Because assert_substrings traps IO, certain test failures won't 12 | # show any debugging output (from IO.inspect). To work on those, 13 | # extract the code that does the work out of the `assert_substrings`, 14 | # like this: 15 | # build_form_fun(form, %{}).() # TEMPORARY 16 | # 17 | # assert_substrings(fn -> 18 | # build_form_fun(form, %{}).() 19 | # ... 20 | 21 | describe "warnings when turning a form into a tree" do 22 | test "a tag without a name" do 23 | html = ~S| | 24 | form = form_for html 25 | 26 | assert_substrings(will_fail_to_create_tree(form), 27 | [Messages.get(:tag_has_no_name), 28 | "It can't be included in the params sent to the controller", 29 | String.trim(html) 30 | ]) 31 | end 32 | 33 | test "a nonsensical name" do 34 | html = ~S| | 35 | form = form_for html 36 | 37 | assert_substrings(will_fail_to_create_tree(form), 38 | [Messages.get(:empty_name), 39 | String.trim(html) 40 | ]) 41 | end 42 | 43 | test "a less-nested name follows a more deeply nexted name" do 44 | # Phoenix (currently) retains the earlier (more nested) value. 45 | html = """ 46 | 47 | 48 | """ 49 | form = form_for html 50 | 51 | assert_substrings(will_fail_to_create_tree(form), 52 | [Messages.get(:form_conflicting_paths), 53 | "top_level[param][subparam]", 54 | "top_level[param]" 55 | ]) 56 | end 57 | 58 | test "a more-nested name follows a shallower one" do 59 | # Phoenix (currently) loses the original value. 60 | html = """ 61 | 62 | 63 | """ 64 | form = form_for html 65 | 66 | assert_substrings(will_fail_to_create_tree(form), 67 | [Messages.get(:form_conflicting_paths), 68 | "top_level[param]", 69 | "top_level[param][subparam]" 70 | ]) 71 | end 72 | end 73 | 74 | # ---------------------------------------------------------------------------- 75 | describe "errors when applying test override values" do 76 | test "setting missing field (as a leaf)" do 77 | form = form_for """ 78 | 79 | """ 80 | edit_tree = %{user: %{i_made_a_typo: "new value"}} 81 | 82 | assert_substrings(will_fail_to_edit_tree(form, edit_tree), 83 | [ Messages.get(:no_such_name_in_form), 84 | "Is this a typo?", ":i_made_a_typo", 85 | "action:", "/form", 86 | "id:", "proper_form", 87 | "[:user, :i_made_a_typo]", "new value" 88 | ]) 89 | end 90 | 91 | test "setting missing field (wrong interior node)" do 92 | form = form_for """ 93 | 94 | """ 95 | edit_tree = %{i_made_a_typo: %{tag: "new value"}} 96 | 97 | assert_substrings(will_fail_to_edit_tree(form, edit_tree), 98 | [ Messages.get(:no_such_name_in_form), 99 | "Is this a typo?", ":i_made_a_typo", 100 | "[:i_made_a_typo, :tag]", "new value" 101 | ]) 102 | end 103 | 104 | test "setting a prefix of a field" do 105 | form = form_for """ 106 | 107 | """ 108 | edit_tree = %{user: %{tag: "new value"}} 109 | 110 | assert_substrings(will_fail_to_edit_tree(form, edit_tree), 111 | [ Messages.get(:no_such_name_in_form), 112 | "You provided only a prefix of all the available names.", 113 | "Here is your path", "[:user, :tag]", 114 | "Here is an available name", "user[tag][name]"]) 115 | end 116 | 117 | test "an edit tree deeper than the actual form" do 118 | form = form_for """ 119 | 120 | """ 121 | edit_tree = %{user: %{tag: %{name: "new value"}}} 122 | 123 | assert_substrings(will_fail_to_edit_tree(form, edit_tree), 124 | [ Messages.get(:no_such_name_in_form), 125 | "Your path is longer than the names it should match", 126 | "Here is your path", "[:user, :tag, :name]", 127 | "Here is an available name", "user[tag]"]) 128 | end 129 | 130 | test "a too-long path will not be fooled by a key in a Tag" do 131 | form = form_for """ 132 | 133 | """ 134 | edit_tree = %{user: %{tag: %{name: %{lower: "new value"}}}} 135 | 136 | assert_substrings(will_fail_to_edit_tree(form, edit_tree), 137 | [ Messages.get(:no_such_name_in_form), 138 | "Your path is longer than the names it should match", 139 | "Here is your path", "[:user, :tag, :name, :lower]", 140 | "Here is an available name", "user[tag]"]) 141 | end 142 | 143 | test "setting a list tag to a scalar" do 144 | form = form_for """ 145 | 146 | """ 147 | edit_tree = %{user: %{tag: "skittish"}} 148 | 149 | assert_substrings(will_fail_to_edit_tree(form, edit_tree), 150 | [ Messages.get(:arity_clash), 151 | "the name of the tag you're setting ends in `[]`", "user[tag][]", 152 | "should be a list", "skittish"]) 153 | end 154 | 155 | test "setting a scalar tag to a list" do 156 | form = form_for """ 157 | 158 | """ 159 | edit_tree = %{user: %{tag: ["skittish"]}} 160 | 161 | assert_substrings(will_fail_to_edit_tree(form, edit_tree), 162 | [ Messages.get(:arity_clash), 163 | "value you want", ~s|["skittish"]|, 164 | "doesn't end in `[]`", "user[tag]"]) 165 | end 166 | 167 | test "you can get more than one error" do 168 | form = form_for """ 169 | 170 | 171 | 172 | """ 173 | edit_tree = %{user: %{i_made_a_typo: "new value", 174 | keys: "false"}} 175 | 176 | assert_substrings(will_fail_to_edit_tree(form, edit_tree), 177 | [ Messages.get(:no_such_name_in_form), 178 | "Is this a typo?", ":i_made_a_typo", 179 | "action:", "/form", 180 | "id:", "proper_form", 181 | "[:user, :i_made_a_typo]", "new value", 182 | 183 | Messages.get(:arity_clash), 184 | "the name of the tag you're setting ends in `[]`", "user[keys][]", 185 | "should be a list", "false"]) 186 | end 187 | 188 | test "it's ok if a tag doesn't have an id" do 189 | form = form_for """ 190 |
193 | """, id: false 194 | edit_tree = %{user: %{i_made_a_typo: "new value"}} 195 | 196 | message = capture_io(will_fail_to_edit_tree(form, edit_tree)) 197 | refute String.contains?(message, "id:") 198 | assert String.contains?(message, ":i_made_a_typo") 199 | end 200 | end 201 | 202 | # ---------------------------------------------------------------------------- 203 | def will_fail_to_create_tree(form) do 204 | fn -> 205 | PhoenixIntegration.Requests.test_build_form_data(form, %{}) 206 | end 207 | end 208 | 209 | def will_fail_to_edit_tree(form, edit_tree) do 210 | fn -> 211 | assert_raise(RuntimeError, fn -> 212 | PhoenixIntegration.Requests.test_build_form_data(form, edit_tree) 213 | end) 214 | end 215 | end 216 | 217 | def assert_substrings(fun, substrings) do 218 | message = capture_io(fun) 219 | # IO.puts "======" 220 | # IO.puts message # for visual inspection 221 | # IO.puts "======" 222 | 223 | Enum.map(substrings, fn substring -> 224 | assert String.contains?(message, substring) 225 | end) 226 | end 227 | end 228 | 229 | -------------------------------------------------------------------------------- /test/details/tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Details.TagTest do 2 | use ExUnit.Case, async: true 3 | import FlowAssertions.MapA 4 | import PhoenixIntegration.FormSupport 5 | alias PhoenixIntegration.Form.Tag 6 | 7 | describe "common transformations" do 8 | test "single-valued names" do 9 | floki_tag = """ 10 | 11 | """ 12 | |> Floki.parse_fragment! 13 | 14 | floki_tag 15 | |> Tag.new! 16 | |> assert_fields(has_list_value: false, 17 | values: ["x"], 18 | name: "top_level[animal]", 19 | path: [:top_level, :animal], 20 | tag: "input", 21 | original: floki_tag) 22 | end 23 | 24 | 25 | test "multi-valued ([]-ending) names" do 26 | floki_tag = """ 27 | 28 | """ 29 | |> Floki.parse_fragment! 30 | 31 | floki_tag 32 | |> Tag.new! 33 | |> assert_fields(has_list_value: true, 34 | values: ["x"], 35 | name: "top_level[animals][]", 36 | path: [:top_level, :animals], 37 | tag: "input", 38 | original: floki_tag) 39 | end 40 | end 41 | 42 | # ---------------------------------------------------------------------------- 43 | test "fields with types record them" do 44 | """ 45 | 46 | """ 47 | |> Floki.parse_fragment! 48 | |> Tag.new! 49 | |> assert_field(type: "text") 50 | end 51 | 52 | # ---------------------------------------------------------------------------- 53 | describe "text special cases" do 54 | # Can't find definitive word on this in the documentation, 55 | # but this is the behavior 56 | test "a text field without a value is the empty string" do 57 | assert_input_values """ 58 | 59 | """, [""] 60 | end 61 | end 62 | 63 | # ---------------------------------------------------------------------------- 64 | describe "checkbox special cases" do 65 | # Special cases for checkboxes as described in 66 | # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input/checkbox 67 | test "a checkbox that's omitted `checked`" do 68 | assert_input_values """ 69 | 70 | """, 71 | [] 72 | end 73 | 74 | test "a checkbox that has any value for `checked`" do 75 | assert_input_values """ 76 | 77 | """, 78 | ["x"] 79 | end 80 | 81 | test "a checkbox that is checked but has no explicit value" do 82 | assert_input_values """ 83 | 84 | """, 85 | ["on"] 86 | end 87 | 88 | test "a checkbox that's part of a list has the same effect" do 89 | assert_input_values """ 90 | 91 | """, 92 | [] 93 | 94 | assert_input_values """ 95 | 96 | """, 97 | ["x"] 98 | 99 | assert_input_values """ 100 | 101 | """, 102 | ["on"] 103 | end 104 | end 105 | 106 | # ---------------------------------------------------------------------------- 107 | test "textareas tag their value from enclosed text" do 108 | """ 109 | 110 | """ 111 | |> Floki.parse_fragment! 112 | |> Tag.new! 113 | |> assert_fields(values: ["Initial user story"], 114 | tag: "textarea", 115 | name: "user[story]", 116 | path: [:user, :story]) 117 | end 118 | 119 | # ---------------------------------------------------------------------------- 120 | describe "select" do 121 | test "a scalar version, one value selected" do 122 | """ 123 | 128 | """ 129 | |> Floki.parse_fragment! 130 | |> Tag.new! 131 | |> assert_fields(values: ["type_two"], 132 | has_list_value: false, 133 | tag: "select", 134 | name: "user[type]", 135 | path: [:user, :type]) 136 | end 137 | 138 | # "if no value attribute is included, the [option] value defaults to the 139 | # text contained inside the element" - 140 | # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select 141 | test "a scalar form with no `value` attribute" do 142 | """ 143 | 148 | """ 149 | |> Floki.parse_fragment! 150 | |> Tag.new! 151 | |> assert_field(values: ["Two"], 152 | has_list_value: false) 153 | end 154 | 155 | test "no selection means the first value is selected" do 156 | """ 157 | 162 | """ 163 | |> Floki.parse_fragment! 164 | |> Tag.new! 165 | |> assert_fields(values: ["One"], has_list_value: false) 166 | end 167 | 168 | test "a silly case: no options" do 169 | """ 170 | 172 | """ 173 | |> Floki.parse_fragment! 174 | |> Tag.new! 175 | |> assert_field(values: [], 176 | has_list_value: false) 177 | end 178 | 179 | # Note: the code doesn't actually check whether the `name` and `multiple` 180 | # attributes are consistent with each other. 181 | test "a multiple select" do 182 | """ 183 | 188 | """ 189 | |> Floki.parse_fragment! 190 | |> Tag.new! 191 | |> assert_fields(values: ["1", "3"], 192 | name: "user[roles][]", 193 | path: [:user, :roles], 194 | has_list_value: true) 195 | end 196 | 197 | test "A *multiple* select does NOT default-select the first value" do 198 | """ 199 | 203 | """ 204 | |> Floki.parse_fragment! 205 | |> Tag.new! 206 | |> assert_fields(values: [], has_list_value: true) 207 | end 208 | end 209 | 210 | # ---------------------------------------------------------------------------- 211 | describe "radio buttons" do 212 | test "checked" do 213 | """ 214 | 215 | """ 216 | |> Floki.parse_fragment! 217 | |> Tag.new! 218 | |> assert_fields(values: ["admin"], 219 | name: "user[role]", 220 | has_list_value: false) 221 | end 222 | 223 | test "unchecked means no value" do 224 | """ 225 | 226 | """ 227 | |> Floki.parse_fragment! 228 | |> Tag.new! 229 | |> assert_field(values: []) 230 | end 231 | 232 | test "checked, but no value, means the value is 'on'" do 233 | """ 234 | 235 | """ 236 | |> Floki.parse_fragment! 237 | |> Tag.new! 238 | |> assert_field(values: ["on"]) 239 | end 240 | end 241 | 242 | # ---------------------------------------------------------------------------- 243 | describe "warning cases" do 244 | test "no name" do 245 | floki_tag = 246 | """ 247 | 248 | """ |> Floki.parse_fragment! 249 | 250 | assert {:warning, :tag_has_no_name, ^floki_tag} = Tag.new(floki_tag) 251 | end 252 | 253 | test "name doesn't parse" do 254 | floki_tag = 255 | """ 256 | 257 | """ |> Floki.parse_fragment! 258 | 259 | assert {:warning, :empty_name, ^floki_tag} = Tag.new(floki_tag) 260 | end 261 | end 262 | 263 | # ---------------------------------------------------------------------------- 264 | defp assert_input_values(fragment, values) do 265 | assert input_to_tag(fragment).values == values 266 | end 267 | end 268 | -------------------------------------------------------------------------------- /test/details/tree_creation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Details.TreeCreationTest do 2 | use ExUnit.Case, async: true 3 | import FlowAssertions.MapA 4 | import PhoenixIntegration.FormSupport 5 | alias PhoenixIntegration.Form.TreeCreation 6 | 7 | ## Note: error cases are tested elsewhere (currently `messages_test.exs`) 8 | 9 | # ---------------------------------------------------------------------------- 10 | describe "adding tags that have no collisions (and type=text)" do 11 | test "into an empty tree" do 12 | tag = """ 13 | 14 | """ |> input_to_tag 15 | 16 | assert TreeCreation.add_tag!(%{}, tag) == %{top_level: %{param: tag}} 17 | end 18 | 19 | test "add tag at the same level as a previous tag" do 20 | first = """ 21 | 22 | """ |> input_to_tag 23 | 24 | second = """ 25 | 26 | """ |> input_to_tag 27 | 28 | actual = test_tree!([first, second]) 29 | expected = %{top_level: 30 | %{param: first, 31 | other_param: second}} 32 | assert actual == expected 33 | end 34 | 35 | test "add tag at a deeper level" do 36 | first = """ 37 | 38 | """ |> input_to_tag 39 | 40 | second = """ 41 | 42 | """ |> input_to_tag 43 | 44 | third = """ 45 | 46 | """ |> input_to_tag 47 | 48 | 49 | actual = test_tree!([first, second, third]) 50 | expected = %{top_level: 51 | %{param: first, 52 | other_param: %{deeper: second, 53 | wider: third}}} 54 | assert actual == expected 55 | end 56 | 57 | test "adding a tag that represents a list" do 58 | first = """ 59 | 60 | """ |> input_to_tag 61 | 62 | second = """ 63 | 64 | """ |> input_to_tag 65 | 66 | actual = test_tree!([first, second]) 67 | expected = %{top_level: 68 | %{param: first, 69 | other_param: second}} 70 | assert actual == expected 71 | end 72 | 73 | test "a missing type is of type input" do 74 | # Because the defaulting of the missing type is done at a top 75 | # level, we can't use the simpler test-support functions. 76 | snippet = """ 77 | 78 | """ 79 | form = form_for(snippet) 80 | created = TreeCreation.build_tree(form) 81 | assert %{top_level: %{param: tag}} = created.tree 82 | assert tag.type == "text" 83 | end 84 | end 85 | 86 | # ---------------------------------------------------------------------------- 87 | describe "simpler cases where a new tag collides with one already in the tree" do 88 | # Note: warnings are tested elsewhere 89 | test "with single values, the second replaces the first" do 90 | first = """ 91 | 92 | """ |> input_to_tag 93 | 94 | second = """ 95 | 96 | """ |> input_to_tag 97 | 98 | assert test_tree!([first, second]) == %{top_level: %{param: second}} 99 | end 100 | 101 | test "if the name is a list, new values add on" do 102 | first = """ 103 | 104 | """ |> input_to_tag 105 | 106 | second = """ 107 | 108 | """ |> input_to_tag 109 | 110 | %{top_level: %{names: actual}} = test_tree!([first, second]) 111 | 112 | assert actual.values == ["x", "y"] 113 | end 114 | 115 | test "the same behavior adding-on behavior holds for checked checkboxes" do 116 | first = """ 117 | 118 | """ |> input_to_tag 119 | 120 | second = """ 121 | 122 | """ |> input_to_tag 123 | 124 | third = """ 125 | 126 | """ |> input_to_tag 127 | 128 | %{top_level: %{grades: actual}} = test_tree!([first, second, third]) 129 | 130 | assert actual.values == ["first", "on"] 131 | end 132 | end 133 | 134 | # ---------------------------------------------------------------------------- 135 | describe "the checkbox hack: a `type=hidden` provides the unchecked value" do 136 | test "unchecked checkbox has no effect" do 137 | hidden = """ 138 | 139 | """ |> input_to_tag 140 | 141 | ignored = """ 142 | 143 | """ |> input_to_tag 144 | 145 | %{top_level: %{grade: actual}} = test_tree!([hidden, ignored]) 146 | 147 | # It shouldn't matter, but it's probably nicest to keep the 148 | # hidden tag, rather than replacing it with the "checkbox", 149 | # since the hidden tag is the one that provides the (default) 150 | # value for the current form. 151 | actual 152 | |> assert_fields(values: ["hidden value"], 153 | type: "hidden") 154 | end 155 | 156 | test "checked checkbox replaces the hidden value" do 157 | hidden = """ 158 | 159 | """ |> input_to_tag 160 | 161 | checked = """ 162 | 163 | """ |> input_to_tag 164 | 165 | %{top_level: %{grade: actual}} = test_tree!([hidden, checked]) 166 | 167 | actual 168 | |> assert_fields(values: ["replace"], 169 | type: "checkbox") 170 | end 171 | end 172 | 173 | # ---------------------------------------------------------------------------- 174 | describe "radio buttons" do 175 | setup do 176 | checked = """ 177 | 178 | """ |> input_to_tag 179 | unchecked = """ 180 | 181 | """ |> input_to_tag 182 | 183 | [checked: checked, unchecked: unchecked] 184 | end 185 | 186 | test "checked radio replaces the unchecked value", 187 | %{checked: checked, unchecked: unchecked} do 188 | 189 | %{top_level: %{contact: actual}} = test_tree!([unchecked, checked]) 190 | assert_field(actual, values: ["email"]) 191 | end 192 | 193 | test "unchecked radio does not replace the checked value", 194 | %{checked: checked, unchecked: unchecked} do 195 | 196 | %{top_level: %{contact: actual}} = test_tree!([checked, unchecked]) 197 | assert_field(actual, values: ["email"]) 198 | end 199 | 200 | test "no checked button results in no value", 201 | %{unchecked: unchecked} do 202 | 203 | %{top_level: %{contact: actual}} = test_tree!(unchecked) 204 | assert_field(actual, values: []) 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /test/details/tree_edit_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Details.TreeEditTest do 2 | use ExUnit.Case, async: true 3 | import FlowAssertions.MapA 4 | import PhoenixIntegration.FormSupport 5 | alias PhoenixIntegration.Form.{TreeEdit, Change, Tag} 6 | 7 | 8 | @shallow """ 9 | 10 | """ |> input_to_tag 11 | 12 | @deeper """ 13 | 14 | """ |> input_to_tag 15 | 16 | @original_tree test_tree!([ 17 | @shallow, 18 | @deeper, 19 | """ 20 | 21 | """ |> input_to_tag, 22 | """ 23 | 24 | """ |> input_to_tag 25 | ]) 26 | 27 | @list get_in(@original_tree, [:top_level, :list]) 28 | 29 | # ---------------------------------------------------------------------------- 30 | describe "successful updates" do 31 | test "update a scalar" do 32 | change = change(@shallow.path, "different value") 33 | 34 | TreeEdit.apply_change(@original_tree, change) 35 | |> require_ok 36 | |> refute_changed([@deeper, @list]) 37 | |> assert_changed(@shallow, values: ["different value"]) 38 | end 39 | 40 | test "update a deeper scalar, just for fun" do 41 | change = change(@deeper.path, "different value") 42 | 43 | TreeEdit.apply_change(@original_tree, change) 44 | |> require_ok 45 | |> assert_changed(@deeper, values: ["different value"]) 46 | end 47 | 48 | test "update a list-valued tag" do 49 | change = change(@list.path, ["different", "values"]) 50 | 51 | TreeEdit.apply_change(@original_tree, change) 52 | |> require_ok 53 | |> assert_changed(@list, values: ["different", "values"]) 54 | end 55 | end 56 | 57 | # ---------------------------------------------------------------------------- 58 | describe "the types of values accepted as keys" do 59 | # Note that the resulting tree always has symbol keys, even if the 60 | # original is a string or integer. 61 | setup do 62 | numeric = """ 63 | 64 | """ |> input_to_tag |> test_tree! 65 | 66 | [numeric: numeric] 67 | end 68 | 69 | test "keys can be symbols", %{numeric: numeric} do 70 | %{top_level: %{lower: %{"0": actual}}} = 71 | TreeEdit.apply_edits(numeric, %{top_level: %{lower: %{"0": "new"}}}) 72 | |> require_ok 73 | 74 | assert actual.values == ["new"] 75 | end 76 | 77 | test "keys can be strings", %{numeric: numeric} do 78 | %{top_level: %{lower: %{"0": actual}}} = 79 | TreeEdit.apply_edits(numeric, %{top_level: %{lower: %{"0" => "new"}}}) 80 | |> require_ok 81 | 82 | assert actual.values == ["new"] 83 | end 84 | 85 | test "keys can be integers", %{numeric: numeric} do 86 | %{top_level: %{lower: %{"0": actual}}} = 87 | TreeEdit.apply_edits(numeric, %{top_level: %{lower: %{0 => "new"}}}) 88 | |> require_ok 89 | 90 | assert actual.values == ["new"] 91 | end 92 | end 93 | 94 | # ---------------------------------------------------------------------------- 95 | test "a bit more complicated example: more than one edited value" do 96 | edits = %{top_level: 97 | %{second: %{deeper: "new deeper value"}, 98 | list: ["shorter list"]} 99 | } 100 | 101 | TreeEdit.apply_edits(@original_tree, edits) 102 | |> require_ok 103 | |> assert_changed(@deeper, values: ["new deeper value"]) 104 | |> assert_changed(@list, values: ["shorter list"]) 105 | |> refute_changed(@shallow) 106 | end 107 | 108 | # ---------------------------------------------------------------------------- 109 | defstruct day: nil, hour: nil 110 | 111 | describe "handling of structures" do 112 | test "a value can be set from structure keys" do 113 | day = 114 | """ 115 | 116 | """ |> input_to_tag 117 | 118 | hour = 119 | """ 120 | 121 | """ |> input_to_tag 122 | tree = test_tree!([day, hour]) 123 | 124 | TreeEdit.apply_edits(tree, %{top_level: %__MODULE__{day: "Fri", hour: "12"}}) 125 | |> require_ok 126 | |> assert_changed(day, values: ["Fri"]) 127 | |> assert_changed(hour, values: ["12"]) 128 | end 129 | 130 | test "unused keys are ignored" do 131 | day = 132 | """ 133 | 134 | """ |> input_to_tag 135 | tree = test_tree!([day]) 136 | 137 | TreeEdit.apply_edits(tree, %{top_level: %__MODULE__{day: "Fri", hour: "12"}}) 138 | |> require_ok 139 | |> assert_changed(day, values: ["Fri"]) 140 | end 141 | end 142 | # ---------------------------------------------------------------------------- 143 | describe "handling of files" do 144 | setup do 145 | tag = 146 | """ 147 | 148 | """ |> input_to_tag 149 | [tree: test_tree!([tag])] 150 | end 151 | 152 | test "one normally sets a Plug.Upload", %{tree: tree} do 153 | upload = %Plug.Upload{content_type: "image/jpg", 154 | path: "/var/mytests/photo.jpg", 155 | filename: "photo.jpg"} 156 | 157 | {:ok, edited} = TreeEdit.apply_edits(tree, %{top_level: %{picture: upload}}) 158 | assert edited.top_level.picture.values == [upload] 159 | end 160 | 161 | test "In case they're not using Plug.Upload, a string is accepted", 162 | %{tree: tree} do 163 | 164 | {:ok, edited} = TreeEdit.apply_edits(tree, %{top_level: %{picture: "filename"}}) 165 | assert edited.top_level.picture.values == ["filename"] 166 | end 167 | end 168 | 169 | # ---------------------------------------------------------------------------- 170 | 171 | 172 | defp require_ok({:ok, val}), do: val 173 | 174 | defp assert_changed(new_tree, old_leaf, changes) do 175 | get_in(new_tree, old_leaf.path) 176 | |> assert_same_map(old_leaf, except: changes) 177 | new_tree 178 | end 179 | 180 | defp refute_changed(new_tree, list) when is_list(list) do 181 | for old_leaf <- list, do: refute_changed(new_tree, old_leaf) 182 | new_tree 183 | end 184 | 185 | defp refute_changed(new_tree, %Tag{} = old_leaf) do 186 | assert get_in(new_tree, old_leaf.path) == old_leaf 187 | end 188 | 189 | defp change(path, value), do: %Change{path: path, value: value} 190 | end 191 | -------------------------------------------------------------------------------- /test/details/tree_finish_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.Details.TreeFinishTest do 2 | use ExUnit.Case, async: true 3 | import PhoenixIntegration.FormSupport 4 | alias PhoenixIntegration.Form.TreeFinish 5 | 6 | describe "converting the values into a map sent via ConnTest" do 7 | test "a scalar" do 8 | actual = """ 9 | 10 | """ 11 | |> input_to_tag 12 | |> test_tree! 13 | |> TreeFinish.to_action_params 14 | 15 | assert actual == %{top_level: %{first: "first value"}} 16 | end 17 | 18 | test "a list" do 19 | actual = [ 20 | """ 21 | 22 | """ |> input_to_tag, 23 | """ 24 | 25 | """ |> input_to_tag 26 | ] 27 | |> test_tree! 28 | |> TreeFinish.to_action_params 29 | 30 | assert %{top_level: %{list: list}} = actual 31 | assert Enum.sort(list) == ["list 1", "list 2"] 32 | end 33 | 34 | test "an empty list means nothing is sent" do 35 | actual = [ 36 | ~s| |, 37 | ~s| |, 38 | # So there's something to see 39 | ~s| |] 40 | |> test_tree! 41 | |> TreeFinish.to_action_params 42 | 43 | assert %{animals: %{name: ""}} == actual 44 | end 45 | end 46 | 47 | # ---------------------------------------------------------------------------- 48 | describe "pruning of the tree when there are no values" do 49 | test "pruning is recursive" do 50 | actual = [ 51 | ~s| |, 52 | """ 53 | 57 | """] 58 | |> test_tree! 59 | |> TreeFinish.to_action_params 60 | 61 | assert %{animals: %{name: "Bossie"}} == actual 62 | # Because there's no value for any of the names "under [animals][stats], 63 | # that entire leaf of the tree is not sent. 64 | end 65 | 66 | test "an unchecked checkbox (and no `hidden`)" do 67 | actual = [""" 68 | 69 | """ |> input_to_tag, 70 | # So there's something to see 71 | """ 72 | 73 | """ |> input_to_tag, 74 | ] 75 | |> test_tree! 76 | |> TreeFinish.to_action_params 77 | 78 | assert %{top_level: %{first: "first value"}} == actual 79 | end 80 | 81 | test "radio buttons need not have anything checked" do 82 | # and so send no value. 83 | 84 | actual = [ 85 | ~s| |, 86 | ~s| | ] 87 | |> test_tree! 88 | |> TreeFinish.to_action_params 89 | 90 | # Since there's nothing else in the form, nothing is actually sent. 91 | assert %{} == actual 92 | end 93 | end 94 | 95 | # ---------------------------------------------------------------------------- 96 | 97 | # This value informs the user that a tag without a value will not be 98 | # sent to the server. (Its name will not be present in the params.) 99 | # This is only used in `fetch_form`. 100 | @not_sent TreeFinish.no_value_msg() 101 | 102 | describe "converting the values into a display: tags with values work the same" do 103 | test "a scalar" do 104 | actual = """ 105 | 106 | """ 107 | |> input_to_tag 108 | |> test_tree! 109 | |> TreeFinish.to_form_params 110 | 111 | assert actual == %{top_level: %{first: "first value"}} 112 | end 113 | 114 | test "a list" do 115 | actual = [ 116 | """ 117 | 118 | """ |> input_to_tag, 119 | """ 120 | 121 | """ |> input_to_tag 122 | ] 123 | |> test_tree! 124 | |> TreeFinish.to_form_params 125 | 126 | assert %{top_level: %{list: list}} = actual 127 | assert Enum.sort(list) == ["list 1", "list 2"] 128 | end 129 | end 130 | 131 | describe "The special labeling of valueless tags" do 132 | test "a list-valued tag" do 133 | actual = [ 134 | ~s| |, 135 | ~s| |] 136 | |> test_tree! 137 | |> TreeFinish.to_form_params 138 | 139 | assert %{animals: %{chosen: @not_sent}} == actual 140 | end 141 | 142 | test "an unchecked checkbox not part of an array nor with a default value" do 143 | actual = """ 144 | 145 | """ 146 | |> test_tree! 147 | |> TreeFinish.to_form_params 148 | 149 | assert %{top_level: %{checked: @not_sent}} == actual 150 | end 151 | 152 | 153 | test "radio buttons with no value checked" do 154 | # and so send no value. 155 | 156 | actual = [ 157 | ~s| |, 158 | ~s| | ] 159 | |> test_tree! 160 | |> TreeFinish.to_form_params 161 | 162 | assert %{animals: %{species: @not_sent}} == actual 163 | end 164 | 165 | test "note that a value-less text input still defaults to empty string " do 166 | actual = [~s| |] 167 | |> test_tree! 168 | |> TreeFinish.to_form_params 169 | 170 | assert %{animals: %{name: ""}} == actual 171 | end 172 | 173 | test "confirmatory test for larger example" do 174 | actual = [ 175 | ~s| |, 176 | """ 177 | 181 | """] 182 | |> test_tree! 183 | |> TreeFinish.to_form_params 184 | 185 | expected = %{animals: 186 | %{name: "Bossie", 187 | stats: %{roles: @not_sent}}} 188 | assert expected == actual 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /test/fixtures/templates/checkbox.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 34 | -------------------------------------------------------------------------------- /test/fixtures/templates/input_types.html: -------------------------------------------------------------------------------- 1 |4 | Various `input type="xyzzy"` tags operate just like `type="text"` 5 | tags so far as this library's code is concerned. 6 |
7 | 8 | 9 | 39 | -------------------------------------------------------------------------------- /test/fixtures/templates/sample.html: -------------------------------------------------------------------------------- 1 |15 | text_here 16 |
17 | 18 | ----------------------------- 19 | A sample link using POST 20 | 21 | POST link text 22 | 23 | 24 | ----------------------------- 25 | A sample link using PUT 26 | 27 | PUT link text 28 | 29 | 30 | ----------------------------- 31 | A sample link using PATCH 32 | 33 | PATCH link text 34 | 35 | 36 | ----------------------------- 37 | A sample link using DELETE 38 | 39 | DELETE link text 40 | 41 | 42 | ----------------------------- 43 | A sample button using POST 44 | 50 | 51 | ----------------------------- 52 | A sample button using PUT 53 | 59 | 60 | ----------------------------- 61 | A sample button using PATCH 62 | 68 | 69 | ----------------------------- 70 | A sample button using DELETE 71 | 77 | 78 | ----------------------------- 79 | A proper one this time, with fields to be filled out. 80 | 313 | 314 | 315 | ----------------------------- 316 | A multipart form with an upload field. 317 | 326 | 327 | ----------------------------- 328 | A form with get method. 329 | Return 4 | 5 | 12 | -------------------------------------------------------------------------------- /test/forms_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.FormsTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.ConnTest 4 | @endpoint PhoenixIntegration.TestEndpoint 5 | 6 | # @href_first_get "/links/first" 7 | # @href_second_get "https://www.example.com/links/second" 8 | 9 | @form_action "/form" 10 | @form_method "put" 11 | @form_id "#proper_form" 12 | 13 | @user_data %{ 14 | user: %{ 15 | name: "User Name", 16 | type: "type_one", 17 | story: "Updated story.", 18 | species: "centauri" 19 | } 20 | } 21 | 22 | # ============================================================================ 23 | # set up context 24 | setup do 25 | html = get(build_conn(:get, "/"), "/sample").resp_body 26 | 27 | {:ok, _action, _method, form} = 28 | PhoenixIntegration.Requests.test_find_html_form(html, @form_id, nil, "form") 29 | 30 | %{html: html, form: form} 31 | end 32 | 33 | # ============================================================================ 34 | # find form 35 | 36 | test "find form via uri or path", %{html: html} do 37 | found = PhoenixIntegration.Requests.test_find_html_form(html, @form_action, nil, "form") 38 | {:ok, @form_action, @form_method, _form} = found 39 | end 40 | 41 | test "find form via id", %{html: html} do 42 | found = PhoenixIntegration.Requests.test_find_html_form(html, @form_id, nil, "form") 43 | {:ok, @form_action, @form_method, _form} = found 44 | end 45 | 46 | test "find form via internal text", %{html: html} do 47 | found = 48 | PhoenixIntegration.Requests.test_find_html_form( 49 | html, 50 | "Text in the proper form", 51 | nil, 52 | "form" 53 | ) 54 | 55 | {:ok, @form_action, @form_method, _form} = found 56 | end 57 | 58 | test "find form raises on missing path", %{html: html} do 59 | assert_raise RuntimeError, fn -> 60 | PhoenixIntegration.Requests.test_find_html_form(html, "/invalid/path", nil, "form") 61 | end 62 | end 63 | 64 | test "find form raises on invalid id", %{html: html} do 65 | assert_raise RuntimeError, fn -> 66 | PhoenixIntegration.Requests.test_find_html_form(html, "#other", nil, "form") 67 | end 68 | end 69 | 70 | test "find form raises on missing text", %{html: html} do 71 | assert_raise RuntimeError, fn -> 72 | PhoenixIntegration.Requests.test_find_html_form(html, "Invalid Text", nil, "form") 73 | end 74 | end 75 | 76 | # ============================================================================ 77 | # build form data to send 78 | 79 | test "build form data works", %{form: form} do 80 | data = PhoenixIntegration.Requests.test_build_form_data(form, @user_data) 81 | %{user: user_params} = data 82 | assert user_params.name == @user_data.user.name 83 | assert user_params.type == @user_data.user.type 84 | assert user_params.story == @user_data.user.story 85 | assert user_params.species == @user_data.user.species 86 | end 87 | 88 | test "build form data sets just text field", %{form: form} do 89 | user_data = %{user: %{name: "Just Name"}} 90 | data = PhoenixIntegration.Requests.test_build_form_data(form, user_data) 91 | %{user: user_params} = data 92 | assert user_params.name == "Just Name" 93 | assert user_params.type == "type_two" 94 | assert user_params.story == "Initial user story" 95 | assert user_params.species == "human" 96 | end 97 | 98 | test "build form data sets just select field", %{form: form} do 99 | user_data = %{user: %{type: "type_three"}} 100 | data = PhoenixIntegration.Requests.test_build_form_data(form, user_data) 101 | %{user: user_params} = data 102 | assert user_params.name == "Initial Name" 103 | assert user_params.type == "type_three" 104 | assert user_params.story == "Initial user story" 105 | assert user_params.species == "human" 106 | end 107 | 108 | test "build form data sets just text area", %{form: form} do 109 | user_data = %{user: %{story: "Just story."}} 110 | data = PhoenixIntegration.Requests.test_build_form_data(form, user_data) 111 | %{user: user_params} = data 112 | assert user_params.name == "Initial Name" 113 | assert user_params.type == "type_two" 114 | assert user_params.story == "Just story." 115 | assert user_params.species == "human" 116 | end 117 | 118 | test "build form data sets just radio", %{form: form} do 119 | user_data = %{user: %{species: "narn"}} 120 | data = PhoenixIntegration.Requests.test_build_form_data(form, user_data) 121 | %{user: user_params} = data 122 | assert user_params.name == "Initial Name" 123 | assert user_params.type == "type_two" 124 | assert user_params.story == "Initial user story" 125 | assert user_params.species == "narn" 126 | end 127 | 128 | test "build form data sets nested forms", %{form: form} do 129 | user_data = %{ 130 | user: %{ 131 | tag: %{name: "new tag"}, 132 | friends: %{"0": %{address: %{city: %{zip: "67890"}}}} 133 | } 134 | } 135 | 136 | data = PhoenixIntegration.Requests.test_build_form_data(form, user_data) 137 | %{user: user_params} = data 138 | assert user_params.name == "Initial Name" 139 | assert user_params.type == "type_two" 140 | assert user_params.story == "Initial user story" 141 | assert user_params.species == "human" 142 | assert user_params.tag.name == "new tag" 143 | assert user_params.friends[:"0"].address.city.zip == "67890" 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /test/links_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.LinksTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.ConnTest 4 | @endpoint PhoenixIntegration.TestEndpoint 5 | 6 | @href_first_get "/links/first" 7 | @href_second_get "https://www.example.com/links/second" 8 | 9 | @href_post "/links/post" 10 | @href_put "/links/put" 11 | @href_patch "/links/patch" 12 | @href_delete "/links/delete" 13 | 14 | # ============================================================================ 15 | # set up context 16 | setup do 17 | %{html: get(build_conn(:get, "/"), "/sample").resp_body} 18 | end 19 | 20 | # ============================================================================ 21 | # get links 22 | 23 | test "find :get anchor in html via uri or path", %{html: html} do 24 | assert PhoenixIntegration.Requests.test_find_html_link(html, @href_first_get, :get) == 25 | {:ok, @href_first_get} 26 | 27 | assert PhoenixIntegration.Requests.test_find_html_link(html, @href_first_get, :get) == 28 | {:ok, @href_first_get} 29 | 30 | assert PhoenixIntegration.Requests.test_find_html_link(html, @href_second_get, :get) == 31 | {:ok, @href_second_get} 32 | end 33 | 34 | test "find :get anchor in html via #id", %{html: html} do 35 | assert PhoenixIntegration.Requests.test_find_html_link(html, "#second", :get) == 36 | {:ok, @href_second_get} 37 | end 38 | 39 | test "find :get anchor in html via text", %{html: html} do 40 | assert PhoenixIntegration.Requests.test_find_html_link(html, "First Link", :get) == 41 | {:ok, @href_first_get} 42 | 43 | assert PhoenixIntegration.Requests.test_find_html_link(html, "Second Link", :get) == 44 | {:ok, @href_second_get} 45 | end 46 | 47 | test "find :get raises on invalid id", %{html: html} do 48 | assert_raise RuntimeError, fn -> 49 | PhoenixIntegration.Requests.test_find_html_link(html, "#other", :get) 50 | end 51 | end 52 | 53 | test "find :get raises on missing text", %{html: html} do 54 | assert_raise RuntimeError, fn -> 55 | PhoenixIntegration.Requests.test_find_html_link(html, "Invalid Text", :get) 56 | end 57 | end 58 | 59 | test "find :get raises on missing path", %{html: html} do 60 | assert_raise RuntimeError, fn -> 61 | PhoenixIntegration.Requests.test_find_html_link(html, "/invalid/path", :get) 62 | end 63 | end 64 | 65 | # ============================================================================ 66 | # post links 67 | 68 | test "find :post anchor in html via uri or path", %{html: html} do 69 | assert PhoenixIntegration.Requests.test_find_html_link(html, @href_post, :post) == 70 | {:ok, @href_post} 71 | end 72 | 73 | test "find :post anchor in html via #id", %{html: html} do 74 | assert PhoenixIntegration.Requests.test_find_html_link(html, "#post_id", :post) == 75 | {:ok, @href_post} 76 | end 77 | 78 | test "find :post anchor in html via text", %{html: html} do 79 | assert PhoenixIntegration.Requests.test_find_html_link(html, "POST link text", :post) == 80 | {:ok, @href_post} 81 | end 82 | 83 | test "find :post raises on invalid id", %{html: html} do 84 | assert_raise RuntimeError, fn -> 85 | PhoenixIntegration.Requests.test_find_html_link(html, "#other", :post) 86 | end 87 | end 88 | 89 | test "find :post raises on missing text", %{html: html} do 90 | assert_raise RuntimeError, fn -> 91 | PhoenixIntegration.Requests.test_find_html_link(html, "Invalid Text", :post) 92 | end 93 | end 94 | 95 | test "find :post raises on missing path", %{html: html} do 96 | assert_raise RuntimeError, fn -> 97 | PhoenixIntegration.Requests.test_find_html_link(html, "/invalid/path", :post) 98 | end 99 | end 100 | 101 | # ============================================================================ 102 | # put links 103 | 104 | test "find :put anchor in html via uri or path", %{html: html} do 105 | assert PhoenixIntegration.Requests.test_find_html_link(html, @href_put, :put) == 106 | {:ok, @href_put} 107 | end 108 | 109 | test "find :put anchor in html via #id", %{html: html} do 110 | assert PhoenixIntegration.Requests.test_find_html_link(html, "#put_id", :put) == 111 | {:ok, @href_put} 112 | end 113 | 114 | test "find :put anchor in html via text", %{html: html} do 115 | assert PhoenixIntegration.Requests.test_find_html_link(html, "PUT link text", :put) == 116 | {:ok, @href_put} 117 | end 118 | 119 | test "find :put raises on invalid id", %{html: html} do 120 | assert_raise RuntimeError, fn -> 121 | PhoenixIntegration.Requests.test_find_html_link(html, "#other", :put) 122 | end 123 | end 124 | 125 | test "find :put raises on missing text", %{html: html} do 126 | assert_raise RuntimeError, fn -> 127 | PhoenixIntegration.Requests.test_find_html_link(html, "Invalid Text", :put) 128 | end 129 | end 130 | 131 | test "find :put raises on missing path", %{html: html} do 132 | assert_raise RuntimeError, fn -> 133 | PhoenixIntegration.Requests.test_find_html_link(html, "/invalid/path", :put) 134 | end 135 | end 136 | 137 | # ============================================================================ 138 | # patch links 139 | 140 | test "find :patch anchor in html via uri or path", %{html: html} do 141 | assert PhoenixIntegration.Requests.test_find_html_link(html, @href_patch, :patch) == 142 | {:ok, @href_patch} 143 | end 144 | 145 | test "find :patch anchor in html via #id", %{html: html} do 146 | assert PhoenixIntegration.Requests.test_find_html_link(html, "#patch_id", :patch) == 147 | {:ok, @href_patch} 148 | end 149 | 150 | test "find :patch anchor in html via text", %{html: html} do 151 | assert PhoenixIntegration.Requests.test_find_html_link(html, "PATCH link text", :patch) == 152 | {:ok, @href_patch} 153 | end 154 | 155 | test "find :patch raises on invalid id", %{html: html} do 156 | assert_raise RuntimeError, fn -> 157 | PhoenixIntegration.Requests.test_find_html_link(html, "#other", :patch) 158 | end 159 | end 160 | 161 | test "find :patch raises on missing text", %{html: html} do 162 | assert_raise RuntimeError, fn -> 163 | PhoenixIntegration.Requests.test_find_html_link(html, "Invalid Text", :patch) 164 | end 165 | end 166 | 167 | test "find :patch raises on missing path", %{html: html} do 168 | assert_raise RuntimeError, fn -> 169 | PhoenixIntegration.Requests.test_find_html_link(html, "/invalid/path", :patch) 170 | end 171 | end 172 | 173 | # ============================================================================ 174 | # delete links 175 | test "find :delete anchor in html via uri or path", %{html: html} do 176 | assert PhoenixIntegration.Requests.test_find_html_link(html, @href_delete, :delete) == 177 | {:ok, @href_delete} 178 | end 179 | 180 | test "find :delete anchor in html via #id", %{html: html} do 181 | assert PhoenixIntegration.Requests.test_find_html_link(html, "#delete_id", :delete) == 182 | {:ok, @href_delete} 183 | end 184 | 185 | test "find :delete anchor in html via text", %{html: html} do 186 | assert PhoenixIntegration.Requests.test_find_html_link(html, "DELETE link text", :delete) == 187 | {:ok, @href_delete} 188 | end 189 | 190 | test "find :delete raises on invalid id", %{html: html} do 191 | assert_raise RuntimeError, fn -> 192 | PhoenixIntegration.Requests.test_find_html_link(html, "#other", :delete) 193 | end 194 | end 195 | 196 | test "find :delete raises on missing text", %{html: html} do 197 | assert_raise RuntimeError, fn -> 198 | PhoenixIntegration.Requests.test_find_html_link(html, "Invalid Text", :delete) 199 | end 200 | end 201 | 202 | test "find :delete raises on missing path", %{html: html} do 203 | assert_raise RuntimeError, fn -> 204 | PhoenixIntegration.Requests.test_find_html_link(html, "/invalid/path", :delete) 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /test/request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.RequestTest do 2 | use ExUnit.Case 3 | import Phoenix.ConnTest 4 | @endpoint PhoenixIntegration.TestEndpoint 5 | 6 | use PhoenixIntegration 7 | alias PhoenixIntegration.Form.TreeFinish 8 | # This value informs the user that a tag has no value and nothing about 9 | # it will be sent to the server in the `params`. 10 | # This is only produced by `fetch_form`. 11 | @not_sent TreeFinish.no_value_msg() 12 | 13 | # import IEx 14 | 15 | # ============================================================================ 16 | # set up context 17 | setup do 18 | %{conn: build_conn(:get, "/")} 19 | end 20 | 21 | # ============================================================================ 22 | # follow_redirect 23 | 24 | test "follow_redirect should get the location redirected to in the conn", %{conn: conn} do 25 | get(conn, "/test_redir") 26 | |> assert_response(status: 302) 27 | |> follow_redirect() 28 | |> assert_response(status: 200, path: "/sample") 29 | end 30 | 31 | test "follow_redirect raises if there are too many redirects", %{conn: conn} do 32 | conn = get(conn, "/circle_redir") 33 | 34 | assert_raise RuntimeError, fn -> 35 | follow_redirect(conn) 36 | end 37 | end 38 | 39 | # ============================================================================ 40 | # follow_path 41 | 42 | test "follow_path gets and redirects all in one", %{conn: conn} do 43 | follow_path(conn, "/test_redir") 44 | |> assert_response(status: 200, path: "/sample") 45 | end 46 | 47 | # ============================================================================ 48 | # click_link 49 | 50 | test "click_link :get clicks a link in the conn's html", %{conn: conn} do 51 | get(conn, "/sample") 52 | |> click_link("First Link") 53 | |> assert_response(status: 200, path: "/links/first") 54 | |> click_link("#return") 55 | |> assert_response(status: 200, path: "/sample") 56 | end 57 | 58 | test "click_link :post clicks a link in the conn's html", %{conn: conn} do 59 | get(conn, "/sample") 60 | |> click_link("#post_id", method: :post) 61 | |> assert_response(status: 302, to: "/second") 62 | end 63 | 64 | test "click_link :put clicks a link in the conn's html", %{conn: conn} do 65 | get(conn, "/sample") 66 | |> click_link("#put_id", method: :put) 67 | |> assert_response(status: 302, to: "/second") 68 | end 69 | 70 | test "click_link :patch clicks a link in the conn's html", %{conn: conn} do 71 | get(conn, "/sample") 72 | |> click_link("#patch_id", method: :patch) 73 | |> assert_response(status: 302, to: "/second") 74 | end 75 | 76 | test "click_link :delete clicks a link in the conn's html", %{conn: conn} do 77 | get(conn, "/sample") 78 | |> click_link("#delete_id", method: :delete) 79 | |> assert_response(status: 302, to: "/second") 80 | end 81 | 82 | # ============================================================================ 83 | # follow_link 84 | 85 | test "follow_link :get clicks a link in the conn's html", %{conn: conn} do 86 | get(conn, "/sample") 87 | |> follow_link("First Link") 88 | |> assert_response(status: 200, path: "/links/first") 89 | |> follow_link("#return") 90 | |> assert_response(status: 200, path: "/sample") 91 | end 92 | 93 | test "follow_link :post clicks a link in the conn's html", %{conn: conn} do 94 | get(conn, "/sample") 95 | |> follow_link("#post_id", method: :post) 96 | |> assert_response(status: 200, path: "/second") 97 | end 98 | 99 | test "follow_link :put clicks a link in the conn's html", %{conn: conn} do 100 | get(conn, "/sample") 101 | |> follow_link("#put_id", method: :put) 102 | |> assert_response(status: 200, path: "/second") 103 | end 104 | 105 | test "follow_link :patch clicks a link in the conn's html", %{conn: conn} do 106 | get(conn, "/sample") 107 | |> follow_link("#patch_id", method: :patch) 108 | |> assert_response(status: 200, path: "/second") 109 | end 110 | 111 | test "follow_link :delete clicks a link in the conn's html", %{conn: conn} do 112 | get(conn, "/sample") 113 | |> follow_link("#delete_id", method: :delete) 114 | |> assert_response(status: 200, path: "/second") 115 | end 116 | 117 | # ============================================================================ 118 | # click_button 119 | 120 | test "click_button :get clicks a link in the conn's html", %{conn: conn} do 121 | get(conn, "/sample") 122 | |> click_button("First Button") 123 | |> assert_response(status: 200, path: "/buttons/first") 124 | |> click_button("#return_button") 125 | |> assert_response(status: 200, path: "/sample") 126 | end 127 | 128 | test "click_button :post clicks a link in the conn's html", %{conn: conn} do 129 | get(conn, "/sample") 130 | |> click_button("#post_button_id", method: :post) 131 | |> assert_response(status: 302, to: "/second") 132 | end 133 | 134 | test "click_button :post clicks a link in the conn's html - getting the method from the button", 135 | %{conn: conn} do 136 | get(conn, "/sample") 137 | |> click_button("#post_button_id") 138 | |> assert_response(status: 302, to: "/second") 139 | end 140 | 141 | test "click_button :put clicks a link in the conn's html", %{conn: conn} do 142 | get(conn, "/sample") 143 | |> click_button("#put_button_id", method: :put) 144 | |> assert_response(status: 302, to: "/second") 145 | end 146 | 147 | test "click_button :patch clicks a link in the conn's html", %{conn: conn} do 148 | get(conn, "/sample") 149 | |> click_button("#patch_button_id", method: :patch) 150 | |> assert_response(status: 302, to: "/second") 151 | end 152 | 153 | test "click_button :delete clicks a link in the conn's html", %{conn: conn} do 154 | get(conn, "/sample") 155 | |> click_button("#delete_button_id", method: :delete) 156 | |> assert_response(status: 302, to: "/second") 157 | end 158 | 159 | # ============================================================================ 160 | # follow_button 161 | 162 | test "follow_button :get clicks a link in the conn's html", %{conn: conn} do 163 | get(conn, "/sample") 164 | |> follow_button("First Button") 165 | |> assert_response(status: 200, path: "/buttons/first") 166 | |> follow_button("#return_button") 167 | |> assert_response(status: 200, path: "/sample") 168 | end 169 | 170 | test "follow_button :post clicks a link in the conn's html", %{conn: conn} do 171 | get(conn, "/sample") 172 | |> follow_button("#post_button_id", method: :post) 173 | |> assert_response(status: 200, path: "/second") 174 | end 175 | 176 | test "follow_button :post clicks a link in the conn's html - getting the method from the button", 177 | %{conn: conn} do 178 | get(conn, "/sample") 179 | |> click_button("#post_button_id") 180 | |> assert_response(status: 302, to: "/second") 181 | end 182 | 183 | test "follow_button :put clicks a link in the conn's html", %{conn: conn} do 184 | get(conn, "/sample") 185 | |> follow_button("#put_button_id", method: :put) 186 | |> assert_response(status: 200, path: "/second") 187 | end 188 | 189 | test "follow_button :patch clicks a link in the conn's html", %{conn: conn} do 190 | get(conn, "/sample") 191 | |> follow_button("#patch_button_id", method: :patch) 192 | |> assert_response(status: 200, path: "/second") 193 | end 194 | 195 | test "follow_button :delete clicks a link in the conn's html", %{conn: conn} do 196 | get(conn, "/sample") 197 | |> follow_button("#delete_button_id", method: :delete) 198 | |> assert_response(status: 200, path: "/second") 199 | end 200 | 201 | # ============================================================================ 202 | # submit_form 203 | 204 | test "submit_form works", %{conn: conn} do 205 | get(conn, "/sample") 206 | |> submit_form(%{user: %{name: "Fine Name", grades: %{a: "true", b: "false"}}}, %{ 207 | identifier: "#proper_form" 208 | }) 209 | |> assert_response(status: 302, to: "/second") 210 | end 211 | 212 | # ============================================================================ 213 | # follow_form 214 | 215 | test "follow_form works", %{conn: conn} do 216 | get(conn, "/sample") 217 | |> follow_form(%{user: %{name: "Fine Name"}}, %{identifier: "#proper_form"}) 218 | |> assert_response(status: 200, path: "/second") 219 | end 220 | 221 | test "follow_form works on upload", %{conn: conn} do 222 | upload = %Plug.Upload{content_type: "text", path: "mix.exs", filename: "mix.exs"} 223 | 224 | get(conn, "/sample") 225 | |> follow_form(%{user: %{photo: upload}}, %{identifier: "#upload_form"}) 226 | |> assert_response(status: 200, path: "/second") 227 | end 228 | 229 | test "follow_form works on dates", %{conn: conn} do 230 | get(conn, "/sample") 231 | |> follow_form( 232 | %{ 233 | user: %{ 234 | joined_at: %DateTime{ 235 | year: 2017, 236 | month: 02, 237 | day: 05, 238 | zone_abbr: "UTC", 239 | hour: 12, 240 | minute: 0, 241 | second: 0, 242 | time_zone: "UTC", 243 | utc_offset: 0, 244 | std_offset: 0 245 | } 246 | } 247 | }, 248 | %{identifier: "#proper_form"} 249 | ) 250 | |> assert_response(status: 200, path: "/second") 251 | end 252 | 253 | test "follow_form works with a :get method form", %{conn: conn} do 254 | get(conn, "/sample") 255 | |> follow_form(%{query: "search stuff"}, identifier: "/admin/search", method: :get) 256 | |> assert_response(status: 200, path: "/admin/search") 257 | end 258 | 259 | test "follow_form works with radio buttons", %{conn: conn} do 260 | get(conn, "/sample") 261 | |> follow_form(%{user: %{usage: "moderate", locale: "rural"}}, identifier: "#proper_form") 262 | |> assert_response(status: 200, path: "/second") 263 | end 264 | 265 | # ============================================================================ 266 | # fetch_form 267 | test "fetch_form works", %{conn: conn} do 268 | form = 269 | get(conn, "/sample") 270 | |> fetch_form(%{identifier: "#proper_form"}) 271 | 272 | assert %{ 273 | action: "/form", 274 | id: "proper_form", 275 | inputs: %{ 276 | _csrf_token: csrf_token, 277 | _method: "put", 278 | _utf8: "✓", 279 | user: %{ 280 | joined_at: %{ 281 | day: "1", 282 | hour: "0", 283 | minute: "0", 284 | month: "1", 285 | year: "2012"}, 286 | name: "Initial Name", 287 | species: "human", 288 | story: "Initial user story", 289 | tag: %{name: "tag"}, 290 | type: "type_two", 291 | friends: %{"0": %{address: %{city: %{zip: "12345"}}}}, 292 | grades: %{a: "true", b: "true", c: "true"}, 293 | allotments: ["1", "3"], 294 | 295 | locale: @not_sent, 296 | usage: @not_sent 297 | } 298 | }, 299 | method: "put" 300 | } = form 301 | 302 | assert is_bitstring(csrf_token) 303 | end 304 | 305 | # ============================================================================ 306 | # follow_fn 307 | 308 | test "follow_form returns fn's conn", %{conn: conn} do 309 | refute conn.assigns[:test] 310 | conn = follow_fn(conn, fn c -> Plug.Conn.assign(c, :test, "response") end) 311 | assert conn.assigns[:test] == "response" 312 | end 313 | 314 | test "follow_form ignores non conn responses", %{conn: conn} do 315 | assert follow_fn(conn, fn _ -> "some string" end) == conn 316 | end 317 | 318 | test "follow_fn follows redirects in the returned conn", %{conn: conn} do 319 | follow_fn(conn, fn c -> get(c, "/test_redir") end) 320 | |> assert_response(status: 200, path: "/sample") 321 | end 322 | end 323 | -------------------------------------------------------------------------------- /test/support/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.TestEndpoint do 2 | use Plug.Test 3 | 4 | # import IEx 5 | 6 | @expected_json_data %{ 7 | "one" => 1, 8 | "two" => "two", 9 | "other" => "Sample" 10 | } 11 | 12 | def init(opts) do 13 | opts 14 | end 15 | 16 | def call(conn, _opts) do 17 | respond(conn, conn.method, conn.request_path) 18 | end 19 | 20 | def respond(conn, "GET", "/test_json") do 21 | pre_get_json(conn, conn_request_path(conn)) 22 | |> resp(200, Jason.encode!(@expected_json_data)) 23 | end 24 | 25 | def respond(conn, "GET", "/test_redir") do 26 | Phoenix.ConnTest.build_conn(:get, conn_request_path(conn)) 27 | |> Plug.Test.recycle_cookies(conn) 28 | |> put_resp_header("location", "/sample") 29 | |> put_status(302) 30 | end 31 | 32 | def respond(conn, "GET", "/circle_redir") do 33 | Phoenix.ConnTest.build_conn(:get, conn_request_path(conn)) 34 | |> Plug.Test.recycle_cookies(conn) 35 | |> put_resp_header("location", conn_request_path(conn)) 36 | |> put_status(302) 37 | end 38 | 39 | def respond(conn, "GET", "/sample") do 40 | # <> query 41 | pre_get_html(conn, conn_request_path(conn)) 42 | |> resp(200, File.read!("test/fixtures/templates/sample.html")) 43 | end 44 | 45 | def respond(conn, "GET", "/checkbox") do 46 | # <> query 47 | pre_get_html(conn, conn_request_path(conn)) 48 | |> resp(200, File.read!("test/fixtures/templates/checkbox.html")) 49 | end 50 | 51 | def respond(conn, "GET", "/input_types") do 52 | # <> query 53 | pre_get_html(conn, conn_request_path(conn)) 54 | |> resp(200, File.read!("test/fixtures/templates/input_types.html")) 55 | end 56 | 57 | def respond(conn, "GET", _path) do 58 | pre_get_html(conn, conn_request_path(conn)) 59 | |> resp(200, File.read!("test/fixtures/templates/second.html")) 60 | end 61 | 62 | def respond(conn, "POST", _path) do 63 | Phoenix.ConnTest.build_conn(:post, conn_request_path(conn)) 64 | |> Plug.Test.recycle_cookies(conn) 65 | |> put_resp_header("location", "/second") 66 | |> put_status(302) 67 | end 68 | 69 | def respond(conn, "PUT", _path) do 70 | Phoenix.ConnTest.build_conn(:put, conn_request_path(conn)) 71 | |> Plug.Test.recycle_cookies(conn) 72 | |> put_resp_header("location", "/second") 73 | |> put_status(302) 74 | end 75 | 76 | def respond(conn, "PATCH", _path) do 77 | Phoenix.ConnTest.build_conn(:patch, conn_request_path(conn)) 78 | |> Plug.Test.recycle_cookies(conn) 79 | |> put_resp_header("location", "/second") 80 | |> put_status(302) 81 | end 82 | 83 | def respond(conn, "DELETE", _path) do 84 | Phoenix.ConnTest.build_conn(:delete, conn_request_path(conn)) 85 | |> Plug.Test.recycle_cookies(conn) 86 | |> put_resp_header("location", "/second") 87 | |> put_status(302) 88 | end 89 | 90 | # ============================================================================ 91 | defp pre_get_html(conn, path) do 92 | Phoenix.ConnTest.build_conn(:get, path) 93 | |> Plug.Test.recycle_cookies(conn) 94 | |> put_resp_content_type("text/html") 95 | end 96 | 97 | defp pre_get_json(conn, path) do 98 | Phoenix.ConnTest.build_conn(:get, path) 99 | |> Plug.Test.recycle_cookies(conn) 100 | |> put_resp_content_type("application/json") 101 | end 102 | 103 | defp conn_request_path(conn) do 104 | conn.request_path <> 105 | case conn.query_string do 106 | nil -> "" 107 | "" -> "" 108 | query -> "?" <> query 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/support/form_support.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixIntegration.FormSupport do 2 | alias PhoenixIntegration.Form.{Tag,TreeCreation} 3 | 4 | def input_to_tag(fragment), 5 | do: Floki.parse_fragment!(fragment) |> Tag.new! 6 | 7 | # ---------------------------------------------------------------------------- 8 | 9 | # These functions are used when you want to build trees 10 | # from Tags (*not* Floki data structures), and you don't 11 | # care about errors, etc. 12 | def test_tree!(tag) when not is_list(tag), 13 | do: test_tree!([tag]) 14 | def test_tree!(tags) when is_list(tags), 15 | do: Enum.reduce(tags, %{}, &create_one/2) 16 | 17 | defp create_one(fragment, acc) when is_binary(fragment), 18 | do: create_one(input_to_tag(fragment), acc) 19 | defp create_one(tag, acc), 20 | do: TreeCreation.add_tag!(acc, tag) 21 | 22 | # ---------------------------------------------------------------------------- 23 | 24 | def form_for(html_snippet, opts \\ []) do 25 | case Keyword.get(opts, :id, true) do 26 | true -> 27 | form_for(html_snippet, ~s|id="proper_form"|, "#proper_form") 28 | false -> 29 | form_for(html_snippet, "", nil) 30 | end 31 | end 32 | 33 | def form_for(html_snippet, id_attr, identifier) do 34 | html = 35 | """ 36 | 39 | """ 40 | {:ok, _action, _method, form} = 41 | PhoenixIntegration.Requests.test_find_html_form( 42 | html, identifier, nil, "form") 43 | form 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------