From f339ce3b20a9a5ba0477ab71ad0e57bcb6c825cc Mon Sep 17 00:00:00 2001 From: Ryn Cao Date: Wed, 10 Jul 2024 16:34:54 +0800 Subject: [PATCH] refactor: ci and basic guides practice --- .dockerignore | 49 ++ .github/workflows/ci.yml | 52 ++ .github/workflows/docker.yml | 44 ++ .github/workflows/release.yml | 193 +++++++ .gitignore | 9 + .iex.exs | 21 + .tool-versions | 2 + Dockerfile | 116 ++++ README.md | 27 +- Taskfile-fly-io.yml | 42 ++ Taskfile.yml | 134 ++++- config/config.exs | 13 + config/dev.exs | 2 + config/git_ops.exs | 33 ++ config/runtime.exs | 21 +- config/test.exs | 9 +- fly.toml | 34 ++ lib/git_commit.ex | 90 +++ lib/hello_phx.ex | 41 ++ lib/hello_phx/accounts.ex | 379 +++++++++++++ lib/hello_phx/accounts/user.ex | 162 ++++++ lib/hello_phx/accounts/user_notifier.ex | 79 +++ lib/hello_phx/accounts/user_token.ex | 181 ++++++ lib/hello_phx/application.ex | 4 + lib/hello_phx/blog.ex | 200 +++++++ lib/hello_phx/blog/comment.ex | 18 + lib/hello_phx/blog/post.ex | 19 + lib/hello_phx/catalog.ex | 236 ++++++++ lib/hello_phx/catalog/category.ex | 18 + lib/hello_phx/catalog/product.ex | 25 + lib/hello_phx/data.ex | 34 ++ lib/hello_phx/news.ex | 104 ++++ lib/hello_phx/news/article.ex | 18 + lib/hello_phx/orders.ex | 231 ++++++++ lib/hello_phx/orders/line_item.ex | 22 + lib/hello_phx/orders/order.ex | 22 + lib/hello_phx/release.ex | 53 ++ lib/hello_phx/shopping_cart.ex | 273 +++++++++ lib/hello_phx/shopping_cart/cart.ex | 21 + lib/hello_phx/shopping_cart/cart_item.ex | 24 + lib/hello_phx/urls.ex | 104 ++++ lib/hello_phx/urls/url.ex | 19 + lib/hello_phx/user_v1.ex | 21 + .../components/layouts/app.html.heex | 10 + .../components/layouts/root.html.heex | 41 ++ .../controllers/api/user_controller.ex | 19 + .../controllers/api_controller.ex | 19 + .../controllers/article_controller.ex | 43 ++ lib/hello_phx_web/controllers/article_json.ex | 25 + .../controllers/cart_controller.ex | 21 + lib/hello_phx_web/controllers/cart_html.ex | 9 + .../controllers/cart_html/show.html.heex | 34 ++ .../controllers/cart_item_controller.ex | 24 + .../controllers/changeset_json.ex | 25 + .../controllers/comment_controller.ex | 62 +++ lib/hello_phx_web/controllers/comment_html.ex | 13 + .../comment_html/comment_form.html.heex | 9 + .../controllers/comment_html/edit.html.heex | 8 + .../controllers/comment_html/index.html.heex | 23 + .../controllers/comment_html/new.html.heex | 8 + .../controllers/comment_html/show.html.heex | 15 + lib/hello_phx_web/controllers/error_html.ex | 2 +- .../controllers/error_html/404.html.heex | 33 ++ lib/hello_phx_web/controllers/error_json.ex | 7 +- .../controllers/fallback_controller.ex | 24 + .../controllers/hello_controller.ex | 22 +- lib/hello_phx_web/controllers/hello_html.ex | 6 + .../controllers/hello_html/index.html.heex | 4 +- .../hello_html/no_layout.html.heex | 3 + .../controllers/hello_html/show.html.heex | 4 +- .../controllers/order_controller.ex | 24 + lib/hello_phx_web/controllers/order_html.ex | 5 + .../controllers/order_html/show.html.heex | 19 + .../controllers/page_controller.ex | 4 +- .../controllers/page_html/home.html.heex | 11 + lib/hello_phx_web/controllers/page_json.ex | 5 + .../controllers/post_controller.ex | 62 +++ lib/hello_phx_web/controllers/post_html.ex | 13 + .../controllers/post_html/edit.html.heex | 8 + .../controllers/post_html/index.html.heex | 24 + .../controllers/post_html/new.html.heex | 8 + .../controllers/post_html/post_form.html.heex | 10 + .../controllers/post_html/show.html.heex | 16 + .../controllers/product_controller.ex | 71 +++ lib/hello_phx_web/controllers/product_html.ex | 23 + .../controllers/product_html/edit.html.heex | 8 + .../controllers/product_html/index.html.heex | 30 + .../controllers/product_html/new.html.heex | 8 + .../product_html/product_form.html.heex | 19 + .../controllers/product_html/show.html.heex | 28 + .../controllers/status_controller.ex | 120 ++++ .../controllers/url_controller.ex | 43 ++ lib/hello_phx_web/controllers/url_json.ex | 25 + .../controllers/user_session_controller.ex | 42 ++ lib/hello_phx_web/endpoint.ex | 43 +- lib/hello_phx_web/errors.ex | 26 + .../user_confirmation_instructions_live.ex | 51 ++ .../live/user_confirmation_live.ex | 58 ++ .../live/user_forgot_password_live.ex | 50 ++ lib/hello_phx_web/live/user_login_live.ex | 43 ++ .../live/user_registration_live.ex | 87 +++ .../live/user_reset_password_live.ex | 89 +++ lib/hello_phx_web/live/user_settings_live.ex | 167 ++++++ lib/hello_phx_web/plugs/locale.ex | 15 + lib/hello_phx_web/router.ex | 123 ++++- lib/hello_phx_web/user_auth.ex | 241 ++++++++ lib/mix/tasks/commit.ex | 13 + lib/mix/tasks/hello.task.ex | 17 + mix.exs | 49 +- mix.lock | 25 +- .../20240619023818_create_v1_users.exs | 14 + .../20240619025805_create_products.exs | 14 + .../20240619091410_create_categories.exs | 13 + ...240619101536_create_product_categories.exs | 13 + .../20240620025801_create_carts.exs | 13 + .../20240620025940_create_cart_items.exs | 18 + .../20240620082846_create_orders.exs | 12 + ...20240620083639_create_order_line_items.exs | 19 + .../migrations/20240620112853_create_urls.exs | 12 + ...0240621092628_create_users_auth_tables.exs | 29 + .../20240624101502_create_posts.exs | 12 + .../20240624111924_create_comments.exs | 14 + .../20240625011446_create_articles.exs | 12 + priv/repo/seeds.exs | 6 + priv/static/images/logo.svg.gz | Bin 0 -> 1613 bytes rel/env.sh.eex | 28 + rel/overlays/bin/migrate | 5 + rel/overlays/bin/migrate.bat | 1 + rel/overlays/bin/server | 5 + rel/overlays/bin/server.bat | 2 + rel/remote.vm.args.eex | 12 + rel/vm.args.eex | 12 + test/hello_phx/accounts_test.exs | 517 ++++++++++++++++++ test/hello_phx/blog/post_test.exs | 9 + test/hello_phx/blog_test.exs | 115 ++++ test/hello_phx/catalog_test.exs | 130 +++++ test/hello_phx/news_test.exs | 61 +++ test/hello_phx/orders_test.exs | 117 ++++ test/hello_phx/shopping_cart_test.exs | 120 ++++ test/hello_phx/urls_test.exs | 61 +++ .../controllers/article_controller_test.exs | 88 +++ .../controllers/comment_controller_test.exs | 84 +++ .../controllers/error_html_test.exs | 2 +- .../controllers/error_json_test.exs | 4 +- .../controllers/post_controller_test.exs | 84 +++ .../controllers/product_controller_test.exs | 90 +++ .../controllers/url_controller_test.exs | 88 +++ .../user_session_controller_test.exs | 113 ++++ ...er_confirmation_instructions_live_test.exs | 67 +++ .../live/user_confirmation_live_test.exs | 89 +++ .../live/user_forgot_password_live_test.exs | 63 +++ .../live/user_login_live_test.exs | 87 +++ .../live/user_registration_live_test.exs | 87 +++ .../live/user_reset_password_live_test.exs | 118 ++++ .../live/user_settings_live_test.exs | 210 +++++++ test/hello_phx_web/user_auth_test.exs | 295 ++++++++++ test/support/conn_case.ex | 26 + test/support/fixtures/accounts_fixtures.ex | 31 ++ test/support/fixtures/blog_fixtures.ex | 35 ++ test/support/fixtures/catalog_fixtures.ex | 42 ++ test/support/fixtures/news_fixtures.ex | 21 + test/support/fixtures/orders_fixtures.ex | 36 ++ .../fixtures/shopping_cart_fixtures.ex | 41 ++ test/support/fixtures/urls_fixtures.ex | 21 + test/test_helper.exs | 2 +- 165 files changed, 8596 insertions(+), 59 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/release.yml create mode 100644 .iex.exs create mode 100644 .tool-versions create mode 100644 Dockerfile create mode 100644 Taskfile-fly-io.yml create mode 100644 config/git_ops.exs create mode 100644 fly.toml create mode 100644 lib/git_commit.ex create mode 100644 lib/hello_phx/accounts.ex create mode 100644 lib/hello_phx/accounts/user.ex create mode 100644 lib/hello_phx/accounts/user_notifier.ex create mode 100644 lib/hello_phx/accounts/user_token.ex create mode 100644 lib/hello_phx/blog.ex create mode 100644 lib/hello_phx/blog/comment.ex create mode 100644 lib/hello_phx/blog/post.ex create mode 100644 lib/hello_phx/catalog.ex create mode 100644 lib/hello_phx/catalog/category.ex create mode 100644 lib/hello_phx/catalog/product.ex create mode 100644 lib/hello_phx/data.ex create mode 100644 lib/hello_phx/news.ex create mode 100644 lib/hello_phx/news/article.ex create mode 100644 lib/hello_phx/orders.ex create mode 100644 lib/hello_phx/orders/line_item.ex create mode 100644 lib/hello_phx/orders/order.ex create mode 100644 lib/hello_phx/release.ex create mode 100644 lib/hello_phx/shopping_cart.ex create mode 100644 lib/hello_phx/shopping_cart/cart.ex create mode 100644 lib/hello_phx/shopping_cart/cart_item.ex create mode 100644 lib/hello_phx/urls.ex create mode 100644 lib/hello_phx/urls/url.ex create mode 100644 lib/hello_phx/user_v1.ex create mode 100644 lib/hello_phx_web/controllers/api/user_controller.ex create mode 100644 lib/hello_phx_web/controllers/api_controller.ex create mode 100644 lib/hello_phx_web/controllers/article_controller.ex create mode 100644 lib/hello_phx_web/controllers/article_json.ex create mode 100644 lib/hello_phx_web/controllers/cart_controller.ex create mode 100644 lib/hello_phx_web/controllers/cart_html.ex create mode 100644 lib/hello_phx_web/controllers/cart_html/show.html.heex create mode 100644 lib/hello_phx_web/controllers/cart_item_controller.ex create mode 100644 lib/hello_phx_web/controllers/changeset_json.ex create mode 100644 lib/hello_phx_web/controllers/comment_controller.ex create mode 100644 lib/hello_phx_web/controllers/comment_html.ex create mode 100644 lib/hello_phx_web/controllers/comment_html/comment_form.html.heex create mode 100644 lib/hello_phx_web/controllers/comment_html/edit.html.heex create mode 100644 lib/hello_phx_web/controllers/comment_html/index.html.heex create mode 100644 lib/hello_phx_web/controllers/comment_html/new.html.heex create mode 100644 lib/hello_phx_web/controllers/comment_html/show.html.heex create mode 100644 lib/hello_phx_web/controllers/error_html/404.html.heex create mode 100644 lib/hello_phx_web/controllers/fallback_controller.ex create mode 100644 lib/hello_phx_web/controllers/hello_html/no_layout.html.heex create mode 100644 lib/hello_phx_web/controllers/order_controller.ex create mode 100644 lib/hello_phx_web/controllers/order_html.ex create mode 100644 lib/hello_phx_web/controllers/order_html/show.html.heex create mode 100644 lib/hello_phx_web/controllers/page_json.ex create mode 100644 lib/hello_phx_web/controllers/post_controller.ex create mode 100644 lib/hello_phx_web/controllers/post_html.ex create mode 100644 lib/hello_phx_web/controllers/post_html/edit.html.heex create mode 100644 lib/hello_phx_web/controllers/post_html/index.html.heex create mode 100644 lib/hello_phx_web/controllers/post_html/new.html.heex create mode 100644 lib/hello_phx_web/controllers/post_html/post_form.html.heex create mode 100644 lib/hello_phx_web/controllers/post_html/show.html.heex create mode 100644 lib/hello_phx_web/controllers/product_controller.ex create mode 100644 lib/hello_phx_web/controllers/product_html.ex create mode 100644 lib/hello_phx_web/controllers/product_html/edit.html.heex create mode 100644 lib/hello_phx_web/controllers/product_html/index.html.heex create mode 100644 lib/hello_phx_web/controllers/product_html/new.html.heex create mode 100644 lib/hello_phx_web/controllers/product_html/product_form.html.heex create mode 100644 lib/hello_phx_web/controllers/product_html/show.html.heex create mode 100644 lib/hello_phx_web/controllers/status_controller.ex create mode 100644 lib/hello_phx_web/controllers/url_controller.ex create mode 100644 lib/hello_phx_web/controllers/url_json.ex create mode 100644 lib/hello_phx_web/controllers/user_session_controller.ex create mode 100644 lib/hello_phx_web/errors.ex create mode 100644 lib/hello_phx_web/live/user_confirmation_instructions_live.ex create mode 100644 lib/hello_phx_web/live/user_confirmation_live.ex create mode 100644 lib/hello_phx_web/live/user_forgot_password_live.ex create mode 100644 lib/hello_phx_web/live/user_login_live.ex create mode 100644 lib/hello_phx_web/live/user_registration_live.ex create mode 100644 lib/hello_phx_web/live/user_reset_password_live.ex create mode 100644 lib/hello_phx_web/live/user_settings_live.ex create mode 100644 lib/hello_phx_web/plugs/locale.ex create mode 100644 lib/hello_phx_web/user_auth.ex create mode 100644 lib/mix/tasks/commit.ex create mode 100644 lib/mix/tasks/hello.task.ex create mode 100644 priv/repo/migrations/20240619023818_create_v1_users.exs create mode 100644 priv/repo/migrations/20240619025805_create_products.exs create mode 100644 priv/repo/migrations/20240619091410_create_categories.exs create mode 100644 priv/repo/migrations/20240619101536_create_product_categories.exs create mode 100644 priv/repo/migrations/20240620025801_create_carts.exs create mode 100644 priv/repo/migrations/20240620025940_create_cart_items.exs create mode 100644 priv/repo/migrations/20240620082846_create_orders.exs create mode 100644 priv/repo/migrations/20240620083639_create_order_line_items.exs create mode 100644 priv/repo/migrations/20240620112853_create_urls.exs create mode 100644 priv/repo/migrations/20240621092628_create_users_auth_tables.exs create mode 100644 priv/repo/migrations/20240624101502_create_posts.exs create mode 100644 priv/repo/migrations/20240624111924_create_comments.exs create mode 100644 priv/repo/migrations/20240625011446_create_articles.exs create mode 100644 priv/static/images/logo.svg.gz create mode 100644 rel/env.sh.eex create mode 100755 rel/overlays/bin/migrate create mode 100755 rel/overlays/bin/migrate.bat create mode 100755 rel/overlays/bin/server create mode 100755 rel/overlays/bin/server.bat create mode 100644 rel/remote.vm.args.eex create mode 100644 rel/vm.args.eex create mode 100644 test/hello_phx/accounts_test.exs create mode 100644 test/hello_phx/blog/post_test.exs create mode 100644 test/hello_phx/blog_test.exs create mode 100644 test/hello_phx/catalog_test.exs create mode 100644 test/hello_phx/news_test.exs create mode 100644 test/hello_phx/orders_test.exs create mode 100644 test/hello_phx/shopping_cart_test.exs create mode 100644 test/hello_phx/urls_test.exs create mode 100644 test/hello_phx_web/controllers/article_controller_test.exs create mode 100644 test/hello_phx_web/controllers/comment_controller_test.exs create mode 100644 test/hello_phx_web/controllers/post_controller_test.exs create mode 100644 test/hello_phx_web/controllers/product_controller_test.exs create mode 100644 test/hello_phx_web/controllers/url_controller_test.exs create mode 100644 test/hello_phx_web/controllers/user_session_controller_test.exs create mode 100644 test/hello_phx_web/live/user_confirmation_instructions_live_test.exs create mode 100644 test/hello_phx_web/live/user_confirmation_live_test.exs create mode 100644 test/hello_phx_web/live/user_forgot_password_live_test.exs create mode 100644 test/hello_phx_web/live/user_login_live_test.exs create mode 100644 test/hello_phx_web/live/user_registration_live_test.exs create mode 100644 test/hello_phx_web/live/user_reset_password_live_test.exs create mode 100644 test/hello_phx_web/live/user_settings_live_test.exs create mode 100644 test/hello_phx_web/user_auth_test.exs create mode 100644 test/support/fixtures/accounts_fixtures.ex create mode 100644 test/support/fixtures/blog_fixtures.ex create mode 100644 test/support/fixtures/catalog_fixtures.ex create mode 100644 test/support/fixtures/news_fixtures.ex create mode 100644 test/support/fixtures/orders_fixtures.ex create mode 100644 test/support/fixtures/shopping_cart_fixtures.ex create mode 100644 test/support/fixtures/urls_fixtures.ex diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5d30eba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# This file excludes paths from the Docker build context. +# +# By default, Docker's build context includes all files (and folders) in the +# current directory. Even if a file isn't copied into the container it is still sent to +# the Docker daemon. +# +# There are multiple reasons to exclude files from the build context: +# +# 1. Prevent nested folders from being copied into the container (ex: exclude +# /assets/node_modules when copying /assets) +# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) +# 3. Avoid sending files containing sensitive information +# +# More information on using .dockerignore is available here: +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +.dockerignore + +# Ignore git, but keep git HEAD and refs to access current commit hash if needed: +# +# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat +# d0b8727759e1e0e7aa3d41707d12376e373d5ecc +.git +!.git/HEAD +!.git/refs + +# Common development/test artifacts +/cover/ +/doc/ +/test/ +/tmp/ +.elixir_ls + +# Mix artifacts +/_build/ +/deps/ +*.ez + +# Generated on crash by the VM +erl_crash.dump + +# Static artifacts - These should be fetched and built inside the Docker image +/assets/node_modules/ +/priv/static/assets/ +/priv/static/cache_manifest.json + +.vscode +/local/ +.env* \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..34a82aa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: Elixir CI +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + + workflow_call: + +permissions: + contents: read + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + # https://github.com/erlef/setup-beam + uses: erlef/setup-beam@v1 + with: + version-file: .tool-versions + version-type: strict + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get --only test + + - name: Run tests + run: mix test diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..e267b9b --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,44 @@ +name: Docker Try +on: + push: + branches: + - docker + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + test-docker: + runs-on: ubuntu-latest + services: + api-server: + image: cao7113/hello-phx + ports: + - 4000:4000 + + # https://github.com/cao7113/hello-phx/pkgs/container/hello-phx + api-server2: + image: ghcr.io/cao7113/hello-phx + ports: + - 4002:4000 + + steps: + # - name: Checkout code + # uses: actions/checkout@v4 + + - name: Ping api-server + run: | + sleep 1 + echo Ping api-server + curl -sSL http://localhost:4000/api/ping + echo Ping api-server2 + curl -sSL http://localhost:4002/api/ping + + - name: Api build info + run: | + echo api-server build info: + curl -sSL http://localhost:4000/api | jq + echo api-server2 build info: + curl -sSL http://localhost:4002/api | jq diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9e24b59 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,193 @@ +name: Release Deployment +on: + push: + tags: + - "v*" + +permissions: + contents: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + check-ci: + name: Check CI + uses: ./.github/workflows/ci.yml + + # direct use ghcr or docker-hub image? + fly-deploy: + name: Deploy fly app + runs-on: ubuntu-latest + needs: check-ci + concurrency: deploy-group + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # https://github.com/superfly/flyctl-actions + # https://github.com/marketplace/actions/github-action-for-flyctl + - name: Setup flyctl + uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy app + run: flyctl deploy --remote-only --build-arg GIT_COMMIT_ID=$(git log -1 --format="%H") --build-arg GIT_COMMIT_TIME=$(git log -1 --format="%ct") + env: + # https://github.com/superfly/flyctl-actions?tab=readme-ov-file#secrets + # https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/#speed-run-your-way-to-continuous-deployment + # gen cmd: fly tokens create deploy -x 999999h + # set in Github Settings -> Secrets -> Actions -> Repository secrets + # https://github.com/cao7113/hello-phx/settings/secrets/actions + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + push-images: + name: Push Docker image to Docker Hub and Github Packages + runs-on: ubuntu-latest + needs: check-ci + permissions: + packages: write + contents: read + attestations: write + id-token: write + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Set Commit Env from git command + run: | + echo GIT_COMMIT_TIME=$(git log -1 --format="%ct") >> $GITHUB_ENV + echo GIT_COMMIT_ID=$(git log -1 --format="%H") >> $GITHUB_ENV + - name: Inspect Commit Env + run: | + echo GIT_COMMIT_ID=$GIT_COMMIT_ID vs ${{ env.GIT_COMMIT_ID }} in env context + echo GIT_COMMIT_TIME=$GIT_COMMIT_TIME vs ${{ env.GIT_COMMIT_TIME }} in env context + + # https://github.com/docker/login-action + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # https://github.com/docker/metadata-action + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + cao7113/hello-phx + ghcr.io/${{ github.repository }} + + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # https://github.com/docker/build-push-action?tab=readme-ov-file#customizing + # GIT_COMMIT_TIME=$(git log -1 --format="%ct") + build-args: | + GIT_COMMIT_ID=${{ github.sha }} + GIT_COMMIT_TIME=${{ env.GIT_COMMIT_TIME }} + + # https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-docker-hub + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + test-docker-images: + name: Test latest docker images + needs: push-images + uses: ./.github/workflows/docker.yml + + release-tar: + # strategy: + # matrix: + # # erlef/setup-beam only supports Ubuntu and Windows at this time + # os: [ubuntu-latest] + # otp: [27.x] + # elixir: [1.17.x] + name: Publish mix release tar + runs-on: ubuntu-latest + needs: check-ci + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + version-file: .tool-versions + version-type: strict + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get --only prod + + - name: Make mix release + run: | + export GIT_COMMIT_ID=${{ github.sha }} + export GIT_COMMIT_TIME=$(git log -1 --format="%ct") + MIX_ENV=prod RELEASE_TAR=1 mix release + + # https://github.com/softprops/action-gh-release + - name: Make Github release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + # todo: add runner.os & runner.arch info in final tar filename + with: + files: _build/prod/*.tar.gz + + # ssh-deploy: + # name: Deoloy to remote server by ssh + # runs-on: ubuntu-latest + # needs: release-tar + # steps: + # # https://github.com/appleboy/ssh-action + # # https://docs.github.com/en/actions/learn-github-actions/contexts#github-context + # - name: executing remote ssh commands + # uses: appleboy/ssh-action@v0.1.8 + # with: + # host: ${{ secrets.REMOTE_HOST }} + # username: ${{ secrets.REMOTE_USERNAME }} + # key: ${{ secrets.REMOTE_PRIVKEY }} + # port: 22 + # script: | + # vsn=${{ github.ref_name }} + # git_repo=${{ github.repository }} + + # app_root=~/hello-phx + # script_path=${app_root}/deploy.sh + + # mkdir -p $app_root + # script_url="https://raw.githubusercontent.com/${git_repo}/main/run/deploy.sh" + # if [ ! -e $script_path ]; then + # wget -q -O $script_path $script_url + # chmod +x $script_path + # echo "Setup deploy-script into ${script_path} from ${script_url}" + # else + # echo "Use existed deploy-script: ${script_path}, manually delete it when need update" + # fi + + # $script_path $vsn bin/hello_api ${git_repo} 3 diff --git a/.gitignore b/.gitignore index 71fc3f6..6473f9c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,12 @@ hello_phx-*.tar npm-debug.log /assets/node_modules/ +.vscode +.elixir_ls + +.env +.env* +/priv/static/favicon-* +/priv/static/images/logo-* +/priv/static/robots-* +/priv/static/*.gz \ No newline at end of file diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 0000000..74960e1 --- /dev/null +++ b/.iex.exs @@ -0,0 +1,21 @@ +# IO.puts("project .iex.exs loaded") + +# helper aliases +alias HelloPhx, as: H +alias HelloPhx.Repo +alias HelloPhx.Data, as: D +alias HelloPhxWeb, as: Hw + +alias HelloPhx.Catalog +alias HelloPhx.Catalog.Product +alias HelloPhx.ShoppingCart.Cart +alias HelloPhx.Orders +alias HelloPhx.Orders.Order + +alias HelloPhx.Urls.Url + +alias HelloPhx.Accounts +alias HelloPhx.Accounts.User + +# handy functions +import Ecto.Query diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..6fe0c3d --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.17.1-otp-27 +erlang 27.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a307aa5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,116 @@ +# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian +# instead of Alpine to avoid DNS resolution issues in production. +# +# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu +# https://hub.docker.com/_/ubuntu?tab=tags +# +# This file is based on these images: +# +# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image +# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240612-slim - for the release image +# - https://pkgs.org/ - resource for finding needed packages +# - Ex: hexpm/elixir:1.17.1-erlang-27.0-debian-bullseye-20240612-slim +# +# hexpm/elixir image build from https://github.com/hexpm/bob#docker-images + +ARG ELIXIR_VERSION=1.17.1 +ARG OTP_VERSION=27.0 +ARG DEBIAN_VERSION=bullseye-20240612-slim +ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" + +FROM ${BUILDER_IMAGE} as builder + +# install build dependencies +RUN apt-get update -y && apt-get install -y build-essential git \ + && apt-get clean && rm -f /var/lib/apt/lists/*_* + +# prepare build dir +WORKDIR /app + +RUN echo GIT_COMMIT_INFO=${GIT_COMMIT_INFO} > commit_info +RUN echo GIT_COMMIT_INFO1=${GIT_COMMIT_INFO1} + +# install hex + rebar +RUN mix local.hex --force && \ + mix local.rebar --force + +# set build ENV +ENV MIX_ENV="prod" +# import commit-info using build arg then use as default env, used in config/config.exs +ARG GIT_COMMIT_ID="" +ENV GIT_COMMIT_ID=$GIT_COMMIT_ID +ARG GIT_COMMIT_TIME="" +ENV GIT_COMMIT_TIME=$GIT_COMMIT_TIME + +# install mix dependencies +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +# copy compile-time config files before we compile dependencies +# to ensure any relevant config change will trigger the dependencies +# to be re-compiled. +# todo: improve me!!! +# COPY config/config.exs config/${MIX_ENV}.exs config/ +COPY config config +RUN mix deps.compile + +COPY priv priv +COPY lib lib +COPY assets assets +# compile assets +RUN mix assets.deploy + +# Compile the release +RUN mix compile + +# Changes to config/runtime.exs don't require recompiling the code +COPY config/runtime.exs config/ + +COPY rel rel +RUN mix release + +# start a new build stage so that the final image will only contain +# the compiled release and other runtime necessities +FROM ${RUNNER_IMAGE} + +ENV GIT_COMMIT_INFO=${GIT_COMMIT_INFO} +ENV GIT_COMMIT_INFO1=${GIT_COMMIT_INFO1} + +RUN apt-get update -y && \ + apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ + && apt-get clean && rm -f /var/lib/apt/lists/*_* + +# Set the locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen + +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + +WORKDIR "/app" +RUN chown nobody /app +RUN echo GIT_COMMIT_INFO="$GIT_COMMIT_INFO" > commit_info +COPY --from=builder --chown=nobody:root /app/commit_info0 ./ + +# set runner ENV +ENV MIX_ENV="prod" +# Appended by flyctl https://fly.io/docs/elixir/getting-started/#important-ipv6-settings +ENV ECTO_IPV6 true +ENV ERL_AFLAGS "-proto_dist inet6_tcp" + +# Only copy the final release from the build stage +COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/hello_phx ./ + +USER nobody + +# iex helpers, Note: bin/server script set cwd to bin/ +COPY .iex.exs _build/${MIX_ENV}/rel/hello_phx/bin + +# If using an environment that doesn't automatically reap zombie processes, it is +# advised to add an init process such as tini via `apt-get install` +# above and adding an entrypoint. See https://github.com/krallin/tini for details +# ENTRYPOINT ["/tini", "--"] + +CMD ["/app/bin/server"] diff --git a/README.md b/README.md index 2bd0c73..8deb06d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,20 @@ -# HelloPhx +# HelloPhx - Hello Phoenix + +[![CI](https://github.com/cao7113/hello-phx/actions/workflows/ci.yml/badge.svg)](https://github.com/cao7113/hello-phx/actions/workflows/ci.yml) +[![Release](https://github.com/cao7113/hello-phx/actions/workflows/release.yml/badge.svg)](https://github.com/cao7113/hello-phx/actions/workflows/release.yml) + +Acts as learning playground following guides at https://hexdocs.pm/phoenix/overview.html. + +## Flying on fly.io + +https://hello-phx-up.fly.dev/ + +## Getting Started To start your Phoenix server: - * Run `mix setup` to install and setup dependencies - * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` +- Run `mix setup` to install and setup dependencies +- Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. @@ -11,8 +22,8 @@ Ready to run in production? Please [check our deployment guides](https://hexdocs ## Learn more - * Official website: https://www.phoenixframework.org/ - * Guides: https://hexdocs.pm/phoenix/overview.html - * Docs: https://hexdocs.pm/phoenix - * Forum: https://elixirforum.com/c/phoenix-forum - * Source: https://github.com/phoenixframework/phoenix +- Official website: https://www.phoenixframework.org/ +- Guides: https://hexdocs.pm/phoenix/overview.html +- Docs: https://hexdocs.pm/phoenix +- Forum: https://elixirforum.com/c/phoenix-forum +- Source: https://github.com/phoenixframework/phoenix diff --git a/Taskfile-fly-io.yml b/Taskfile-fly-io.yml new file mode 100644 index 0000000..64eb990 --- /dev/null +++ b/Taskfile-fly-io.yml @@ -0,0 +1,42 @@ +# https://taskfile.dev +version: "3" + +## Fly.io https://hexdocs.pm/phoenix/fly.html +# https://fly.io/docs/elixir/getting-started/ +vars: + APP: hello_phx + FLY_APP_NAME: hello-phx-up + FLY_DB_APP_NAME: hello-phx-up + +tasks: + default: echo fly on app {{.FLY_APP_NAME}} + + # IEx require mem: 512m+ on fly.io + sh: fly ssh console --pty -C "/app/bin/{{.APP}} remote" + ssh: fly ssh console + st: fly status + # deploy new app version + up: fly deploy --build-arg GIT_COMMIT_ID=$(git log -1 --format="%H") --build-arg GIT_COMMIT_TIME=$(git log -1 --format="%ct") + reup: fly app restart + log: fly logs + open: fly open + # create new app and fly.toml + launch: fly launch --debug --verbose # --now + vm: fly machine ls + ls: fly app ls + db: fly postgres connect -a {{.FLY_DB_APP_NAME}} + # plain env and secrets + env: fly config env + sec: fly secrets ls + show: fly scale show + # fly scale count 2 + # sets up a root certificate for your account and then issues a certificate. + ssh-agent: fly ssh issue --agent + # Opening https://fly.io/apps/hello-phx-up + board: fly dashboard + # Add a dedicated ipv4 with: fly ips allocate-v4 + + # https://fly.io/docs/flyctl/install/ + install: brew install flyctl + # fly auth login + # fly help diff --git a/Taskfile.yml b/Taskfile.yml index f2afc51..43feadc 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,6 +1,138 @@ # https://taskfile.dev version: "3" +vars: + HOST: "http://localhost:4000" +# dotenv: [".envrc"] + +includes: + fly-io: + taskfile: ./Taskfile-fly-io.yml + aliases: [f, fly] + optional: true + tasks: default: mix test sh: iex --erl "-kernel shell_history enabled" -S mix - run: mix phx.server + # iex with test support in test env + tsh: MIX_ENV=test task sh + run: iex --erl "-kernel shell_history enabled" -S mix phx.server + open: open {{.HOST}} + board: open {{.HOST}}/dev/dashboard/home + # json format request: http://localhost:4000/?_format=json + # unknonw page: http://localhost:4000/unknown-page/to/404 + deps: mix do deps.get + deps.compile + + allow-env: + run: once + cmds: + - direnv allow + sources: + - .envrc + + ## local prod deployment https://hexdocs.pm/phoenix/deployment.html + lp: + cmds: + - | + source .env.prod + MIX_ENV=prod mix phx.server + # with iex + # MIX_ENV=prod iex -S mix phx.server + # in daemon mode + # MIX_ENV=prod elixir --erl "-detached" -S mix phx.server + lp-sh: + cmds: + - | + source .env.prod + MIX_ENV=prod iex -S mix + + lp-build: + desc: build local prod release + cmds: + - | + mix deps.get --only prod + MIX_ENV=prod mix compile + MIX_ENV=prod mix assets.deploy + + lp-db-setup: + cmds: + - | + source .env.prod + MIX_ENV=prod mix ecto.setup + + lp-mig: + cmds: + - | + source .env.prod + MIX_ENV=prod mix ecto.migrate + + ## Release mode https://hexdocs.pm/phoenix/releases.html + psh: task pbin -- remote + prun: task pbin -- start_iex + pbin: + desc: "run by: task pbin -- start" + cmds: + - | + source .env.prod + _build/prod/rel/hello_phx/bin/hello_phx {{.CLI_ARGS}} + prel: + deps: + - lp-build + cmds: + - | + MIX_ENV=prod mix release --overwrite + + pdb: pgcli hello_phx_prod + pmig: + cmds: + - | + source .env.prod + _build/prod/rel/hello_phx/bin/migrate + + ## Docker build with orbstack + dk-build: docker build -t hello_phx . + + ## Render has first class support for Phoenix applications. + + ## DB Setup + # db: sudo -u postgres pgcli hello_phx_dev + sql: pgcli hello_phx_dev + + mig: mix ecto.migrate + db-redo: + cmds: + - mix ecto.rollback --step 1 + - mix ecto.migrate --step 1 + + install-pg: + desc: install pg + sources: + - which psql + cmds: + - brew install postgresql + + init-pg-user: + desc: create init dev user + cmds: + - echo use postgres as default password + # create postgres role sql: CREATE ROLE postgres LOGIN CREATEDB; + - createuser postgres -s -P + + install-pgcli: + desc: install pgcli + sources: + - which pgcli + cmds: + - brew install pgcli + + ## Setup phx env + + setup: mix setup + + # https://hexdocs.pm/phoenix/installation.html + # for elixir and erlang install ref https://github.com/cao7113/hello-elixir/blob/main/Taskfile.yml#L27 + + # install phx app generator + install-phx-new: mix archive.install hex phx_new --force + # mix help phx.new + + mk-hello: mix phx.new hello-phx --module HelloPhx --app hello_phx --verbose --install diff --git a/config/config.exs b/config/config.exs index 15507fa..9aa7e1e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -11,6 +11,15 @@ config :hello_phx, ecto_repos: [HelloPhx.Repo], generators: [timestamp_type: :utc_datetime] +config :hello_phx, + build_mode: config_env(), + build_time: DateTime.utc_now(), + source_url: Mix.Project.config()[:source_url], + commit: %{ + commit_id: System.get_env("GIT_COMMIT_ID", ""), + commit_time: System.get_env("GIT_COMMIT_TIME", "") + } + # Configures the endpoint config :hello_phx, HelloPhxWeb.Endpoint, url: [host: "localhost"], @@ -61,6 +70,10 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +## deps +config :endon, + repo: HelloPhx.Repo + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index f8a7cc8..c9666c1 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -83,3 +83,5 @@ config :phoenix_live_view, # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false + +import_config("git_ops.exs") diff --git a/config/git_ops.exs b/config/git_ops.exs new file mode 100644 index 0000000..9b9cabd --- /dev/null +++ b/config/git_ops.exs @@ -0,0 +1,33 @@ +import Config + +# https://github.com/zachdaniel/git_ops?tab=readme-ov-file#configuration +config :git_ops, + mix_project: Mix.Project.get!(), + changelog_file: "CHANGELOG.md", + repository_url: Mix.Project.config()[:source_url], + types: [ + # Makes an allowed commit type called `tidbit` that is not + # shown in the changelog + tidbit: [ + hidden?: true + ], + # Makes an allowed commit type called `important` that gets + # a section in the changelog with the header "Important Changes" + important: [ + header: "Important Changes" + ] + ], + tags: [ + # Only add commits to the changelog that has the "backend" tag + allowed: ["backend"], + # Filter out or not commits that don't contain tags + allow_untagged?: true + ], + # Instructs the tool to manage your mix version in your `mix.exs` file + # See below for more information + manage_mix_version?: true, + # Instructs the tool to manage the version in your README.md + # Pass in `true` to use `"README.md"` or a string to customize + # manage_readme_version: "README.md", + # manage_readme_version: nil, + version_tag_prefix: "v" diff --git a/config/runtime.exs b/config/runtime.exs index 4d9ba84..d9d64e5 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -54,7 +54,8 @@ if config_env() == :prod do config :hello_phx, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :hello_phx, HelloPhxWeb.Endpoint, - url: [host: host, port: 443, scheme: "https"], + # url: [host: host, port: 443, scheme: "https"], + url: [host: host, port: port, scheme: "http"], http: [ # Enable IPv6 and bind on all interfaces. # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. @@ -65,6 +66,24 @@ if config_env() == :prod do ], secret_key_base: secret_key_base + # app_name = + # System.get_env("FLY_APP_NAME") || + # raise "FLY_APP_NAME not available" + if app_name = + System.get_env("FLY_APP_NAME") do + config :libcluster, + topologies: [ + fly6pn: [ + strategy: Cluster.Strategy.DNSPoll, + config: [ + polling_interval: 5_000, + query: "#{app_name}.internal", + node_basename: app_name + ] + ] + ] + end + # ## SSL Support # # To get SSL working, you will need to add the `https` key diff --git a/config/test.exs b/config/test.exs index da4d420..41d2af0 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,14 +1,17 @@ import Config +# Only in tests, remove the complexity from the password hashing algorithm +config :bcrypt_elixir, :log_rounds, 1 + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used # to provide built-in test partitioning in CI environment. # Run `mix help test` for more information. config :hello_phx, HelloPhx.Repo, - username: "postgres", - password: "postgres", - hostname: "localhost", + username: System.get_env("DB_USER") || "postgres", + password: System.get_env("DB_PASSWORD") || "postgres", + hostname: System.get_env("DB_HOST") || "localhost", database: "hello_phx_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, pool_size: System.schedulers_online() * 2 diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..c9fc3e2 --- /dev/null +++ b/fly.toml @@ -0,0 +1,34 @@ +# fly.toml app configuration file generated for hello-phx-up on 2024-06-26T16:48:26+08:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'hello-phx-up' +primary_region = 'nrt' +kill_signal = 'SIGTERM' + +[build] + +[deploy] + release_command = '/app/bin/migrate' + +[env] + PHX_HOST = 'hello-phx-up.fly.dev' + PORT = '8080' + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + + [http_service.concurrency] + type = 'connections' + hard_limit = 1000 + soft_limit = 1000 + +[[vm]] + size = 'shared-cpu-1x' + memory = "512mb" diff --git a/lib/git_commit.ex b/lib/git_commit.ex new file mode 100644 index 0000000..62c066a --- /dev/null +++ b/lib/git_commit.ex @@ -0,0 +1,90 @@ +defmodule GitCommit do + @moduledoc """ + Get commit info from git command or .git/refs/heads/main file + + git log -1 --format="%H %ct" + git log -1 origin/main --format="%H %cd" --date=local + """ + + def latest do + [ + &from_env/0, + &from_git_cmd/0, + # &from_head_file/0, + &from_not_support/0 + ] + |> Enum.find_value(fn e -> e.() end) + |> commit_info + end + + # GIT_COMMIT_INFO="ca9178adfd867375b2eef73c288066c3fcc1bfb0 1719629897" + def from_env do + info = System.get_env("GIT_COMMIT_INFO") + + if info not in [nil, ""] do + do_parse_cmd_or_env(info, :env) + end + end + + def from_git_cmd() do + try do + do_git_log_query() + |> do_parse_cmd_or_env(:git_cmd) + rescue + _ -> false + end + end + + def do_parse_cmd_or_env(info, src) do + # "ca9178adfd867375b2eef73c288066c3fcc1bfb0 1719629897\n" + [id, tm_str] = + info + |> String.trim() + |> String.split() + + {id, + tm_str + |> String.to_integer() + |> DateTime.from_unix!(), src} + end + + def do_git_log_query, + do: + System.cmd("git", ["log", "-1", "--format=%H %ct"], stderr_to_stdout: true) + |> elem(0) + + @git_branch "main" + @head_file ".git/refs/heads/#{@git_branch}" + + def from_head_file(head_file \\ @head_file) do + if File.exists?(head_file) do + { + head_file |> File.read!() |> String.trim(), + File.stat!(head_file).mtime + |> NaiveDateTime.from_erl!() + |> DateTime.from_naive!("Etc/UTC"), + :head_file + } + end + end + + def from_not_support do + { + String.duplicate("0", 40), + DateTime.utc_now(), + :not_support + } + end + + def commit_info({<> = commit_id, tm, src}) do + version = "#{Calendar.strftime(tm, "%Y%m%d%H%M%S")}-#{short_id}" + + %{ + id: commit_id, + short_id: short_id, + time: tm, + version: version, + source: src + } + end +end diff --git a/lib/hello_phx.ex b/lib/hello_phx.ex index a855ead..914f450 100644 --- a/lib/hello_phx.ex +++ b/lib/hello_phx.ex @@ -6,4 +6,45 @@ defmodule HelloPhx do Contexts are also responsible for managing your data, regardless if it comes from the database, an external API or others. """ + + # @app :hello_phx + # def app, do: @app + def app, do: Application.get_application(__MODULE__) + def vsn, do: Application.spec(app(), :vsn) |> to_string() + def source_url, do: Application.get_env(app(), :source_url) + + ## Build Info + + def build_info do + %{ + app: app(), + version: vsn(), + source_url: source_url(), + build_mode: build_mode(), + build_time: build_time(), + system: System.build_info(), + commit: commit() + } + end + + def build_mode, do: Application.get_env(app(), :build_mode) + def build_time, do: Application.get_env(app(), :build_time) |> to_string + + # put into standalone hex pkg? + def commit do + %{ + commit_id: Application.get_env(app(), :commit_id, "") |> String.trim(), + commit_time: Application.get_env(app(), :commit_time, "") |> parse_commit_time + } + end + + def parse_commit_time(""), do: nil + + def parse_commit_time(tm_str), + do: tm_str |> String.trim() |> String.to_integer() |> DateTime.from_unix!() |> to_string() + + ## Helper Info + + def all_env, do: Application.get_all_env(app()) + def priv_dir, do: :code.priv_dir(app()) |> to_string() end diff --git a/lib/hello_phx/accounts.ex b/lib/hello_phx/accounts.ex new file mode 100644 index 0000000..c5451f7 --- /dev/null +++ b/lib/hello_phx/accounts.ex @@ -0,0 +1,379 @@ +defmodule HelloPhx.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias HelloPhx.Repo + + alias HelloPhx.Accounts.{User, UserToken, UserNotifier} + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs, hash_password: false, validate_email: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}) do + User.email_changeset(user, attrs, validate_email: false) + end + + @doc """ + Emulates that the email will change without actually changing + it in the database. + + ## Examples + + iex> apply_user_email(user, "valid password", %{email: ...}) + {:ok, %User{}} + + iex> apply_user_email(user, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_user_email(user, password, attrs) do + user + |> User.email_changeset(attrs) + |> User.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do + :ok + else + _ -> :error + end + end + + defp user_email_multi(user, email, context) do + changeset = + user + |> User.email_changeset(%{email: email}) + |> User.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context])) + end + + @doc ~S""" + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}) do + User.password_changeset(user, attrs, hash_password: false) + end + + @doc """ + Updates the user password. + + ## Examples + + iex> update_user_password(user, "valid password", %{password: ...}) + {:ok, %User{}} + + iex> update_user_password(user, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, password, attrs) do + changeset = + user + |> User.password_changeset(attrs) + |> User.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(UserToken.by_token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc ~S""" + Delivers the confirmation email instructions to the given user. + + ## Examples + + iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) + {:ok, %{to: ..., body: ...}} + + iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) + {:error, :already_confirmed} + + """ + def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if user.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a user by the given token. + + If the token matches, the user account is marked as confirmed + and the token is deleted. + """ + def confirm_user(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), + %User{} = user <- Repo.one(query), + {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do + {:ok, user} + else + _ -> :error + end + end + + defp confirm_user_multi(user) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"])) + end + + ## Reset password + + @doc ~S""" + Delivers the reset password email to the given user. + + ## Examples + + iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") + Repo.insert!(user_token) + UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the user by reset password token. + + ## Examples + + iex> get_user_by_reset_password_token("validtoken") + %User{} + + iex> get_user_by_reset_password_token("invalidtoken") + nil + + """ + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), + %User{} = user <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Resets the user password. + + ## Examples + + iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %User{}} + + iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_user_password(user, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + ## API + + @doc """ + Creates a new api token for a user. + + The token returned must be saved somewhere safe. + This token cannot be recovered from the database. + """ + def create_user_api_token(user) do + {encoded_token, user_token} = UserToken.build_email_token(user, "api-token") + Repo.insert!(user_token) + encoded_token + end + + @doc """ + Fetches the user by API token. + """ + def fetch_user_by_api_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "api-token"), + %User{} = user <- Repo.one(query) do + {:ok, user} + else + _ -> :error + end + end +end diff --git a/lib/hello_phx/accounts/user.ex b/lib/hello_phx/accounts/user.ex new file mode 100644 index 0000000..6ca4c74 --- /dev/null +++ b/lib/hello_phx/accounts/user.ex @@ -0,0 +1,162 @@ +defmodule HelloPhx.Accounts.User do + use Ecto.Schema + use Endon + import Ecto.Changeset + + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :current_password, :string, virtual: true, redact: true + field :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registration. + + It is important to validate the length of both email and password. + Otherwise databases may truncate the email without warnings, which + could lead to unpredictable or insecure behaviour. Long passwords may + also be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + + * `:validate_email` - Validates the uniqueness of the email, in case + you don't want to validate the uniqueness of the email (like when + using this changeset for validations on a LiveView form before + submitting the form), this option can be set to `false`. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email, :password]) + |> validate_email(opts) + |> validate_password(opts) + end + + defp validate_email(changeset, opts) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> maybe_validate_unique_email(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + defp maybe_validate_unique_email(changeset, opts) do + if Keyword.get(opts, :validate_email, true) do + changeset + |> unsafe_validate_unique(:email, HelloPhx.Repo) + |> unique_constraint(:email) + else + changeset + end + end + + @doc """ + A user changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email(opts) + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A user changeset for changing the password. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%HelloPhx.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + @doc """ + Validates the current password otherwise adds an error to the changeset. + """ + def validate_current_password(changeset, password) do + changeset = cast(changeset, %{current_password: password}, [:current_password]) + + if valid_password?(changeset.data, password) do + changeset + else + add_error(changeset, :current_password, "is not valid") + end + end +end diff --git a/lib/hello_phx/accounts/user_notifier.ex b/lib/hello_phx/accounts/user_notifier.ex new file mode 100644 index 0000000..7b93045 --- /dev/null +++ b/lib/hello_phx/accounts/user_notifier.ex @@ -0,0 +1,79 @@ +defmodule HelloPhx.Accounts.UserNotifier do + import Swoosh.Email + + alias HelloPhx.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"HelloPhx", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to confirm account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to reset a user password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset password instructions", """ + + ============================== + + Hi #{user.email}, + + You can reset your password by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end +end diff --git a/lib/hello_phx/accounts/user_token.ex b/lib/hello_phx/accounts/user_token.ex new file mode 100644 index 0000000..1f5d287 --- /dev/null +++ b/lib/hello_phx/accounts/user_token.ex @@ -0,0 +1,181 @@ +defmodule HelloPhx.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias HelloPhx.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the reset password token expiry short, + # since someone with access to the email may take over the account. + @reset_password_validity_in_days 1 + @confirm_validity_in_days 7 + @change_email_validity_in_days 7 + @session_validity_in_days 60 + @api_token_validity_in_days 365 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, HelloPhx.Accounts.User + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix' default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %UserToken{token: token, context: "session", user_id: user.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in by_token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: user + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The given token is valid if it matches its hashed counterpart in the + database and the user email has not changed. This function also checks + if the token is being used within a certain period, depending on the + context. The default contexts supported by this function are either + "confirm", for account confirmation emails, and "reset_password", + for resetting the password. For verifying requests to change the email, + see `verify_change_email_token_query/2`. + """ + def verify_email_token_query(token, context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + days = days_for_context(context) + + query = + from token in by_token_and_context_query(hashed_token, context), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, + select: user + + {:ok, query} + + :error -> + :error + end + end + + defp days_for_context("confirm"), do: @confirm_validity_in_days + defp days_for_context("reset_password"), do: @reset_password_validity_in_days + defp days_for_context("api-token"), do: @api_token_validity_in_days + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + This is used to validate requests to change the user + email. It is different from `verify_email_token_query/2` precisely because + `verify_email_token_query/2` validates the email has not changed, which is + the starting point by this function. + + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Returns the token struct for the given token value and context. + """ + def by_token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def by_user_and_contexts_query(user, :all) do + from t in UserToken, where: t.user_id == ^user.id + end + + def by_user_and_contexts_query(user, [_ | _] = contexts) do + from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end +end diff --git a/lib/hello_phx/application.ex b/lib/hello_phx/application.ex index 8632653..bba929b 100644 --- a/lib/hello_phx/application.ex +++ b/lib/hello_phx/application.ex @@ -7,6 +7,8 @@ defmodule HelloPhx.Application do @impl true def start(_type, _args) do + topologies = Application.get_env(:libcluster, :topologies) || [] + children = [ HelloPhxWeb.Telemetry, HelloPhx.Repo, @@ -14,6 +16,8 @@ defmodule HelloPhx.Application do {Phoenix.PubSub, name: HelloPhx.PubSub}, # Start the Finch HTTP client for sending emails {Finch, name: HelloPhx.Finch}, + # setup for clustering + {Cluster.Supervisor, [topologies, [name: HelloPhx.ClusterSupervisor]]}, # Start a worker by calling: HelloPhx.Worker.start_link(arg) # {HelloPhx.Worker, arg}, # Start to serve requests, typically the last entry diff --git a/lib/hello_phx/blog.ex b/lib/hello_phx/blog.ex new file mode 100644 index 0000000..8b80aa8 --- /dev/null +++ b/lib/hello_phx/blog.ex @@ -0,0 +1,200 @@ +defmodule HelloPhx.Blog do + @moduledoc """ + The Blog context. + """ + + import Ecto.Query, warn: false + alias HelloPhx.Repo + + alias HelloPhx.Blog.Post + + @doc """ + Returns the list of posts. + + ## Examples + + iex> list_posts() + [%Post{}, ...] + + """ + def list_posts do + Repo.all(Post) + end + + @doc """ + Gets a single post. + + Raises `Ecto.NoResultsError` if the Post does not exist. + + ## Examples + + iex> get_post!(123) + %Post{} + + iex> get_post!(456) + ** (Ecto.NoResultsError) + + """ + def get_post!(id), do: Repo.get!(Post, id) + + @doc """ + Creates a post. + + ## Examples + + iex> create_post(%{field: value}) + {:ok, %Post{}} + + iex> create_post(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_post(attrs \\ %{}) do + %Post{} + |> Post.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a post. + + ## Examples + + iex> update_post(post, %{field: new_value}) + {:ok, %Post{}} + + iex> update_post(post, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_post(%Post{} = post, attrs) do + post + |> Post.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a post. + + ## Examples + + iex> delete_post(post) + {:ok, %Post{}} + + iex> delete_post(post) + {:error, %Ecto.Changeset{}} + + """ + def delete_post(%Post{} = post) do + Repo.delete(post) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking post changes. + + ## Examples + + iex> change_post(post) + %Ecto.Changeset{data: %Post{}} + + """ + def change_post(%Post{} = post, attrs \\ %{}) do + Post.changeset(post, attrs) + end + + alias HelloPhx.Blog.Comment + + @doc """ + Returns the list of comments. + + ## Examples + + iex> list_comments() + [%Comment{}, ...] + + """ + def list_comments do + Repo.all(Comment) + end + + @doc """ + Gets a single comment. + + Raises `Ecto.NoResultsError` if the Comment does not exist. + + ## Examples + + iex> get_comment!(123) + %Comment{} + + iex> get_comment!(456) + ** (Ecto.NoResultsError) + + """ + def get_comment!(id), do: Repo.get!(Comment, id) + + @doc """ + Creates a comment. + + ## Examples + + iex> create_comment(%{field: value}) + {:ok, %Comment{}} + + iex> create_comment(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_comment(attrs \\ %{}) do + %Comment{} + |> Comment.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a comment. + + ## Examples + + iex> update_comment(comment, %{field: new_value}) + {:ok, %Comment{}} + + iex> update_comment(comment, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_comment(%Comment{} = comment, attrs) do + comment + |> Comment.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a comment. + + ## Examples + + iex> delete_comment(comment) + {:ok, %Comment{}} + + iex> delete_comment(comment) + {:error, %Ecto.Changeset{}} + + """ + def delete_comment(%Comment{} = comment) do + Repo.delete(comment) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking comment changes. + + ## Examples + + iex> change_comment(comment) + %Ecto.Changeset{data: %Comment{}} + + """ + def change_comment(%Comment{} = comment, attrs \\ %{}) do + Comment.changeset(comment, attrs) + end +end diff --git a/lib/hello_phx/blog/comment.ex b/lib/hello_phx/blog/comment.ex new file mode 100644 index 0000000..23f4d83 --- /dev/null +++ b/lib/hello_phx/blog/comment.ex @@ -0,0 +1,18 @@ +defmodule HelloPhx.Blog.Comment do + use Ecto.Schema + import Ecto.Changeset + + schema "comments" do + field :body, :string + field :post_id, :id + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(comment, attrs) do + comment + |> cast(attrs, [:body]) + |> validate_required([:body]) + end +end diff --git a/lib/hello_phx/blog/post.ex b/lib/hello_phx/blog/post.ex new file mode 100644 index 0000000..90eb331 --- /dev/null +++ b/lib/hello_phx/blog/post.ex @@ -0,0 +1,19 @@ +defmodule HelloPhx.Blog.Post do + use Ecto.Schema + import Ecto.Changeset + + schema "posts" do + field :title, :string + field :body, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(post, attrs) do + post + |> cast(attrs, [:title, :body]) + |> validate_required([:title, :body]) + |> validate_length(:title, min: 2) + end +end diff --git a/lib/hello_phx/catalog.ex b/lib/hello_phx/catalog.ex new file mode 100644 index 0000000..d0f9fe8 --- /dev/null +++ b/lib/hello_phx/catalog.ex @@ -0,0 +1,236 @@ +defmodule HelloPhx.Catalog do + @moduledoc """ + The Catalog context. + """ + + import Ecto.Query, warn: false + alias HelloPhx.Repo + + alias HelloPhx.Catalog.Product + alias HelloPhx.Catalog.Category + + @doc """ + Returns the list of products. + + ## Examples + + iex> list_products() + [%Product{}, ...] + + """ + def list_products do + Product + |> preload(:categories) + |> Repo.all() + + # Repo.all(Product) + end + + @doc """ + Gets a single product. + + Raises `Ecto.NoResultsError` if the Product does not exist. + + ## Examples + + iex> get_product!(123) + %Product{} + + iex> get_product!(456) + ** (Ecto.NoResultsError) + + """ + # def get_product!(id), do: Repo.get!(Product, id) + def get_product!(id) do + Product + |> Repo.get!(id) + |> Repo.preload(:categories) + end + + @doc """ + Creates a product. + + ## Examples + + iex> create_product(%{field: value}) + {:ok, %Product{}} + + iex> create_product(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_product(attrs \\ %{}) do + %Product{} + |> change_product(attrs) + |> Repo.insert() + end + + @doc """ + Updates a product. + + ## Examples + + iex> update_product(product, %{field: new_value}) + {:ok, %Product{}} + + iex> update_product(product, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_product(%Product{} = product, attrs) do + product + |> change_product(attrs) + |> Repo.update() + end + + @doc """ + Increase page views on a product + + ## Example + + iex> inc_page_views(product) + %Product{} + + """ + def inc_page_views(%Product{} = product) do + {1, [%Product{views: views}]} = + from(p in Product, where: p.id == ^product.id, select: [:views]) + |> Repo.update_all(inc: [views: 1]) + + put_in(product.views, views) + end + + @doc """ + Deletes a product. + + ## Examples + + iex> delete_product(product) + {:ok, %Product{}} + + iex> delete_product(product) + {:error, %Ecto.Changeset{}} + + """ + def delete_product(%Product{} = product) do + Repo.delete(product) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking product changes. + + ## Examples + + iex> change_product(product) + %Ecto.Changeset{data: %Product{}} + + """ + def change_product(%Product{} = product, attrs \\ %{}) do + categories = list_categories_by_id(attrs["category_ids"]) + + product + |> Repo.preload(:categories) + |> Product.changeset(attrs) + |> Ecto.Changeset.put_assoc(:categories, categories) + end + + def list_categories_by_id(nil), do: [] + + def list_categories_by_id(category_ids) do + Repo.all(from c in Category, where: c.id in ^category_ids) + end + + @doc """ + Returns the list of categories. + + ## Examples + + iex> list_categories() + [%Category{}, ...] + + """ + def list_categories do + Repo.all(Category) + end + + @doc """ + Gets a single category. + + Raises `Ecto.NoResultsError` if the Category does not exist. + + ## Examples + + iex> get_category!(123) + %Category{} + + iex> get_category!(456) + ** (Ecto.NoResultsError) + + """ + def get_category!(id), do: Repo.get!(Category, id) + + @doc """ + Creates a category. + + ## Examples + + iex> create_category(%{field: value}) + {:ok, %Category{}} + + iex> create_category(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_category(attrs \\ %{}) do + %Category{} + |> Category.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a category. + + ## Examples + + iex> update_category(category, %{field: new_value}) + {:ok, %Category{}} + + iex> update_category(category, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_category(%Category{} = category, attrs) do + category + |> Category.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a category. + + ## Examples + + iex> delete_category(category) + {:ok, %Category{}} + + iex> delete_category(category) + {:error, %Ecto.Changeset{}} + + """ + def delete_category(%Category{} = category) do + Repo.delete(category) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking category changes. + + ## Examples + + iex> change_category(category) + %Ecto.Changeset{data: %Category{}} + + """ + def change_category(%Category{} = category, attrs \\ %{}) do + Category.changeset(category, attrs) + end +end diff --git a/lib/hello_phx/catalog/category.ex b/lib/hello_phx/catalog/category.ex new file mode 100644 index 0000000..84aa7b5 --- /dev/null +++ b/lib/hello_phx/catalog/category.ex @@ -0,0 +1,18 @@ +defmodule HelloPhx.Catalog.Category do + use Ecto.Schema + import Ecto.Changeset + + schema "categories" do + field :title, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(category, attrs) do + category + |> cast(attrs, [:title]) + |> validate_required([:title]) + |> unique_constraint(:title) + end +end diff --git a/lib/hello_phx/catalog/product.ex b/lib/hello_phx/catalog/product.ex new file mode 100644 index 0000000..1c89d91 --- /dev/null +++ b/lib/hello_phx/catalog/product.ex @@ -0,0 +1,25 @@ +defmodule HelloPhx.Catalog.Product do + use Ecto.Schema + use Endon + import Ecto.Changeset + + alias HelloPhx.Catalog.Category + + schema "products" do + field :description, :string + field :title, :string + field :price, :decimal + field :views, :integer, default: 0 + + many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(product, attrs) do + product + |> cast(attrs, [:title, :description, :price, :views]) + |> validate_required([:title, :description, :price, :views]) + end +end diff --git a/lib/hello_phx/data.ex b/lib/hello_phx/data.ex new file mode 100644 index 0000000..f39db98 --- /dev/null +++ b/lib/hello_phx/data.ex @@ -0,0 +1,34 @@ +defmodule HelloPhx.Data do + @moduledoc """ + Sample dev fixture data + """ + + def product_fixture(attrs \\ %{}) do + {:ok, product} = + attrs + |> Enum.into(%{ + description: Faker.Lorem.sentence(), + price: + :rand.uniform() + |> Decimal.from_float() + |> Decimal.mult(Enum.random(1..100)) + |> Decimal.round(2), + title: Faker.Person.name(), + views: Enum.random(1..50) + }) + |> HelloPhx.Catalog.create_product() + + product + end + + def batch_gen(n \\ 3, tp \\ :product) do + fun = + case tp do + :product -> &HelloPhx.Data.product_fixture/0 + end + + fun + |> Stream.repeatedly() + |> Enum.take(n) + end +end diff --git a/lib/hello_phx/news.ex b/lib/hello_phx/news.ex new file mode 100644 index 0000000..5891ead --- /dev/null +++ b/lib/hello_phx/news.ex @@ -0,0 +1,104 @@ +defmodule HelloPhx.News do + @moduledoc """ + The News context. + """ + + import Ecto.Query, warn: false + alias HelloPhx.Repo + + alias HelloPhx.News.Article + + @doc """ + Returns the list of articles. + + ## Examples + + iex> list_articles() + [%Article{}, ...] + + """ + def list_articles do + Repo.all(Article) + end + + @doc """ + Gets a single article. + + Raises `Ecto.NoResultsError` if the Article does not exist. + + ## Examples + + iex> get_article!(123) + %Article{} + + iex> get_article!(456) + ** (Ecto.NoResultsError) + + """ + def get_article!(id), do: Repo.get!(Article, id) + + @doc """ + Creates a article. + + ## Examples + + iex> create_article(%{field: value}) + {:ok, %Article{}} + + iex> create_article(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_article(attrs \\ %{}) do + %Article{} + |> Article.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a article. + + ## Examples + + iex> update_article(article, %{field: new_value}) + {:ok, %Article{}} + + iex> update_article(article, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_article(%Article{} = article, attrs) do + article + |> Article.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a article. + + ## Examples + + iex> delete_article(article) + {:ok, %Article{}} + + iex> delete_article(article) + {:error, %Ecto.Changeset{}} + + """ + def delete_article(%Article{} = article) do + Repo.delete(article) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking article changes. + + ## Examples + + iex> change_article(article) + %Ecto.Changeset{data: %Article{}} + + """ + def change_article(%Article{} = article, attrs \\ %{}) do + Article.changeset(article, attrs) + end +end diff --git a/lib/hello_phx/news/article.ex b/lib/hello_phx/news/article.ex new file mode 100644 index 0000000..1f93ff2 --- /dev/null +++ b/lib/hello_phx/news/article.ex @@ -0,0 +1,18 @@ +defmodule HelloPhx.News.Article do + use Ecto.Schema + import Ecto.Changeset + + schema "articles" do + field :title, :string + field :body, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(article, attrs) do + article + |> cast(attrs, [:title, :body]) + |> validate_required([:title, :body]) + end +end diff --git a/lib/hello_phx/orders.ex b/lib/hello_phx/orders.ex new file mode 100644 index 0000000..f99f845 --- /dev/null +++ b/lib/hello_phx/orders.ex @@ -0,0 +1,231 @@ +defmodule HelloPhx.Orders do + @moduledoc """ + The Orders context. + """ + + import Ecto.Query, warn: false + alias HelloPhx.Repo + + alias HelloPhx.Orders.Order + + @doc """ + Returns the list of orders. + + ## Examples + + iex> list_orders() + [%Order{}, ...] + + """ + def list_orders do + Repo.all(Order) + end + + @doc """ + Gets a single order. + + Raises `Ecto.NoResultsError` if the Order does not exist. + + ## Examples + + iex> get_order!("7488a646-e31f-11e4-aace-600308960662", 123) + %Order{} + + iex> get_order!("7488a646-e31f-11e4-aace-600308960662", 456) + ** (Ecto.NoResultsError) + + """ + def get_order!(user_uuid, id) do + Order + |> Repo.get_by!(id: id, user_uuid: user_uuid) + |> Repo.preload(line_items: [:product]) + end + + @doc """ + Creates a order. + + ## Examples + + iex> create_order(%{field: value}) + {:ok, %Order{}} + + iex> create_order(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_order(attrs \\ %{}) do + %Order{} + |> Order.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a order. + + ## Examples + + iex> update_order(order, %{field: new_value}) + {:ok, %Order{}} + + iex> update_order(order, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_order(%Order{} = order, attrs) do + order + |> Order.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a order. + + ## Examples + + iex> delete_order(order) + {:ok, %Order{}} + + iex> delete_order(order) + {:error, %Ecto.Changeset{}} + + """ + def delete_order(%Order{} = order) do + Repo.delete(order) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking order changes. + + ## Examples + + iex> change_order(order) + %Ecto.Changeset{data: %Order{}} + + """ + def change_order(%Order{} = order, attrs \\ %{}) do + Order.changeset(order, attrs) + end + + alias HelloPhx.Orders.LineItem + + @doc """ + Returns the list of order_line_items. + + ## Examples + + iex> list_order_line_items() + [%LineItem{}, ...] + + """ + def list_order_line_items do + Repo.all(LineItem) + end + + @doc """ + Gets a single line_item. + + Raises `Ecto.NoResultsError` if the Line item does not exist. + + ## Examples + + iex> get_line_item!(123) + %LineItem{} + + iex> get_line_item!(456) + ** (Ecto.NoResultsError) + + """ + def get_line_item!(id), do: Repo.get!(LineItem, id) + + @doc """ + Creates a line_item. + + ## Examples + + iex> create_line_item(%{field: value}) + {:ok, %LineItem{}} + + iex> create_line_item(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_line_item(attrs \\ %{}) do + %LineItem{} + |> LineItem.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a line_item. + + ## Examples + + iex> update_line_item(line_item, %{field: new_value}) + {:ok, %LineItem{}} + + iex> update_line_item(line_item, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_line_item(%LineItem{} = line_item, attrs) do + line_item + |> LineItem.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a line_item. + + ## Examples + + iex> delete_line_item(line_item) + {:ok, %LineItem{}} + + iex> delete_line_item(line_item) + {:error, %Ecto.Changeset{}} + + """ + def delete_line_item(%LineItem{} = line_item) do + Repo.delete(line_item) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking line_item changes. + + ## Examples + + iex> change_line_item(line_item) + %Ecto.Changeset{data: %LineItem{}} + + """ + def change_line_item(%LineItem{} = line_item, attrs \\ %{}) do + LineItem.changeset(line_item, attrs) + end + + alias HelloPhx.ShoppingCart + + def complete_order(%ShoppingCart.Cart{} = cart) do + line_items = + Enum.map(cart.items, fn item -> + %{product_id: item.product_id, price: item.product.price, quantity: item.quantity} + end) + + order = + Ecto.Changeset.change(%Order{}, + user_uuid: cart.user_uuid, + total_price: ShoppingCart.total_cart_price(cart), + line_items: line_items + ) + + Ecto.Multi.new() + |> Ecto.Multi.insert(:order, order) + |> Ecto.Multi.run(:prune_cart, fn _repo, _changes -> + ShoppingCart.prune_cart_items(cart) + end) + |> Repo.transaction() + |> case do + {:ok, %{order: order}} -> {:ok, order} + {:error, name, value, _changes_so_far} -> {:error, {name, value}} + end + end +end diff --git a/lib/hello_phx/orders/line_item.ex b/lib/hello_phx/orders/line_item.ex new file mode 100644 index 0000000..4cc785d --- /dev/null +++ b/lib/hello_phx/orders/line_item.ex @@ -0,0 +1,22 @@ +defmodule HelloPhx.Orders.LineItem do + use Ecto.Schema + use Endon + import Ecto.Changeset + + schema "order_line_items" do + field :price, :decimal + field :quantity, :integer + + belongs_to :order, HelloPhx.Orders.Order + belongs_to :product, HelloPhx.Catalog.Product + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(line_item, attrs) do + line_item + |> cast(attrs, [:price, :quantity]) + |> validate_required([:price, :quantity]) + end +end diff --git a/lib/hello_phx/orders/order.ex b/lib/hello_phx/orders/order.ex new file mode 100644 index 0000000..e14b287 --- /dev/null +++ b/lib/hello_phx/orders/order.ex @@ -0,0 +1,22 @@ +defmodule HelloPhx.Orders.Order do + use Ecto.Schema + use Endon + import Ecto.Changeset + + schema "orders" do + field :user_uuid, Ecto.UUID + field :total_price, :decimal + + has_many :line_items, HelloPhx.Orders.LineItem + has_many :products, through: [:line_items, :product] + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(order, attrs) do + order + |> cast(attrs, [:user_uuid, :total_price]) + |> validate_required([:user_uuid, :total_price]) + end +end diff --git a/lib/hello_phx/release.ex b/lib/hello_phx/release.ex new file mode 100644 index 0000000..67a3f78 --- /dev/null +++ b/lib/hello_phx/release.ex @@ -0,0 +1,53 @@ +defmodule HelloPhx.Release do + @moduledoc """ + Used for executing DB release tasks when run in production without Mix + installed. + """ + require Logger + + @app HelloPhx.app() + + def migrate(opts \\ [all: true]) do + load_app() + + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, opts)) + end + end + + def rollback(repo, version) do + load_app() + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + def reset!(opts \\ [all: true]) do + load_app() + + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, opts)) + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, opts)) + Logger.warning("reset repo: #{inspect(repo)}") + end + + load_seeds!() + end + + def load_seeds!(seed_file \\ HelloPhx.priv_dir() <> "/repo/seeds.exs") do + Code.eval_file(seed_file) + Logger.warning("eval data seeds from: #{seed_file}") + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp load_app do + Application.load(@app) + end + + # defp start_app do + # load_app() + # Application.put_env(@app, :minimal, true) + # Application.ensure_all_started(@app) + # end +end diff --git a/lib/hello_phx/shopping_cart.ex b/lib/hello_phx/shopping_cart.ex new file mode 100644 index 0000000..dc8a554 --- /dev/null +++ b/lib/hello_phx/shopping_cart.ex @@ -0,0 +1,273 @@ +defmodule HelloPhx.ShoppingCart do + @moduledoc """ + The ShoppingCart context. + """ + + import Ecto.Query, warn: false + alias HelloPhx.Repo + + alias HelloPhx.Catalog + alias HelloPhx.ShoppingCart.{Cart, CartItem} + + @doc """ + Returns the list of carts. + + ## Examples + + iex> list_carts() + [%Cart{}, ...] + + """ + def list_carts do + Repo.all(from c in Cart, preload: [:items]) + end + + @doc """ + Gets a single cart. + + Raises `Ecto.NoResultsError` if the Cart does not exist. + + ## Examples + + iex> get_cart!(123) + %Cart{} + + iex> get_cart!(456) + ** (Ecto.NoResultsError) + + """ + def get_cart!(id), do: Repo.get!(Cart, id) + + @doc """ + Creates a cart. + + ## Examples + + iex> create_cart("fb3a631c-c5f4-47f2-8ea6-38f98ce79311") + {:ok, %Cart{}} + + iex> create_cart("bad-value") + {:error, %Ecto.Changeset{}} + + """ + def create_cart(user_uuid) do + %Cart{user_uuid: user_uuid} + |> Cart.changeset(%{}) + |> Repo.insert() + |> case do + {:ok, cart} -> {:ok, reload_cart(cart)} + {:error, changeset} -> {:error, changeset} + end + end + + defp reload_cart(%Cart{} = cart), do: get_cart_by_user_uuid(cart.user_uuid) + + @doc """ + Updates a cart. + + ## Examples + + iex> update_cart(cart, %{field: new_value}) + {:ok, %Cart{}} + + iex> update_cart(cart, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_cart(%Cart{} = cart, attrs) do + changeset = + cart + |> Cart.changeset(attrs) + |> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2) + + Ecto.Multi.new() + |> Ecto.Multi.update(:cart, changeset) + |> Ecto.Multi.delete_all(:discarded_items, fn %{cart: cart} -> + from(i in CartItem, where: i.cart_id == ^cart.id and i.quantity == 0) + end) + |> Repo.transaction() + |> case do + {:ok, %{cart: cart}} -> {:ok, cart} + {:error, :cart, changeset, _changes_so_far} -> {:error, changeset} + end + end + + @doc """ + Deletes a cart. + + ## Examples + + iex> delete_cart(cart) + {:ok, %Cart{}} + + iex> delete_cart(cart) + {:error, %Ecto.Changeset{}} + + """ + def delete_cart(%Cart{} = cart) do + Repo.delete(cart) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking cart changes. + + ## Examples + + iex> change_cart(cart) + %Ecto.Changeset{data: %Cart{}} + + """ + def change_cart(%Cart{} = cart, attrs \\ %{}) do + Cart.changeset(cart, attrs) + end + + alias HelloPhx.ShoppingCart.CartItem + + @doc """ + Returns the list of cart_items. + + ## Examples + + iex> list_cart_items() + [%CartItem{}, ...] + + """ + def list_cart_items do + Repo.all(CartItem) + end + + @doc """ + Gets a single cart_item. + + Raises `Ecto.NoResultsError` if the Cart item does not exist. + + ## Examples + + iex> get_cart_item!(123) + %CartItem{} + + iex> get_cart_item!(456) + ** (Ecto.NoResultsError) + + """ + def get_cart_item!(id), do: Repo.get!(CartItem, id) + + @doc """ + Creates a cart_item. + + ## Examples + + iex> create_cart_item(%{field: value}) + {:ok, %CartItem{}} + + iex> create_cart_item(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_cart_item(attrs \\ %{}) do + %CartItem{} + |> CartItem.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a cart_item. + + ## Examples + + iex> update_cart_item(cart_item, %{field: new_value}) + {:ok, %CartItem{}} + + iex> update_cart_item(cart_item, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_cart_item(%CartItem{} = cart_item, attrs) do + cart_item + |> CartItem.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a cart_item. + + ## Examples + + iex> delete_cart_item(cart_item) + {:ok, %CartItem{}} + + iex> delete_cart_item(cart_item) + {:error, %Ecto.Changeset{}} + + """ + def delete_cart_item(%CartItem{} = cart_item) do + Repo.delete(cart_item) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking cart_item changes. + + ## Examples + + iex> change_cart_item(cart_item) + %Ecto.Changeset{data: %CartItem{}} + + """ + def change_cart_item(%CartItem{} = cart_item, attrs \\ %{}) do + CartItem.changeset(cart_item, attrs) + end + + def get_cart_by_user_uuid(user_uuid) do + Repo.one( + from(c in Cart, + where: c.user_uuid == ^user_uuid, + left_join: i in assoc(c, :items), + left_join: p in assoc(i, :product), + order_by: [asc: i.inserted_at], + preload: [items: {i, product: p}] + ) + ) + end + + def add_item_to_cart(%Cart{} = cart, product_id) do + product = Catalog.get_product!(product_id) + + %CartItem{quantity: 1, price_when_carted: product.price} + |> CartItem.changeset(%{}) + |> Ecto.Changeset.put_assoc(:cart, cart) + |> Ecto.Changeset.put_assoc(:product, product) + |> Repo.insert( + on_conflict: [inc: [quantity: 1]], + conflict_target: [:cart_id, :product_id] + ) + end + + def remove_item_from_cart(%Cart{} = cart, product_id) do + {1, _} = + Repo.delete_all( + from(i in CartItem, + where: i.cart_id == ^cart.id, + where: i.product_id == ^product_id + ) + ) + + {:ok, reload_cart(cart)} + end + + def total_item_price(%CartItem{} = item) do + Decimal.mult(item.product.price, item.quantity) + end + + def total_cart_price(%Cart{} = cart) do + Enum.reduce(cart.items, 0, fn item, acc -> + item + |> total_item_price() + |> Decimal.add(acc) + end) + end + + def prune_cart_items(%Cart{} = cart) do + {_, _} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id)) + {:ok, reload_cart(cart)} + end +end diff --git a/lib/hello_phx/shopping_cart/cart.ex b/lib/hello_phx/shopping_cart/cart.ex new file mode 100644 index 0000000..3a5a638 --- /dev/null +++ b/lib/hello_phx/shopping_cart/cart.ex @@ -0,0 +1,21 @@ +defmodule HelloPhx.ShoppingCart.Cart do + use Ecto.Schema + use Endon + import Ecto.Changeset + + schema "carts" do + field :user_uuid, Ecto.UUID + + has_many :items, HelloPhx.ShoppingCart.CartItem + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(cart, attrs) do + cart + |> cast(attrs, [:user_uuid]) + |> validate_required([:user_uuid]) + |> unique_constraint(:user_uuid) + end +end diff --git a/lib/hello_phx/shopping_cart/cart_item.ex b/lib/hello_phx/shopping_cart/cart_item.ex new file mode 100644 index 0000000..dcd875a --- /dev/null +++ b/lib/hello_phx/shopping_cart/cart_item.ex @@ -0,0 +1,24 @@ +defmodule HelloPhx.ShoppingCart.CartItem do + use Ecto.Schema + import Ecto.Changeset + + schema "cart_items" do + field :price_when_carted, :decimal + field :quantity, :integer + + # field :cart_id, :id + # field :product_id, :id + belongs_to :cart, HelloPhx.ShoppingCart.Cart + belongs_to :product, HelloPhx.Catalog.Product + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(cart_item, attrs) do + cart_item + |> cast(attrs, [:price_when_carted, :quantity]) + |> validate_required([:price_when_carted, :quantity]) + |> validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100) + end +end diff --git a/lib/hello_phx/urls.ex b/lib/hello_phx/urls.ex new file mode 100644 index 0000000..090a6e1 --- /dev/null +++ b/lib/hello_phx/urls.ex @@ -0,0 +1,104 @@ +defmodule HelloPhx.Urls do + @moduledoc """ + The Urls context. + """ + + import Ecto.Query, warn: false + alias HelloPhx.Repo + + alias HelloPhx.Urls.Url + + @doc """ + Returns the list of urls. + + ## Examples + + iex> list_urls() + [%Url{}, ...] + + """ + def list_urls do + Repo.all(Url) + end + + @doc """ + Gets a single url. + + Raises `Ecto.NoResultsError` if the Url does not exist. + + ## Examples + + iex> get_url!(123) + %Url{} + + iex> get_url!(456) + ** (Ecto.NoResultsError) + + """ + def get_url!(id), do: Repo.get!(Url, id) + + @doc """ + Creates a url. + + ## Examples + + iex> create_url(%{field: value}) + {:ok, %Url{}} + + iex> create_url(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_url(attrs \\ %{}) do + %Url{} + |> Url.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a url. + + ## Examples + + iex> update_url(url, %{field: new_value}) + {:ok, %Url{}} + + iex> update_url(url, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_url(%Url{} = url, attrs) do + url + |> Url.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a url. + + ## Examples + + iex> delete_url(url) + {:ok, %Url{}} + + iex> delete_url(url) + {:error, %Ecto.Changeset{}} + + """ + def delete_url(%Url{} = url) do + Repo.delete(url) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking url changes. + + ## Examples + + iex> change_url(url) + %Ecto.Changeset{data: %Url{}} + + """ + def change_url(%Url{} = url, attrs \\ %{}) do + Url.changeset(url, attrs) + end +end diff --git a/lib/hello_phx/urls/url.ex b/lib/hello_phx/urls/url.ex new file mode 100644 index 0000000..194ebe9 --- /dev/null +++ b/lib/hello_phx/urls/url.ex @@ -0,0 +1,19 @@ +defmodule HelloPhx.Urls.Url do + use Ecto.Schema + use Endon + import Ecto.Changeset + + schema "urls" do + field :link, :string + field :title, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(url, attrs) do + url + |> cast(attrs, [:link, :title]) + |> validate_required([:link, :title]) + end +end diff --git a/lib/hello_phx/user_v1.ex b/lib/hello_phx/user_v1.ex new file mode 100644 index 0000000..ffd92a1 --- /dev/null +++ b/lib/hello_phx/user_v1.ex @@ -0,0 +1,21 @@ +defmodule HelloPhx.UserV1 do + use Ecto.Schema + use Endon + import Ecto.Changeset + + schema "v1_users" do + field :name, :string + field :email, :string + field :bio, :string + field :num_of_pets, :integer + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(user, attrs) do + user + |> cast(attrs, [:name, :email, :bio, :num_of_pets]) + |> validate_required([:name, :email, :bio, :num_of_pets]) + end +end diff --git a/lib/hello_phx_web/components/layouts/app.html.heex b/lib/hello_phx_web/components/layouts/app.html.heex index e23bfc8..ba068a8 100644 --- a/lib/hello_phx_web/components/layouts/app.html.heex +++ b/lib/hello_phx_web/components/layouts/app.html.heex @@ -7,8 +7,18 @@

v<%= Application.spec(:phoenix, :vsn) %>

+ <.link href={~p"/hello"}>Hello + <.link href={~p"/products"}>Catalog Products + <.link href={~p"/cart"}>My Cart + <.link href={~p"/posts"}>Blog Posts + <%= if HelloPhx.build_mode() == :dev do %> + || <.link href="/dev/dashboard">Live Dashboard + <.link href="/dev/mailbox">Mailbox + <.link href="https://hello-phx-up.fly.dev" target="_blank">Fly app + <% end %>
+ <%= HelloPhx.build_mode() %> mode @elixirphoenix diff --git a/lib/hello_phx_web/components/layouts/root.html.heex b/lib/hello_phx_web/components/layouts/root.html.heex index ad53794..1337189 100644 --- a/lib/hello_phx_web/components/layouts/root.html.heex +++ b/lib/hello_phx_web/components/layouts/root.html.heex @@ -12,6 +12,47 @@ +
    + <%= if @current_user do %> +
  • + <%= @current_user.email %> +
  • +
  • + <.link + href={~p"/users/settings"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Settings + +
  • +
  • + <.link + href={~p"/users/log_out"} + method="delete" + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Log out + +
  • + <% else %> +
  • + <.link + href={~p"/users/register"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Register + +
  • +
  • + <.link + href={~p"/users/log_in"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Log in + +
  • + <% end %> +
<%= @inner_content %> diff --git a/lib/hello_phx_web/controllers/api/user_controller.ex b/lib/hello_phx_web/controllers/api/user_controller.ex new file mode 100644 index 0000000..0ec65b6 --- /dev/null +++ b/lib/hello_phx_web/controllers/api/user_controller.ex @@ -0,0 +1,19 @@ +defmodule HelloPhxWeb.Api.UserController do + use HelloPhxWeb, :controller + + action_fallback HelloPhxWeb.FallbackController + + def current(conn, _params) do + data = + if user = conn.assigns.current_user do + %{ + current_user_email: user.email + } + else + %{} + end + |> Map.put(:msg, :ok) + + json(conn, data) + end +end diff --git a/lib/hello_phx_web/controllers/api_controller.ex b/lib/hello_phx_web/controllers/api_controller.ex new file mode 100644 index 0000000..4145564 --- /dev/null +++ b/lib/hello_phx_web/controllers/api_controller.ex @@ -0,0 +1,19 @@ +defmodule HelloPhxWeb.ApiController do + use HelloPhxWeb, :controller + + action_fallback(HelloPhxWeb.FallbackController) + + def home(conn, _) do + json(conn, HelloPhx.build_info()) + end + + def ping(conn, _params) do + json(conn, %{ + msg: :pong + }) + end + + def mock_404(_conn, _) do + {:error, :not_found} + end +end diff --git a/lib/hello_phx_web/controllers/article_controller.ex b/lib/hello_phx_web/controllers/article_controller.ex new file mode 100644 index 0000000..a474404 --- /dev/null +++ b/lib/hello_phx_web/controllers/article_controller.ex @@ -0,0 +1,43 @@ +defmodule HelloPhxWeb.ArticleController do + use HelloPhxWeb, :controller + + alias HelloPhx.News + alias HelloPhx.News.Article + + action_fallback HelloPhxWeb.FallbackController + + def index(conn, _params) do + articles = News.list_articles() + render(conn, :index, articles: articles) + end + + def create(conn, %{"article" => article_params}) do + with {:ok, %Article{} = article} <- News.create_article(article_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/articles/#{article}") + |> render(:show, article: article) + end + end + + def show(conn, %{"id" => id}) do + article = News.get_article!(id) + render(conn, :show, article: article) + end + + def update(conn, %{"id" => id, "article" => article_params}) do + article = News.get_article!(id) + + with {:ok, %Article{} = article} <- News.update_article(article, article_params) do + render(conn, :show, article: article) + end + end + + def delete(conn, %{"id" => id}) do + article = News.get_article!(id) + + with {:ok, %Article{}} <- News.delete_article(article) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/hello_phx_web/controllers/article_json.ex b/lib/hello_phx_web/controllers/article_json.ex new file mode 100644 index 0000000..524c7fc --- /dev/null +++ b/lib/hello_phx_web/controllers/article_json.ex @@ -0,0 +1,25 @@ +defmodule HelloPhxWeb.ArticleJSON do + alias HelloPhx.News.Article + + @doc """ + Renders a list of articles. + """ + def index(%{articles: articles}) do + %{data: for(article <- articles, do: data(article))} + end + + @doc """ + Renders a single article. + """ + def show(%{article: article}) do + %{data: data(article)} + end + + defp data(%Article{} = article) do + %{ + id: article.id, + title: article.title, + body: article.body + } + end +end diff --git a/lib/hello_phx_web/controllers/cart_controller.ex b/lib/hello_phx_web/controllers/cart_controller.ex new file mode 100644 index 0000000..4f6c8a0 --- /dev/null +++ b/lib/hello_phx_web/controllers/cart_controller.ex @@ -0,0 +1,21 @@ +defmodule HelloPhxWeb.CartController do + use HelloPhxWeb, :controller + + alias HelloPhx.ShoppingCart + + def show(conn, _params) do + render(conn, :show, changeset: ShoppingCart.change_cart(conn.assigns.cart)) + end + + def update(conn, %{"cart" => cart_params}) do + case ShoppingCart.update_cart(conn.assigns.cart, cart_params) do + {:ok, _cart} -> + redirect(conn, to: ~p"/cart") + + {:error, _changeset} -> + conn + |> put_flash(:error, "There was an error updating your cart") + |> redirect(to: ~p"/cart") + end + end +end diff --git a/lib/hello_phx_web/controllers/cart_html.ex b/lib/hello_phx_web/controllers/cart_html.ex new file mode 100644 index 0000000..46383e4 --- /dev/null +++ b/lib/hello_phx_web/controllers/cart_html.ex @@ -0,0 +1,9 @@ +defmodule HelloPhxWeb.CartHTML do + use HelloPhxWeb, :html + + alias HelloPhx.ShoppingCart + + embed_templates "cart_html/*" + + def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}" +end diff --git a/lib/hello_phx_web/controllers/cart_html/show.html.heex b/lib/hello_phx_web/controllers/cart_html/show.html.heex new file mode 100644 index 0000000..266a8de --- /dev/null +++ b/lib/hello_phx_web/controllers/cart_html/show.html.heex @@ -0,0 +1,34 @@ +<%= if @cart.items == [] do %> + <.header> + My Cart + <:subtitle>Your cart is empty + +<% else %> + <.header> + My Cart + <:actions> + <.link href={~p"/orders"} method="post"> + <.button>Complete order + + + + + <.simple_form :let={f} for={@changeset} action={~p"/cart"}> + <.inputs_for :let={item_form} field={f[:items]}> + <% item = item_form.data %> + <.input + field={item_form[:quantity]} + type="number" + label={"Product: "<>item.product.title <> " || Price: " <> currency_to_str(item.product.price)} + /> + <%= currency_to_str(ShoppingCart.total_item_price(item)) %> + + <:actions> + <.button>Update cart + + + + Total: <%= currency_to_str(ShoppingCart.total_cart_price(@cart)) %> +<% end %> + +<.back navigate={~p"/products"}>Back to products diff --git a/lib/hello_phx_web/controllers/cart_item_controller.ex b/lib/hello_phx_web/controllers/cart_item_controller.ex new file mode 100644 index 0000000..55db0f5 --- /dev/null +++ b/lib/hello_phx_web/controllers/cart_item_controller.ex @@ -0,0 +1,24 @@ +defmodule HelloPhxWeb.CartItemController do + use HelloPhxWeb, :controller + + alias HelloPhx.ShoppingCart + + def create(conn, %{"product_id" => product_id}) do + case ShoppingCart.add_item_to_cart(conn.assigns.cart, product_id) do + {:ok, _item} -> + conn + |> put_flash(:info, "Item added to your cart") + |> redirect(to: ~p"/cart") + + {:error, _changeset} -> + conn + |> put_flash(:error, "There was an error adding the item to your cart") + |> redirect(to: ~p"/cart") + end + end + + def delete(conn, %{"id" => product_id}) do + {:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.cart, product_id) + redirect(conn, to: ~p"/cart") + end +end diff --git a/lib/hello_phx_web/controllers/changeset_json.ex b/lib/hello_phx_web/controllers/changeset_json.ex new file mode 100644 index 0000000..ffd4683 --- /dev/null +++ b/lib/hello_phx_web/controllers/changeset_json.ex @@ -0,0 +1,25 @@ +defmodule HelloPhxWeb.ChangesetJSON do + @doc """ + Renders changeset errors. + """ + def error(%{changeset: changeset}) do + # When encoded, the changeset returns its errors + # as a JSON object. So we just pass it forward. + %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)} + end + + defp translate_error({msg, opts}) do + # You can make use of gettext to translate error messages by + # uncommenting and adjusting the following code: + + # if count = opts[:count] do + # Gettext.dngettext(HelloPhxWeb.Gettext, "errors", msg, msg, count, opts) + # else + # Gettext.dgettext(HelloPhxWeb.Gettext, "errors", msg, opts) + # end + + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end +end diff --git a/lib/hello_phx_web/controllers/comment_controller.ex b/lib/hello_phx_web/controllers/comment_controller.ex new file mode 100644 index 0000000..9a66911 --- /dev/null +++ b/lib/hello_phx_web/controllers/comment_controller.ex @@ -0,0 +1,62 @@ +defmodule HelloPhxWeb.CommentController do + use HelloPhxWeb, :controller + + alias HelloPhx.Blog + alias HelloPhx.Blog.Comment + + def index(conn, _params) do + comments = Blog.list_comments() + render(conn, :index, comments: comments) + end + + def new(conn, _params) do + changeset = Blog.change_comment(%Comment{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"comment" => comment_params}) do + case Blog.create_comment(comment_params) do + {:ok, comment} -> + conn + |> put_flash(:info, "Comment created successfully.") + |> redirect(to: ~p"/comments/#{comment}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + comment = Blog.get_comment!(id) + render(conn, :show, comment: comment) + end + + def edit(conn, %{"id" => id}) do + comment = Blog.get_comment!(id) + changeset = Blog.change_comment(comment) + render(conn, :edit, comment: comment, changeset: changeset) + end + + def update(conn, %{"id" => id, "comment" => comment_params}) do + comment = Blog.get_comment!(id) + + case Blog.update_comment(comment, comment_params) do + {:ok, comment} -> + conn + |> put_flash(:info, "Comment updated successfully.") + |> redirect(to: ~p"/comments/#{comment}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :edit, comment: comment, changeset: changeset) + end + end + + def delete(conn, %{"id" => id}) do + comment = Blog.get_comment!(id) + {:ok, _comment} = Blog.delete_comment(comment) + + conn + |> put_flash(:info, "Comment deleted successfully.") + |> redirect(to: ~p"/comments") + end +end diff --git a/lib/hello_phx_web/controllers/comment_html.ex b/lib/hello_phx_web/controllers/comment_html.ex new file mode 100644 index 0000000..e63f696 --- /dev/null +++ b/lib/hello_phx_web/controllers/comment_html.ex @@ -0,0 +1,13 @@ +defmodule HelloPhxWeb.CommentHTML do + use HelloPhxWeb, :html + + embed_templates "comment_html/*" + + @doc """ + Renders a comment form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + + def comment_form(assigns) +end diff --git a/lib/hello_phx_web/controllers/comment_html/comment_form.html.heex b/lib/hello_phx_web/controllers/comment_html/comment_form.html.heex new file mode 100644 index 0000000..4b77a74 --- /dev/null +++ b/lib/hello_phx_web/controllers/comment_html/comment_form.html.heex @@ -0,0 +1,9 @@ +<.simple_form :let={f} for={@changeset} action={@action}> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:body]} type="text" label="Body" /> + <:actions> + <.button>Save Comment + + diff --git a/lib/hello_phx_web/controllers/comment_html/edit.html.heex b/lib/hello_phx_web/controllers/comment_html/edit.html.heex new file mode 100644 index 0000000..888c9b5 --- /dev/null +++ b/lib/hello_phx_web/controllers/comment_html/edit.html.heex @@ -0,0 +1,8 @@ +<.header> + Edit Comment <%= @comment.id %> + <:subtitle>Use this form to manage comment records in your database. + + +<.comment_form changeset={@changeset} action={~p"/comments/#{@comment}"} /> + +<.back navigate={~p"/comments"}>Back to comments diff --git a/lib/hello_phx_web/controllers/comment_html/index.html.heex b/lib/hello_phx_web/controllers/comment_html/index.html.heex new file mode 100644 index 0000000..f733f53 --- /dev/null +++ b/lib/hello_phx_web/controllers/comment_html/index.html.heex @@ -0,0 +1,23 @@ +<.header> + Listing Comments + <:actions> + <.link href={~p"/comments/new"}> + <.button>New Comment + + + + +<.table id="comments" rows={@comments} row_click={&JS.navigate(~p"/comments/#{&1}")}> + <:col :let={comment} label="Body"><%= comment.body %> + <:action :let={comment}> +
+ <.link navigate={~p"/comments/#{comment}"}>Show +
+ <.link navigate={~p"/comments/#{comment}/edit"}>Edit + + <:action :let={comment}> + <.link href={~p"/comments/#{comment}"} method="delete" data-confirm="Are you sure?"> + Delete + + + diff --git a/lib/hello_phx_web/controllers/comment_html/new.html.heex b/lib/hello_phx_web/controllers/comment_html/new.html.heex new file mode 100644 index 0000000..ad7ab2b --- /dev/null +++ b/lib/hello_phx_web/controllers/comment_html/new.html.heex @@ -0,0 +1,8 @@ +<.header> + New Comment + <:subtitle>Use this form to manage comment records in your database. + + +<.comment_form changeset={@changeset} action={~p"/comments"} /> + +<.back navigate={~p"/comments"}>Back to comments diff --git a/lib/hello_phx_web/controllers/comment_html/show.html.heex b/lib/hello_phx_web/controllers/comment_html/show.html.heex new file mode 100644 index 0000000..8032f76 --- /dev/null +++ b/lib/hello_phx_web/controllers/comment_html/show.html.heex @@ -0,0 +1,15 @@ +<.header> + Comment <%= @comment.id %> + <:subtitle>This is a comment record from your database. + <:actions> + <.link href={~p"/comments/#{@comment}/edit"}> + <.button>Edit comment + + + + +<.list> + <:item title="Body"><%= @comment.body %> + + +<.back navigate={~p"/comments"}>Back to comments diff --git a/lib/hello_phx_web/controllers/error_html.ex b/lib/hello_phx_web/controllers/error_html.ex index 5e5df72..9e00b84 100644 --- a/lib/hello_phx_web/controllers/error_html.ex +++ b/lib/hello_phx_web/controllers/error_html.ex @@ -13,7 +13,7 @@ defmodule HelloPhxWeb.ErrorHTML do # * lib/hello_phx_web/controllers/error_html/404.html.heex # * lib/hello_phx_web/controllers/error_html/500.html.heex # - # embed_templates "error_html/*" + embed_templates "error_html/*" # The default is to render a plain text page based on # the template name. For example, "404.html" becomes diff --git a/lib/hello_phx_web/controllers/error_html/404.html.heex b/lib/hello_phx_web/controllers/error_html/404.html.heex new file mode 100644 index 0000000..8fe2ffe --- /dev/null +++ b/lib/hello_phx_web/controllers/error_html/404.html.heex @@ -0,0 +1,33 @@ + + + + + + Welcome to Phoenix! + <%!-- + --%> + + + + +
+
+ + <%!-- --%> +
+
+
+
+

Sorry, the page you are looking for does not exist. Not Found

+
+
+ + diff --git a/lib/hello_phx_web/controllers/error_json.ex b/lib/hello_phx_web/controllers/error_json.ex index 58d6067..3d7d706 100644 --- a/lib/hello_phx_web/controllers/error_json.ex +++ b/lib/hello_phx_web/controllers/error_json.ex @@ -7,10 +7,9 @@ defmodule HelloPhxWeb.ErrorJSON do # If you want to customize a particular status code, # you may add your own clauses, such as: - # - # def render("500.json", _assigns) do - # %{errors: %{detail: "Internal Server Error"}} - # end + def render("404.json", _assigns) do + %{errors: %{detail: "Customized Not Found"}} + end # By default, Phoenix returns the status message from # the template name. For example, "404.json" becomes diff --git a/lib/hello_phx_web/controllers/fallback_controller.ex b/lib/hello_phx_web/controllers/fallback_controller.ex new file mode 100644 index 0000000..597f5fd --- /dev/null +++ b/lib/hello_phx_web/controllers/fallback_controller.ex @@ -0,0 +1,24 @@ +defmodule HelloPhxWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use HelloPhxWeb, :controller + + # This clause handles errors returned by Ecto's insert/update/delete. + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: HelloPhxWeb.ChangesetJSON) + |> render(:error, changeset: changeset) + end + + # This clause is an example of how to handle resources that cannot be found. + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(html: HelloPhxWeb.ErrorHTML, json: HelloPhxWeb.ErrorJSON) + |> render(:"404") + end +end diff --git a/lib/hello_phx_web/controllers/hello_controller.ex b/lib/hello_phx_web/controllers/hello_controller.ex index cd2839b..5b82cf8 100644 --- a/lib/hello_phx_web/controllers/hello_controller.ex +++ b/lib/hello_phx_web/controllers/hello_controller.ex @@ -6,6 +6,26 @@ defmodule HelloPhxWeb.HelloController do end def show(conn, %{"messenger" => messenger} = _params) do - render(conn, :show, messenger: messenger, layout: false) + render(conn, :show, messenger: messenger) + end + + def fun_component(conn, _params) do + render(conn, :fun_component) + end + + def text_action(conn, _p) do + text(conn, "hello text response!") + end + + def no_layout(conn, _) do + conn + |> put_root_layout(false) + |> put_layout(false) + # |> put_root_layout(html: :admin) + |> render(:no_layout) + end + + def mock_error(_, _) do + raise HelloPhxWeb.StatusCodeError, message: "mock error" end end diff --git a/lib/hello_phx_web/controllers/hello_html.ex b/lib/hello_phx_web/controllers/hello_html.ex index 3cbd603..37fa713 100644 --- a/lib/hello_phx_web/controllers/hello_html.ex +++ b/lib/hello_phx_web/controllers/hello_html.ex @@ -7,4 +7,10 @@ defmodule HelloPhxWeb.HelloHTML do use HelloPhxWeb, :html embed_templates("hello_html/*") + + def fun_component(assigns) do + ~H""" + Hello from simple view function component! + """ + end end diff --git a/lib/hello_phx_web/controllers/hello_html/index.html.heex b/lib/hello_phx_web/controllers/hello_html/index.html.heex index 59a6172..27fe4a2 100644 --- a/lib/hello_phx_web/controllers/hello_html/index.html.heex +++ b/lib/hello_phx_web/controllers/hello_html/index.html.heex @@ -1,3 +1,3 @@
-

Hello from Phoenixframework!

-
\ No newline at end of file +

Hello World, from Phoenix!

+ diff --git a/lib/hello_phx_web/controllers/hello_html/no_layout.html.heex b/lib/hello_phx_web/controllers/hello_html/no_layout.html.heex new file mode 100644 index 0000000..c0a2a66 --- /dev/null +++ b/lib/hello_phx_web/controllers/hello_html/no_layout.html.heex @@ -0,0 +1,3 @@ +
+

No layout content test!

+
diff --git a/lib/hello_phx_web/controllers/hello_html/show.html.heex b/lib/hello_phx_web/controllers/hello_html/show.html.heex index aa6dd4a..7f34709 100644 --- a/lib/hello_phx_web/controllers/hello_html/show.html.heex +++ b/lib/hello_phx_web/controllers/hello_html/show.html.heex @@ -1,3 +1,3 @@
-

Hello messenger: <%=@messenger%>!

-
\ No newline at end of file +

Hello World, from messenger: <%= @messenger %>!

+ diff --git a/lib/hello_phx_web/controllers/order_controller.ex b/lib/hello_phx_web/controllers/order_controller.ex new file mode 100644 index 0000000..ee3a56e --- /dev/null +++ b/lib/hello_phx_web/controllers/order_controller.ex @@ -0,0 +1,24 @@ +defmodule HelloPhxWeb.OrderController do + use HelloPhxWeb, :controller + + alias HelloPhx.Orders + + def create(conn, _) do + case Orders.complete_order(conn.assigns.cart) do + {:ok, order} -> + conn + |> put_flash(:info, "Order created successfully.") + |> redirect(to: ~p"/orders/#{order}") + + {:error, _reason} -> + conn + |> put_flash(:error, "There was an error processing your order") + |> redirect(to: ~p"/cart") + end + end + + def show(conn, %{"id" => id}) do + order = Orders.get_order!(conn.assigns.current_uuid, id) + render(conn, :show, order: order) + end +end diff --git a/lib/hello_phx_web/controllers/order_html.ex b/lib/hello_phx_web/controllers/order_html.ex new file mode 100644 index 0000000..843fc78 --- /dev/null +++ b/lib/hello_phx_web/controllers/order_html.ex @@ -0,0 +1,5 @@ +defmodule HelloPhxWeb.OrderHTML do + use HelloPhxWeb, :html + + embed_templates "order_html/*" +end diff --git a/lib/hello_phx_web/controllers/order_html/show.html.heex b/lib/hello_phx_web/controllers/order_html/show.html.heex new file mode 100644 index 0000000..7b79d4d --- /dev/null +++ b/lib/hello_phx_web/controllers/order_html/show.html.heex @@ -0,0 +1,19 @@ +<.header> + Thank you for your order! + <:subtitle> + User uuid: <%= @order.user_uuid %> + + + +<.table id="items" rows={@order.line_items}> + <:col :let={item} label="Title"><%= item.product.title %> + <:col :let={item} label="Quantity"><%= item.quantity %> + <:col :let={item} label="Price"> + <%= HelloPhxWeb.CartHTML.currency_to_str(item.price) %> + + + +Total price: +<%= HelloPhxWeb.CartHTML.currency_to_str(@order.total_price) %> + +<.back navigate={~p"/products"}>Back to products diff --git a/lib/hello_phx_web/controllers/page_controller.ex b/lib/hello_phx_web/controllers/page_controller.ex index cc43fe3..2a9e460 100644 --- a/lib/hello_phx_web/controllers/page_controller.ex +++ b/lib/hello_phx_web/controllers/page_controller.ex @@ -4,6 +4,8 @@ defmodule HelloPhxWeb.PageController do def home(conn, _params) do # The home page is often custom made, # so skip the default app layout. - render(conn, :home, layout: false) + conn + # |> put_flash(:error, "Let's pretend we have an error.") + |> render(:home, layout: false) end end diff --git a/lib/hello_phx_web/controllers/page_html/home.html.heex b/lib/hello_phx_web/controllers/page_html/home.html.heex index dc1820b..7507fa1 100644 --- a/lib/hello_phx_web/controllers/page_html/home.html.heex +++ b/lib/hello_phx_web/controllers/page_html/home.html.heex @@ -52,6 +52,17 @@ v<%= Application.spec(:phoenix, :vsn) %> +

+ <.link href={~p"/products"}> Explore Products + || + <%= if HelloPhx.build_mode() == :dev do %> + <.link href="https://hello-phx-up.fly.dev" target="_blank">Go Fly app + <% end %> +

+

+ Mode: <%= HelloPhx.build_mode() %> || <%= HelloPhx.commit() |> inspect %> on v<%= HelloPhx.vsn() %> || Locale: <%= @locale %> +

+

Peace of mind from prototype to production.

diff --git a/lib/hello_phx_web/controllers/page_json.ex b/lib/hello_phx_web/controllers/page_json.ex new file mode 100644 index 0000000..c67e9fc --- /dev/null +++ b/lib/hello_phx_web/controllers/page_json.ex @@ -0,0 +1,5 @@ +defmodule HelloPhxWeb.PageJSON do + def home(_assigns) do + %{message: "this is some JSON"} + end +end diff --git a/lib/hello_phx_web/controllers/post_controller.ex b/lib/hello_phx_web/controllers/post_controller.ex new file mode 100644 index 0000000..4bdb324 --- /dev/null +++ b/lib/hello_phx_web/controllers/post_controller.ex @@ -0,0 +1,62 @@ +defmodule HelloPhxWeb.PostController do + use HelloPhxWeb, :controller + + alias HelloPhx.Blog + alias HelloPhx.Blog.Post + + def index(conn, _params) do + posts = Blog.list_posts() + render(conn, :index, posts: posts) + end + + def new(conn, _params) do + changeset = Blog.change_post(%Post{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"post" => post_params}) do + case Blog.create_post(post_params) do + {:ok, post} -> + conn + |> put_flash(:info, "Post created successfully.") + |> redirect(to: ~p"/posts/#{post}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + post = Blog.get_post!(id) + render(conn, :show, post: post) + end + + def edit(conn, %{"id" => id}) do + post = Blog.get_post!(id) + changeset = Blog.change_post(post) + render(conn, :edit, post: post, changeset: changeset) + end + + def update(conn, %{"id" => id, "post" => post_params}) do + post = Blog.get_post!(id) + + case Blog.update_post(post, post_params) do + {:ok, post} -> + conn + |> put_flash(:info, "Post updated successfully.") + |> redirect(to: ~p"/posts/#{post}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :edit, post: post, changeset: changeset) + end + end + + def delete(conn, %{"id" => id}) do + post = Blog.get_post!(id) + {:ok, _post} = Blog.delete_post(post) + + conn + |> put_flash(:info, "Post deleted successfully.") + |> redirect(to: ~p"/posts") + end +end diff --git a/lib/hello_phx_web/controllers/post_html.ex b/lib/hello_phx_web/controllers/post_html.ex new file mode 100644 index 0000000..049a542 --- /dev/null +++ b/lib/hello_phx_web/controllers/post_html.ex @@ -0,0 +1,13 @@ +defmodule HelloPhxWeb.PostHTML do + use HelloPhxWeb, :html + + embed_templates "post_html/*" + + @doc """ + Renders a post form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + + def post_form(assigns) +end diff --git a/lib/hello_phx_web/controllers/post_html/edit.html.heex b/lib/hello_phx_web/controllers/post_html/edit.html.heex new file mode 100644 index 0000000..3e59e5b --- /dev/null +++ b/lib/hello_phx_web/controllers/post_html/edit.html.heex @@ -0,0 +1,8 @@ +<.header> + Edit Post <%= @post.id %> + <:subtitle>Use this form to manage post records in your database. + + +<.post_form changeset={@changeset} action={~p"/posts/#{@post}"} /> + +<.back navigate={~p"/posts"}>Back to posts diff --git a/lib/hello_phx_web/controllers/post_html/index.html.heex b/lib/hello_phx_web/controllers/post_html/index.html.heex new file mode 100644 index 0000000..aab94e2 --- /dev/null +++ b/lib/hello_phx_web/controllers/post_html/index.html.heex @@ -0,0 +1,24 @@ +<.header> + Listing Posts + <:actions> + <.link href={~p"/posts/new"}> + <.button>New Post + + + + +<.table id="posts" rows={@posts} row_click={&JS.navigate(~p"/posts/#{&1}")}> + <:col :let={post} label="Title"><%= post.title %> + <:col :let={post} label="Body"><%= post.body %> + <:action :let={post}> +
+ <.link navigate={~p"/posts/#{post}"}>Show +
+ <.link navigate={~p"/posts/#{post}/edit"}>Edit + + <:action :let={post}> + <.link href={~p"/posts/#{post}"} method="delete" data-confirm="Are you sure?"> + Delete + + + diff --git a/lib/hello_phx_web/controllers/post_html/new.html.heex b/lib/hello_phx_web/controllers/post_html/new.html.heex new file mode 100644 index 0000000..1beca24 --- /dev/null +++ b/lib/hello_phx_web/controllers/post_html/new.html.heex @@ -0,0 +1,8 @@ +<.header> + New Post + <:subtitle>Use this form to manage post records in your database. + + +<.post_form changeset={@changeset} action={~p"/posts"} /> + +<.back navigate={~p"/posts"}>Back to posts diff --git a/lib/hello_phx_web/controllers/post_html/post_form.html.heex b/lib/hello_phx_web/controllers/post_html/post_form.html.heex new file mode 100644 index 0000000..5822029 --- /dev/null +++ b/lib/hello_phx_web/controllers/post_html/post_form.html.heex @@ -0,0 +1,10 @@ +<.simple_form :let={f} for={@changeset} action={@action}> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:title]} type="text" label="Title" /> + <.input field={f[:body]} type="text" label="Body" /> + <:actions> + <.button>Save Post + + diff --git a/lib/hello_phx_web/controllers/post_html/show.html.heex b/lib/hello_phx_web/controllers/post_html/show.html.heex new file mode 100644 index 0000000..9567650 --- /dev/null +++ b/lib/hello_phx_web/controllers/post_html/show.html.heex @@ -0,0 +1,16 @@ +<.header> + Post <%= @post.id %> + <:subtitle>This is a post record from your database. + <:actions> + <.link href={~p"/posts/#{@post}/edit"}> + <.button>Edit post + + + + +<.list> + <:item title="Title"><%= @post.title %> + <:item title="Body"><%= @post.body %> + + +<.back navigate={~p"/posts"}>Back to posts diff --git a/lib/hello_phx_web/controllers/product_controller.ex b/lib/hello_phx_web/controllers/product_controller.ex new file mode 100644 index 0000000..9e3920a --- /dev/null +++ b/lib/hello_phx_web/controllers/product_controller.ex @@ -0,0 +1,71 @@ +defmodule HelloPhxWeb.ProductController do + use HelloPhxWeb, :controller + + alias HelloPhx.Catalog + alias HelloPhx.Catalog.Product + require Logger + + def index(conn, _params) do + products = Catalog.list_products() + render(conn, :index, products: products) + end + + def new(conn, _params) do + changeset = Catalog.change_product(%Product{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"product" => product_params}) do + case Catalog.create_product(product_params) do + {:ok, product} -> + conn + |> put_flash(:info, "Product created successfully.") + |> redirect(to: ~p"/products/#{product}") + + {:error, %Ecto.Changeset{} = changeset} -> + unless changeset.valid? do + Logger.warning("invalid product changeset: #{inspect(changeset)}") + end + + render(conn, :new, changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + product = + id + |> Catalog.get_product!() + |> Catalog.inc_page_views() + + render(conn, :show, product: product) + end + + def edit(conn, %{"id" => id}) do + product = Catalog.get_product!(id) + changeset = Catalog.change_product(product) + render(conn, :edit, product: product, changeset: changeset) + end + + def update(conn, %{"id" => id, "product" => product_params}) do + product = Catalog.get_product!(id) + + case Catalog.update_product(product, product_params) do + {:ok, product} -> + conn + |> put_flash(:info, "Product updated successfully.") + |> redirect(to: ~p"/products/#{product}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :edit, product: product, changeset: changeset) + end + end + + def delete(conn, %{"id" => id}) do + product = Catalog.get_product!(id) + {:ok, _product} = Catalog.delete_product(product) + + conn + |> put_flash(:info, "Product deleted successfully.") + |> redirect(to: ~p"/products") + end +end diff --git a/lib/hello_phx_web/controllers/product_html.ex b/lib/hello_phx_web/controllers/product_html.ex new file mode 100644 index 0000000..5463363 --- /dev/null +++ b/lib/hello_phx_web/controllers/product_html.ex @@ -0,0 +1,23 @@ +defmodule HelloPhxWeb.ProductHTML do + use HelloPhxWeb, :html + + embed_templates "product_html/*" + + @doc """ + Renders a product form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + + def product_form(assigns) + + def category_opts(changeset) do + existing_ids = + changeset + |> Ecto.Changeset.get_change(:categories, []) + |> Enum.map(& &1.data.id) + + for cat <- HelloPhx.Catalog.list_categories(), + do: [key: cat.title, value: cat.id, selected: cat.id in existing_ids] + end +end diff --git a/lib/hello_phx_web/controllers/product_html/edit.html.heex b/lib/hello_phx_web/controllers/product_html/edit.html.heex new file mode 100644 index 0000000..2d72c77 --- /dev/null +++ b/lib/hello_phx_web/controllers/product_html/edit.html.heex @@ -0,0 +1,8 @@ +<.header> + Edit Product <%= @product.id %> + <:subtitle>Use this form to manage product records in your database. + + +<.product_form changeset={@changeset} action={~p"/products/#{@product}"} /> + +<.back navigate={~p"/products"}>Back to products diff --git a/lib/hello_phx_web/controllers/product_html/index.html.heex b/lib/hello_phx_web/controllers/product_html/index.html.heex new file mode 100644 index 0000000..4a81267 --- /dev/null +++ b/lib/hello_phx_web/controllers/product_html/index.html.heex @@ -0,0 +1,30 @@ +<.header> + Listing Products + <:actions> + <.link href={~p"/products/new"}> + <.button>New Product + + <.link href={~p"/cart"}> + <.button>My Cart + + + + +<.table id="products" rows={@products} row_click={&JS.navigate(~p"/products/#{&1}")}> + <:col :let={product} label="ID"><%= product.id %> + <:col :let={product} label="Title"><%= product.title %> + <:col :let={product} label="Description"><%= product.description %> + <:col :let={product} label="Price"><%= product.price %> + <:col :let={product} label="Views"><%= product.views %> + <:action :let={product}> +
+ <.link navigate={~p"/products/#{product}"}>Show +
+ <.link navigate={~p"/products/#{product}/edit"}>Edit + + <:action :let={product}> + <.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?"> + Delete + + + diff --git a/lib/hello_phx_web/controllers/product_html/new.html.heex b/lib/hello_phx_web/controllers/product_html/new.html.heex new file mode 100644 index 0000000..60fdeeb --- /dev/null +++ b/lib/hello_phx_web/controllers/product_html/new.html.heex @@ -0,0 +1,8 @@ +<.header> + New Product + <:subtitle>Use this form to manage product records in your database. + + +<.product_form changeset={@changeset} action={~p"/products"} /> + +<.back navigate={~p"/products"}>Back to products diff --git a/lib/hello_phx_web/controllers/product_html/product_form.html.heex b/lib/hello_phx_web/controllers/product_html/product_form.html.heex new file mode 100644 index 0000000..77e4576 --- /dev/null +++ b/lib/hello_phx_web/controllers/product_html/product_form.html.heex @@ -0,0 +1,19 @@ +<.simple_form :let={f} for={@changeset} action={@action}> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:title]} type="text" label="Title" /> + <.input field={f[:description]} type="text" label="Description" /> + <.input field={f[:price]} type="number" label="Price" step="any" /> + <.input + field={f[:category_ids]} + type="select" + label="Categories" + multiple={true} + options={category_opts(@changeset)} + /> + + <:actions> + <.button>Save Product + + diff --git a/lib/hello_phx_web/controllers/product_html/show.html.heex b/lib/hello_phx_web/controllers/product_html/show.html.heex new file mode 100644 index 0000000..12c590c --- /dev/null +++ b/lib/hello_phx_web/controllers/product_html/show.html.heex @@ -0,0 +1,28 @@ +<.header> + Product <%= @product.id %> + <:subtitle>This is a product record from your database. + <:actions> + <.link href={~p"/products/#{@product}/edit"}> + <.button>Edit product + + <.link href={~p"/cart_items?product_id=#{@product.id}"} method="post"> + <.button>Add to cart + + + + +<.list> + <:item title="Title"><%= @product.title %> + <:item title="Description"><%= @product.description %> + <:item title="Price"><%= @product.price %> + <:item title="Views"><%= @product.views %> + <:item title="Categories"> + <%= for cat <- @product.categories do %> + <%= cat.title %>
+ <% end %> + + <:item title="Inserted At"><%= @product.inserted_at %> + <:item title="Updated At"><%= @product.updated_at %> + + +<.back navigate={~p"/products"}>Back to products diff --git a/lib/hello_phx_web/controllers/status_controller.ex b/lib/hello_phx_web/controllers/status_controller.ex new file mode 100644 index 0000000..e2ada03 --- /dev/null +++ b/lib/hello_phx_web/controllers/status_controller.ex @@ -0,0 +1,120 @@ +defmodule HelloPhxWeb.StatusController do + use HelloPhxWeb, :controller + + ## Copied from Plug.Conn.Status + # https://hexdocs.pm/plug/Plug.Conn.Status.html#code/1-known-status-codes + @statuses %{ + 100 => "Continue", + 101 => "Switching Protocols", + 102 => "Processing", + 103 => "Early Hints", + 200 => "OK", + 201 => "Created", + 202 => "Accepted", + 203 => "Non-Authoritative Information", + 204 => "No Content", + 205 => "Reset Content", + 206 => "Partial Content", + 207 => "Multi-Status", + 208 => "Already Reported", + 226 => "IM Used", + 300 => "Multiple Choices", + 301 => "Moved Permanently", + 302 => "Found", + 303 => "See Other", + 304 => "Not Modified", + 305 => "Use Proxy", + 306 => "Switch Proxy", + 307 => "Temporary Redirect", + 308 => "Permanent Redirect", + 400 => "Bad Request", + 401 => "Unauthorized", + 402 => "Payment Required", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 406 => "Not Acceptable", + 407 => "Proxy Authentication Required", + 408 => "Request Timeout", + 409 => "Conflict", + 410 => "Gone", + 411 => "Length Required", + 412 => "Precondition Failed", + 413 => "Request Entity Too Large", + 414 => "Request-URI Too Long", + 415 => "Unsupported Media Type", + 416 => "Requested Range Not Satisfiable", + 417 => "Expectation Failed", + 418 => "I'm a teapot", + 421 => "Misdirected Request", + 422 => "Unprocessable Entity", + 423 => "Locked", + 424 => "Failed Dependency", + 425 => "Too Early", + 426 => "Upgrade Required", + 428 => "Precondition Required", + 429 => "Too Many Requests", + 431 => "Request Header Fields Too Large", + 451 => "Unavailable For Legal Reasons", + 500 => "Internal Server Error", + 501 => "Not Implemented", + 502 => "Bad Gateway", + 503 => "Service Unavailable", + 504 => "Gateway Timeout", + 505 => "HTTP Version Not Supported", + 506 => "Variant Also Negotiates", + 507 => "Insufficient Storage", + 508 => "Loop Detected", + 510 => "Not Extended", + 511 => "Network Authentication Required" + } + + def index(conn, _) do + conn + |> put_resp_content_type("text/plain") + |> json(statuses()) + end + + def show(conn, %{} = params) do + raw_code = params["code"] + code = norm_status_code(raw_code) + rcode = resp_code(code) + phrase = phrase_reason(code) + + data = %{ + status_code: code, + phrase: phrase, + raw_code: raw_code, + resp_code: rcode + } + + conn + |> put_status(rcode) + |> json(data) + end + + def norm_status_code(code_str) do + try do + int_code = String.to_integer(code_str) + + if int_code in codes() do + int_code + else + :invalid_code + end + catch + _ -> + :invalid_code + end + end + + def phrase_reason(code) when is_integer(code), do: Plug.Conn.Status.reason_phrase(code) + def phrase_reason(_code), do: "invalid code" + + def resp_code(code) when is_integer(code), do: code + def resp_code(_code), do: 400 + + def statuses, do: @statuses + def codes, do: statuses() |> Map.keys() + def phrase(code) when is_integer(code), do: statuses()[code] +end diff --git a/lib/hello_phx_web/controllers/url_controller.ex b/lib/hello_phx_web/controllers/url_controller.ex new file mode 100644 index 0000000..50cb154 --- /dev/null +++ b/lib/hello_phx_web/controllers/url_controller.ex @@ -0,0 +1,43 @@ +defmodule HelloPhxWeb.UrlController do + use HelloPhxWeb, :controller + + alias HelloPhx.Urls + alias HelloPhx.Urls.Url + + action_fallback HelloPhxWeb.FallbackController + + def index(conn, _params) do + urls = Urls.list_urls() + render(conn, :index, urls: urls) + end + + def create(conn, %{"url" => url_params}) do + with {:ok, %Url{} = url} <- Urls.create_url(url_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/urls/#{url}") + |> render(:show, url: url) + end + end + + def show(conn, %{"id" => id}) do + url = Urls.get_url!(id) + render(conn, :show, url: url) + end + + def update(conn, %{"id" => id, "url" => url_params}) do + url = Urls.get_url!(id) + + with {:ok, %Url{} = url} <- Urls.update_url(url, url_params) do + render(conn, :show, url: url) + end + end + + def delete(conn, %{"id" => id}) do + url = Urls.get_url!(id) + + with {:ok, %Url{}} <- Urls.delete_url(url) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/hello_phx_web/controllers/url_json.ex b/lib/hello_phx_web/controllers/url_json.ex new file mode 100644 index 0000000..9a3b0c5 --- /dev/null +++ b/lib/hello_phx_web/controllers/url_json.ex @@ -0,0 +1,25 @@ +defmodule HelloPhxWeb.UrlJSON do + alias HelloPhx.Urls.Url + + @doc """ + Renders a list of urls. + """ + def index(%{urls: urls}) do + %{data: for(url <- urls, do: data(url))} + end + + @doc """ + Renders a single url. + """ + def show(%{url: url}) do + %{data: data(url)} + end + + defp data(%Url{} = url) do + %{ + id: url.id, + link: url.link, + title: url.title + } + end +end diff --git a/lib/hello_phx_web/controllers/user_session_controller.ex b/lib/hello_phx_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..cc7d014 --- /dev/null +++ b/lib/hello_phx_web/controllers/user_session_controller.ex @@ -0,0 +1,42 @@ +defmodule HelloPhxWeb.UserSessionController do + use HelloPhxWeb, :controller + + alias HelloPhx.Accounts + alias HelloPhxWeb.UserAuth + + def create(conn, %{"_action" => "registered"} = params) do + create(conn, params, "Account created successfully!") + end + + def create(conn, %{"_action" => "password_updated"} = params) do + conn + |> put_session(:user_return_to, ~p"/users/settings") + |> create(params, "Password updated successfully!") + end + + def create(conn, params) do + create(conn, params, "Welcome back!") + end + + defp create(conn, %{"user" => user_params}, info) do + %{"email" => email, "password" => password} = user_params + + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + else + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + conn + |> put_flash(:error, "Invalid email or password") + |> put_flash(:email, String.slice(email, 0, 160)) + |> redirect(to: ~p"/users/log_in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/hello_phx_web/endpoint.ex b/lib/hello_phx_web/endpoint.ex index c3d36a9..8b090ea 100644 --- a/lib/hello_phx_web/endpoint.ex +++ b/lib/hello_phx_web/endpoint.ex @@ -11,43 +11,58 @@ defmodule HelloPhxWeb.Endpoint do same_site: "Lax" ] - socket "/live", Phoenix.LiveView.Socket, + socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]], longpoll: [connect_info: [session: @session_options]] + ) # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phx.digest # when deploying your static files in production. - plug Plug.Static, + plug(Plug.Static, at: "/", from: :hello_phx, gzip: false, only: HelloPhxWeb.static_paths() + ) # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if code_reloading? do - socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket - plug Phoenix.LiveReloader - plug Phoenix.CodeReloader - plug Phoenix.Ecto.CheckRepoStatus, otp_app: :hello_phx + socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) + plug(Phoenix.LiveReloader) + plug(Phoenix.CodeReloader) + plug(Phoenix.Ecto.CheckRepoStatus, otp_app: :hello_phx) end - plug Phoenix.LiveDashboard.RequestLogger, + plug(Phoenix.LiveDashboard.RequestLogger, param_key: "request_logger", cookie_key: "request_logger" + ) - plug Plug.RequestId - plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + plug(Plug.RequestId) + plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint], log: :info) - plug Plug.Parsers, + plug(Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Phoenix.json_library() + ) - plug Plug.MethodOverride - plug Plug.Head - plug Plug.Session, @session_options - plug HelloPhxWeb.Router + plug(Plug.MethodOverride) + plug(Plug.Head) + plug(Plug.Session, @session_options) + # plug(:introspect) + plug(HelloPhxWeb.Router) + + def introspect(conn, _opts) do + IO.puts(""" + Verb: #{inspect(conn.method)} + Host: #{inspect(conn.host)} + Headers: #{inspect(conn.req_headers)} + """) + + conn + end end diff --git a/lib/hello_phx_web/errors.ex b/lib/hello_phx_web/errors.ex new file mode 100644 index 0000000..250f232 --- /dev/null +++ b/lib/hello_phx_web/errors.ex @@ -0,0 +1,26 @@ +defmodule HelloPhxWeb.StatusCodeError do + # https://hexdocs.pm/phoenix/custom_error_pages.html#custom-exceptions + defexception code: 0, message: "unknown status code", plug_status: 404 +end + +defimpl Plug.Exception, for: HelloPhxWeb.StatusCodeError do + require Logger + def status(_exception), do: 500 + + def actions(_exception) do + [ + # %{ + # label: "Run seeds", + # handler: {Code, :eval_file, ["priv/repo/seeds.exs"]} + # } + %{ + label: "Exception TestAction1", + handler: {__MODULE__, :do_fix_action, []} + } + ] + end + + def do_fix_action() do + Logger.warning("mocking do exception actions...") + end +end diff --git a/lib/hello_phx_web/live/user_confirmation_instructions_live.ex b/lib/hello_phx_web/live/user_confirmation_instructions_live.ex new file mode 100644 index 0000000..789f331 --- /dev/null +++ b/lib/hello_phx_web/live/user_confirmation_instructions_live.ex @@ -0,0 +1,51 @@ +defmodule HelloPhxWeb.UserConfirmationInstructionsLive do + use HelloPhxWeb, :live_view + + alias HelloPhx.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + No confirmation instructions received? + <:subtitle>We'll send a new confirmation link to your inbox + + + <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Resend confirmation instructions + + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + end + + info = + "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/hello_phx_web/live/user_confirmation_live.ex b/lib/hello_phx_web/live/user_confirmation_live.ex new file mode 100644 index 0000000..1fe88d3 --- /dev/null +++ b/lib/hello_phx_web/live/user_confirmation_live.ex @@ -0,0 +1,58 @@ +defmodule HelloPhxWeb.UserConfirmationLive do + use HelloPhxWeb, :live_view + + alias HelloPhx.Accounts + + def render(%{live_action: :edit} = assigns) do + ~H""" +
+ <.header class="text-center">Confirm Account + + <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account"> + + <:actions> + <.button phx-disable-with="Confirming..." class="w-full">Confirm my account + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + form = to_form(%{"token" => token}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: nil]} + end + + # Do not log in the user after confirmation to avoid a + # leaked token giving the user access to the account. + def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do + case Accounts.confirm_user(token) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "User confirmed successfully.") + |> redirect(to: ~p"/")} + + :error -> + # If there is a current user and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the user themselves, so we redirect without + # a warning message. + case socket.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + {:noreply, redirect(socket, to: ~p"/")} + + %{} -> + {:noreply, + socket + |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> redirect(to: ~p"/")} + end + end + end +end diff --git a/lib/hello_phx_web/live/user_forgot_password_live.ex b/lib/hello_phx_web/live/user_forgot_password_live.ex new file mode 100644 index 0000000..d7f7072 --- /dev/null +++ b/lib/hello_phx_web/live/user_forgot_password_live.ex @@ -0,0 +1,50 @@ +defmodule HelloPhxWeb.UserForgotPasswordLive do + use HelloPhxWeb, :live_view + + alias HelloPhx.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Forgot your password? + <:subtitle>We'll send a password reset link to your inbox + + + <.simple_form for={@form} id="reset_password_form" phx-submit="send_email"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Send password reset instructions + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_reset_password_instructions( + user, + &url(~p"/users/reset_password/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions to reset your password shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/hello_phx_web/live/user_login_live.ex b/lib/hello_phx_web/live/user_login_live.ex new file mode 100644 index 0000000..49c4541 --- /dev/null +++ b/lib/hello_phx_web/live/user_login_live.ex @@ -0,0 +1,43 @@ +defmodule HelloPhxWeb.UserLoginLive do + use HelloPhxWeb, :live_view + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Log in to account + <:subtitle> + Don't have an account? + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> + Sign up + + for an account now. + + + + <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore"> + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> + <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> + Forgot your password? + + + <:actions> + <.button phx-disable-with="Logging in..." class="w-full"> + Log in + + + +
+ """ + end + + def mount(_params, _session, socket) do + email = Phoenix.Flash.get(socket.assigns.flash, :email) + form = to_form(%{"email" => email}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: form]} + end +end diff --git a/lib/hello_phx_web/live/user_registration_live.ex b/lib/hello_phx_web/live/user_registration_live.ex new file mode 100644 index 0000000..09263a9 --- /dev/null +++ b/lib/hello_phx_web/live/user_registration_live.ex @@ -0,0 +1,87 @@ +defmodule HelloPhxWeb.UserRegistrationLive do + use HelloPhxWeb, :live_view + + alias HelloPhx.Accounts + alias HelloPhx.Accounts.User + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + + + <.simple_form + for={@form} + id="registration_form" + phx-submit="save" + phx-change="validate" + phx-trigger-action={@trigger_submit} + action={~p"/users/log_in?_action=registered"} + method="post" + > + <.error :if={@check_errors}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.button phx-disable-with="Creating account..." class="w-full">Create an account + + +
+ """ + end + + def mount(_params, _session, socket) do + changeset = Accounts.change_user_registration(%User{}) + + socket = + socket + |> assign(trigger_submit: false, check_errors: false) + |> assign_form(changeset) + + {:ok, socket, temporary_assigns: [form: nil]} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + + changeset = Accounts.change_user_registration(user) + {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_registration(%User{}, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + form = to_form(changeset, as: "user") + + if changeset.valid? do + assign(socket, form: form, check_errors: false) + else + assign(socket, form: form) + end + end +end diff --git a/lib/hello_phx_web/live/user_reset_password_live.ex b/lib/hello_phx_web/live/user_reset_password_live.ex new file mode 100644 index 0000000..2fd8652 --- /dev/null +++ b/lib/hello_phx_web/live/user_reset_password_live.ex @@ -0,0 +1,89 @@ +defmodule HelloPhxWeb.UserResetPasswordLive do + use HelloPhxWeb, :live_view + + alias HelloPhx.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center">Reset Password + + <.simple_form + for={@form} + id="reset_password_form" + phx-submit="reset_password" + phx-change="validate" + > + <.error :if={@form.errors != []}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:password]} type="password" label="New password" required /> + <.input + field={@form[:password_confirmation]} + type="password" + label="Confirm new password" + required + /> + <:actions> + <.button phx-disable-with="Resetting..." class="w-full">Reset Password + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(params, _session, socket) do + socket = assign_user_and_token(socket, params) + + form_source = + case socket.assigns do + %{user: user} -> + Accounts.change_user_password(user) + + _ -> + %{} + end + + {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]} + end + + # Do not log in the user after reset password to avoid a + # leaked token giving the user access to the account. + def handle_event("reset_password", %{"user" => user_params}, socket) do + case Accounts.reset_user_password(socket.assigns.user, user_params) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: ~p"/users/log_in")} + + {:error, changeset} -> + {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_password(socket.assigns.user, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_user_and_token(socket, %{"token" => token}) do + if user = Accounts.get_user_by_reset_password_token(token) do + assign(socket, user: user, token: token) + else + socket + |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> redirect(to: ~p"/") + end + end + + defp assign_form(socket, %{} = source) do + assign(socket, :form, to_form(source, as: "user")) + end +end diff --git a/lib/hello_phx_web/live/user_settings_live.ex b/lib/hello_phx_web/live/user_settings_live.ex new file mode 100644 index 0000000..ad3f14c --- /dev/null +++ b/lib/hello_phx_web/live/user_settings_live.ex @@ -0,0 +1,167 @@ +defmodule HelloPhxWeb.UserSettingsLive do + use HelloPhxWeb, :live_view + + alias HelloPhx.Accounts + + def render(assigns) do + ~H""" + <.header class="text-center"> + Account Settings + <:subtitle>Manage your account email address and password settings + + +
+
+ <.simple_form + for={@email_form} + id="email_form" + phx-submit="update_email" + phx-change="validate_email" + > + <.input field={@email_form[:email]} type="email" label="Email" required /> + <.input + field={@email_form[:current_password]} + name="current_password" + id="current_password_for_email" + type="password" + label="Current password" + value={@email_form_current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Email + + +
+
+ <.simple_form + for={@password_form} + id="password_form" + action={~p"/users/log_in?_action=password_updated"} + method="post" + phx-change="validate_password" + phx-submit="update_password" + phx-trigger-action={@trigger_submit} + > + + <.input field={@password_form[:password]} type="password" label="New password" required /> + <.input + field={@password_form[:password_confirmation]} + type="password" + label="Confirm new password" + /> + <.input + field={@password_form[:current_password]} + name="current_password" + type="password" + label="Current password" + id="current_password_for_password" + value={@current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Password + + +
+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + socket = + case Accounts.update_user_email(socket.assigns.current_user, token) do + :ok -> + put_flash(socket, :info, "Email changed successfully.") + + :error -> + put_flash(socket, :error, "Email change link is invalid or it has expired.") + end + + {:ok, push_navigate(socket, to: ~p"/users/settings")} + end + + def mount(_params, _session, socket) do + user = socket.assigns.current_user + email_changeset = Accounts.change_user_email(user) + password_changeset = Accounts.change_user_password(user) + + socket = + socket + |> assign(:current_password, nil) + |> assign(:email_form_current_password, nil) + |> assign(:current_email, user.email) + |> assign(:email_form, to_form(email_changeset)) + |> assign(:password_form, to_form(password_changeset)) + |> assign(:trigger_submit, false) + + {:ok, socket} + end + + def handle_event("validate_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + email_form = + socket.assigns.current_user + |> Accounts.change_user_email(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, email_form: email_form, email_form_current_password: password)} + end + + def handle_event("update_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.apply_user_email(user, password, user_params) do + {:ok, applied_user} -> + Accounts.deliver_user_update_email_instructions( + applied_user, + user.email, + &url(~p"/users/settings/confirm_email/#{&1}") + ) + + info = "A link to confirm your email change has been sent to the new address." + {:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)} + + {:error, changeset} -> + {:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))} + end + end + + def handle_event("validate_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + password_form = + socket.assigns.current_user + |> Accounts.change_user_password(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, password_form: password_form, current_password: password)} + end + + def handle_event("update_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.update_user_password(user, password, user_params) do + {:ok, user} -> + password_form = + user + |> Accounts.change_user_password(user_params) + |> to_form() + + {:noreply, assign(socket, trigger_submit: true, password_form: password_form)} + + {:error, changeset} -> + {:noreply, assign(socket, password_form: to_form(changeset))} + end + end +end diff --git a/lib/hello_phx_web/plugs/locale.ex b/lib/hello_phx_web/plugs/locale.ex new file mode 100644 index 0000000..dc0f431 --- /dev/null +++ b/lib/hello_phx_web/plugs/locale.ex @@ -0,0 +1,15 @@ +defmodule HelloPhxWeb.Plugs.Locale do + import Plug.Conn + + @locales ["en", "fr", "de"] + + def init(default), do: default + + def call(%Plug.Conn{params: %{"locale" => loc}} = conn, _default) when loc in @locales do + assign(conn, :locale, loc) + end + + def call(conn, default) do + assign(conn, :locale, default) + end +end diff --git a/lib/hello_phx_web/router.ex b/lib/hello_phx_web/router.ex index da4d0f7..2ad20b4 100644 --- a/lib/hello_phx_web/router.ex +++ b/lib/hello_phx_web/router.ex @@ -1,17 +1,46 @@ defmodule HelloPhxWeb.Router do use HelloPhxWeb, :router + import HelloPhxWeb.UserAuth + pipeline :browser do - plug(:accepts, ["html"]) + plug(:accepts, ["html", "json"]) plug(:fetch_session) plug(:fetch_live_flash) plug(:put_root_layout, html: {HelloPhxWeb.Layouts, :root}) plug(:protect_from_forgery) plug(:put_secure_browser_headers) + plug(:fetch_current_user) + plug(HelloPhxWeb.Plugs.Locale, "en") end - pipeline :api do - plug(:accepts, ["json"]) + pipeline :shopping_on_browser do + plug(:browser) + plug(:fetch_current_user_uuid) + plug(:fetch_current_cart) + end + + def fetch_current_user_uuid(conn, _opts) do + if user_uuid = get_session(conn, :current_uuid) do + assign(conn, :current_uuid, user_uuid) + else + new_uuid = Ecto.UUID.generate() + + conn + |> assign(:current_uuid, new_uuid) + |> put_session(:current_uuid, new_uuid) + end + end + + alias HelloPhx.ShoppingCart + + defp fetch_current_cart(conn, _opts) do + if cart = ShoppingCart.get_cart_by_user_uuid(conn.assigns.current_uuid) do + assign(conn, :cart, cart) + else + {:ok, new_cart} = ShoppingCart.create_cart(conn.assigns.current_uuid) + assign(conn, :cart, new_cart) + end end scope "/", HelloPhxWeb do @@ -20,13 +49,55 @@ defmodule HelloPhxWeb.Router do get("/", PageController, :home) get("/hello", HelloController, :index) + get("/hello/func", HelloController, :fun_component) + get("/hello/text", HelloController, :text_action) + get("/hello/no-layout", HelloController, :no_layout) + get("/hello/mock-error", HelloController, :mock_error) get("/hello/:messenger", HelloController, :show) + + # Status code + get("/statuses", StatusController, :index) + get("/statuses/:code", StatusController, :show) + + # Blog posts + resources("/posts", PostController) + resources("/comments", CommentController) + end + + ## Shopping routes + + scope "/", HelloPhxWeb do + pipe_through(:shopping_on_browser) + + # catalog products + resources("/products", ProductController) + resources("/cart_items", CartItemController, only: [:create, :delete]) + get("/cart", CartController, :show) + put("/cart", CartController, :update) + resources("/orders", OrderController, only: [:create, :show]) + end + + ## API Routes + + pipeline :api do + plug(:accepts, ["json"]) end - # Other scopes may use custom stacks. - # scope "/api", HelloPhxWeb do - # pipe_through :api - # end + scope "/api", HelloPhxWeb do + pipe_through(:api) + get("/", ApiController, :home) + get("/ping", ApiController, :ping) + get("/mock-404", ApiController, :mock_404) + + resources("/urls", UrlController, except: [:new, :edit]) + resources("/articles", ArticleController, except: [:new, :edit]) + end + + scope "/api", HelloPhxWeb do + pipe_through([:api, :fetch_api_user]) + + get("/users/current", Api.UserController, :current) + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:hello_phx, :dev_routes) do @@ -44,4 +115,42 @@ defmodule HelloPhxWeb.Router do forward("/mailbox", Plug.Swoosh.MailboxPreview) end end + + ## Authentication routes + + scope "/", HelloPhxWeb do + pipe_through([:browser, :redirect_if_user_is_authenticated]) + + live_session :redirect_if_user_is_authenticated, + on_mount: [{HelloPhxWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live("/users/register", UserRegistrationLive, :new) + live("/users/log_in", UserLoginLive, :new) + live("/users/reset_password", UserForgotPasswordLive, :new) + live("/users/reset_password/:token", UserResetPasswordLive, :edit) + end + + post("/users/log_in", UserSessionController, :create) + end + + scope "/", HelloPhxWeb do + pipe_through([:browser, :require_authenticated_user]) + + live_session :require_authenticated_user, + on_mount: [{HelloPhxWeb.UserAuth, :ensure_authenticated}] do + live("/users/settings", UserSettingsLive, :edit) + live("/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email) + end + end + + scope "/", HelloPhxWeb do + pipe_through([:browser]) + + delete("/users/log_out", UserSessionController, :delete) + + live_session :current_user, + on_mount: [{HelloPhxWeb.UserAuth, :mount_current_user}] do + live("/users/confirm/:token", UserConfirmationLive, :edit) + live("/users/confirm", UserConfirmationInstructionsLive, :new) + end + end end diff --git a/lib/hello_phx_web/user_auth.ex b/lib/hello_phx_web/user_auth.ex new file mode 100644 index 0000000..c78d65e --- /dev/null +++ b/lib/hello_phx_web/user_auth.ex @@ -0,0 +1,241 @@ +defmodule HelloPhxWeb.UserAuth do + use HelloPhxWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias HelloPhx.Accounts + + # Make the remember me cookie valid for 60 days. + # If you want bump or reduce this value, also change + # the token expiry itself in UserToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_hello_phx_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the user in. + + It renews the session ID and clears the whole session + to avoid fixation attacks. See the renew_session + function to customize this behaviour. + + It also sets a `:live_socket_id` key in the session, + so LiveView sessions are identified and automatically + disconnected on log out. The line can be safely removed + if you are not using LiveView. + """ + def log_in_user(conn, user, params \\ %{}) do + token = Accounts.generate_user_session_token(user) + user_return_to = get_session(conn, :user_return_to) + + conn + |> renew_session() + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + end + + defp maybe_write_remember_me_cookie(conn, _token, _params) do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn) do + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn) do + delete_csrf_token() + + conn + |> configure_session(renew: true) + |> clear_session() + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + HelloPhxWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session + and remember me token. + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && Accounts.get_user_by_session_token(user_token) + assign(conn, :current_user, user) + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, put_token_in_session(conn, token)} + else + {nil, conn} + end + end + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + + defmodule HelloPhxWeb.PageLive do + use HelloPhxWeb, :live_view + + on_mount {HelloPhxWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{HelloPhxWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(socket, session)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} + else + {:cont, socket} + end + end + + defp mount_current_user(socket, session) do + Phoenix.Component.assign_new(socket, :current_user, fn -> + if user_token = session["user_token"] do + Accounts.get_user_by_session_token(user_token) + end + end) + end + + @doc """ + Used for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the user to be authenticated. + + If you want to enforce the user email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log_in") + |> halt() + end + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: ~p"/" + + def fetch_api_user(conn, _opts) do + with ["Bearer " <> token] <- get_req_header(conn, "authorization"), + {:ok, user} <- Accounts.fetch_user_by_api_token(token) do + assign(conn, :current_user, user) + else + _ -> + conn + |> send_resp(:unauthorized, "No access for you") + |> halt() + end + end +end diff --git a/lib/mix/tasks/commit.ex b/lib/mix/tasks/commit.ex new file mode 100644 index 0000000..f5141a8 --- /dev/null +++ b/lib/mix/tasks/commit.ex @@ -0,0 +1,13 @@ +defmodule Mix.Tasks.Commit do + use Mix.Task + + @shortdoc "Get git commit info" + @requirements ["app.start"] + + @impl Mix.Task + def run(_args) do + HelloPhx.commit() + |> inspect(pretty: true, width: 80) + |> Mix.shell().info + end +end diff --git a/lib/mix/tasks/hello.task.ex b/lib/mix/tasks/hello.task.ex new file mode 100644 index 0000000..9af2f90 --- /dev/null +++ b/lib/mix/tasks/hello.task.ex @@ -0,0 +1,17 @@ +defmodule Mix.Tasks.Hello.Task do + use Mix.Task + + @shortdoc "Try hello task" + + @moduledoc """ + This is where we would put any long form documentation and doctests. + """ + + @requirements ["app.start"] + + @impl Mix.Task + def run(_args) do + Mix.shell().info("Now I have access to Repo and other goodies!") + Mix.shell().info("Greetings from the Hello Phoenix Application!") + end +end diff --git a/mix.exs b/mix.exs index f0fd965..0b5e98c 100644 --- a/mix.exs +++ b/mix.exs @@ -1,11 +1,15 @@ defmodule HelloPhx.MixProject do use Mix.Project + @version "0.1.0" + @source_url "https://github.com/cao7113/hello-phx" + def project do [ app: :hello_phx, - version: "0.1.0", - elixir: "~> 1.14", + version: @version, + source_url: @source_url, + elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), @@ -25,6 +29,8 @@ defmodule HelloPhx.MixProject do # Specifies which paths to compile per environment. defp elixirc_paths(:test), do: ["lib", "test/support"] + # for handy dev testing + defp elixirc_paths(:dev), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] # Specifies your project dependencies. @@ -32,7 +38,8 @@ defmodule HelloPhx.MixProject do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, "~> 1.7.12"}, + {:bcrypt_elixir, "~> 3.0"}, + {:phoenix, "~> 1.7.14"}, {:phoenix_ecto, "~> 4.4"}, {:ecto_sql, "~> 3.10"}, {:postgrex, ">= 0.0.0"}, @@ -41,6 +48,8 @@ defmodule HelloPhx.MixProject do {:phoenix_live_view, "~> 0.20.2"}, {:floki, ">= 0.30.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.3"}, + # enable Ecto stats in live_dashboard at /dev/dashboard/ecto_stats?nav=diagnose + {:ecto_psql_extras, "~> 0.6"}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, {:heroicons, @@ -57,7 +66,24 @@ defmodule HelloPhx.MixProject do {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.2"} + {:bandit, "~> 1.2"}, + + ## Tools + # {:faker, "~> 0.18", only: :test} + # also used in HelloPhx.Data + {:faker, "~> 0.18"}, + {:endon, "~> 2.0"}, + # {:timex, "~> 3.7"}, + {:git_ops, "~> 2.6", only: [:dev], runtime: false}, + + ## cluster + {:libcluster, "~> 3.3"} + + ## Admin + # https://github.com/aesmail/kaffy + # {:kaffy, "~> 0.10.2"}, + # {:kaffy, github: "aesmail/kaffy", depth: 1} + # https://github.com/naymspace/backpex ] end @@ -73,13 +99,28 @@ defmodule HelloPhx.MixProject do "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "test.reset": ["ecto.drop", "test"], "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], "assets.build": ["tailwind hello_phx", "esbuild hello_phx"], "assets.deploy": [ "tailwind hello_phx --minify", "esbuild hello_phx --minify", "phx.digest" + ], + "test.demo": &test_demo_task/1 + ] + end + + def cli do + [ + preferred_envs: [ + "test.reset": :test, + "test.demo": :test ] ] end + + def test_demo_task(_args) do + Mix.shell().info("mix env: #{Mix.env()}") + end end diff --git a/mix.lock b/mix.lock index 7dd4305..28595dd 100644 --- a/mix.lock +++ b/mix.lock @@ -1,36 +1,47 @@ %{ - "bandit": {:hex, :bandit, "1.5.4", "8e56e7cfc06f3c57995be0d9bf4e45b972d8732f5c7e96ef8ec0735f52079527", [:mix], [{:hpax, "~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "04c2b38874769af67fe7f10034f606ad6dda1d8f80c4d7a0c616b347584d5aff"}, + "bandit": {:hex, :bandit, "1.5.5", "df28f1c41f745401fe9e85a6882033f5f3442ab6d30c8a2948554062a4ab56e0", [:mix], [{:hpax, "~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f21579a29ea4bc08440343b2b5f16f7cddf2fea5725d31b72cf973ec729079e1"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, + "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"}, "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, + "ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.0", "440719cd74f09b3f01c84455707a2c3972b725c513808e68eb6c5b0ab82bf523", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 0.18.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "f1512812dc196bcb932a96c82e55f69b543dc125e9d39f5e3631a9c4ec65ef12"}, "ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"}, + "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, + "endon": {:hex, :endon, "2.0.1", "85bcde2b5064b5fd5bc717a9ab618b513a9e69a48529c77cf9c7cdb8b8d8be00", [:mix], [{:ecto, "~> 3.8", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "aaa03ccbc32008b444e1979162d8a0986fd31ed6862ac6ff5b499f270469f92c"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"}, + "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, + "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, + "git_ops": {:hex, :git_ops, "2.6.1", "cc7799a68c26cf814d6d1a5121415b4f5bf813de200908f930b27a2f1fe9dad5", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "ce62d07e41fe993ec22c35d5edb11cf333a21ddaead6f5d9868fcb607d42039e"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"}, - "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"}, + "libcluster": {:hex, :libcluster, "3.3.3", "a4f17721a19004cfc4467268e17cff8b1f951befe428975dd4f6f7b84d927fe0", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7c0a2275a0bb83c07acd17dab3c3bfb4897b145106750eeccc62d302e3bdfee5"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mint": {:hex, :mint, "1.6.1", "065e8a5bc9bbd46a41099dfea3e0656436c5cbcb6e741c80bd2bad5cd872446f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4fc518dcc191d02f433393a72a7ba3f6f94b101d094cb6bf532ea54c89423780"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.1", "96798325fab2fed5a824ca204e877b81f9afd2e480f581e81f7b4b64a5a477f2", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "0ae544ff99f3c482b0807c5cec2c8289e810ecacabc04959d82c3337f4703391"}, + "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, + "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"}, "swoosh": {:hex, :swoosh, "1.16.9", "20c6a32ea49136a4c19f538e27739bb5070558c0fa76b8a95f4d5d5ca7d319a1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "878b1a7a6c10ebbf725a3349363f48f79c5e3d792eb621643b0d276a38acc0a6"}, + "table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"}, "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, diff --git a/priv/repo/migrations/20240619023818_create_v1_users.exs b/priv/repo/migrations/20240619023818_create_v1_users.exs new file mode 100644 index 0000000..6fb7e8f --- /dev/null +++ b/priv/repo/migrations/20240619023818_create_v1_users.exs @@ -0,0 +1,14 @@ +defmodule HelloPhx.Repo.Migrations.CreateV1Users do + use Ecto.Migration + + def change do + create table(:v1_users) do + add :name, :string + add :email, :string + add :bio, :string + add :num_of_pets, :integer + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20240619025805_create_products.exs b/priv/repo/migrations/20240619025805_create_products.exs new file mode 100644 index 0000000..cbd50d4 --- /dev/null +++ b/priv/repo/migrations/20240619025805_create_products.exs @@ -0,0 +1,14 @@ +defmodule HelloPhx.Repo.Migrations.CreateProducts do + use Ecto.Migration + + def change do + create table(:products) do + add :title, :string + add :description, :string + add :price, :decimal, precision: 10, scale: 2, null: false + add :views, :integer, default: 0, null: false + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20240619091410_create_categories.exs b/priv/repo/migrations/20240619091410_create_categories.exs new file mode 100644 index 0000000..43de17b --- /dev/null +++ b/priv/repo/migrations/20240619091410_create_categories.exs @@ -0,0 +1,13 @@ +defmodule HelloPhx.Repo.Migrations.CreateCategories do + use Ecto.Migration + + def change do + create table(:categories) do + add :title, :string + + timestamps(type: :utc_datetime) + end + + create unique_index(:categories, [:title]) + end +end diff --git a/priv/repo/migrations/20240619101536_create_product_categories.exs b/priv/repo/migrations/20240619101536_create_product_categories.exs new file mode 100644 index 0000000..d5342ab --- /dev/null +++ b/priv/repo/migrations/20240619101536_create_product_categories.exs @@ -0,0 +1,13 @@ +defmodule HelloPhx.Repo.Migrations.CreateProductCategories do + use Ecto.Migration + + def change do + create table(:product_categories, primary_key: false) do + add :product_id, references(:products, on_delete: :delete_all) + add :category_id, references(:categories, on_delete: :delete_all) + end + + create index(:product_categories, [:product_id]) + create unique_index(:product_categories, [:category_id, :product_id]) + end +end diff --git a/priv/repo/migrations/20240620025801_create_carts.exs b/priv/repo/migrations/20240620025801_create_carts.exs new file mode 100644 index 0000000..d6fa387 --- /dev/null +++ b/priv/repo/migrations/20240620025801_create_carts.exs @@ -0,0 +1,13 @@ +defmodule HelloPhx.Repo.Migrations.CreateCarts do + use Ecto.Migration + + def change do + create table(:carts) do + add :user_uuid, :uuid + + timestamps(type: :utc_datetime) + end + + create unique_index(:carts, [:user_uuid]) + end +end diff --git a/priv/repo/migrations/20240620025940_create_cart_items.exs b/priv/repo/migrations/20240620025940_create_cart_items.exs new file mode 100644 index 0000000..e138526 --- /dev/null +++ b/priv/repo/migrations/20240620025940_create_cart_items.exs @@ -0,0 +1,18 @@ +defmodule HelloPhx.Repo.Migrations.CreateCartItems do + use Ecto.Migration + + def change do + create table(:cart_items) do + add :price_when_carted, :decimal, precision: 10, scale: 2, null: false + add :quantity, :integer + add :cart_id, references(:carts, on_delete: :delete_all) + add :product_id, references(:products, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + # create index(:cart_items, [:cart_id]) + create index(:cart_items, [:product_id]) + create unique_index(:cart_items, [:cart_id, :product_id]) + end +end diff --git a/priv/repo/migrations/20240620082846_create_orders.exs b/priv/repo/migrations/20240620082846_create_orders.exs new file mode 100644 index 0000000..9ded2dd --- /dev/null +++ b/priv/repo/migrations/20240620082846_create_orders.exs @@ -0,0 +1,12 @@ +defmodule HelloPhx.Repo.Migrations.CreateOrders do + use Ecto.Migration + + def change do + create table(:orders) do + add :user_uuid, :uuid + add :total_price, :decimal, precision: 10, scale: 2, null: false + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20240620083639_create_order_line_items.exs b/priv/repo/migrations/20240620083639_create_order_line_items.exs new file mode 100644 index 0000000..37318eb --- /dev/null +++ b/priv/repo/migrations/20240620083639_create_order_line_items.exs @@ -0,0 +1,19 @@ +defmodule HelloPhx.Repo.Migrations.CreateOrderLineItems do + use Ecto.Migration + + def change do + create table(:order_line_items) do + add :price, :decimal, precision: 10, scale: 2, null: false + add :quantity, :integer + # add :order_id, references(:orders, on_delete: :nothing) + # add :product_id, references(:products, on_delete: :nothing) + add :order_id, references(:orders, on_delete: :delete_all) + add :product_id, references(:products, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create index(:order_line_items, [:order_id]) + create index(:order_line_items, [:product_id]) + end +end diff --git a/priv/repo/migrations/20240620112853_create_urls.exs b/priv/repo/migrations/20240620112853_create_urls.exs new file mode 100644 index 0000000..353839c --- /dev/null +++ b/priv/repo/migrations/20240620112853_create_urls.exs @@ -0,0 +1,12 @@ +defmodule HelloPhx.Repo.Migrations.CreateUrls do + use Ecto.Migration + + def change do + create table(:urls) do + add :link, :string + add :title, :string + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20240621092628_create_users_auth_tables.exs b/priv/repo/migrations/20240621092628_create_users_auth_tables.exs new file mode 100644 index 0000000..72550cc --- /dev/null +++ b/priv/repo/migrations/20240621092628_create_users_auth_tables.exs @@ -0,0 +1,29 @@ +defmodule HelloPhx.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users) do + add :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20240624101502_create_posts.exs b/priv/repo/migrations/20240624101502_create_posts.exs new file mode 100644 index 0000000..5cf9c77 --- /dev/null +++ b/priv/repo/migrations/20240624101502_create_posts.exs @@ -0,0 +1,12 @@ +defmodule HelloPhx.Repo.Migrations.CreatePosts do + use Ecto.Migration + + def change do + create table(:posts) do + add :title, :string + add :body, :text + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20240624111924_create_comments.exs b/priv/repo/migrations/20240624111924_create_comments.exs new file mode 100644 index 0000000..a4a5075 --- /dev/null +++ b/priv/repo/migrations/20240624111924_create_comments.exs @@ -0,0 +1,14 @@ +defmodule HelloPhx.Repo.Migrations.CreateComments do + use Ecto.Migration + + def change do + create table(:comments) do + add :body, :text + add :post_id, references(:posts, on_delete: :nothing) + + timestamps(type: :utc_datetime) + end + + create index(:comments, [:post_id]) + end +end diff --git a/priv/repo/migrations/20240625011446_create_articles.exs b/priv/repo/migrations/20240625011446_create_articles.exs new file mode 100644 index 0000000..56c4b6c --- /dev/null +++ b/priv/repo/migrations/20240625011446_create_articles.exs @@ -0,0 +1,12 @@ +defmodule HelloPhx.Repo.Migrations.CreateArticles do + use Ecto.Migration + + def change do + create table(:articles) do + add :title, :string + add :body, :string + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 7d3a6d6..caa68ac 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -9,3 +9,9 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. + +for title <- ["Home Improvement", "Power Tools", "Gardening", "Books", "Education"] do + {:ok, _} = HelloPhx.Catalog.create_category(%{title: title}) +end + +HelloPhx.Data.batch_gen(5, :product) diff --git a/priv/static/images/logo.svg.gz b/priv/static/images/logo.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..d8d1f38b39fd9260b40ceceae1fd11e34f4a9518 GIT binary patch literal 1613 zcmV-T2D14diwFP!000006HS)SZXP!h#P5EJf%ZHUn{2Ylg=HYgBDZ}3-Gt9hY(TaH zMTzqG`>P%w7O+P%{P?H4x~iJ*|NQ&+Ve!p+E z)_Z2$9e;oM^!D@je;)4YQID|0*WK~km*?k)yW3wcFQ2}>{__3#`^(+&^z!BD{QTwP z$4}oL?p|O1`gHf<-EqACs)0|XW3Ydr?1KBvl{EW zZB~TObFhvj(>NunW_Qz$QxIPXEIULL;TePA5rvq- z+ZAh<+PY^@lA6cV(k%@_E#l`e7nW5?^{SnEpIPt6uN|@~`v6d=OVC5GPdzvS5|RS- zEv-ot3*}m(D5>%)si17BSo_AfsL_(^#aN_?aQ4y+V@(rvz^FrSp1YGgRD{|KIH>IPFwT+05laM|?E+ z2X9llN&v#tg|=%1wi7ot+5l~KnUqwA1jK?WvuQFwSaQiI6c$BxMxk@0H%0$xN?LyW z_#^9O6`oYf3LUXkwJhS4XUdRbsHBt&iI|_gQt@$9afNpXl&S}^E2+-8Q~o36!d839 z`YM|tuO-eKQQ|M-Vt3$l5sF(ZL9qB%a&V{!O{A{Wm;xv-(G4=MlJ2yJfU=&R&QlK2 zu>yxY>?d@)B@aZ9t~yNW;=Fj*@dMUCdS62dJCRmR=NO|ue#J@QsGUMSvuTTep8#7xIVAacUgMG zOe>*M;i8YumFO%_RiCnu4xomOd6Q)kfVu+Ti8%}u3^HJf)YJ|H17ka>w*;|C6dP5A z%NvKOV5_Q#t%+&gv$yAaRDo8nhSD*?0h=J*poZEGJ4|add25Dy|_Sc;H-Bf4LPYFel=WIH(3aq^w>!P=90O_?v<03eu~ z`kxOq5naL1pX^=A-_mVesEDF87*8|pCaPF&TJz4ehprucM&8U|>*#S&LtK~Bxh_dU z!m8z6Ydz5h`ByLhf>C)JsuKuBC2Ufcm$Y#@9U*N(PEjs(f@t&qUwdA6n$V>rO<2Po zVxPBcI^t{goV|1_0%c&?g1WA!T_$3_o|l_ayvSz9RY3hC6*jVMC?<&{t!}8NTvwlv zqmAx)-PE$#@ + +## support libcluster on fly.io +if [ -n "$FLY_APP_NAME" ]; then + echo "==>running app: ${FLY_APP_NAME} cluster on fly.io" + ip=$(grep fly-local-6pn /etc/hosts | cut -f 1) + export RELEASE_DISTRIBUTION=name + export RELEASE_NODE=$FLY_APP_NAME@$ip +fi diff --git a/rel/overlays/bin/migrate b/rel/overlays/bin/migrate new file mode 100755 index 0000000..9617041 --- /dev/null +++ b/rel/overlays/bin/migrate @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +exec ./hello_phx eval HelloPhx.Release.migrate diff --git a/rel/overlays/bin/migrate.bat b/rel/overlays/bin/migrate.bat new file mode 100755 index 0000000..d8ad1fe --- /dev/null +++ b/rel/overlays/bin/migrate.bat @@ -0,0 +1 @@ +call "%~dp0\hello_phx" eval HelloPhx.Release.migrate diff --git a/rel/overlays/bin/server b/rel/overlays/bin/server new file mode 100755 index 0000000..d5752eb --- /dev/null +++ b/rel/overlays/bin/server @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +PHX_SERVER=true exec ./hello_phx start diff --git a/rel/overlays/bin/server.bat b/rel/overlays/bin/server.bat new file mode 100755 index 0000000..2e9cef7 --- /dev/null +++ b/rel/overlays/bin/server.bat @@ -0,0 +1,2 @@ +set PHX_SERVER=true +call "%~dp0\hello_phx" start diff --git a/rel/remote.vm.args.eex b/rel/remote.vm.args.eex new file mode 100644 index 0000000..d9dfd37 --- /dev/null +++ b/rel/remote.vm.args.eex @@ -0,0 +1,12 @@ +## Customize flags given to the VM: https://www.erlang.org/doc/man/erl.html +## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here + +## Increase number of concurrent ports/sockets +##+Q 65536 + +## Tweak GC to run more often +##-env ERL_FULLSWEEP_AFTER 10 + +## Enable deployment without epmd +## (requires changing both vm.args and remote.vm.args) +##-start_epmd false -erl_epmd_port 6789 -dist_listen false diff --git a/rel/vm.args.eex b/rel/vm.args.eex new file mode 100644 index 0000000..a37b126 --- /dev/null +++ b/rel/vm.args.eex @@ -0,0 +1,12 @@ +## Customize flags given to the VM: https://www.erlang.org/doc/man/erl.html +## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here + +## Increase number of concurrent ports/sockets +##+Q 65536 + +## Tweak GC to run more often +##-env ERL_FULLSWEEP_AFTER 10 + +## Enable deployment without epmd +## (requires changing both vm.args and remote.vm.args) +##-start_epmd false -erl_epmd_port 6789false diff --git a/test/hello_phx/accounts_test.exs b/test/hello_phx/accounts_test.exs new file mode 100644 index 0000000..13cf6a2 --- /dev/null +++ b/test/hello_phx/accounts_test.exs @@ -0,0 +1,517 @@ +defmodule HelloPhx.AccountsTest do + use HelloPhx.DataCase + + alias HelloPhx.Accounts + + import HelloPhx.AccountsFixtures + alias HelloPhx.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 12 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users with a hashed password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_binary(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "change_user_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) + assert changeset.required == [:password, :email] + end + + test "allows fields to be set" do + email = unique_user_email() + password = valid_user_password() + + changeset = + Accounts.change_user_registration( + %User{}, + valid_user_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_user_email/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "apply_user_email/3" do + setup do + %{user: user_fixture()} + end + + test "requires email to change", %{user: user} do + {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{user: user} do + %{email: email} = user_fixture() + password = valid_user_password() + + {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{user: user} do + email = unique_user_email() + {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email}) + assert user.email == email + assert Accounts.get_user!(user.id).email != email + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert Accounts.update_user_email(user, token) == :ok + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + assert changed_user.confirmed_at + assert changed_user.confirmed_at != user.confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_user_email(user, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password(%User{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/3" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{user: user} do + {:ok, user} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, _} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_user_confirmation_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "confirm" + end + end + + describe "confirm_user/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "confirms the email with a valid token", %{user: user, token: token} do + assert {:ok, confirmed_user} = Accounts.confirm_user(token) + assert confirmed_user.confirmed_at + assert confirmed_user.confirmed_at != user.confirmed_at + assert Repo.get!(User, user.id).confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm with invalid token", %{user: user} do + assert Accounts.confirm_user("oops") == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_user(token) == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "deliver_user_reset_password_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "reset_password" + end + end + + describe "get_user_by_reset_password_token/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "returns the user with valid token", %{user: %{id: id}, token: token} do + assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: id) + end + + test "does not return the user with invalid token", %{user: user} do + refute Accounts.get_user_by_reset_password_token("oops") + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not return the user if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "reset_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.reset_user_password(user, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"}) + assert is_nil(updated_user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end + + describe "create_user_api_token/1 and fetch_user_by_api_token/1" do + test "creates and fetches by token" do + user = user_fixture() + token = Accounts.create_user_api_token(user) + assert Accounts.fetch_user_by_api_token(token) == {:ok, user} + assert Accounts.fetch_user_by_api_token("invalid") == :error + end + end +end diff --git a/test/hello_phx/blog/post_test.exs b/test/hello_phx/blog/post_test.exs new file mode 100644 index 0000000..feb8c4b --- /dev/null +++ b/test/hello_phx/blog/post_test.exs @@ -0,0 +1,9 @@ +defmodule HelloPhx.Blog.PostTest do + use HelloPhx.DataCase, async: true + alias HelloPhx.Blog.Post + + test "title must be at least two characters long" do + changeset = Post.changeset(%Post{}, %{title: "I"}) + assert %{title: ["should be at least 2 character(s)"]} = errors_on(changeset) + end +end diff --git a/test/hello_phx/blog_test.exs b/test/hello_phx/blog_test.exs new file mode 100644 index 0000000..dda388a --- /dev/null +++ b/test/hello_phx/blog_test.exs @@ -0,0 +1,115 @@ +defmodule HelloPhx.BlogTest do + use HelloPhx.DataCase + + alias HelloPhx.Blog + + describe "posts" do + alias HelloPhx.Blog.Post + + import HelloPhx.BlogFixtures + + @invalid_attrs %{title: nil, body: nil} + + test "list_posts/0 returns all posts" do + post = post_fixture() + assert Blog.list_posts() == [post] + end + + test "get_post!/1 returns the post with given id" do + post = post_fixture() + assert Blog.get_post!(post.id) == post + end + + test "create_post/1 with valid data creates a post" do + valid_attrs = %{title: "some title", body: "some body"} + + assert {:ok, %Post{} = post} = Blog.create_post(valid_attrs) + assert post.title == "some title" + assert post.body == "some body" + end + + test "create_post/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Blog.create_post(@invalid_attrs) + end + + test "update_post/2 with valid data updates the post" do + post = post_fixture() + update_attrs = %{title: "some updated title", body: "some updated body"} + + assert {:ok, %Post{} = post} = Blog.update_post(post, update_attrs) + assert post.title == "some updated title" + assert post.body == "some updated body" + end + + test "update_post/2 with invalid data returns error changeset" do + post = post_fixture() + assert {:error, %Ecto.Changeset{}} = Blog.update_post(post, @invalid_attrs) + assert post == Blog.get_post!(post.id) + end + + test "delete_post/1 deletes the post" do + post = post_fixture() + assert {:ok, %Post{}} = Blog.delete_post(post) + assert_raise Ecto.NoResultsError, fn -> Blog.get_post!(post.id) end + end + + test "change_post/1 returns a post changeset" do + post = post_fixture() + assert %Ecto.Changeset{} = Blog.change_post(post) + end + end + + describe "comments" do + alias HelloPhx.Blog.Comment + + import HelloPhx.BlogFixtures + + @invalid_attrs %{body: nil} + + test "list_comments/0 returns all comments" do + comment = comment_fixture() + assert Blog.list_comments() == [comment] + end + + test "get_comment!/1 returns the comment with given id" do + comment = comment_fixture() + assert Blog.get_comment!(comment.id) == comment + end + + test "create_comment/1 with valid data creates a comment" do + valid_attrs = %{body: "some body"} + + assert {:ok, %Comment{} = comment} = Blog.create_comment(valid_attrs) + assert comment.body == "some body" + end + + test "create_comment/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Blog.create_comment(@invalid_attrs) + end + + test "update_comment/2 with valid data updates the comment" do + comment = comment_fixture() + update_attrs = %{body: "some updated body"} + + assert {:ok, %Comment{} = comment} = Blog.update_comment(comment, update_attrs) + assert comment.body == "some updated body" + end + + test "update_comment/2 with invalid data returns error changeset" do + comment = comment_fixture() + assert {:error, %Ecto.Changeset{}} = Blog.update_comment(comment, @invalid_attrs) + assert comment == Blog.get_comment!(comment.id) + end + + test "delete_comment/1 deletes the comment" do + comment = comment_fixture() + assert {:ok, %Comment{}} = Blog.delete_comment(comment) + assert_raise Ecto.NoResultsError, fn -> Blog.get_comment!(comment.id) end + end + + test "change_comment/1 returns a comment changeset" do + comment = comment_fixture() + assert %Ecto.Changeset{} = Blog.change_comment(comment) + end + end +end diff --git a/test/hello_phx/catalog_test.exs b/test/hello_phx/catalog_test.exs new file mode 100644 index 0000000..1e14ffa --- /dev/null +++ b/test/hello_phx/catalog_test.exs @@ -0,0 +1,130 @@ +defmodule HelloPhx.CatalogTest do + use HelloPhx.DataCase, async: true + + alias HelloPhx.Catalog + + describe "products" do + alias HelloPhx.Catalog.Product + + import HelloPhx.CatalogFixtures + + @invalid_attrs %{description: nil, title: nil, price: nil, views: nil} + + test "list_products/0 returns all products" do + product = product_fixture() + assert Catalog.list_products() == [product] + end + + test "get_product!/1 returns the product with given id" do + product = product_fixture() + assert Catalog.get_product!(product.id) == product + end + + test "create_product/1 with valid data creates a product" do + valid_attrs = %{ + description: "some description", + title: "some title", + price: "120.5", + views: 42 + } + + assert {:ok, %Product{} = product} = Catalog.create_product(valid_attrs) + assert product.description == "some description" + assert product.title == "some title" + assert product.price == Decimal.new("120.5") + assert product.views == 42 + end + + test "create_product/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Catalog.create_product(@invalid_attrs) + end + + test "update_product/2 with valid data updates the product" do + product = product_fixture() + + update_attrs = %{ + description: "some updated description", + title: "some updated title", + price: "456.7", + views: 43 + } + + assert {:ok, %Product{} = product} = Catalog.update_product(product, update_attrs) + assert product.description == "some updated description" + assert product.title == "some updated title" + assert product.price == Decimal.new("456.7") + assert product.views == 43 + end + + test "update_product/2 with invalid data returns error changeset" do + product = product_fixture() + assert {:error, %Ecto.Changeset{}} = Catalog.update_product(product, @invalid_attrs) + assert product == Catalog.get_product!(product.id) + end + + test "delete_product/1 deletes the product" do + product = product_fixture() + assert {:ok, %Product{}} = Catalog.delete_product(product) + assert_raise Ecto.NoResultsError, fn -> Catalog.get_product!(product.id) end + end + + test "change_product/1 returns a product changeset" do + product = product_fixture() + assert %Ecto.Changeset{} = Catalog.change_product(product) + end + end + + describe "categories" do + alias HelloPhx.Catalog.Category + + import HelloPhx.CatalogFixtures + + @invalid_attrs %{title: nil} + + test "list_categories/0 returns all categories" do + category = category_fixture() + assert Catalog.list_categories() == [category] + end + + test "get_category!/1 returns the category with given id" do + category = category_fixture() + assert Catalog.get_category!(category.id) == category + end + + test "create_category/1 with valid data creates a category" do + valid_attrs = %{title: "some title"} + + assert {:ok, %Category{} = category} = Catalog.create_category(valid_attrs) + assert category.title == "some title" + end + + test "create_category/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Catalog.create_category(@invalid_attrs) + end + + test "update_category/2 with valid data updates the category" do + category = category_fixture() + update_attrs = %{title: "some updated title"} + + assert {:ok, %Category{} = category} = Catalog.update_category(category, update_attrs) + assert category.title == "some updated title" + end + + test "update_category/2 with invalid data returns error changeset" do + category = category_fixture() + assert {:error, %Ecto.Changeset{}} = Catalog.update_category(category, @invalid_attrs) + assert category == Catalog.get_category!(category.id) + end + + test "delete_category/1 deletes the category" do + category = category_fixture() + assert {:ok, %Category{}} = Catalog.delete_category(category) + assert_raise Ecto.NoResultsError, fn -> Catalog.get_category!(category.id) end + end + + test "change_category/1 returns a category changeset" do + category = category_fixture() + assert %Ecto.Changeset{} = Catalog.change_category(category) + end + end +end diff --git a/test/hello_phx/news_test.exs b/test/hello_phx/news_test.exs new file mode 100644 index 0000000..78201f8 --- /dev/null +++ b/test/hello_phx/news_test.exs @@ -0,0 +1,61 @@ +defmodule HelloPhx.NewsTest do + use HelloPhx.DataCase + + alias HelloPhx.News + + describe "articles" do + alias HelloPhx.News.Article + + import HelloPhx.NewsFixtures + + @invalid_attrs %{title: nil, body: nil} + + test "list_articles/0 returns all articles" do + article = article_fixture() + assert News.list_articles() == [article] + end + + test "get_article!/1 returns the article with given id" do + article = article_fixture() + assert News.get_article!(article.id) == article + end + + test "create_article/1 with valid data creates a article" do + valid_attrs = %{title: "some title", body: "some body"} + + assert {:ok, %Article{} = article} = News.create_article(valid_attrs) + assert article.title == "some title" + assert article.body == "some body" + end + + test "create_article/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = News.create_article(@invalid_attrs) + end + + test "update_article/2 with valid data updates the article" do + article = article_fixture() + update_attrs = %{title: "some updated title", body: "some updated body"} + + assert {:ok, %Article{} = article} = News.update_article(article, update_attrs) + assert article.title == "some updated title" + assert article.body == "some updated body" + end + + test "update_article/2 with invalid data returns error changeset" do + article = article_fixture() + assert {:error, %Ecto.Changeset{}} = News.update_article(article, @invalid_attrs) + assert article == News.get_article!(article.id) + end + + test "delete_article/1 deletes the article" do + article = article_fixture() + assert {:ok, %Article{}} = News.delete_article(article) + assert_raise Ecto.NoResultsError, fn -> News.get_article!(article.id) end + end + + test "change_article/1 returns a article changeset" do + article = article_fixture() + assert %Ecto.Changeset{} = News.change_article(article) + end + end +end diff --git a/test/hello_phx/orders_test.exs b/test/hello_phx/orders_test.exs new file mode 100644 index 0000000..e530b3f --- /dev/null +++ b/test/hello_phx/orders_test.exs @@ -0,0 +1,117 @@ +defmodule HelloPhx.OrdersTest do + use HelloPhx.DataCase, async: true + + alias HelloPhx.Orders + + describe "orders" do + alias HelloPhx.Orders.Order + + import HelloPhx.OrdersFixtures + + @invalid_attrs %{user_uuid: nil, total_price: nil} + + test "list_orders/0 returns all orders" do + order = order_fixture() + assert Orders.list_orders() == [order] + end + + test "get_order!/1 returns the order with given id" do + order = order_fixture() + assert Orders.get_order!(order.user_uuid, order.id).id == order.id + end + + test "create_order/1 with valid data creates a order" do + valid_attrs = %{user_uuid: "7488a646-e31f-11e4-aace-600308960662", total_price: "120.5"} + + assert {:ok, %Order{} = order} = Orders.create_order(valid_attrs) + assert order.user_uuid == "7488a646-e31f-11e4-aace-600308960662" + assert order.total_price == Decimal.new("120.5") + end + + test "create_order/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Orders.create_order(@invalid_attrs) + end + + test "update_order/2 with valid data updates the order" do + order = order_fixture() + update_attrs = %{user_uuid: "7488a646-e31f-11e4-aace-600308960668", total_price: "456.7"} + + assert {:ok, %Order{} = order} = Orders.update_order(order, update_attrs) + assert order.user_uuid == "7488a646-e31f-11e4-aace-600308960668" + assert order.total_price == Decimal.new("456.7") + end + + test "update_order/2 with invalid data returns error changeset" do + order = order_fixture() + assert {:error, %Ecto.Changeset{}} = Orders.update_order(order, @invalid_attrs) + assert order.id == Orders.get_order!(order.user_uuid, order.id).id + end + + test "delete_order/1 deletes the order" do + order = order_fixture() + assert {:ok, %Order{}} = Orders.delete_order(order) + assert_raise Ecto.NoResultsError, fn -> Orders.get_order!(order.user_uuid, order.id) end + end + + test "change_order/1 returns a order changeset" do + order = order_fixture() + assert %Ecto.Changeset{} = Orders.change_order(order) + end + end + + describe "order_line_items" do + alias HelloPhx.Orders.LineItem + + import HelloPhx.OrdersFixtures + + @invalid_attrs %{price: nil, quantity: nil} + + test "list_order_line_items/0 returns all order_line_items" do + line_item = line_item_fixture() + assert Orders.list_order_line_items() == [line_item] + end + + test "get_line_item!/1 returns the line_item with given id" do + line_item = line_item_fixture() + assert Orders.get_line_item!(line_item.id) == line_item + end + + test "create_line_item/1 with valid data creates a line_item" do + valid_attrs = %{price: "120.5", quantity: 42} + + assert {:ok, %LineItem{} = line_item} = Orders.create_line_item(valid_attrs) + assert line_item.price == Decimal.new("120.5") + assert line_item.quantity == 42 + end + + test "create_line_item/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Orders.create_line_item(@invalid_attrs) + end + + test "update_line_item/2 with valid data updates the line_item" do + line_item = line_item_fixture() + update_attrs = %{price: "456.7", quantity: 43} + + assert {:ok, %LineItem{} = line_item} = Orders.update_line_item(line_item, update_attrs) + assert line_item.price == Decimal.new("456.7") + assert line_item.quantity == 43 + end + + test "update_line_item/2 with invalid data returns error changeset" do + line_item = line_item_fixture() + assert {:error, %Ecto.Changeset{}} = Orders.update_line_item(line_item, @invalid_attrs) + assert line_item == Orders.get_line_item!(line_item.id) + end + + test "delete_line_item/1 deletes the line_item" do + line_item = line_item_fixture() + assert {:ok, %LineItem{}} = Orders.delete_line_item(line_item) + assert_raise Ecto.NoResultsError, fn -> Orders.get_line_item!(line_item.id) end + end + + test "change_line_item/1 returns a line_item changeset" do + line_item = line_item_fixture() + assert %Ecto.Changeset{} = Orders.change_line_item(line_item) + end + end +end diff --git a/test/hello_phx/shopping_cart_test.exs b/test/hello_phx/shopping_cart_test.exs new file mode 100644 index 0000000..2ffc5a5 --- /dev/null +++ b/test/hello_phx/shopping_cart_test.exs @@ -0,0 +1,120 @@ +defmodule HelloPhx.ShoppingCartTest do + use HelloPhx.DataCase, async: true + + alias HelloPhx.ShoppingCart + + describe "carts" do + alias HelloPhx.ShoppingCart.Cart + + import HelloPhx.ShoppingCartFixtures + + @invalid_attrs %{user_uuid: nil} + + test "list_carts/0 returns all carts" do + cart = cart_fixture() + assert ShoppingCart.list_carts() == [cart] + end + + test "get_cart!/1 returns the cart with given id" do + cart = cart_fixture() + assert cart == ShoppingCart.get_cart!(cart.id) |> Map.put(:items, []) + end + + test "create_cart/1 with valid data creates a cart" do + valid_user_uuid = "7488a646-e31f-11e4-aace-600308960662" + + assert {:ok, %Cart{} = cart} = ShoppingCart.create_cart(valid_user_uuid) + assert cart.user_uuid == "7488a646-e31f-11e4-aace-600308960662" + end + + test "create_cart/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = ShoppingCart.create_cart(nil) + end + + test "update_cart/2 with valid data updates the cart" do + cart = cart_fixture() + update_attrs = %{user_uuid: "7488a646-e31f-11e4-aace-600308960668"} + + assert {:ok, %Cart{} = cart} = ShoppingCart.update_cart(cart, update_attrs) + assert cart.user_uuid == "7488a646-e31f-11e4-aace-600308960668" + end + + test "update_cart/2 with invalid data returns error changeset" do + cart = cart_fixture() + assert {:error, %Ecto.Changeset{}} = ShoppingCart.update_cart(cart, @invalid_attrs) + assert cart == ShoppingCart.get_cart!(cart.id) |> Map.put(:items, []) + end + + test "delete_cart/1 deletes the cart" do + cart = cart_fixture() + assert {:ok, %Cart{}} = ShoppingCart.delete_cart(cart) + assert_raise Ecto.NoResultsError, fn -> ShoppingCart.get_cart!(cart.id) end + end + + test "change_cart/1 returns a cart changeset" do + cart = cart_fixture() + assert %Ecto.Changeset{} = ShoppingCart.change_cart(cart) + end + end + + describe "cart_items" do + alias HelloPhx.ShoppingCart.CartItem + + import HelloPhx.ShoppingCartFixtures + + @invalid_attrs %{price_when_carted: nil, quantity: nil} + + test "list_cart_items/0 returns all cart_items" do + cart_item = cart_item_fixture() + assert ShoppingCart.list_cart_items() == [cart_item] + end + + test "get_cart_item!/1 returns the cart_item with given id" do + cart_item = cart_item_fixture() + assert ShoppingCart.get_cart_item!(cart_item.id) == cart_item + end + + test "create_cart_item/1 with valid data creates a cart_item" do + valid_attrs = %{price_when_carted: "120.5", quantity: 42} + + assert {:ok, %CartItem{} = cart_item} = ShoppingCart.create_cart_item(valid_attrs) + assert cart_item.price_when_carted == Decimal.new("120.5") + assert cart_item.quantity == 42 + end + + test "create_cart_item/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = ShoppingCart.create_cart_item(@invalid_attrs) + end + + test "update_cart_item/2 with valid data updates the cart_item" do + cart_item = cart_item_fixture() + update_attrs = %{price_when_carted: "456.7", quantity: 43} + + assert {:ok, %CartItem{} = cart_item} = + ShoppingCart.update_cart_item(cart_item, update_attrs) + + assert cart_item.price_when_carted == Decimal.new("456.7") + assert cart_item.quantity == 43 + end + + test "update_cart_item/2 with invalid data returns error changeset" do + cart_item = cart_item_fixture() + + assert {:error, %Ecto.Changeset{}} = + ShoppingCart.update_cart_item(cart_item, @invalid_attrs) + + assert cart_item == ShoppingCart.get_cart_item!(cart_item.id) + end + + test "delete_cart_item/1 deletes the cart_item" do + cart_item = cart_item_fixture() + assert {:ok, %CartItem{}} = ShoppingCart.delete_cart_item(cart_item) + assert_raise Ecto.NoResultsError, fn -> ShoppingCart.get_cart_item!(cart_item.id) end + end + + test "change_cart_item/1 returns a cart_item changeset" do + cart_item = cart_item_fixture() + assert %Ecto.Changeset{} = ShoppingCart.change_cart_item(cart_item) + end + end +end diff --git a/test/hello_phx/urls_test.exs b/test/hello_phx/urls_test.exs new file mode 100644 index 0000000..e84ffc8 --- /dev/null +++ b/test/hello_phx/urls_test.exs @@ -0,0 +1,61 @@ +defmodule HelloPhx.UrlsTest do + use HelloPhx.DataCase + + alias HelloPhx.Urls + + describe "urls" do + alias HelloPhx.Urls.Url + + import HelloPhx.UrlsFixtures + + @invalid_attrs %{link: nil, title: nil} + + test "list_urls/0 returns all urls" do + url = url_fixture() + assert Urls.list_urls() == [url] + end + + test "get_url!/1 returns the url with given id" do + url = url_fixture() + assert Urls.get_url!(url.id) == url + end + + test "create_url/1 with valid data creates a url" do + valid_attrs = %{link: "some link", title: "some title"} + + assert {:ok, %Url{} = url} = Urls.create_url(valid_attrs) + assert url.link == "some link" + assert url.title == "some title" + end + + test "create_url/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Urls.create_url(@invalid_attrs) + end + + test "update_url/2 with valid data updates the url" do + url = url_fixture() + update_attrs = %{link: "some updated link", title: "some updated title"} + + assert {:ok, %Url{} = url} = Urls.update_url(url, update_attrs) + assert url.link == "some updated link" + assert url.title == "some updated title" + end + + test "update_url/2 with invalid data returns error changeset" do + url = url_fixture() + assert {:error, %Ecto.Changeset{}} = Urls.update_url(url, @invalid_attrs) + assert url == Urls.get_url!(url.id) + end + + test "delete_url/1 deletes the url" do + url = url_fixture() + assert {:ok, %Url{}} = Urls.delete_url(url) + assert_raise Ecto.NoResultsError, fn -> Urls.get_url!(url.id) end + end + + test "change_url/1 returns a url changeset" do + url = url_fixture() + assert %Ecto.Changeset{} = Urls.change_url(url) + end + end +end diff --git a/test/hello_phx_web/controllers/article_controller_test.exs b/test/hello_phx_web/controllers/article_controller_test.exs new file mode 100644 index 0000000..c428697 --- /dev/null +++ b/test/hello_phx_web/controllers/article_controller_test.exs @@ -0,0 +1,88 @@ +defmodule HelloPhxWeb.ArticleControllerTest do + use HelloPhxWeb.ConnCase + + import HelloPhx.NewsFixtures + + alias HelloPhx.News.Article + + @create_attrs %{ + title: "some title", + body: "some body" + } + @update_attrs %{ + title: "some updated title", + body: "some updated body" + } + @invalid_attrs %{title: nil, body: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all articles", %{conn: conn} do + conn = get(conn, ~p"/api/articles") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create article" do + test "renders article when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/articles", article: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/articles/#{id}") + + assert %{ + "id" => ^id, + "body" => "some body", + "title" => "some title" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/articles", article: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update article" do + setup [:create_article] + + test "renders article when data is valid", %{conn: conn, article: %Article{id: id} = article} do + conn = put(conn, ~p"/api/articles/#{article}", article: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/articles/#{id}") + + assert %{ + "id" => ^id, + "body" => "some updated body", + "title" => "some updated title" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, article: article} do + conn = put(conn, ~p"/api/articles/#{article}", article: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete article" do + setup [:create_article] + + test "deletes chosen article", %{conn: conn, article: article} do + conn = delete(conn, ~p"/api/articles/#{article}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/articles/#{article}") + end + end + end + + defp create_article(_) do + article = article_fixture() + %{article: article} + end +end diff --git a/test/hello_phx_web/controllers/comment_controller_test.exs b/test/hello_phx_web/controllers/comment_controller_test.exs new file mode 100644 index 0000000..b7fd2a0 --- /dev/null +++ b/test/hello_phx_web/controllers/comment_controller_test.exs @@ -0,0 +1,84 @@ +defmodule HelloPhxWeb.CommentControllerTest do + use HelloPhxWeb.ConnCase + + import HelloPhx.BlogFixtures + + @create_attrs %{body: "some body"} + @update_attrs %{body: "some updated body"} + @invalid_attrs %{body: nil} + + describe "index" do + test "lists all comments", %{conn: conn} do + conn = get(conn, ~p"/comments") + assert html_response(conn, 200) =~ "Listing Comments" + end + end + + describe "new comment" do + test "renders form", %{conn: conn} do + conn = get(conn, ~p"/comments/new") + assert html_response(conn, 200) =~ "New Comment" + end + end + + describe "create comment" do + test "redirects to show when data is valid", %{conn: conn} do + conn = post(conn, ~p"/comments", comment: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == ~p"/comments/#{id}" + + conn = get(conn, ~p"/comments/#{id}") + assert html_response(conn, 200) =~ "Comment #{id}" + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/comments", comment: @invalid_attrs) + assert html_response(conn, 200) =~ "New Comment" + end + end + + describe "edit comment" do + setup [:create_comment] + + test "renders form for editing chosen comment", %{conn: conn, comment: comment} do + conn = get(conn, ~p"/comments/#{comment}/edit") + assert html_response(conn, 200) =~ "Edit Comment" + end + end + + describe "update comment" do + setup [:create_comment] + + test "redirects when data is valid", %{conn: conn, comment: comment} do + conn = put(conn, ~p"/comments/#{comment}", comment: @update_attrs) + assert redirected_to(conn) == ~p"/comments/#{comment}" + + conn = get(conn, ~p"/comments/#{comment}") + assert html_response(conn, 200) =~ "some updated body" + end + + test "renders errors when data is invalid", %{conn: conn, comment: comment} do + conn = put(conn, ~p"/comments/#{comment}", comment: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit Comment" + end + end + + describe "delete comment" do + setup [:create_comment] + + test "deletes chosen comment", %{conn: conn, comment: comment} do + conn = delete(conn, ~p"/comments/#{comment}") + assert redirected_to(conn) == ~p"/comments" + + assert_error_sent 404, fn -> + get(conn, ~p"/comments/#{comment}") + end + end + end + + defp create_comment(_) do + comment = comment_fixture() + %{comment: comment} + end +end diff --git a/test/hello_phx_web/controllers/error_html_test.exs b/test/hello_phx_web/controllers/error_html_test.exs index 7b0d7b2..30e07ac 100644 --- a/test/hello_phx_web/controllers/error_html_test.exs +++ b/test/hello_phx_web/controllers/error_html_test.exs @@ -5,7 +5,7 @@ defmodule HelloPhxWeb.ErrorHTMLTest do import Phoenix.Template test "renders 404.html" do - assert render_to_string(HelloPhxWeb.ErrorHTML, "404", "html", []) == "Not Found" + assert render_to_string(HelloPhxWeb.ErrorHTML, "404", "html", []) =~ "Not Found" end test "renders 500.html" do diff --git a/test/hello_phx_web/controllers/error_json_test.exs b/test/hello_phx_web/controllers/error_json_test.exs index 9a75004..cbf642e 100644 --- a/test/hello_phx_web/controllers/error_json_test.exs +++ b/test/hello_phx_web/controllers/error_json_test.exs @@ -2,7 +2,9 @@ defmodule HelloPhxWeb.ErrorJSONTest do use HelloPhxWeb.ConnCase, async: true test "renders 404" do - assert HelloPhxWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + assert HelloPhxWeb.ErrorJSON.render("404.json", %{}) == %{ + errors: %{detail: "Customized Not Found"} + } end test "renders 500" do diff --git a/test/hello_phx_web/controllers/post_controller_test.exs b/test/hello_phx_web/controllers/post_controller_test.exs new file mode 100644 index 0000000..10c5b45 --- /dev/null +++ b/test/hello_phx_web/controllers/post_controller_test.exs @@ -0,0 +1,84 @@ +defmodule HelloPhxWeb.PostControllerTest do + use HelloPhxWeb.ConnCase + + import HelloPhx.BlogFixtures + + @create_attrs %{title: "some title", body: "some body"} + @update_attrs %{title: "some updated title", body: "some updated body"} + @invalid_attrs %{title: nil, body: nil} + + describe "index" do + test "lists all posts", %{conn: conn} do + conn = get(conn, ~p"/posts") + assert html_response(conn, 200) =~ "Listing Posts" + end + end + + describe "new post" do + test "renders form", %{conn: conn} do + conn = get(conn, ~p"/posts/new") + assert html_response(conn, 200) =~ "New Post" + end + end + + describe "create post" do + test "redirects to show when data is valid", %{conn: conn} do + conn = post(conn, ~p"/posts", post: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == ~p"/posts/#{id}" + + conn = get(conn, ~p"/posts/#{id}") + assert html_response(conn, 200) =~ "Post #{id}" + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/posts", post: @invalid_attrs) + assert html_response(conn, 200) =~ "New Post" + end + end + + describe "edit post" do + setup [:create_post] + + test "renders form for editing chosen post", %{conn: conn, post: post} do + conn = get(conn, ~p"/posts/#{post}/edit") + assert html_response(conn, 200) =~ "Edit Post" + end + end + + describe "update post" do + setup [:create_post] + + test "redirects when data is valid", %{conn: conn, post: post} do + conn = put(conn, ~p"/posts/#{post}", post: @update_attrs) + assert redirected_to(conn) == ~p"/posts/#{post}" + + conn = get(conn, ~p"/posts/#{post}") + assert html_response(conn, 200) =~ "some updated title" + end + + test "renders errors when data is invalid", %{conn: conn, post: post} do + conn = put(conn, ~p"/posts/#{post}", post: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit Post" + end + end + + describe "delete post" do + setup [:create_post] + + test "deletes chosen post", %{conn: conn, post: post} do + conn = delete(conn, ~p"/posts/#{post}") + assert redirected_to(conn) == ~p"/posts" + + assert_error_sent 404, fn -> + get(conn, ~p"/posts/#{post}") + end + end + end + + defp create_post(_) do + post = post_fixture() + %{post: post} + end +end diff --git a/test/hello_phx_web/controllers/product_controller_test.exs b/test/hello_phx_web/controllers/product_controller_test.exs new file mode 100644 index 0000000..bd6f16f --- /dev/null +++ b/test/hello_phx_web/controllers/product_controller_test.exs @@ -0,0 +1,90 @@ +defmodule HelloPhxWeb.ProductControllerTest do + use HelloPhxWeb.ConnCase + + import HelloPhx.CatalogFixtures + + @create_attrs %{description: "some description", title: "some title", price: "120.5", views: 42} + @update_attrs %{ + description: "some updated description", + title: "some updated title", + price: "456.7", + views: 43 + } + @invalid_attrs %{description: nil, title: nil, price: nil, views: nil} + + describe "index" do + test "lists all products", %{conn: conn} do + conn = get(conn, ~p"/products") + assert html_response(conn, 200) =~ "Listing Products" + end + end + + describe "new product" do + test "renders form", %{conn: conn} do + conn = get(conn, ~p"/products/new") + assert html_response(conn, 200) =~ "New Product" + end + end + + describe "create product" do + test "redirects to show when data is valid", %{conn: conn} do + conn = post(conn, ~p"/products", product: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == ~p"/products/#{id}" + + conn = get(conn, ~p"/products/#{id}") + assert html_response(conn, 200) =~ "Product #{id}" + end + + @tag :capture_log + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/products", product: @invalid_attrs) + assert html_response(conn, 200) =~ "New Product" + end + end + + describe "edit product" do + setup [:create_product] + + test "renders form for editing chosen product", %{conn: conn, product: product} do + conn = get(conn, ~p"/products/#{product}/edit") + assert html_response(conn, 200) =~ "Edit Product" + end + end + + describe "update product" do + setup [:create_product] + + test "redirects when data is valid", %{conn: conn, product: product} do + conn = put(conn, ~p"/products/#{product}", product: @update_attrs) + assert redirected_to(conn) == ~p"/products/#{product}" + + conn = get(conn, ~p"/products/#{product}") + assert html_response(conn, 200) =~ "some updated description" + end + + test "renders errors when data is invalid", %{conn: conn, product: product} do + conn = put(conn, ~p"/products/#{product}", product: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit Product" + end + end + + describe "delete product" do + setup [:create_product] + + test "deletes chosen product", %{conn: conn, product: product} do + conn = delete(conn, ~p"/products/#{product}") + assert redirected_to(conn) == ~p"/products" + + assert_error_sent 404, fn -> + get(conn, ~p"/products/#{product}") + end + end + end + + defp create_product(_) do + product = product_fixture() + %{product: product} + end +end diff --git a/test/hello_phx_web/controllers/url_controller_test.exs b/test/hello_phx_web/controllers/url_controller_test.exs new file mode 100644 index 0000000..a50eee8 --- /dev/null +++ b/test/hello_phx_web/controllers/url_controller_test.exs @@ -0,0 +1,88 @@ +defmodule HelloPhxWeb.UrlControllerTest do + use HelloPhxWeb.ConnCase + + import HelloPhx.UrlsFixtures + + alias HelloPhx.Urls.Url + + @create_attrs %{ + link: "some link", + title: "some title" + } + @update_attrs %{ + link: "some updated link", + title: "some updated title" + } + @invalid_attrs %{link: nil, title: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all urls", %{conn: conn} do + conn = get(conn, ~p"/api/urls") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create url" do + test "renders url when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/urls", url: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/urls/#{id}") + + assert %{ + "id" => ^id, + "link" => "some link", + "title" => "some title" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/urls", url: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update url" do + setup [:create_url] + + test "renders url when data is valid", %{conn: conn, url: %Url{id: id} = url} do + conn = put(conn, ~p"/api/urls/#{url}", url: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/urls/#{id}") + + assert %{ + "id" => ^id, + "link" => "some updated link", + "title" => "some updated title" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, url: url} do + conn = put(conn, ~p"/api/urls/#{url}", url: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete url" do + setup [:create_url] + + test "deletes chosen url", %{conn: conn, url: url} do + conn = delete(conn, ~p"/api/urls/#{url}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/urls/#{url}") + end + end + end + + defp create_url(_) do + url = url_fixture() + %{url: url} + end +end diff --git a/test/hello_phx_web/controllers/user_session_controller_test.exs b/test/hello_phx_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..60366c9 --- /dev/null +++ b/test/hello_phx_web/controllers/user_session_controller_test.exs @@ -0,0 +1,113 @@ +defmodule HelloPhxWeb.UserSessionControllerTest do + use HelloPhxWeb.ConnCase, async: true + + import HelloPhx.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "POST /users/log_in" do + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log_out" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_hello_phx_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "login following registration", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "registered", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" + end + + test "login following password update", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "password_updated", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" + end + + test "redirects to login page with invalid credentials", %{conn: conn} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log_in" + end + end + + describe "DELETE /users/log_out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/hello_phx_web/live/user_confirmation_instructions_live_test.exs b/test/hello_phx_web/live/user_confirmation_instructions_live_test.exs new file mode 100644 index 0000000..f79dafa --- /dev/null +++ b/test/hello_phx_web/live/user_confirmation_instructions_live_test.exs @@ -0,0 +1,67 @@ +defmodule HelloPhxWeb.UserConfirmationInstructionsLiveTest do + use HelloPhxWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import HelloPhx.AccountsFixtures + + alias HelloPhx.Accounts + alias HelloPhx.Repo + + setup do + %{user: user_fixture()} + end + + describe "Resend confirmation" do + test "renders the resend confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm") + assert html =~ "Resend confirmation instructions" + end + + test "sends a new confirmation token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" + end + + test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do + Repo.update!(Accounts.User.confirm_changeset(user)) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/hello_phx_web/live/user_confirmation_live_test.exs b/test/hello_phx_web/live/user_confirmation_live_test.exs new file mode 100644 index 0000000..53ee676 --- /dev/null +++ b/test/hello_phx_web/live/user_confirmation_live_test.exs @@ -0,0 +1,89 @@ +defmodule HelloPhxWeb.UserConfirmationLiveTest do + use HelloPhxWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import HelloPhx.AccountsFixtures + + alias HelloPhx.Accounts + alias HelloPhx.Repo + + setup do + %{user: user_fixture()} + end + + describe "Confirm user" do + test "renders confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") + assert html =~ "Confirm Account" + end + + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "User confirmed successfully" + + assert Accounts.get_user!(user.id).confirmed_at + refute get_session(conn, :user_token) + assert Repo.all(Accounts.UserToken) == [] + + # when not logged in + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + # when logged in + conn = + build_conn() + |> log_in_user(user) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + refute Phoenix.Flash.get(conn.assigns.flash, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") + + {:ok, conn} = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + refute Accounts.get_user!(user.id).confirmed_at + end + end +end diff --git a/test/hello_phx_web/live/user_forgot_password_live_test.exs b/test/hello_phx_web/live/user_forgot_password_live_test.exs new file mode 100644 index 0000000..8194663 --- /dev/null +++ b/test/hello_phx_web/live/user_forgot_password_live_test.exs @@ -0,0 +1,63 @@ +defmodule HelloPhxWeb.UserForgotPasswordLiveTest do + use HelloPhxWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import HelloPhx.AccountsFixtures + + alias HelloPhx.Accounts + alias HelloPhx.Repo + + describe "Forgot password page" do + test "renders email page", %{conn: conn} do + {:ok, lv, html} = live(conn, ~p"/users/reset_password") + + assert html =~ "Forgot your password?" + assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register") + assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in") + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/reset_password") + |> follow_redirect(conn, ~p"/") + + assert {:ok, _conn} = result + end + end + + describe "Reset link" do + setup do + %{user: user_fixture()} + end + + test "sends a new reset password token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => user.email}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == + "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/hello_phx_web/live/user_login_live_test.exs b/test/hello_phx_web/live/user_login_live_test.exs new file mode 100644 index 0000000..f4bbdb6 --- /dev/null +++ b/test/hello_phx_web/live/user_login_live_test.exs @@ -0,0 +1,87 @@ +defmodule HelloPhxWeb.UserLoginLiveTest do + use HelloPhxWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import HelloPhx.AccountsFixtures + + describe "Log in page" do + test "renders log in page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/log_in") + + assert html =~ "Log in" + assert html =~ "Register" + assert html =~ "Forgot your password?" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/log_in") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + end + + describe "user login" do + test "redirects if user login with valid credentials", %{conn: conn} do + password = "123456789abcd" + user = user_fixture(%{password: password}) + + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) + + conn = submit_form(form, conn) + + assert redirected_to(conn) == ~p"/" + end + + test "redirects to login page with a flash error if there are no valid credentials", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", + user: %{email: "test@email.com", password: "123456", remember_me: true} + ) + + conn = submit_form(form, conn) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + + assert redirected_to(conn) == "/users/log_in" + end + end + + describe "login navigation" do + test "redirects to registration page when the Register button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Sign up")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert login_html =~ "Register" + end + + test "redirects to forgot password page when the Forgot Password button is clicked", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Forgot your password?")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/reset_password") + + assert conn.resp_body =~ "Forgot your password?" + end + end +end diff --git a/test/hello_phx_web/live/user_registration_live_test.exs b/test/hello_phx_web/live/user_registration_live_test.exs new file mode 100644 index 0000000..ddca68c --- /dev/null +++ b/test/hello_phx_web/live/user_registration_live_test.exs @@ -0,0 +1,87 @@ +defmodule HelloPhxWeb.UserRegistrationLiveTest do + use HelloPhxWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import HelloPhx.AccountsFixtures + + describe "Registration page" do + test "renders registration page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/register") + + assert html =~ "Register" + assert html =~ "Log in" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/register") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + + test "renders errors for invalid data", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + result = + lv + |> element("#registration_form") + |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) + + assert result =~ "Register" + assert result =~ "must have the @ sign and no spaces" + assert result =~ "should be at least 12 character" + end + end + + describe "register user" do + test "creates account and logs the user in", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + email = unique_user_email() + form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) + render_submit(form) + conn = follow_trigger_action(form, conn) + + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings" + assert response =~ "Log out" + end + + test "renders errors for duplicated email", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + user = user_fixture(%{email: "test@email.com"}) + + result = + lv + |> form("#registration_form", + user: %{"email" => user.email, "password" => "valid_password"} + ) + |> render_submit() + + assert result =~ "has already been taken" + end + end + + describe "registration navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert login_html =~ "Log in" + end + end +end diff --git a/test/hello_phx_web/live/user_reset_password_live_test.exs b/test/hello_phx_web/live/user_reset_password_live_test.exs new file mode 100644 index 0000000..32a7296 --- /dev/null +++ b/test/hello_phx_web/live/user_reset_password_live_test.exs @@ -0,0 +1,118 @@ +defmodule HelloPhxWeb.UserResetPasswordLiveTest do + use HelloPhxWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import HelloPhx.AccountsFixtures + + alias HelloPhx.Accounts + + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token, user: user} + end + + describe "Reset password page" do + test "renders reset password with valid token", %{conn: conn, token: token} do + {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") + + assert html =~ "Reset Password" + end + + test "does not render reset password with invalid token", %{conn: conn} do + {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") + + assert to == %{ + flash: %{"error" => "Reset password link is invalid or it has expired."}, + to: ~p"/" + } + end + + test "renders errors for invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> element("#reset_password_form") + |> render_change( + user: %{"password" => "secret12", "password_confirmation" => "secret123456"} + ) + + assert result =~ "should be at least 12 character" + assert result =~ "does not match password" + end + end + + describe "Reset Password" do + test "resets password once", %{conn: conn, token: token, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> form("#reset_password_form", + user: %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/users/log_in") + + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> form("#reset_password_form", + user: %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + ) + |> render_submit() + + assert result =~ "Reset Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + end + + describe "Reset password navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert conn.resp_body =~ "Log in" + end + + test "redirects to registration page when the Register button is clicked", %{ + conn: conn, + token: token + } do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Register")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert conn.resp_body =~ "Register" + end + end +end diff --git a/test/hello_phx_web/live/user_settings_live_test.exs b/test/hello_phx_web/live/user_settings_live_test.exs new file mode 100644 index 0000000..4b731ae --- /dev/null +++ b/test/hello_phx_web/live/user_settings_live_test.exs @@ -0,0 +1,210 @@ +defmodule HelloPhxWeb.UserSettingsLiveTest do + use HelloPhxWeb.ConnCase, async: true + + alias HelloPhx.Accounts + import Phoenix.LiveViewTest + import HelloPhx.AccountsFixtures + + describe "Settings page" do + test "renders settings page", %{conn: conn} do + {:ok, _lv, html} = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/settings") + + assert html =~ "Change Email" + assert html =~ "Change Password" + end + + test "redirects if user is not logged in", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/users/settings") + + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => "You must log in to access this page."} = flash + end + end + + describe "update email form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user email", %{conn: conn, password: password, user: user} do + new_email = unique_user_email() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => password, + "user" => %{"email" => new_email} + }) + |> render_submit() + + assert result =~ "A link to confirm your email" + assert Accounts.get_user_by_email(user.email) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#email_form") + |> render_change(%{ + "action" => "update_email", + "current_password" => "invalid", + "user" => %{"email" => "with spaces"} + }) + + assert result =~ "Change Email" + assert result =~ "must have the @ sign and no spaces" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => "invalid", + "user" => %{"email" => user.email} + }) + |> render_submit() + + assert result =~ "Change Email" + assert result =~ "did not change" + assert result =~ "is not valid" + end + end + + describe "update password form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user password", %{conn: conn, user: user, password: password} do + new_password = valid_user_password() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + form = + form(lv, "#password_form", %{ + "current_password" => password, + "user" => %{ + "email" => user.email, + "password" => new_password, + "password_confirmation" => new_password + } + }) + + render_submit(form) + + new_password_conn = follow_trigger_action(form, conn) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, new_password) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#password_form") + |> render_change(%{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#password_form", %{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + |> render_submit() + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + assert result =~ "is not valid" + end + end + + describe "confirm email" do + setup %{conn: conn} do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{conn: log_in_user(conn, user), token: token, email: email, user: user} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"info" => message} = flash + assert message == "Email changed successfully." + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + # use confirm token again + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => message} = flash + assert message == "You must log in to access this page." + end + end +end diff --git a/test/hello_phx_web/user_auth_test.exs b/test/hello_phx_web/user_auth_test.exs new file mode 100644 index 0000000..efbea5d --- /dev/null +++ b/test/hello_phx_web/user_auth_test.exs @@ -0,0 +1,295 @@ +defmodule HelloPhxWeb.UserAuthTest do + use HelloPhxWeb.ConnCase, async: true + + alias Phoenix.LiveView + alias HelloPhx.Accounts + alias HelloPhxWeb.UserAuth + import HelloPhx.AccountsFixtures + + @remember_me_cookie "_hello_phx_web_user_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, HelloPhxWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: user_fixture(), conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == 5_184_000 + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "users_sessions:abcdef-token" + HelloPhxWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> UserAuth.log_out_user() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) + assert conn.assigns.current_user.id == user.id + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_user([]) + + assert conn.assigns.current_user.id == user.id + assert get_session(conn, :user_token) == user_token + + assert get_session(conn, :live_socket_id) == + "users_sessions:#{Base.url_encode64(user_token)}" + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_user + end + end + + describe "on_mount :mount_current_user" do + test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + + test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :ensure_authenticated" do + test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "redirects to login page if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: HelloPhxWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + + test "redirects to login page if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + socket = %LiveView.Socket{ + endpoint: HelloPhxWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :redirect_if_user_is_authenticated" do + test "redirects if there is an authenticated user ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert {:halt, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + + test "doesn't redirect if there is no authenticated user", %{conn: conn} do + session = conn |> get_session() + + assert {:cont, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + end + + describe "redirect_if_user_is_authenticated/2" do + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log_in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) + refute conn.halted + refute conn.status + end + end + + describe "fetch_api_user/2" do + test "ping test", %{conn: conn} do + resp = + conn + |> get(~p"/api/ping") + |> json_response(:ok) + + assert resp["msg"] == "pong" + end + + test "authorize user ok", %{conn: conn, user: user} do + encoded_token = Accounts.create_user_api_token(user) + + resp = + conn + |> put_req_header("authorization", "Bearer " <> encoded_token) + |> get(~p"/api/users/current") + |> json_response(:ok) + + assert resp["current_user_email"] == user.email + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 3d6c468..4dc8ec3 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,4 +35,30 @@ defmodule HelloPhxWeb.ConnCase do HelloPhx.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn}) do + user = HelloPhx.AccountsFixtures.user_fixture() + %{conn: log_in_user(conn, user), user: user} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user) do + token = HelloPhx.Accounts.generate_user_session_token(user) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..38c7b00 --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,31 @@ +defmodule HelloPhx.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `HelloPhx.Accounts` context. + """ + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email(), + password: valid_user_password() + }) + end + + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> HelloPhx.Accounts.register_user() + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end +end diff --git a/test/support/fixtures/blog_fixtures.ex b/test/support/fixtures/blog_fixtures.ex new file mode 100644 index 0000000..28b1c38 --- /dev/null +++ b/test/support/fixtures/blog_fixtures.ex @@ -0,0 +1,35 @@ +defmodule HelloPhx.BlogFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `HelloPhx.Blog` context. + """ + + @doc """ + Generate a post. + """ + def post_fixture(attrs \\ %{}) do + {:ok, post} = + attrs + |> Enum.into(%{ + body: "some body", + title: "some title" + }) + |> HelloPhx.Blog.create_post() + + post + end + + @doc """ + Generate a comment. + """ + def comment_fixture(attrs \\ %{}) do + {:ok, comment} = + attrs + |> Enum.into(%{ + body: "some body" + }) + |> HelloPhx.Blog.create_comment() + + comment + end +end diff --git a/test/support/fixtures/catalog_fixtures.ex b/test/support/fixtures/catalog_fixtures.ex new file mode 100644 index 0000000..494029c --- /dev/null +++ b/test/support/fixtures/catalog_fixtures.ex @@ -0,0 +1,42 @@ +defmodule HelloPhx.CatalogFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `HelloPhx.Catalog` context. + """ + + @doc """ + Generate a product. + """ + def product_fixture(attrs \\ %{}) do + {:ok, product} = + attrs + |> Enum.into(%{ + description: "some description", + price: "120.50", + title: "some title", + views: 42 + }) + |> HelloPhx.Catalog.create_product() + + product + end + + @doc """ + Generate a unique category title. + """ + def unique_category_title, do: "some title#{System.unique_integer([:positive])}" + + @doc """ + Generate a category. + """ + def category_fixture(attrs \\ %{}) do + {:ok, category} = + attrs + |> Enum.into(%{ + title: unique_category_title() + }) + |> HelloPhx.Catalog.create_category() + + category + end +end diff --git a/test/support/fixtures/news_fixtures.ex b/test/support/fixtures/news_fixtures.ex new file mode 100644 index 0000000..bbeb542 --- /dev/null +++ b/test/support/fixtures/news_fixtures.ex @@ -0,0 +1,21 @@ +defmodule HelloPhx.NewsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `HelloPhx.News` context. + """ + + @doc """ + Generate a article. + """ + def article_fixture(attrs \\ %{}) do + {:ok, article} = + attrs + |> Enum.into(%{ + body: "some body", + title: "some title" + }) + |> HelloPhx.News.create_article() + + article + end +end diff --git a/test/support/fixtures/orders_fixtures.ex b/test/support/fixtures/orders_fixtures.ex new file mode 100644 index 0000000..584789a --- /dev/null +++ b/test/support/fixtures/orders_fixtures.ex @@ -0,0 +1,36 @@ +defmodule HelloPhx.OrdersFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `HelloPhx.Orders` context. + """ + + @doc """ + Generate a order. + """ + def order_fixture(attrs \\ %{}) do + {:ok, order} = + attrs + |> Enum.into(%{ + total_price: "120.50", + user_uuid: "7488a646-e31f-11e4-aace-600308960662" + }) + |> HelloPhx.Orders.create_order() + + order + end + + @doc """ + Generate a line_item. + """ + def line_item_fixture(attrs \\ %{}) do + {:ok, line_item} = + attrs + |> Enum.into(%{ + price: "120.50", + quantity: 42 + }) + |> HelloPhx.Orders.create_line_item() + + line_item + end +end diff --git a/test/support/fixtures/shopping_cart_fixtures.ex b/test/support/fixtures/shopping_cart_fixtures.ex new file mode 100644 index 0000000..a534fdc --- /dev/null +++ b/test/support/fixtures/shopping_cart_fixtures.ex @@ -0,0 +1,41 @@ +defmodule HelloPhx.ShoppingCartFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `HelloPhx.ShoppingCart` context. + """ + + @doc """ + Generate a unique cart user_uuid. + """ + def unique_cart_user_uuid do + # raise "implement the logic to generate a unique cart user_uuid" + # Ecto.UUID.generate() + Ecto.UUID.generate() + end + + @doc """ + Generate a cart. + """ + def cart_fixture(user_uuid \\ nil) do + {:ok, cart} = + (user_uuid || unique_cart_user_uuid()) + |> HelloPhx.ShoppingCart.create_cart() + + cart + end + + @doc """ + Generate a cart_item. + """ + def cart_item_fixture(attrs \\ %{}) do + {:ok, cart_item} = + attrs + |> Enum.into(%{ + price_when_carted: "120.50", + quantity: 42 + }) + |> HelloPhx.ShoppingCart.create_cart_item() + + cart_item + end +end diff --git a/test/support/fixtures/urls_fixtures.ex b/test/support/fixtures/urls_fixtures.ex new file mode 100644 index 0000000..8545926 --- /dev/null +++ b/test/support/fixtures/urls_fixtures.ex @@ -0,0 +1,21 @@ +defmodule HelloPhx.UrlsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `HelloPhx.Urls` context. + """ + + @doc """ + Generate a url. + """ + def url_fixture(attrs \\ %{}) do + {:ok, url} = + attrs + |> Enum.into(%{ + link: "some link", + title: "some title" + }) + |> HelloPhx.Urls.create_url() + + url + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 282baf1..c9bf18d 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,2 +1,2 @@ -ExUnit.start() +ExUnit.start(exclude: [external: true, manual: true]) Ecto.Adapters.SQL.Sandbox.mode(HelloPhx.Repo, :manual)