diff --git a/.env.example b/.env.example index 730fe2f..e1903fc 100644 --- a/.env.example +++ b/.env.example @@ -3,14 +3,20 @@ RUST_LOG=info # https://docs.rs/env_logger/latest/env_logger/#enabling-logging # Subscriptions Application SUBSCRIPTIONS__PORT=1337 SUBSCRIPTIONS__HOST=0.0.0.0 +SUBSCRIPTIONS__BASE_URL=http://localhost:1337 # Subscriptions Database -SUBSCRIPTIONS__DATABASE__URL=ws://database:4000 +SUBSCRIPTIONS__DATABASE__BASE_URL=ws://database:4000 SUBSCRIPTIONS__DATABASE__USERNAME=subscriptions SUBSCRIPTIONS__DATABASE__PASSWORD=password SUBSCRIPTIONS__DATABASE__NAMESPACE=main SUBSCRIPTIONS__DATABASE__NAME=db +# Subscriptions Email Client +SUBSCRIPTIONS__EMAIL_CLIENT__SENDER_EMAIL= +SUBSCRIPTIONS__EMAIL_CLIENT__BASE_URL= +SUBSCRIPTIONS__EMAIL_CLIENT__AUTH_TOKEN= + # surrealdb-migrations CLI SURREAL_MIG_ADDRESS=ws://localhost:4000 SURREAL_MIG_USER=admin diff --git a/.env.test b/.env.test index 7baf8d8..aa8f03a 100644 --- a/.env.test +++ b/.env.test @@ -1,12 +1,19 @@ -RUST_LOG=info # https://docs.rs/env_logger/latest/env_logger/#enabling-logging +RUST_LOG=debug # https://docs.rs/env_logger/latest/env_logger/#enabling-logging # Subscriptions Application -SUBSCRIPTIONS__PORT=1337 -SUBSCRIPTIONS__HOST=0.0.0.0 +SUBSCRIPTIONS__PORT=1337 # not used by tests +SUBSCRIPTIONS__HOST=0.0.0.0 # not used by tests +SUBSCRIPTIONS__BASE_URL=http://localhost:1337 # not used by tests # Subscriptions Database -SUBSCRIPTIONS__DATABASE__URL=mem:// +SUBSCRIPTIONS__DATABASE__BASE_URL=mem:// SUBSCRIPTIONS__DATABASE__USERNAME=subscriptions SUBSCRIPTIONS__DATABASE__PASSWORD=password SUBSCRIPTIONS__DATABASE__NAMESPACE=main -SUBSCRIPTIONS__DATABASE__NAME=db \ No newline at end of file +SUBSCRIPTIONS__DATABASE__NAME=db + +# Subscriptions Email Client +SUBSCRIPTIONS__EMAIL_CLIENT__SENDER_EMAIL=admin@example.com +SUBSCRIPTIONS__EMAIL_CLIENT__BASE_URL=http://example.com/path # overided with mocked url by tests +SUBSCRIPTIONS__EMAIL_CLIENT__AUTH_TOKEN=token +SUBSCRIPTIONS__EMAIL_CLIENT__TIMEOUT=1s diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index bc2bb21..f95d6d2 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -71,11 +71,15 @@ jobs: RUST_LOG=${{ vars.RUST_LOG }} SUBSCRIPTIONS__PORT=${{ vars.SUBSCRIPTIONS__PORT }} SUBSCRIPTIONS__HOST=${{ vars.SUBSCRIPTIONS__HOST }} - SUBSCRIPTIONS__DATABASE__URL=${{ vars.SUBSCRIPTIONS__DATABASE__URL }} + SUBSCRIPTIONS__BASE_URL=${{ vars.SUBSCRIPTIONS__BASE_URL }} + SUBSCRIPTIONS__DATABASE__BASE_URL=${{ vars.SUBSCRIPTIONS__DATABASE__BASE_URL }} SUBSCRIPTIONS__DATABASE__USERNAME=${{ vars.SUBSCRIPTIONS__DATABASE__USERNAME }} SUBSCRIPTIONS__DATABASE__PASSWORD=${{ secrets.SUBSCRIPTIONS__DATABASE__PASSWORD }} SUBSCRIPTIONS__DATABASE__NAMESPACE=${{ vars.SUBSCRIPTIONS__DATABASE__NAMESPACE }} SUBSCRIPTIONS__DATABASE__NAME=${{ vars.SUBSCRIPTIONS__DATABASE__NAME }} + SUBSCRIPTIONS__EMAIL_CLIENT__SENDER_EMAIL=${{ vars.SUBSCRIPTIONS__EMAIL_CLIENT__SENDER_EMAIL }} + SUBSCRIPTIONS__EMAIL_CLIENT__BASE_URL=${{ vars.SUBSCRIPTIONS__EMAIL_CLIENT__BASE_URL }} + SUBSCRIPTIONS__EMAIL_CLIENT__AUTH_TOKEN=${{ secrets.SUBSCRIPTIONS__EMAIL_CLIENT__AUTH_TOKEN }} - name: Deployment URL run: 'echo "${{ steps.deploy.outputs.url }}"' diff --git a/.moon/tasks/rust-backend-application.yml b/.moon/tasks/rust-backend-application.yml index ac07f97..fd445e4 100644 --- a/.moon/tasks/rust-backend-application.yml +++ b/.moon/tasks/rust-backend-application.yml @@ -5,3 +5,9 @@ tasks: command: cargo run description: Run a binary or example of the local package preset: server + migrations: + command: surrealdb-migrations + description: An awesome CLI for SurrealDB migrations (provides commands to scaffold, create and apply migrations) + local: true + options: + envFile: .env diff --git a/Cargo.lock b/Cargo.lock index 93bceee..d9bceb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90c6333e01ba7235575b6ab53e5af10f1c327927fd97c36462917e289557ea64" +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + [[package]] name = "approx" version = "0.4.0" @@ -228,6 +234,16 @@ dependencies = [ "term", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -381,6 +397,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + [[package]] name = "autocfg" version = "1.5.0" @@ -441,6 +463,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-test" +version = "18.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a5c86736717a01abbe75c16e46972a9d77abde7852f556d4ec5cad859997a0" +dependencies = [ + "anyhow", + "auto-future", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -660,6 +712,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bytesize" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" + [[package]] name = "castaway" version = "0.2.4" @@ -671,10 +729,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.34" +version = "1.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -921,9 +980,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.14" +version = "0.15.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4092bf3922a966e2bd74640b80f36c73eaa7251a4fd0fbcda1f8a4de401352" +checksum = "0faa974509d38b33ff89282db9c3295707ccf031727c0de9772038ec526852ba" dependencies = [ "async-trait", "convert_case", @@ -974,6 +1033,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1137,11 +1216,28 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "deadpool" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ed5957ff93768adf7a65ab167a17835c3d2c3c50d084fe305174c112f468e2f" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", "serde", @@ -1153,13 +1249,19 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "diffy" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b545b8c50194bdd008283985ab0b31dba153cfd5b3066a92770634fbc0d7d291" dependencies = [ - "nu-ansi-term 0.50.1", + "nu-ansi-term", ] [[package]] @@ -1273,6 +1375,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "ena" version = "0.14.3" @@ -1344,6 +1455,34 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "expect-json" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "serde", + "serde_json", + "thiserror 2.0.16", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "ext-sort" version = "0.1.5" @@ -1384,6 +1523,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1408,6 +1553,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1634,7 +1794,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.3+wasi-0.2.4", "wasm-bindgen", ] @@ -1644,6 +1804,25 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.11.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.6.0" @@ -1792,6 +1971,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" +dependencies = [ + "quick-error", +] + [[package]] name = "humantime" version = "2.2.0" @@ -1808,6 +1996,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1837,6 +2026,22 @@ dependencies = [ "webpki-roots 1.0.2", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.16" @@ -1855,10 +2060,12 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2054,6 +2261,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -2187,7 +2403,7 @@ dependencies = [ "petgraph", "pico-args", "regex", - "regex-syntax 0.8.6", + "regex-syntax", "string_cache", "term", "tiny-keccak", @@ -2201,7 +2417,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata 0.4.10", + "regex-automata", ] [[package]] @@ -2262,6 +2478,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -2341,11 +2566,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -2465,6 +2690,23 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndarray" version = "0.15.6" @@ -2529,21 +2771,25 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "overload", - "winapi", + "windows-sys 0.52.0", ] [[package]] -name = "nu-ansi-term" -version = "0.50.1" +name = "num" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "windows-sys 0.52.0", + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", ] [[package]] @@ -2580,6 +2826,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2620,7 +2888,7 @@ dependencies = [ "chrono", "futures", "http", - "humantime", + "humantime 2.2.0", "itertools 0.14.0", "parking_lot", "percent-encoding", @@ -2645,6 +2913,50 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2655,12 +2967,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "owo-colors" version = "4.2.2" @@ -2893,11 +3199,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -2923,6 +3235,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -2955,7 +3277,7 @@ dependencies = [ "rand 0.9.2", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.6", + "regex-syntax", "rusty-fork", "tempfile", "unarray", @@ -3028,9 +3350,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", @@ -3039,7 +3361,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2", "thiserror 2.0.16", "tokio", "tracing", @@ -3048,9 +3370,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.12" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", "getrandom 0.3.3", @@ -3069,16 +3391,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.13" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3261,17 +3583,8 @@ checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.10", - "regex-syntax 0.8.6", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", + "regex-automata", + "regex-syntax", ] [[package]] @@ -3282,7 +3595,7 @@ checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.6", + "regex-syntax", ] [[package]] @@ -3291,12 +3604,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "943f41321c63ef1c92fd763bfe054d2668f7f225a5c29f0105903dc2fc04ba30" -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.8.6" @@ -3320,17 +3627,22 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3341,6 +3653,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -3354,6 +3667,15 @@ dependencies = [ "webpki-roots 1.0.2", ] +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror 2.0.16", +] + [[package]] name = "revision" version = "0.10.0" @@ -3517,13 +3839,27 @@ dependencies = [ [[package]] name = "rust-ini" -version = "0.21.1" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ "cfg-if", "ordered-multimap", - "trim-in-place", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.9.2", + "thiserror 2.0.16", ] [[package]] @@ -3673,6 +4009,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3731,6 +4076,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.26" @@ -3764,6 +4132,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-humantime" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c367b5dafa12cef19c554638db10acde90d5e9acea2b80e1ad98b00f88068f7d" +dependencies = [ + "humantime 1.3.0", + "serde", +] + [[package]] name = "serde-untagged" version = "0.1.8" @@ -3950,16 +4328,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.0" @@ -4083,15 +4451,20 @@ name = "subscriptions" version = "0.1.0" dependencies = [ "axum", + "axum-test", "claims", "config", "dotenvy", "fake", - "http-body-util", + "linkify", "mime", "proptest", + "rand 0.9.2", + "reqwest", "secrecy", "serde", + "serde-humantime", + "serde_json", "surrealdb", "surrealdb-migrations", "thiserror 2.0.16", @@ -4103,6 +4476,7 @@ dependencies = [ "unicode-segmentation", "url", "validator", + "wiremock", ] [[package]] @@ -4368,6 +4742,27 @@ dependencies = [ "windows", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -4469,12 +4864,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "8ca967379f9d8eb8058d86ed467d81d03e81acd45757e4ca341c24affbe8e8e3" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde", @@ -4484,15 +4878,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "a9108bb380861b07264b950ded55a44a14a4adc68b9f5efd85aafc3aa4d40a68" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "7182799245a7264ce590b349d90338f1c1affad93d2639aed5f8f69c090b334c" dependencies = [ "num-conv", "time-core", @@ -4545,7 +4939,7 @@ dependencies = [ "mio", "pin-project-lite", "slab", - "socket2 0.6.0", + "socket2", "tokio-macros", "windows-sys 0.59.0", ] @@ -4561,6 +4955,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.2" @@ -4753,14 +5157,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", - "nu-ansi-term 0.46.0", + "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -4780,12 +5184,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "trim-in-place" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" - [[package]] name = "try-lock" version = "0.2.5" @@ -4825,6 +5223,30 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "typetag" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f22b40dd7bfe8c14230cf9702081366421890435b2d625fa92b4acc4c3de6f" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f5380909ffc31b4de4f4bdf96b877175a016aa2ca98cee39fcfd8c4d53d952" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "ucd-trie" version = "0.1.7" @@ -4996,6 +5418,12 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03dccea250abfe68c00eee55f95af111e041b75bc11796cb83d1c05c5029efd9" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -5038,11 +5466,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.3+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -5308,6 +5736,17 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result 0.3.4", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -5501,14 +5940,34 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wiremock" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ - "bitflags", + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", ] +[[package]] +name = "wit-bindgen" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" + [[package]] name = "writeable" version = "0.6.1" @@ -5554,6 +6013,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index c7eb251..4c9621b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,13 +6,21 @@ authors = ["Mustapha Annouaoui "] edition = "2024" license = "MIT" +[profile.release] +lto = true +strip = true +opt-level = 3 +panic = 'abort' +codegen-units = 1 + [dependencies] axum = { version = "0.8.4", features = ["tracing"] } -claims = "0.8.0" -config = "0.15.14" +config = "0.15.15" dotenvy = "0.15.7" +reqwest = { version = "0.12.23", features = ["json", "rustls-tls"] } secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.219", features = ["derive"] } +serde-humantime = "0.1.1" surrealdb = { version = "2.3.7", features = ["kv-mem"] } surrealdb-migrations = "2.3.0" thiserror = "2.0.16" @@ -20,13 +28,18 @@ tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } tower = "0.5.2" tower-http = { version = "0.6.6", features = ["trace", "request-id"] } tracing = "0.1.41" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } unicode-segmentation = "1.12.0" url = { version = "2.5.7", features = ["serde"] } validator = "0.20.0" +serde_json = "1.0.143" +rand = { version = "0.9.2", features = ["std_rng"] } [dev-dependencies] -http-body-util = "0.1.3" mime = "0.3.17" fake = "4.4.0" proptest = "1.7.0" +claims = "0.8.0" +wiremock = "0.6.5" +axum-test = "18.0.2" +linkify = "0.10.0" diff --git a/migrations/definitions/_initial.json b/migrations/definitions/_initial.json index 0410657..7bfae32 100644 --- a/migrations/definitions/_initial.json +++ b/migrations/definitions/_initial.json @@ -1 +1 @@ -{"schemas":"DEFINE TABLE OVERWRITE script_migration SCHEMAFULL\n PERMISSIONS\n FOR select FULL\n FOR create, update, delete NONE;\n\nDEFINE FIELD OVERWRITE script_name ON script_migration TYPE string;\nDEFINE FIELD OVERWRITE executed_at ON script_migration TYPE datetime VALUE time::now() READONLY;\nDEFINE FIELD OVERWRITE checksum ON script_migration TYPE option;\n\n# --- TABLE ---\nDEFINE TABLE OVERWRITE subscriptions SCHEMAFULL\nCOMMENT 'Subscription table';\n\n# --- FIELDS ---\nDEFINE FIELD OVERWRITE email ON subscriptions TYPE string ASSERT string::is::email($value);\nDEFINE FIELD OVERWRITE name ON subscriptions TYPE string;\nDEFINE FIELD OVERWRITE created_at ON TABLE subscriptions TYPE datetime VALUE time::now() READONLY;\n\n# --- INDEXES ---\nDEFINE INDEX OVERWRITE unique_email ON subscriptions COLUMNS email UNIQUE;\n","events":""} \ No newline at end of file +{"schemas":"DEFINE TABLE OVERWRITE script_migration SCHEMAFULL\n PERMISSIONS\n FOR select FULL\n FOR create, update, delete NONE;\n\nDEFINE FIELD OVERWRITE script_name ON script_migration TYPE string;\nDEFINE FIELD OVERWRITE executed_at ON script_migration TYPE datetime VALUE time::now() READONLY;\nDEFINE FIELD OVERWRITE checksum ON script_migration TYPE option;\n\n# --- TABLE ---\nDEFINE TABLE OVERWRITE subscription_tokens SCHEMAFULL\nCOMMENT 'Subscription Tokens table';\n\n# --- FIELDS ---\nDEFINE FIELD OVERWRITE token ON subscription_tokens TYPE string;\nDEFINE FIELD OVERWRITE created_at ON TABLE subscription_tokens TYPE datetime VALUE time::now() READONLY;\n\n# --- INDEXES ---\nDEFINE INDEX OVERWRITE unique_token ON subscription_tokens COLUMNS token UNIQUE;\n\n# --- TABLE ---\nDEFINE TABLE OVERWRITE subscriptions SCHEMAFULL\nCOMMENT 'Subscription table';\n\n# --- FIELDS ---\nDEFINE FIELD OVERWRITE email ON subscriptions TYPE string ASSERT string::is::email($value);\nDEFINE FIELD OVERWRITE name ON subscriptions TYPE string;\nDEFINE FIELD OVERWRITE status ON subscriptions TYPE 'PENDING' | 'CONFIRMED' DEFAULT 'PENDING';\nDEFINE FIELD OVERWRITE token ON subscriptions TYPE record;\nDEFINE FIELD OVERWRITE created_at ON TABLE subscriptions TYPE datetime VALUE time::now() READONLY;\n\n# --- INDEXES ---\nDEFINE INDEX OVERWRITE unique_email ON subscriptions COLUMNS email UNIQUE;\n","events":""} \ No newline at end of file diff --git a/schemas/subscription_tokens.surql b/schemas/subscription_tokens.surql new file mode 100644 index 0000000..1e008f0 --- /dev/null +++ b/schemas/subscription_tokens.surql @@ -0,0 +1,10 @@ +# --- TABLE --- +DEFINE TABLE OVERWRITE subscription_tokens SCHEMAFULL +COMMENT 'Subscription Tokens table'; + +# --- FIELDS --- +DEFINE FIELD OVERWRITE token ON subscription_tokens TYPE string; +DEFINE FIELD OVERWRITE created_at ON TABLE subscription_tokens TYPE datetime VALUE time::now() READONLY; + +# --- INDEXES --- +DEFINE INDEX OVERWRITE unique_token ON subscription_tokens COLUMNS token UNIQUE; diff --git a/schemas/subscriptions.surql b/schemas/subscriptions.surql index bb816cd..3be0ddd 100644 --- a/schemas/subscriptions.surql +++ b/schemas/subscriptions.surql @@ -5,6 +5,8 @@ COMMENT 'Subscription table'; # --- FIELDS --- DEFINE FIELD OVERWRITE email ON subscriptions TYPE string ASSERT string::is::email($value); DEFINE FIELD OVERWRITE name ON subscriptions TYPE string; +DEFINE FIELD OVERWRITE status ON subscriptions TYPE 'PENDING' | 'CONFIRMED' DEFAULT 'PENDING'; +DEFINE FIELD OVERWRITE token ON subscriptions TYPE record; DEFINE FIELD OVERWRITE created_at ON TABLE subscriptions TYPE datetime VALUE time::now() READONLY; # --- INDEXES --- diff --git a/src/config.rs b/src/config.rs index a181514..6d44946 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,15 +1,31 @@ +use crate::domain::SubscriberEmail; +use config::ConfigError; +use secrecy::SecretString; use serde::Deserialize; +use std::time::Duration; +use url::Url; #[derive(Debug, Deserialize, Clone)] pub struct Config { pub port: u16, pub host: String, + pub base_url: Url, pub database: DatabaseConfig, + pub email_client: EmailClientConfig, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct EmailClientConfig { + pub sender_email: SubscriberEmail, + pub base_url: Url, + pub auth_token: SecretString, + #[serde(with = "serde_humantime")] + pub timeout: Duration, } #[derive(Debug, Deserialize, Clone)] pub struct DatabaseConfig { - pub url: url::Url, + pub base_url: Url, pub username: String, pub password: secrecy::SecretString, pub namespace: String, @@ -17,7 +33,7 @@ pub struct DatabaseConfig { } impl Config { - pub fn load() -> Result { + pub fn load() -> Result { config::Config::builder() .add_source(config::Environment::with_prefix(env!("CARGO_PKG_NAME")).separator("__")) .build()? diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 8d68b6f..708433f 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,3 +1,4 @@ mod subscriber; pub use subscriber::Subscriber; +pub use subscriber::SubscriberEmail; diff --git a/src/domain/subscriber/email.rs b/src/domain/subscriber/email.rs index cbb0492..1132f15 100644 --- a/src/domain/subscriber/email.rs +++ b/src/domain/subscriber/email.rs @@ -1,6 +1,8 @@ +use serde::Deserialize; +use std::convert::TryFrom; use validator::{ValidateEmail, ValidationError}; -#[derive(Debug)] +#[derive(Debug, Clone, Deserialize)] pub struct SubscriberEmail(String); impl AsRef for SubscriberEmail { @@ -57,8 +59,6 @@ mod tests { proptest! { #[test] fn test_valid_emails_are_accepted(email in valid_email_strategy()) { - dbg!(&email); - assert_ok!(SubscriberEmail::try_from(email)); } } diff --git a/src/domain/subscriber/mod.rs b/src/domain/subscriber/mod.rs index 3a1813b..5cbd11d 100644 --- a/src/domain/subscriber/mod.rs +++ b/src/domain/subscriber/mod.rs @@ -1,8 +1,9 @@ mod email; mod name; +pub use email::SubscriberEmail; + use crate::handlers::FormData; -use email::SubscriberEmail; use name::SubscriberName; use validator::ValidationErrors; diff --git a/src/email_client.rs b/src/email_client.rs new file mode 100644 index 0000000..8faf2e0 --- /dev/null +++ b/src/email_client.rs @@ -0,0 +1,216 @@ +use crate::{config::EmailClientConfig, domain::SubscriberEmail}; +use axum::http::HeaderName; +use reqwest::Client; +use secrecy::ExposeSecret; +use serde::Serialize; + +const EMAIL_CLIENT_AUTH_HEADER: HeaderName = HeaderName::from_static("x-postmark-server-token"); + +#[derive(Debug)] +pub struct EmailClient { + http_client: Client, + config: EmailClientConfig, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +struct SendEmailRequest<'a> { + from: &'a str, + to: &'a str, + subject: &'a str, + html_body: &'a str, + text_body: &'a str, +} + +impl EmailClient { + pub fn new(config: EmailClientConfig) -> Result { + let http_client = Client::builder().timeout(config.timeout).build()?; + Ok(Self { + http_client, + config, + }) + } + + pub async fn send_email( + &self, + recipeint: &SubscriberEmail, + subject: &str, + html_content: &str, + text_content: &str, + ) -> Result<(), reqwest::Error> { + let request_body = SendEmailRequest { + from: self.config.sender_email.as_ref(), + to: recipeint.as_ref(), + subject, + html_body: html_content, + text_body: text_content, + }; + + self.http_client + .post(self.config.base_url.as_str()) + .header( + EMAIL_CLIENT_AUTH_HEADER, + self.config.auth_token.expose_secret(), + ) + .json(&request_body) + .send() + .await? + .error_for_status()?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::EmailClientConfig; + use crate::domain::SubscriberEmail; + use claims::{assert_err, assert_ok}; + use fake::faker::internet::en::SafeEmail; + use fake::faker::lorem::en::{Paragraph, Sentence}; + use fake::{Fake, Faker}; + use reqwest::Method; + use reqwest::header::CONTENT_TYPE; + use secrecy::SecretString; + use std::str::FromStr; + use std::time::Duration; + use url::Url; + use wiremock::matchers::{any, header, header_exists, method}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + struct SendEmailBodyMatcher; + + impl wiremock::Match for SendEmailBodyMatcher { + fn matches(&self, request: &wiremock::Request) -> bool { + let result: std::result::Result = + serde_json::from_slice(&request.body); + + if let Ok(body) = result { + // Check that all the mandatory fields are populated + // without inspecting the field values + body.get("From").is_some() + && body.get("To").is_some() + && body.get("Subject").is_some() + && body.get("HtmlBody").is_some() + && body.get("TextBody").is_some() + } else { + false + } + } + } + + #[tokio::test] + async fn send_email_sends_the_expected_request() { + // Arrange + let mock_server = MockServer::start().await; + let email_client = email_client(&mock_server.uri()); + + Mock::given(header_exists(EMAIL_CLIENT_AUTH_HEADER)) + .and(header(CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())) + .and(method(Method::POST)) + .and(SendEmailBodyMatcher) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + // Act + _ = email_client + .send_email(&email(), &subject(), &content(), &content()) + .await; + + // Assert + // Mock expectations are checked on drop + } + + #[tokio::test] + async fn send_email_succeeds_if_the_server_returns_200() { + // Arrange + let mock_server = MockServer::start().await; + let email_client = email_client(&mock_server.uri()); + + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let outcome = email_client + .send_email(&email(), &subject(), &content(), &content()) + .await; + + // Assert + assert_ok!(outcome); + } + + #[tokio::test] + async fn send_email_fails_if_the_server_returns_500() { + // Arrange + let mock_server = MockServer::start().await; + let email_client = email_client(&mock_server.uri()); + + Mock::given(any()) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let outcome = email_client + .send_email(&email(), &subject(), &content(), &content()) + .await; + + // Assert + assert_err!(outcome); + } + + #[tokio::test] + async fn send_email_timeout_if_the_server_takes_to_long() { + // Arrange + let mock_server = MockServer::start().await; + let email_client = email_client(&mock_server.uri()); + + Mock::given(any()) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(60 * 3))) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let outcome = email_client + .send_email(&email(), &subject(), &content(), &content()) + .await; + + // Assert + assert_err!(outcome); + } + + /// Generate a random email subject + fn subject() -> String { + Sentence(1..2).fake() + } + + /// Generate a random email content + fn content() -> String { + Paragraph(1..10).fake() + } + + /// Generate a random subscriber email + fn email() -> SubscriberEmail { + SubscriberEmail::try_from(SafeEmail().fake::()) + .expect("Expect to get valid subscriber email!") + } + + /// Get a test instance of `EmailClient`. + fn email_client(base_url: &str) -> EmailClient { + let config = EmailClientConfig { + sender_email: email(), + base_url: Url::from_str(base_url).expect("Expect to get valid email client base url"), + auth_token: SecretString::new(Faker.fake::().into()), + timeout: Duration::from_millis(200), + }; + EmailClient::new(config).expect("Expect email client to be initialized.") + } +} diff --git a/src/errors.rs b/src/errors.rs index 8fe2c10..05313d7 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -15,13 +15,20 @@ pub enum Error { #[error(transparent)] UrlParse(#[from] url::ParseError), #[error(transparent)] - ValidationError(#[from] validator::ValidationErrors), + ValidationErrors(#[from] validator::ValidationErrors), + #[error(transparent)] + ValidationError(#[from] validator::ValidationError), + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + + #[error("{0:?}")] + Custom(String), } impl IntoResponse for Error { fn into_response(self) -> axum::response::Response { match self { - Self::ValidationError(_) => { + Self::ValidationErrors(_) | Self::ValidationError(_) => { tracing::info!("Bad request: - {self:?}"); StatusCode::BAD_REQUEST.into_response() } diff --git a/src/handlers.rs b/src/handlers.rs deleted file mode 100644 index 9bbf0ed..0000000 --- a/src/handlers.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::Result; -use crate::model::ModelManager; -use axum::{Form, extract::State, http::StatusCode}; -use serde::Deserialize; -use std::sync::Arc; - -#[derive(Debug, Deserialize)] -pub struct FormData { - pub email: String, - pub name: String, -} - -#[tracing::instrument(skip(mm))] -pub async fn subscribe( - State(mm): State>, - Form(form): Form, -) -> Result { - mm.create_subscriber(form.try_into()?).await?; - - Ok(StatusCode::CREATED) -} - -#[tracing::instrument(skip(mm))] -pub async fn health(State(mm): State>) -> Result { - mm.db().await?.health().await?; - - Ok(StatusCode::OK) -} diff --git a/src/handlers/health_check.rs b/src/handlers/health_check.rs new file mode 100644 index 0000000..4dd2711 --- /dev/null +++ b/src/handlers/health_check.rs @@ -0,0 +1,12 @@ +use crate::Result; +use crate::model::ModelManager; +use axum::extract::State; +use reqwest::StatusCode; +use std::sync::Arc; + +#[tracing::instrument(skip(mm))] +pub async fn health(State(mm): State>) -> Result { + mm.db().await?.health().await?; + + Ok(StatusCode::OK) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..320cdca --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,5 @@ +mod health_check; +mod subscription; + +pub use health_check::*; +pub use subscription::*; diff --git a/src/handlers/subscription.rs b/src/handlers/subscription.rs new file mode 100644 index 0000000..587d534 --- /dev/null +++ b/src/handlers/subscription.rs @@ -0,0 +1,102 @@ +use crate::Config; +use crate::domain::Subscriber; +use crate::model::ModelManager; +use crate::{Result, email_client::EmailClient}; +use axum::extract::Query; +use axum::{Form, extract::State}; +use rand::Rng; +use rand::distr::Alphanumeric; +use reqwest::StatusCode; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct FormData { + pub email: String, + pub name: String, +} + +#[tracing::instrument(skip(mm, config, email_client))] +pub async fn subscribe( + State(mm): State>, + State(config): State>, + State(email_client): State>, + Form(form): Form, +) -> Result { + let subscriber: Subscriber = form.try_into()?; + + let token = get_confirmation_token(); + + mm.create_subscriber(&subscriber, &token).await?; + + send_confirmation_email(&email_client, &config, &subscriber, &token).await?; + + Ok(StatusCode::CREATED) +} + +#[derive(Debug, Deserialize)] +pub struct Params { + token: String, +} + +#[tracing::instrument(skip(mm))] +pub async fn confirm( + State(mm): State>, + Query(params): Query, +) -> Result { + _ = mm + .db() + .await? + .query( + r#" + UPDATE subscriptions + SET status = 'CONFIRMED' + WHERE token = ( + SELECT id + FROM ONLY subscription_tokens + WHERE token = $token_val + ).id + AND status = 'PENDING'; + "#, + ) + .bind(("token_val", params.token)) + .await? + .take::(0)?; + + Ok(StatusCode::OK) +} + +fn get_confirmation_token() -> String { + let mut rng = rand::rng(); + std::iter::repeat_with(|| rng.sample(Alphanumeric)) + .map(char::from) + .take(25) + .collect() +} + +async fn send_confirmation_email( + email_client: &EmailClient, + config: &Config, + subscriber: &Subscriber, + token: &str, +) -> Result<()> { + let mut confirmation_link = config.base_url.join("subscriptions/confirm")?; + confirmation_link.set_query(Some(&format!("token={token}"))); + + email_client + .send_email( + &subscriber.email, + "Welcome!", + &format!( + "Welcome to our newsletter!
\ + Click here to confirm your subscription.", + confirmation_link + ), + &format!( + "Welcome to our newsletter!\nVisit {} to confirm your subscription.", + confirmation_link + ), + ) + .await?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index d2f0b76..215f26b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ mod config; mod domain; +mod email_client; mod errors; mod handlers; mod model; diff --git a/src/main.rs b/src/main.rs index 15ddfb7..98d2a8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,10 +21,12 @@ async fn main() { }); // Initialize application - let (router, _) = subscriptions::init(&config).await.unwrap_or_else(|error| { - tracing::error!(%error, "Failed to initialize application"); - process::exit(1); - }); + let (router, _) = subscriptions::init(config.clone()) + .await + .unwrap_or_else(|error| { + tracing::error!(%error, "Failed to initialize application"); + process::exit(1); + }); // Bind address let listener = TcpListener::bind((config.host.clone(), config.port)) diff --git a/src/model.rs b/src/model.rs index 7473f44..06774b5 100644 --- a/src/model.rs +++ b/src/model.rs @@ -22,12 +22,28 @@ impl ModelManager { self.db.get_or_try_init(async || self.connect().await).await } - pub async fn create_subscriber(&self, subscriber: domain::Subscriber) -> Result<()> { + pub async fn create_subscriber( + &self, + subscriber: &domain::Subscriber, + token: &str, + ) -> Result<()> { self.db() .await? - .query("CREATE subscriptions SET name = $name, email = $email") - .bind(("name", subscriber.name.as_ref().to_string())) + .query( + r#" + BEGIN TRANSACTION; + LET $subscription_token = (CREATE ONLY subscription_tokens CONTENT { token: $token_val }); + CREATE subscriptions CONTENT { + email: $email, + name: $name, + token: $subscription_token.id + }; + COMMIT TRANSACTION; + "#, + ) + .bind(("token_val", token.to_string())) .bind(("email", subscriber.email.as_ref().to_string())) + .bind(("name", subscriber.name.as_ref().to_string())) .await? .check()?; @@ -38,10 +54,10 @@ impl ModelManager { let config = &self.config; let db = Surreal::::init(); - tracing::info!("Connecting to database: {}", config.url.as_str()); - db.connect(config.url.as_str()).await?; + tracing::info!("Connecting to database: {}", config.base_url.as_str()); + db.connect(config.base_url.as_str()).await?; - if config.url.scheme() == "mem" { + if config.base_url.scheme() == "mem" { db.query(format!("DEFINE NAMESPACE {}", config.namespace)) .query(format!("DEFINE DATABASE {}", config.name)) .query(format!("USE NS {} DB {}", config.namespace, config.name)) diff --git a/src/startup.rs b/src/startup.rs index c000925..2a7d120 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,7 +1,7 @@ use crate::{ Result, config::Config, - handlers::{health, subscribe}, + handlers::{confirm, health, subscribe}, state::AppState, }; use axum::{ @@ -17,7 +17,7 @@ use tower_http::{ const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id"); -pub async fn init(config: &Config) -> Result<(Router, AppState)> { +pub async fn init(config: Config) -> Result<(Router, AppState)> { let state = AppState::new(config).await?; let middleware = ServiceBuilder::new() @@ -35,6 +35,7 @@ pub async fn init(config: &Config) -> Result<(Router, AppState)> { let router = Router::new() .route("/health", get(health)) .route("/subscriptions", post(subscribe)) + .route("/subscriptions/confirm", get(confirm)) .layer(middleware) .with_state(state.clone()); diff --git a/src/state.rs b/src/state.rs index 121273c..d6b1f17 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,6 @@ use crate::{ - Result, - config::Config, + Config, Result, + email_client::EmailClient, model::{self, ModelManager}, }; use axum::extract::FromRef; @@ -8,13 +8,17 @@ use std::sync::Arc; #[derive(Debug, Clone)] pub struct AppState { + pub config: Arc, pub mm: Arc, + pub email_client: Arc, } impl AppState { - pub async fn new(config: &Config) -> Result { + pub async fn new(config: Config) -> Result { Ok(Self { mm: Arc::new(model::ModelManager::new(config.database.clone())), + email_client: Arc::new(EmailClient::new(config.email_client.clone())?), + config: Arc::new(config), }) } } @@ -24,3 +28,15 @@ impl FromRef for Arc { input.mm.clone() } } + +impl FromRef for Arc { + fn from_ref(input: &AppState) -> Self { + input.email_client.clone() + } +} + +impl FromRef for Arc { + fn from_ref(input: &AppState) -> Self { + input.config.clone() + } +} diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs new file mode 100644 index 0000000..c4f6170 --- /dev/null +++ b/tests/api/health_check.rs @@ -0,0 +1,16 @@ +use crate::helpers::TestApp; + +#[tokio::test] +async fn health_works() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected App to be initialized!"); + + // Act + let response = app.server.get("/health").await; + + // Assert + assert!(response.status_code().is_success()); + assert!(response.as_bytes().is_empty()); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs new file mode 100644 index 0000000..faf9b57 --- /dev/null +++ b/tests/api/helpers.rs @@ -0,0 +1,72 @@ +use std::str::FromStr; + +use axum_test::TestServer; +use subscriptions::{AppState, Config}; +use tokio::sync::OnceCell; +use tracing_subscriber::prelude::*; +use url::Url; +use wiremock::MockServer; + +pub type Result = std::result::Result>; + +pub struct TestApp { + pub server: TestServer, + pub state: AppState, + pub email_server: MockServer, +} + +pub struct ConfirmationLinks { + pub html: Url, + pub plain_text: Url, +} + +impl TestApp { + pub async fn new() -> Result { + static TRACING: OnceCell<()> = OnceCell::const_new(); + + TRACING + .get_or_init(async || { + // Load environment variables from .env file if exists + dotenvy::from_filename_override(".env.test").ok(); + + // Initialize tracing + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::from_default_env()) + .with(tracing_subscriber::fmt::layer().with_test_writer()) + .init(); + }) + .await; + + let mut config = Config::load()?; + let email_server = MockServer::start().await; + config.email_client.base_url = Url::from_str(&email_server.uri())?; + + let (router, state) = subscriptions::init(config).await?; + + Ok(TestApp { + server: TestServer::new(router)?, + state, + email_server, + }) + } + + pub fn get_conformation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks { + // Parse the body as json + let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); + + // Extract the link from one of requests fields + let get_link = |s: &str| -> Url { + let links: Vec<_> = linkify::LinkFinder::new() + .links(s) + .filter(|l| *l.kind() == linkify::LinkKind::Url) + .collect(); + let raw_link = links[0].as_str().to_owned(); + Url::parse(&raw_link).unwrap() + }; + + let html = get_link(body["HtmlBody"].as_str().unwrap()); + let plain_text = get_link(body["TextBody"].as_str().unwrap()); + + ConfirmationLinks { html, plain_text } + } +} diff --git a/tests/api/main.rs b/tests/api/main.rs new file mode 100644 index 0000000..177847a --- /dev/null +++ b/tests/api/main.rs @@ -0,0 +1,4 @@ +mod health_check; +mod helpers; +mod subscriptions; +mod subscriptions_confirm; diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs new file mode 100644 index 0000000..5ffd075 --- /dev/null +++ b/tests/api/subscriptions.rs @@ -0,0 +1,191 @@ +use crate::helpers::TestApp; +use reqwest::{Method, StatusCode}; +use serde::Deserialize; +use wiremock::{ + Mock, ResponseTemplate, + matchers::{any, method}, +}; + +#[tokio::test] +async fn subscribe_works() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected App to be initialized!"); + let body = [("name", "let guin"), ("email", "ursula_le_guin@gmail.com")]; + + Mock::given(any()) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(StatusCode::OK)) + .expect(1) + .mount(&app.email_server) + .await; + + // Act + let response = app.server.post("/subscriptions").form(&body).await; + + // Assert + #[derive(Deserialize)] + struct QueryResult {} + let result = app + .state + .mm + .db() + .await + .expect("Expected Database to be connected") + .query("SELECT id FROM ONLY subscriptions WHERE email = 'ursula_le_guin@gmail.com'") + .await + .expect("query should be successful") + .take::>(0) + .expect("query result should be valid"); + + assert!( + result.is_some(), + "Subscription with the email 'ursula_le_guin@gmail.com' not found" + ); + assert_eq!(response.status_code(), StatusCode::CREATED); + assert!(response.as_bytes().is_empty()); +} + +#[tokio::test] +async fn subscribe_failed() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected App to be initialized!"); + let test_cases = [ + ( + vec![("name", "le guin")], + "missing the email", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + vec![("email", "ursula_le_guin@gmail.com")], + "missing the name", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + vec![], + "missing the name and email", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + vec![("name", ""), ("email", "ursula_le_guin@gmail.com")], + "empty name", + StatusCode::BAD_REQUEST, + ), + ( + vec![("name", "wow"), ("email", "")], + "empty email", + StatusCode::BAD_REQUEST, + ), + ( + vec![("name", "Ursula"), ("email", "definitely-not-an-email")], + "invalid email", + StatusCode::BAD_REQUEST, + ), + ]; + + for (invalid_body, error_message, expected_status) in test_cases { + // Act + let response = app.server.post("/subscriptions").form(&invalid_body).await; + + // Assert + assert_eq!( + response.status_code(), + expected_status, + "The Api did not fail with status {expected_status} when payload was {error_message}." + ); + } +} + +#[tokio::test] +pub async fn subscribe_sends_a_confirmation_email_for_valid_data() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected App to be initialized!"); + let body = [("name", "let guin"), ("email", "ursula_le_guin@gmail.com")]; + + Mock::given(any()) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(StatusCode::OK)) + .expect(1) + .mount(&app.email_server) + .await; + + // Act + _ = app.server.post("/subscriptions").form(&body).await; + + // Assert + // Mock assert on drop +} + +#[tokio::test] +pub async fn subscribe_sends_a_confirmation_email_with_a_link() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected App to be initialized!"); + let body = [("name", "let guin"), ("email", "ursula_le_guin@gmail.com")]; + + Mock::given(any()) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(StatusCode::OK)) + .mount(&app.email_server) + .await; + + // Act + _ = app.server.post("/subscriptions").form(&body).await; + + // Assert + // Get the first interapted request + let email_request = &app.email_server.received_requests().await.unwrap()[0]; + let confirmation_links = app.get_conformation_links(email_request); + // the two link should be adentical + assert_eq!(confirmation_links.html, confirmation_links.plain_text); +} + +#[tokio::test] +pub async fn subscribe_returns_a_200_for_valid_form_data() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected App to be initialized!"); + let body = [("name", "le guin"), ("email", "ursula_le_guin@gmail.com")]; + + Mock::given(any()) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(StatusCode::OK)) + .mount(&app.email_server) + .await; + + // Act + let response = app.server.post("/subscriptions").form(&body).await; + + // Assert + assert_eq!(response.status_code(), StatusCode::CREATED); + + #[derive(Debug, Deserialize)] + struct QueryResult { + email: String, + name: String, + status: String, + } + let result = app + .state + .mm + .db() + .await + .expect("Expected Database to be connected") + .query("SELECT * FROM ONLY subscriptions WHERE email = 'ursula_le_guin@gmail.com'") + .await + .expect("query should be successful") + .take::>(0) + .expect("query result should be valid") + .expect("query result should to not be empty"); + + assert_eq!(result.email, "ursula_le_guin@gmail.com"); + assert_eq!(result.name, "le guin"); + assert_eq!(result.status, "PENDING"); +} diff --git a/tests/api/subscriptions_confirm.rs b/tests/api/subscriptions_confirm.rs new file mode 100644 index 0000000..1b58a6f --- /dev/null +++ b/tests/api/subscriptions_confirm.rs @@ -0,0 +1,75 @@ +use crate::helpers::TestApp; +use reqwest::{Method, StatusCode}; +use serde::Deserialize; +use wiremock::{ + Mock, ResponseTemplate, + matchers::{any, method}, +}; + +#[tokio::test] +async fn confirmations_without_token_are_rejected_with_400() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected App to be initialized!"); + + // Act + let response = app.server.get("/subscriptions/confirm").await; + + // Assert + assert_eq!(response.status_code(), StatusCode::BAD_REQUEST) +} + +#[tokio::test] +async fn confirmation_works() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected App to be initialized!"); + let body = [("name", "le guin"), ("email", "ursula_le_guin@gmail.com")]; + + Mock::given(any()) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(StatusCode::OK)) + .mount(&app.email_server) + .await; + + // Act + _ = app.server.post("/subscriptions").form(&body).await; + let email_request = &app.email_server.received_requests().await.unwrap()[0]; + let confirmation_links = app.get_conformation_links(email_request); + + let response = app + .server + .get(&format!( + "{}?{}", + confirmation_links.html.path(), + confirmation_links.html.query().unwrap() + )) + .await; + + #[derive(Debug, Deserialize)] + struct QueryResult { + email: String, + name: String, + status: String, + } + let result = app + .state + .mm + .db() + .await + .expect("Expected Database to be connected") + .query("SELECT * FROM ONLY subscriptions WHERE email = 'ursula_le_guin@gmail.com'") + .await + .expect("query should be successful") + .take::>(0) + .expect("query result should be valid") + .expect("query result should to not be empty"); + + // Assert + assert_eq!(response.status_code(), StatusCode::OK); + assert_eq!(result.email, "ursula_le_guin@gmail.com"); + assert_eq!(result.name, "le guin"); + assert_eq!(result.status, "CONFIRMED"); +} diff --git a/tests/tests.rs b/tests/tests.rs deleted file mode 100644 index 4c9e0f5..0000000 --- a/tests/tests.rs +++ /dev/null @@ -1,165 +0,0 @@ -use axum::{ - Router, - body::Body, - http::{self, Method, StatusCode}, -}; -use http_body_util::BodyExt; -use serde::Deserialize; -use subscriptions::{AppState, Config}; -use surrealdb::RecordId; -use tokio::sync::OnceCell; -use tower::ServiceExt; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -type Result = std::result::Result>; - -async fn init() -> Result<(Router, AppState)> { - static TRACING: OnceCell<()> = OnceCell::const_new(); - - TRACING - .get_or_init(async || { - // Load environment variables from .env file if exists - dotenvy::from_filename_override(".env.test").ok(); - - // Initialize tracing - tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::from_default_env()) - .with(tracing_subscriber::fmt::layer().with_test_writer()) - .init(); - }) - .await; - - let config = Config::load()?; - Ok(subscriptions::init(&config).await?) -} - -#[tokio::test] -async fn health_works() { - // Arrange - let (app, _) = init().await.expect("Expected App to be initialized!"); - - // Act - let response = app - .oneshot( - http::Request::builder() - .method(Method::GET) - .uri("/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Expected Request to be successful"); - - // Assert - assert!(response.status().is_success()); - let body = response.into_body().collect().await.unwrap().to_bytes(); - assert!(body.is_empty()); -} - -#[tokio::test] -async fn subscribe_works() { - // Arrange - let (app, state) = init().await.expect("Expected App to be initialized!"); - - // Act - let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; - let response = app - .oneshot( - http::Request::builder() - .method(Method::POST) - .uri("/subscriptions") - .header( - http::header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body)) - .unwrap(), - ) - .await - .expect("Expected Request to be successful"); - - // Assert - #[derive(Deserialize)] - struct QueryResult { - #[serde(rename = "id")] - _id: RecordId, - } - let result: Option = state - .mm - .db() - .await - .expect("Expected Database to be connected") - .query("SELECT id FROM ONLY subscriptions WHERE email = 'ursula_le_guin@gmail.com'") - .await - .expect("query should be successful") - .take(0) - .expect("query result should be valid"); - - assert!( - result.is_some(), - "Subscription with the email 'ursula_le_guin@gmail.com' not found" - ); - assert_eq!(response.status(), StatusCode::CREATED); - let body = response.into_body().collect().await.unwrap().to_bytes(); - assert!(body.is_empty()); -} - -#[tokio::test] -async fn subscribe_failed() { - // Arrange - let (app, _) = init().await.expect("Expected App to be initialized!"); - let test_cases = [ - ( - "name=le%20guin", - "missing the email", - StatusCode::UNPROCESSABLE_ENTITY, - ), - ( - "email=ursula_le_guin%40gmail.com", - "missing the name", - StatusCode::UNPROCESSABLE_ENTITY, - ), - ( - "", - "missing the name and email", - StatusCode::UNPROCESSABLE_ENTITY, - ), - ( - "name=&email=ursula_le_guin%40gmail.com", - "empty name", - StatusCode::BAD_REQUEST, - ), - ("name=Ursula&email=", "empty email", StatusCode::BAD_REQUEST), - ( - "name=Ursula&email=definitely-not-an-email", - "invalid email", - StatusCode::BAD_REQUEST, - ), - ]; - - for (invalid_body, error_message, expected_status) in test_cases { - // Act - let response = app - .clone() - .oneshot( - http::Request::builder() - .method(Method::POST) - .uri("/subscriptions") - .header( - http::header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(invalid_body)) - .unwrap(), - ) - .await - .expect("Expected Request to be successful"); - - // Assert - assert_eq!( - response.status(), - expected_status, - "The Api did not fail with status {expected_status} when payload was {error_message}." - ); - } -}