diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 647ad3f..569c405 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: with: components: rustfmt - name: cargo fmt - run: cargo fmt --check + run: cargo fmt --all --check clippy: name: Clippy @@ -50,13 +50,17 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: cargo clippy # --all-targets lints the test code as well. - run: cargo clippy --locked --all-targets -- -D warnings + run: cargo clippy --locked --workspace --all-targets -- -D warnings test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - name: Start MySQL and Redis + # Started before the toolchain so the containers initialise meanwhile. + # test-utils connects with defaults that match compose.yaml. + run: docker compose up --detach --wait database redis - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@v2 @@ -65,10 +69,13 @@ jobs: - name: cargo nextest # insta detects CI and fails on any snapshot mismatch instead of # writing a new snapshot. - run: cargo nextest run --locked --all-targets + run: cargo nextest run --locked --workspace --all-targets - name: doctests # nextest does not run doctests. - run: cargo test --locked --doc + run: cargo test --locked --workspace --doc + - name: Service logs + if: failure() + run: docker compose logs database redis cargo-deny: name: cargo-deny check ${{ matrix.checks }} diff --git a/Cargo.lock b/Cargo.lock index be2c82c..c9d444e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,7 +111,7 @@ dependencies = [ "async-io", "async-trait", "asynk-strim", - "base64", + "base64 0.22.1", "bytes", "chrono", "fast_chemail", @@ -293,7 +293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -356,6 +356,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.0" @@ -1259,6 +1265,7 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", ] [[package]] @@ -1267,14 +1274,22 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1492,10 +1507,17 @@ checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", + "serde", "similar", "tempfile", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itoa" version = "1.0.15" @@ -2258,6 +2280,37 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -2659,7 +2712,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -2735,7 +2788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "bytes", @@ -2778,7 +2831,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "chrono", @@ -2929,6 +2982,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2954,6 +3010,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "test-utils" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "envconfig", + "insta", + "reqwest", + "sea-orm", + "serde_json", + "tokio", + "uuid", + "website_backend2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3156,6 +3228,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-http" version = "0.7.1" @@ -3254,6 +3344,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.29.0" @@ -3357,7 +3453,10 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ + "getrandom 0.3.4", + "js-sys", "serde", + "wasm-bindgen", ] [[package]] @@ -3387,6 +3486,15 @@ dependencies = [ "libc", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3434,6 +3542,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.100" @@ -3466,6 +3587,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3502,11 +3633,13 @@ dependencies = [ "redis", "sea-orm", "serde_json", + "sha2", "smallvec", + "test-utils", "thiserror 2.0.18", "tokio", "tower", - "tower-http", + "tower-http 0.7.1", "tracing", "tracing-subscriber", ] diff --git a/Cargo.toml b/Cargo.toml index 9a7ae33..b662303 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,5 +26,12 @@ icu_collator = "2.3.1" icu_locale_core = "2.3.0" [dev-dependencies] -insta = "1.48.0" +insta = { version = "1.48.0", features = ["json"] } proptest = "1.11.0" +sha2 = "0.10.9" +test-utils = { path = "test-utils" } + +# test-utils is a separate crate so integration tests share one harness; it +# depends on this crate, which only uses it as a dev-dependency. +[workspace] +members = [".", "test-utils"] diff --git a/compose.yaml b/compose.yaml index f7b1a07..8866191 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,8 @@ -version: '3' - +# Local services for development and for the integration tests. +# +# Images follow production (website_k8s: verseghy-website/mysql and +# verseghy-website/backend2-redis). The integration tests connect to these +# ports as root by default; see docs/testing.md. services: database: image: docker.io/mysql:8 @@ -8,8 +11,18 @@ services: - "MYSQL_USER=backend" - "MYSQL_PASSWORD=password" - "MYSQL_ROOT_PASSWORD=secret" + # Every integration test opens its own small connection pool, and many run + # in parallel. + command: ["--max-connections=500"] ports: - "33061:3306" + healthcheck: + # Ping over TCP: during first-time initialisation the image runs a + # temporary server without networking, which would answer a socket ping. + test: ["CMD", "mysqladmin", "ping", "--host=127.0.0.1", "--user=root", "--password=secret"] + interval: 2s + timeout: 5s + retries: 60 redis: image: docker.io/redis:6.2-alpine environment: @@ -18,3 +31,8 @@ services: - "6379:6379" command: - "redis-server" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 5s + retries: 30 diff --git a/docs/testing.md b/docs/testing.md index e6cd332..a50e2e1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -5,8 +5,12 @@ safe before it is merged. The tests are organised in layers, cheapest first. ## Running the tests +Unit tests and the schema snapshot need nothing but Rust. The integration tests +also need MySQL and Redis, which `compose.yaml` provides: + ```sh -cargo nextest run # or: cargo test +docker compose up -d --wait # or: podman compose up -d +cargo nextest run --workspace # or: cargo test --workspace ``` CI runs the same suite (plus doctests) on every pull request. `Build`, `Format`, @@ -41,12 +45,91 @@ and test that function directly, as `src/utils/date_range.rs` does. its type or nullability, makes the test fail and prints the diff. website_frontend2 is written against this schema, so a snapshot change is an -API change. Confirm the frontend still works before accepting it, then update -the snapshot and commit it together with the code change: +API change: confirm the frontend still works before accepting it. + +## Integration tests + +Integration tests live in `tests/` and use the harness in `test-utils/`. Each +test starts its own `TestApp`, which consists of: + +- a new MySQL database named `test_`, whose tables are generated from the + sea-orm entities in `src/entity/`; +- optionally, the standard data set from `test-utils/src/seed.rs`; +- the real application router, middleware included, on a random local port. + +The database is dropped when the test ends, so tests never see each other's +data and can run in parallel. + +```rust +use test_utils::prelude::*; + +#[tokio::test] +async fn colleagues_are_sorted_by_name() { + let app = TestApp::seeded().await; + + let response = app.graphql("{ colleagues { name } }").await; + + assert_response_snapshot!(response); +} +``` + +`assert_response_snapshot!` is `insta::assert_json_snapshot!` with object keys +sorted: the order of sibling fields in responses is not stable between runs, +while array order, which tests rely on, is kept. + +| File | Covers | +| --- | --- | +| `tests/frontend_queries.rs` | The exact operations website_frontend2 sends, copied to `tests/frontend/` | +| `tests/posts.rs` | Post lists, filters, search, preview tokens, archive, pagination | +| `tests/calendar.rs` | Canteen weeks and event months | +| `tests/content.rs` | Pages, menu, colleagues, authors, labels | +| `tests/http.rs` | Health checks, metrics, CORS, compression, limits, routing | +| `tests/persisted_queries.rs` | Apollo persisted queries and the Redis cache | + +When website_frontend2 changes one of its operations, update the copy in +`tests/frontend/` in the same way. + +### Schema from entities + +The test schema is generated from the entities, so an entity that disagrees +with production makes the tests disagree with production too. When a migration +in `migrations/` changes a table, change its entity in the same pull request, +and add new entities to `create_tables` in `test-utils/src/database.rs`. + +Test databases use the `utf8mb4_unicode_ci` collation of the production tables, +which is what makes searches ignore case and accents. + +### Seed data + +`test-utils/src/seed.rs` is a small, fixed data set covering the cases the +resolvers treat differently; its module documentation lists them, and its +constants name the rows tests refer to. Changing it changes many snapshots, so +a test that needs an unusual row should insert it through `app.db()` instead. + +### Configuration + +| Variable | Default (matches `compose.yaml`) | +| --- | --- | +| `TEST_MYSQL_URL` | `mysql://root:secret@127.0.0.1:33061` (a server URL without a database name) | +| `TEST_REDIS_URL` | `redis://127.0.0.1:6379` | + +A test run that is killed leaves its `test_*` databases behind. They are +harmless, and recreating the container removes them: + +```sh +docker compose up -d --wait --force-recreate database +``` + +## Updating snapshots + +Snapshot files live in `snapshots/` directories next to the tests that own +them. When a change is intended, re-run the tests with `INSTA_UPDATE=always` +(or use `cargo insta review`), check the diff, and commit the updated `.snap` +files together with the code change: ```sh -INSTA_UPDATE=always cargo test # or: cargo insta review -git diff src/graphql/snapshots/ +INSTA_UPDATE=always cargo test --workspace +git diff -- '*.snap' ``` In CI, insta never writes snapshots; any mismatch fails the build. @@ -55,9 +138,10 @@ In CI, insta never writes snapshots; any mismatch fails the build. A test describing correct behaviour that the code does not have yet is marked `#[ignore = "bug: "]`. It documents the bug without failing CI, and -the pull request that fixes the bug removes the `#[ignore]`. +the pull request that fixes the bug removes the `#[ignore]`. Snapshots record +current behaviour, so that pull request also updates the affected snapshots. ```sh -cargo nextest list --run-ignored only # list known bugs -cargo nextest run --run-ignored only # run them (they are expected to fail) +cargo nextest list --workspace --run-ignored only # list known bugs +cargo nextest run --workspace --run-ignored only # run them (they are expected to fail) ``` diff --git a/src/entity/canteen_menus.rs b/src/entity/canteen_menus.rs index 59d6bfe..d49fe54 100644 --- a/src/entity/canteen_menus.rs +++ b/src/entity/canteen_menus.rs @@ -6,9 +6,10 @@ use sea_orm::entity::prelude::*; #[sea_orm(table_name = "canteen_menus")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: u32, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub menu: String, - pub r#type: i16, + pub r#type: u16, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/src/entity/canteen_pivot_menus_data.rs b/src/entity/canteen_pivot_menus_data.rs index 55d1f25..0dfa52b 100644 --- a/src/entity/canteen_pivot_menus_data.rs +++ b/src/entity/canteen_pivot_menus_data.rs @@ -5,10 +5,11 @@ use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "canteen_pivot_menus_data")] pub struct Model { + // Field order is the primary key order in production: (menu_id, data_id). #[sea_orm(primary_key, auto_increment = false)] - pub data_id: i32, + pub menu_id: u32, #[sea_orm(primary_key, auto_increment = false)] - pub menu_id: i32, + pub data_id: u32, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/entity/colleagues_data.rs b/src/entity/colleagues_data.rs index 6cc8fac..a4cfa18 100644 --- a/src/entity/colleagues_data.rs +++ b/src/entity/colleagues_data.rs @@ -6,14 +6,20 @@ use sea_orm::entity::prelude::*; #[sea_orm(table_name = "colleagues_data")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: u32, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub name: Option, + #[sea_orm(column_type = "Text")] pub jobs: Option, + #[sea_orm(column_type = "Text")] pub subjects: Option, + #[sea_orm(column_type = "Text")] pub roles: Option, + #[sea_orm(column_type = "Text")] pub awards: Option, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub image: Option, - pub category: Option, + pub category: Option, } #[derive(Copy, Clone, Debug, EnumIter)] diff --git a/src/entity/events_data.rs b/src/entity/events_data.rs index 88c041b..07fdb45 100644 --- a/src/entity/events_data.rs +++ b/src/entity/events_data.rs @@ -6,12 +6,15 @@ use sea_orm::entity::prelude::*; #[sea_orm(table_name = "events_data")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: u32, pub date_from: DateTime, pub date_to: DateTime, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub title: String, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub description: Option, - pub color: Option, + #[sea_orm(column_type = "String(StringLen::N(7))")] + pub color: String, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/src/entity/menu_items.rs b/src/entity/menu_items.rs index f07564a..812e60d 100644 --- a/src/entity/menu_items.rs +++ b/src/entity/menu_items.rs @@ -6,15 +6,18 @@ use sea_orm::entity::prelude::*; #[sea_orm(table_name = "menu_items")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: u32, + #[sea_orm(column_type = "String(StringLen::N(100))")] pub name: String, + #[sea_orm(column_type = "String(StringLen::N(20))")] pub r#type: String, + #[sea_orm(column_type = "String(StringLen::N(255))")] pub link: Option, - pub page_id: Option, - pub parent_id: Option, - pub lft: i32, - pub rgt: i32, - pub depth: i32, + pub page_id: Option, + pub parent_id: Option, + pub lft: u32, + pub rgt: u32, + pub depth: u32, pub created_at: DateTime, pub updated_at: DateTime, pub deleted_at: Option, diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 7cae263..981af75 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -1,4 +1,16 @@ -//! SeaORM Entity. Generated by sea-orm-codegen 0.4.2 +//! SeaORM entities for the tables the API reads. +//! +//! The tables were created by the earlier Laravel backend and are migrated with +//! the SQL files in `migrations/`. The entities describe their production shape: +//! unsigned integer ids, string lengths, nullability and column types. The +//! integration tests generate their schema from these definitions (see +//! `test-utils/src/database.rs`), so a schema change in production has to be +//! mirrored here, and a new entity also has to be added to the table list there. +//! +//! The unsigned types matter beyond the tests: sqlx only decodes `u32`/`u16` +//! from `UNSIGNED` columns, and the resolvers read ids as `u32`. Where +//! production uses `LONGTEXT`, the entities declare `TEXT`; both decode to +//! `String`. pub mod prelude; diff --git a/src/entity/pages.rs b/src/entity/pages.rs index cc73e2b..34c5b90 100644 --- a/src/entity/pages.rs +++ b/src/entity/pages.rs @@ -6,10 +6,14 @@ use sea_orm::entity::prelude::*; #[sea_orm(table_name = "pages")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: u32, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub template: String, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub name: String, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub title: String, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub slug: String, #[sea_orm(column_type = "Text")] pub content: String, diff --git a/src/entity/posts_authors.rs b/src/entity/posts_authors.rs index bd6d4e5..0481133 100644 --- a/src/entity/posts_authors.rs +++ b/src/entity/posts_authors.rs @@ -6,9 +6,12 @@ use sea_orm::entity::prelude::*; #[sea_orm(table_name = "posts_authors")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: u32, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub name: String, + #[sea_orm(column_type = "Text")] pub description: Option, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub image: Option, } diff --git a/src/entity/posts_data.rs b/src/entity/posts_data.rs index 6588312..c6c436e 100644 --- a/src/entity/posts_data.rs +++ b/src/entity/posts_data.rs @@ -6,20 +6,28 @@ use sea_orm::entity::prelude::*; #[sea_orm(table_name = "posts_data")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: u32, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub title: String, + #[sea_orm(column_type = "String(StringLen::N(7))")] pub color: String, + #[sea_orm(column_type = "Text")] pub description: Option, + #[sea_orm(column_type = "Text")] pub content: Option, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub index_image: Option, - pub author_id: Option, + pub author_id: Option, pub images: Json, - pub date: Option, + pub date: Option, pub created_at: DateTime, pub updated_at: DateTime, pub featured: i8, pub published: i8, - #[sea_orm(column_name = "previewToken")] + #[sea_orm( + column_name = "previewToken", + column_type = "String(StringLen::N(191))" + )] pub preview_token: Option, } diff --git a/src/entity/posts_labels.rs b/src/entity/posts_labels.rs index 0ed63fb..0c5b01e 100644 --- a/src/entity/posts_labels.rs +++ b/src/entity/posts_labels.rs @@ -6,8 +6,10 @@ use sea_orm::entity::prelude::*; #[sea_orm(table_name = "posts_labels")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: u32, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub name: String, + #[sea_orm(column_type = "String(StringLen::N(191))")] pub color: String, } diff --git a/src/entity/posts_pivot_labels_data.rs b/src/entity/posts_pivot_labels_data.rs index f06af86..795d02d 100644 --- a/src/entity/posts_pivot_labels_data.rs +++ b/src/entity/posts_pivot_labels_data.rs @@ -6,9 +6,9 @@ use sea_orm::entity::prelude::*; #[sea_orm(table_name = "posts_pivot_labels_data")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] - pub labels_id: i32, + pub labels_id: u32, #[sea_orm(primary_key, auto_increment = false)] - pub posts_id: i32, + pub posts_id: u32, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/lib.rs b/src/lib.rs index d97c339..62d9e9c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,7 @@ //! [`AppState::new`]; tests do the same with their own databases and caches. pub mod database; -mod entity; +pub mod entity; pub mod graphql; mod http; mod utils; diff --git a/test-utils/Cargo.toml b/test-utils/Cargo.toml new file mode 100644 index 0000000..4a565b2 --- /dev/null +++ b/test-utils/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "test-utils" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" +publish = false +description = "Integration test harness for website_backend2" + +[dependencies] +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] } +chrono = { version = "0.4.44", default-features = false } +envconfig = "0.11.1" +insta = { version = "1.48.0", features = ["json"] } +# Tests only talk plain HTTP to 127.0.0.1, so no TLS backend is needed. +reqwest = { version = "0.13.5", default-features = false, features = ["json"] } +sea-orm = { version = "1.1.20", features = ["sqlx-mysql", "runtime-actix-rustls", "macros", "with-chrono", "with-json"], default-features = false } +serde_json = { version = "1.0.149", features = ["preserve_order"] } +tokio = { version = "1.50.0", features = ["rt", "net", "macros"] } +uuid = { version = "1.18.1", features = ["v4"] } +website_backend2 = { path = ".." } diff --git a/test-utils/src/app.rs b/test-utils/src/app.rs new file mode 100644 index 0000000..96e47a8 --- /dev/null +++ b/test-utils/src/app.rs @@ -0,0 +1,195 @@ +//! The application under test, served on an ephemeral local port. + +use crate::{TestConfig, TestDatabase, seed}; +use envconfig::Envconfig; +use reqwest::{Client, Method, RequestBuilder, StatusCode}; +use sea_orm::DatabaseConnection; +use serde_json::{Value, json}; +use std::net::{Ipv4Addr, SocketAddr}; +use tokio::{net::TcpListener, task::JoinHandle}; +use website_backend2::{ + AppState, Config, + graphql::{RedisCache, build_schema}, +}; + +/// Storage base URL the application renders image URLs with during tests. +/// +/// It is fixed so snapshots do not depend on the environment. +pub const STORAGE_BASE_URL: &str = "https://storage.test"; + +/// The rows a test database starts with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Data { + /// All tables exist but are empty. + Empty, + /// The standard data set from [`seed`]. + Seeded, +} + +/// A running instance of the application with a database of its own. +/// +/// Dropping it stops the server and drops the database. +pub struct TestApp { + address: SocketAddr, + client: Client, + server: JoinHandle<()>, + database: TestDatabase, +} + +impl TestApp { + /// Starts the application on the [`seed`] data, with [`TestConfig`] read + /// from the environment. + pub async fn seeded() -> Self { + Self::start(&config_from_env(), Data::Seeded).await + } + + /// Starts the application on empty tables, with [`TestConfig`] read from + /// the environment. + pub async fn empty() -> Self { + Self::start(&config_from_env(), Data::Empty).await + } + + /// Starts the application. + /// + /// * `config` - where the MySQL server and Redis are. + /// * `data` - which rows the new database starts with. + /// + /// # Panics + /// + /// Panics with a hint if MySQL or Redis cannot be reached, or if the seed + /// data cannot be inserted. + pub async fn start(config: &TestConfig, data: Data) -> Self { + let database = TestDatabase::create(&config.mysql_url).await; + + if data == Data::Seeded { + seed::insert(database.connection()).await; + } + + let cache = RedisCache::new(&config.redis_url) + .await + .unwrap_or_else(|error| { + panic!( + "could not connect to Redis in TEST_REDIS_URL: {error}\n\ + Start the test services with `docker compose up -d --wait` (see docs/testing.md)." + ) + }); + + let app_config = Config { + bind_addr: Ipv4Addr::LOCALHOST.into(), + bind_port: 0, + // Only the binary uses this to connect; the pool is passed in directly. + database_url: String::new(), + redis_url: config.redis_url.clone(), + storage_base_url: STORAGE_BASE_URL.to_owned(), + }; + let state = AppState::new( + build_schema(cache), + database.connection().clone(), + app_config, + ); + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("could not bind a local port for the test server"); + let address = listener + .local_addr() + .expect("a bound listener has an address"); + let server = tokio::spawn(async move { + axum::serve(listener, website_backend2::app(state)) + .await + .expect("test server failed"); + }); + + Self { + address, + client: Client::new(), + server, + database, + } + } + + /// Connection to this app's database, for inserting rows a test needs. + pub fn db(&self) -> &DatabaseConnection { + self.database.connection() + } + + /// Absolute URL of `path` (such as `/graphql`) on the running app. + pub fn url(&self, path: &str) -> String { + format!("http://{}{path}", self.address) + } + + /// Starts a request with any method to `path`. + pub fn request(&self, method: Method, path: &str) -> RequestBuilder { + self.client.request(method, self.url(path)) + } + + /// Starts a GET request to `path`. + pub fn get(&self, path: &str) -> RequestBuilder { + self.request(Method::GET, path) + } + + /// Starts a POST request to `path`. + pub fn post(&self, path: &str) -> RequestBuilder { + self.request(Method::POST, path) + } + + /// Runs a GraphQL document without variables and returns the response body. + /// + /// * `query` - the GraphQL document. + /// + /// GraphQL errors are part of the returned body; see [`TestApp::graphql_request`] + /// for what panics. + pub async fn graphql(&self, query: &str) -> Value { + self.graphql_with(query, json!({})).await + } + + /// Runs a GraphQL document with variables and returns the response body. + /// + /// * `query` - the GraphQL document. + /// * `variables` - JSON object of variable values. + pub async fn graphql_with(&self, query: &str, variables: Value) -> Value { + self.graphql_request(json!({ "query": query, "variables": variables })) + .await + } + + /// Posts a complete GraphQL request body to `/graphql` and returns the + /// response body. + /// + /// * `body` - request object, for when a test needs `operationName` or + /// `extensions`. + /// + /// # Panics + /// + /// Panics if the server does not answer with HTTP 200 and a JSON body. + /// Tests that expect another status should use [`TestApp::post`]. + pub async fn graphql_request(&self, body: Value) -> Value { + let response = self + .post("/graphql") + .json(&body) + .send() + .await + .expect("could not send the request to the test server"); + let status = response.status(); + let text = response + .text() + .await + .expect("could not read the response body"); + + assert_eq!(status, StatusCode::OK, "GraphQL request failed: {text}"); + + serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("response is not JSON ({error}): {text}")) + } +} + +impl Drop for TestApp { + fn drop(&mut self) { + // Stop serving before the fields, and with them the database, are dropped. + self.server.abort(); + } +} + +fn config_from_env() -> TestConfig { + TestConfig::init_from_env() + .unwrap_or_else(|error| panic!("invalid integration test configuration: {error}")) +} diff --git a/test-utils/src/config.rs b/test-utils/src/config.rs new file mode 100644 index 0000000..8b4b8dd --- /dev/null +++ b/test-utils/src/config.rs @@ -0,0 +1,25 @@ +//! Where the services the integration tests need can be reached. + +use envconfig::Envconfig; + +/// Connection settings for the MySQL server and Redis the tests use. +/// +/// [`TestApp::seeded`](crate::TestApp::seeded) and +/// [`TestApp::empty`](crate::TestApp::empty) read these from the environment. +/// The defaults match `compose.yaml`, so `docker compose up -d --wait` is all a +/// local run needs. +#[derive(Debug, Clone, Envconfig)] +pub struct TestConfig { + /// URL of the MySQL *server*, without a database name (`TEST_MYSQL_URL`). + /// + /// Every test creates and drops its own database there, so the user needs + /// permission to do both. + #[envconfig( + from = "TEST_MYSQL_URL", + default = "mysql://root:secret@127.0.0.1:33061" + )] + pub mysql_url: String, + /// Redis URL for the persisted-query cache (`TEST_REDIS_URL`). + #[envconfig(from = "TEST_REDIS_URL", default = "redis://127.0.0.1:6379")] + pub redis_url: String, +} diff --git a/test-utils/src/database.rs b/test-utils/src/database.rs new file mode 100644 index 0000000..225c0ef --- /dev/null +++ b/test-utils/src/database.rs @@ -0,0 +1,264 @@ +//! Throw-away MySQL databases, one per test. + +use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Schema}; +use std::{fmt, thread}; +use uuid::Uuid; +use website_backend2::entity::{ + canteen_data, canteen_menus, canteen_pivot_menus_data, colleagues_data, events_data, + menu_items, pages, posts_authors, posts_data, posts_labels, posts_pivot_labels_data, +}; + +/// Most connections the pool of one test database opens. +/// +/// nextest runs every test in its own process and many processes at once, so +/// the pools are kept small to stay well below the server's `max_connections`. +const MAX_CONNECTIONS: u32 = 4; + +/// A MySQL database that exists as long as this value does. +/// +/// [`TestDatabase::create`] makes a uniquely named database containing every +/// application table; dropping the value drops the database again. +pub struct TestDatabase { + server: ServerUrl, + name: String, + connection: DatabaseConnection, +} + +impl TestDatabase { + /// Creates a uniquely named database with all application tables and no rows. + /// + /// * `server_url` - MySQL server URL without a database name, such as + /// `mysql://root:secret@127.0.0.1:33061`. The user needs permission to + /// create and drop databases. + /// + /// The database uses `utf8mb4_unicode_ci`, the collation the Laravel + /// backend that created the production tables was configured with. It + /// decides how `LIKE` searches treat case and accents. + /// + /// # Panics + /// + /// Panics with a hint if the URL is not a server URL, the server cannot be + /// reached, or a table cannot be created: a test cannot run without its + /// database. + pub async fn create(server_url: &str) -> Self { + let server = ServerUrl::parse(server_url).unwrap_or_else(|message| panic!("{message}")); + let name = format!("test_{}", Uuid::new_v4().simple()); + + let admin = Database::connect(server.to_string()) + .await + .unwrap_or_else(|error| { + panic!( + "could not connect to the MySQL server in TEST_MYSQL_URL: {error}\n\ + Start the test services with `docker compose up -d --wait` (see docs/testing.md)." + ) + }); + admin + .execute_unprepared(&format!( + "CREATE DATABASE `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + )) + .await + .unwrap_or_else(|error| panic!("could not create database {name}: {error}")); + // Best effort: the pool is released when `admin` is dropped anyway. + let _ = admin.close().await; + + let mut options = ConnectOptions::new(server.database_url(&name)); + options.max_connections(MAX_CONNECTIONS).sqlx_logging(false); + let connection = Database::connect(options) + .await + .unwrap_or_else(|error| panic!("could not connect to database {name}: {error}")); + + // Construct the value before creating tables, so the database is + // dropped even if a statement below panics. + let database = Self { + server, + name, + connection, + }; + create_tables(&database.connection).await; + database + } + + /// Connection pool for this database. + pub fn connection(&self) -> &DatabaseConnection { + &self.connection + } + + /// Name of the database on the server. + pub fn name(&self) -> &str { + &self.name + } +} + +impl Drop for TestDatabase { + fn drop(&mut self) { + // `Drop` cannot await, and it normally runs inside the test's Tokio + // runtime, where blocking on a future panics. Run the statement on a + // separate thread with a runtime of its own instead. + let server_url = self.server.to_string(); + let statement = format!("DROP DATABASE IF EXISTS `{}`", self.name); + + let outcome = thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())?; + + runtime.block_on(async { + let admin = Database::connect(server_url) + .await + .map_err(|error| error.to_string())?; + admin + .execute_unprepared(&statement) + .await + .map_err(|error| error.to_string())?; + admin.close().await.map_err(|error| error.to_string()) + }) + }) + .join(); + + // Panicking in `drop` would abort a test that is already failing, so + // report problems instead. Leftover `test_*` databases are harmless. + match outcome { + Ok(Ok(())) => {} + Ok(Err(error)) => eprintln!("could not drop test database {}: {error}", self.name), + Err(_) => eprintln!("dropping test database {} panicked", self.name), + } + } +} + +/// Creates every application table, parents before the tables whose foreign +/// keys reference them. +/// +/// The list mirrors `src/entity/mod.rs`; a new entity must be added to both. +async fn create_tables(connection: &DatabaseConnection) { + let backend = connection.get_database_backend(); + let schema = Schema::new(backend); + + let statements = [ + schema.create_table_from_entity(canteen_data::Entity), + schema.create_table_from_entity(canteen_menus::Entity), + schema.create_table_from_entity(canteen_pivot_menus_data::Entity), + schema.create_table_from_entity(colleagues_data::Entity), + schema.create_table_from_entity(events_data::Entity), + schema.create_table_from_entity(pages::Entity), + schema.create_table_from_entity(menu_items::Entity), + schema.create_table_from_entity(posts_authors::Entity), + schema.create_table_from_entity(posts_labels::Entity), + schema.create_table_from_entity(posts_data::Entity), + schema.create_table_from_entity(posts_pivot_labels_data::Entity), + ]; + + for statement in &statements { + let statement = backend.build(statement); + connection + .execute(statement.clone()) + .await + .unwrap_or_else(|error| panic!("could not create a table: {error}\n{statement}")); + } +} + +/// A validated `mysql://` URL that names a server but no database. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServerUrl { + /// Scheme and authority, e.g. `mysql://root:secret@127.0.0.1:33061`. + base: String, + /// Query string without the leading `?`; empty if there is none. + query: String, +} + +impl ServerUrl { + /// Validates `url`. The error message says what to change. + fn parse(url: &str) -> Result { + let Some(rest) = url.strip_prefix("mysql://") else { + return Err("TEST_MYSQL_URL must be a mysql:// URL".to_owned()); + }; + let (rest, query) = rest.split_once('?').unwrap_or((rest, "")); + let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); + + if authority.is_empty() { + return Err("TEST_MYSQL_URL must include a host".to_owned()); + } + if !path.is_empty() { + return Err(format!( + "TEST_MYSQL_URL must not name a database (found {path:?}): every test creates its own" + )); + } + + Ok(Self { + base: format!("mysql://{authority}"), + query: query.to_owned(), + }) + } + + /// URL of the database `name` on this server. + fn database_url(&self, name: &str) -> String { + match self.query.as_str() { + "" => format!("{}/{name}", self.base), + query => format!("{}/{name}?{query}", self.base), + } + } +} + +impl fmt::Display for ServerUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.query.as_str() { + "" => f.write_str(&self.base), + query => write!(f, "{}?{query}", self.base), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_url_without_database_is_accepted() { + let url = ServerUrl::parse("mysql://root:secret@127.0.0.1:33061").unwrap(); + + assert_eq!(url.to_string(), "mysql://root:secret@127.0.0.1:33061"); + assert_eq!( + url.database_url("test_1"), + "mysql://root:secret@127.0.0.1:33061/test_1" + ); + } + + #[test] + fn trailing_slash_is_accepted() { + let url = ServerUrl::parse("mysql://root@db:3306/").unwrap(); + + assert_eq!(url.database_url("test_1"), "mysql://root@db:3306/test_1"); + } + + #[test] + fn query_string_is_kept() { + let url = ServerUrl::parse("mysql://root@db:3306?ssl-mode=disabled").unwrap(); + + assert_eq!(url.to_string(), "mysql://root@db:3306?ssl-mode=disabled"); + assert_eq!( + url.database_url("test_1"), + "mysql://root@db:3306/test_1?ssl-mode=disabled" + ); + } + + #[test] + fn other_schemes_are_rejected() { + let error = ServerUrl::parse("postgres://root@db:5432").unwrap_err(); + + assert!(error.contains("mysql://"), "{error}"); + } + + #[test] + fn url_naming_a_database_is_rejected() { + let error = ServerUrl::parse("mysql://root@db:3306/backend").unwrap_err(); + + assert!(error.contains("\"backend\""), "{error}"); + } + + #[test] + fn url_without_host_is_rejected() { + let error = ServerUrl::parse("mysql:///backend").unwrap_err(); + + assert!(error.contains("host"), "{error}"); + } +} diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs new file mode 100644 index 0000000..a26ecaa --- /dev/null +++ b/test-utils/src/lib.rs @@ -0,0 +1,35 @@ +//! Integration-test harness for website_backend2. +//! +//! Each [`TestApp`] serves the real application router +//! ([`website_backend2::app`]) on an ephemeral local port, backed by a MySQL +//! database created for that test alone. The database's tables are generated +//! from the application's sea-orm entities, so the entities double as the +//! schema definition the tests trust, and they have to be kept in line with +//! production. +//! +//! Tests are independent of each other. They share only the MySQL server and +//! Redis, and Redis holds nothing but persisted queries keyed by the hash of +//! their text, so one test cannot observe another's data. +//! +//! The services are expected at the addresses in `compose.yaml`; see +//! [`TestConfig`] to point the tests elsewhere. + +mod app; +mod config; +mod database; +pub mod prelude; +mod response; +pub mod seed; +mod snapshot; + +pub use app::{Data, STORAGE_BASE_URL, TestApp}; +pub use config::TestConfig; +pub use database::TestDatabase; +pub use response::{error_messages, expect_data, node_ids, unique_name}; + +/// Crates the exported macros expand to, so tests need no direct dependency on +/// them. Not part of the public API. +#[doc(hidden)] +pub mod macro_support { + pub use insta; +} diff --git a/test-utils/src/prelude.rs b/test-utils/src/prelude.rs new file mode 100644 index 0000000..2fdec34 --- /dev/null +++ b/test-utils/src/prelude.rs @@ -0,0 +1,8 @@ +//! Everything a typical integration test uses: `use test_utils::prelude::*;`. + +pub use crate::{ + Data, STORAGE_BASE_URL, TestApp, assert_response_snapshot, error_messages, expect_data, + node_ids, seed, unique_name, +}; +pub use reqwest::{Method, StatusCode, header}; +pub use serde_json::{Value, json}; diff --git a/test-utils/src/response.rs b/test-utils/src/response.rs new file mode 100644 index 0000000..85237af --- /dev/null +++ b/test-utils/src/response.rs @@ -0,0 +1,121 @@ +//! Helpers for inspecting GraphQL responses. + +use serde_json::Value; +use uuid::Uuid; + +/// Returns the `data` of a GraphQL response. +/// +/// * `response` - a body returned by [`TestApp::graphql`](crate::TestApp::graphql) +/// or its variants. +/// +/// # Panics +/// +/// Panics and prints the response if it contains errors. +pub fn expect_data(response: &Value) -> &Value { + assert!( + response.get("errors").is_none(), + "unexpected GraphQL errors: {response:#}" + ); + + &response["data"] +} + +/// Returns the messages of a GraphQL response's errors, in order. +/// +/// * `response` - a body returned by [`TestApp::graphql`](crate::TestApp::graphql) +/// or its variants. +/// +/// The result is empty if the response has no errors. +pub fn error_messages(response: &Value) -> Vec<&str> { + response["errors"] + .as_array() + .map(|errors| { + errors + .iter() + .filter_map(|error| error["message"].as_str()) + .collect() + }) + .unwrap_or_default() +} + +/// Returns the node ids of a connection, in order. +/// +/// * `connection` - a connection object selected with at least +/// `edges { node { id } }`. +/// +/// # Panics +/// +/// Panics if `connection` has no `edges` array or a node has no numeric id. +pub fn node_ids(connection: &Value) -> Vec { + connection["edges"] + .as_array() + .unwrap_or_else(|| panic!("not a connection with edges: {connection:#}")) + .iter() + .map(|edge| { + edge["node"]["id"] + .as_u64() + .and_then(|id| u32::try_from(id).ok()) + .unwrap_or_else(|| panic!("edge without a numeric node id: {edge:#}")) + }) + .collect() +} + +/// Returns `prefix` followed by a random suffix no other test run uses. +/// +/// * `prefix` - readable start of the name; it must itself be valid wherever +/// the name is used (a GraphQL alias, a Redis key, ...). +/// +/// Use it for values that end up in services shared between tests, such as +/// Redis. +pub fn unique_name(prefix: &str) -> String { + format!("{prefix}{}", Uuid::new_v4().simple()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn data_of_a_successful_response() { + let response = json!({ "data": { "posts": [] } }); + + assert_eq!(expect_data(&response), &json!({ "posts": [] })); + } + + #[test] + #[should_panic(expected = "unexpected GraphQL errors")] + fn data_of_a_failed_response_panics() { + expect_data(&json!({ "data": null, "errors": [{ "message": "boom" }] })); + } + + #[test] + fn error_messages_are_listed_in_order() { + let response = json!({ "errors": [{ "message": "first" }, { "message": "second" }] }); + + assert_eq!(error_messages(&response), ["first", "second"]); + assert!(error_messages(&json!({ "data": {} })).is_empty()); + } + + #[test] + fn node_ids_are_read_in_order() { + let connection = json!({ "edges": [{ "node": { "id": 3 } }, { "node": { "id": 1 } }] }); + + assert_eq!(node_ids(&connection), [3, 1]); + } + + #[test] + #[should_panic(expected = "not a connection")] + fn node_ids_of_something_else_panics() { + node_ids(&json!({ "id": 1 })); + } + + #[test] + fn unique_names_keep_their_prefix_and_differ() { + let first = unique_name("alias_"); + let second = unique_name("alias_"); + + assert!(first.starts_with("alias_")); + assert_ne!(first, second); + } +} diff --git a/test-utils/src/seed.rs b/test-utils/src/seed.rs new file mode 100644 index 0000000..8591124 --- /dev/null +++ b/test-utils/src/seed.rs @@ -0,0 +1,662 @@ +//! The standard data set most integration tests run against. +//! +//! It is small enough to read in one sitting, yet covers the cases the +//! resolvers treat differently: published, featured, unpublished and backdated +//! posts; authors and labels with and without posts; a nested menu linking to +//! pages; soft-deleted pages and menu items; canteen days on both sides of an +//! ISO year boundary; events around a month's calendar grid; and colleague +//! names that only sort correctly under Hungarian collation. +//! +//! Every value is fixed, so responses are deterministic and suitable for +//! snapshots. Editing this data changes many snapshots at once; a test that +//! needs an unusual row should insert it itself through +//! [`TestApp::db`](crate::TestApp::db). The constants below name the rows tests +//! refer to. + +use chrono::{NaiveDate, NaiveDateTime}; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityName, EntityTrait}; +use serde_json::json; +use website_backend2::entity::{ + canteen_data, canteen_menus, canteen_pivot_menus_data, colleagues_data, events_data, + menu_items, pages, posts_authors, posts_data, posts_labels, posts_pivot_labels_data, +}; + +/// Published posts in the order listings return them, newest first. +pub const PUBLISHED_POSTS_NEWEST_FIRST: [u32; 5] = [1, 2, 3, 6, 5]; +/// Published and featured posts, newest first. +pub const FEATURED_POSTS_NEWEST_FIRST: [u32; 2] = [1, 5]; +/// The only unpublished post. It has the newest date of all posts, so a list +/// that leaked it would show it first. +pub const UNPUBLISHED_POST: u32 = 4; +/// Preview token that unlocks [`UNPUBLISHED_POST`]. +pub const PREVIEW_TOKEN: &str = "preview-token-4"; +/// A post with a higher id than posts 1 to 5 but an older date than posts 1 to 3. +pub const BACKDATED_POST: u32 = 6; +/// Author with a description, an image, and posts 1, 3 and the unpublished 4. +pub const AUTHOR_WITH_IMAGE: u32 = 1; +/// Author without description or image, with posts 2 and 6. +pub const AUTHOR_WITHOUT_IMAGE: u32 = 2; +/// Author without posts. +pub const AUTHOR_WITHOUT_POSTS: u32 = 3; +/// Label on posts 1, 6 and the unpublished 4. +pub const LABEL_NEWS: u32 = 1; +/// Label on post 2 only. +pub const LABEL_SPORT: u32 = 3; +/// Label without posts. +pub const LABEL_WITHOUT_POSTS: u32 = 4; +/// Slug of a live page. +pub const PAGE_SLUG: &str = "rolunk"; +/// Slug of a soft-deleted page. +pub const DELETED_PAGE_SLUG: &str = "regi-oldal"; +/// Name of a soft-deleted top-level menu item. +pub const DELETED_MENU_ITEM: &str = "Törölt menüpont"; +/// Event on Sunday 4 October 2026, the last day of September 2026's calendar grid. +pub const EVENT_ON_LAST_GRID_DAY: u32 = 5; + +/// Inserts the standard data set. +/// +/// * `db` - connection to a test database whose tables exist and are empty. +/// +/// # Panics +/// +/// Panics if an insert fails, which means the entities and this data set +/// disagree. +pub async fn insert(db: &DatabaseConnection) { + insert_posts(db).await; + insert_pages_and_menu(db).await; + insert_canteen(db).await; + insert_colleagues(db).await; + insert_events(db).await; +} + +async fn insert_rows(db: &DatabaseConnection, rows: Vec) +where + A: ActiveModelTrait, + ::Model: sea_orm::IntoActiveModel, +{ + let table = ::default().table_name().to_owned(); + + ::insert_many(rows) + .exec_without_returning(db) + .await + .unwrap_or_else(|error| panic!("could not seed {table}: {error}")); +} + +fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("seed dates are valid") +} + +fn at(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> NaiveDateTime { + date(year, month, day) + .and_hms_opt(hour, minute, 0) + .expect("seed times are valid") +} + +/// `created_at` and `updated_at` of every row; the API never exposes them. +fn created() -> NaiveDateTime { + at(2026, 1, 1, 0, 0) +} + +fn text(value: &str) -> String { + value.to_owned() +} + +fn some(value: &str) -> Option { + Some(value.to_owned()) +} + +async fn insert_posts(db: &DatabaseConnection) { + insert_rows( + db, + vec![ + posts_authors::ActiveModel { + id: Set(1), + name: Set(text("Kovács Anna")), + description: Set(some("Magyar és történelem szakos tanár.")), + image: Set(some("kovacs-anna.jpg")), + }, + posts_authors::ActiveModel { + id: Set(2), + name: Set(text("Nagy Béla")), + description: Set(None), + image: Set(None), + }, + posts_authors::ActiveModel { + id: Set(3), + name: Set(text("Szabó Csilla")), + description: Set(some("Könyvtáros.")), + image: Set(some("szabo-csilla.jpg")), + }, + ], + ) + .await; + + insert_rows( + db, + vec![ + label(1, "Hírek", "#1e88e5"), + label(2, "Ünnepségek", "#8e24aa"), + label(3, "Sport", "#43a047"), + label(4, "Archívum", "#757575"), + ], + ) + .await; + + insert_rows( + db, + vec![ + posts_data::ActiveModel { + id: Set(1), + title: Set(text("Tanévnyitó ünnepség")), + color: Set(text("#1e88e5")), + description: Set(some("Ünnepélyesen megnyitottuk a 2026/2027-es tanévet.")), + content: Set(some("

Az ünnepségen a diákok műsorral készültek.

")), + index_image: Set(some("tanevnyito.jpg")), + author_id: Set(Some(1)), + images: Set(json!(["tanevnyito-1.jpg", "tanevnyito-2.jpg"])), + date: Set(Some(date(2026, 9, 1))), + created_at: Set(created()), + updated_at: Set(created()), + featured: Set(1), + published: Set(1), + preview_token: Set(None), + }, + posts_data::ActiveModel { + id: Set(2), + title: Set(text("Őszi sportnap")), + color: Set(text("#43a047")), + description: Set(some("Futóverseny és focibajnokság az udvaron.")), + content: Set(some("

Minden évfolyam csapatot indított.

")), + index_image: Set(some("sportnap.jpg")), + author_id: Set(Some(2)), + // The images column is also stored as an object in production. + images: Set(json!({ "elso": "sportnap-1.jpg", "masodik": "sportnap-2.jpg" })), + date: Set(Some(date(2026, 8, 20))), + created_at: Set(created()), + updated_at: Set(created()), + featured: Set(0), + published: Set(1), + preview_token: Set(None), + }, + posts_data::ActiveModel { + id: Set(3), + title: Set(text("Nyári tábor beszámoló")), + color: Set(text("#fb8c00")), + description: Set(None), + content: Set(some("

Egy hét a Balatonnál.

")), + index_image: Set(some("tabor.jpg")), + author_id: Set(Some(1)), + images: Set(json!([])), + date: Set(Some(date(2026, 7, 10))), + created_at: Set(created()), + updated_at: Set(created()), + featured: Set(0), + published: Set(1), + preview_token: Set(None), + }, + posts_data::ActiveModel { + id: Set(4), + title: Set(text("Szalagavató előzetes")), + color: Set(text("#8e24aa")), + description: Set(some("Hamarosan részletek.")), + content: Set(some("

A szalagavató ünnepség programja.

")), + index_image: Set(some("szalagavato.jpg")), + author_id: Set(Some(1)), + images: Set(json!([])), + date: Set(Some(date(2026, 9, 10))), + created_at: Set(created()), + updated_at: Set(created()), + featured: Set(1), + published: Set(0), + preview_token: Set(some(PREVIEW_TOKEN)), + }, + posts_data::ActiveModel { + id: Set(5), + title: Set(text("Karácsonyi koncert")), + color: Set(text("#e53935")), + description: Set(some("Az iskolakórus ünnepi hangversenye.")), + content: Set(some("

Telt ház volt a díszteremben.

")), + index_image: Set(some("koncert.jpg")), + author_id: Set(None), + images: Set(json!(["koncert-1.jpg"])), + date: Set(Some(date(2025, 12, 19))), + created_at: Set(created()), + updated_at: Set(created()), + featured: Set(1), + published: Set(1), + preview_token: Set(None), + }, + posts_data::ActiveModel { + id: Set(6), + title: Set(text("Farsangi bál")), + color: Set(text("#fdd835")), + description: Set(some("Utólag rögzített beszámoló.")), + content: Set(some("

Jelmezverseny és tombola.

")), + index_image: Set(some("farsang.jpg")), + author_id: Set(Some(2)), + images: Set(json!([])), + date: Set(Some(date(2026, 2, 13))), + created_at: Set(created()), + updated_at: Set(created()), + featured: Set(0), + published: Set(1), + preview_token: Set(None), + }, + ], + ) + .await; + + insert_rows( + db, + [(1, 1), (2, 1), (3, 2), (1, 4), (2, 5), (1, 6)] + .into_iter() + .map( + |(labels_id, posts_id)| posts_pivot_labels_data::ActiveModel { + labels_id: Set(labels_id), + posts_id: Set(posts_id), + }, + ) + .collect(), + ) + .await; +} + +fn label(id: u32, name: &str, color: &str) -> posts_labels::ActiveModel { + posts_labels::ActiveModel { + id: Set(id), + name: Set(text(name)), + color: Set(text(color)), + } +} + +async fn insert_pages_and_menu(db: &DatabaseConnection) { + insert_rows( + db, + vec![ + pages::ActiveModel { + id: Set(1), + template: Set(text("default")), + name: Set(text("rolunk")), + title: Set(text("Rólunk")), + slug: Set(text(PAGE_SLUG)), + content: Set(text("

Iskolánk bemutatkozása.

")), + extras: Set(json!({ "meta_description": "Az iskola bemutatása" })), + created_at: Set(created()), + updated_at: Set(created()), + deleted_at: Set(None), + }, + pages::ActiveModel { + id: Set(2), + template: Set(text("contact")), + name: Set(text("kapcsolat")), + title: Set(text("Kapcsolat")), + slug: Set(text("kapcsolat")), + content: Set(text("

Elérhetőségeink.

")), + extras: Set(json!({ "address": "1000 Budapest, Példa utca 1.", "email": "info@example.test" })), + created_at: Set(created()), + updated_at: Set(created()), + deleted_at: Set(None), + }, + pages::ActiveModel { + id: Set(3), + template: Set(text("default")), + name: Set(text("regi-oldal")), + title: Set(text("Régi oldal")), + slug: Set(text(DELETED_PAGE_SLUG)), + content: Set(text("

Ez az oldal törölve lett.

")), + extras: Set(json!({})), + created_at: Set(created()), + updated_at: Set(created()), + deleted_at: Set(Some(at(2026, 3, 1, 12, 0))), + }, + ], + ) + .await; + + // Nested set: "Iskolánk" (1-6) contains "Rólunk" (2-3) and "Régi oldal" (4-5). + insert_rows( + db, + vec![ + menu_item( + 1, + "Iskolánk", + "internal_link", + some("/informaciok"), + None, + None, + (1, 6, 1), + None, + ), + menu_item( + 2, + "Rólunk", + "page_link", + None, + Some(1), + Some(1), + (2, 3, 2), + None, + ), + menu_item( + 3, + "Régi oldal", + "page_link", + None, + Some(3), + Some(1), + (4, 5, 2), + None, + ), + menu_item( + 4, + "Kapcsolat", + "page_link", + None, + Some(2), + None, + (7, 8, 1), + None, + ), + menu_item( + 5, + "E-napló", + "external_link", + some("https://example.test/e-naplo"), + None, + None, + (9, 10, 1), + None, + ), + menu_item( + 6, + DELETED_MENU_ITEM, + "external_link", + some("https://example.test/torolt"), + None, + None, + (11, 12, 1), + Some(at(2026, 3, 1, 12, 0)), + ), + ], + ) + .await; +} + +#[allow(clippy::too_many_arguments)] +fn menu_item( + id: u32, + name: &str, + kind: &str, + link: Option, + page_id: Option, + parent_id: Option, + (lft, rgt, depth): (u32, u32, u32), + deleted_at: Option, +) -> menu_items::ActiveModel { + menu_items::ActiveModel { + id: Set(id), + name: Set(text(name)), + r#type: Set(text(kind)), + link: Set(link), + page_id: Set(page_id), + parent_id: Set(parent_id), + lft: Set(lft), + rgt: Set(rgt), + depth: Set(depth), + created_at: Set(created()), + updated_at: Set(created()), + deleted_at: Set(deleted_at), + } +} + +async fn insert_canteen(db: &DatabaseConnection) { + insert_rows( + db, + [ + (1, "Gulyásleves", 0), + (2, "Rántott csirke rizzsel", 1), + (3, "Túrós palacsinta", 2), + (4, "Zöldségleves", 0), + (5, "Paprikás krumpli", 1), + ] + .into_iter() + .map(|(id, menu, kind)| canteen_menus::ActiveModel { + id: Set(id), + menu: Set(text(menu)), + r#type: Set(kind), + created_at: Set(created()), + updated_at: Set(created()), + }) + .collect(), + ) + .await; + + // Monday of ISO week 2025-W52, Monday and Friday of 2026-W01 (which starts + // in December 2025), and Monday of 2026-W02. + let days = [ + (1, date(2025, 12, 22), vec![1, 2]), + (2, date(2025, 12, 29), vec![4, 5, 3]), + (3, date(2026, 1, 2), vec![1, 5]), + (4, date(2026, 1, 5), vec![4, 2]), + ]; + + insert_rows( + db, + days.iter() + .map(|(id, day, _)| canteen_data::ActiveModel { + id: Set(*id), + date: Set(*day), + created_at: Set(created()), + updated_at: Set(created()), + }) + .collect(), + ) + .await; + + insert_rows( + db, + days.iter() + .flat_map(|(data_id, _, menus)| { + menus + .iter() + .map(|menu_id| canteen_pivot_menus_data::ActiveModel { + menu_id: Set(*menu_id), + data_id: Set(*data_id), + }) + }) + .collect(), + ) + .await; +} + +async fn insert_colleagues(db: &DatabaseConnection) { + // Byte order would sort these differently from Hungarian collation: "Á" + // after "Z", "Cs" before "Ci", "Ő" after "Z". + insert_rows( + db, + vec![ + colleague( + 1, + "Oláh Zsófia", + some("tanár"), + some("angol nyelv"), + None, + None, + None, + Some(1), + ), + colleague( + 2, + "Dr. Cirkó Béla", + some("tanár"), + some("fizika"), + some("munkaközösség-vezető"), + some("Az év tanára (2024)"), + some("cirko-bela.jpg"), + Some(1), + ), + colleague( + 3, + "Csizmadia Ágnes", + some("igazgatóhelyettes"), + some("matematika"), + None, + None, + some("csizmadia-agnes.jpg"), + Some(0), + ), + colleague( + 4, + "Ádám Éva", + some("óraadó tanár"), + some("rajz"), + None, + None, + None, + Some(2), + ), + colleague( + 5, + "Adorján Péter", + some("rendszergazda"), + None, + None, + None, + None, + Some(3), + ), + colleague( + 6, + "Őri Gábor", + some("karbantartó"), + None, + None, + None, + None, + Some(4), + ), + colleague( + 7, + "Zsigmond Lili", + some("tanár"), + some("biológia"), + None, + None, + None, + Some(1), + ), + colleague( + 8, + "Zakariás Ottó", + some("tanár"), + some("testnevelés"), + None, + None, + None, + None, + ), + ], + ) + .await; +} + +#[allow(clippy::too_many_arguments)] +fn colleague( + id: u32, + name: &str, + jobs: Option, + subjects: Option, + roles: Option, + awards: Option, + image: Option, + category: Option, +) -> colleagues_data::ActiveModel { + colleagues_data::ActiveModel { + id: Set(id), + name: Set(some(name)), + jobs: Set(jobs), + subjects: Set(subjects), + roles: Set(roles), + awards: Set(awards), + image: Set(image), + category: Set(category), + } +} + +async fn insert_events(db: &DatabaseConnection) { + // September 2026's calendar grid runs from Monday 31 August to Sunday 4 October. + insert_rows( + db, + vec![ + event( + 1, + "Nyári szünet utolsó napja", + at(2026, 8, 30, 10, 0), + at(2026, 8, 30, 11, 0), + None, + "#757575", + ), + event( + 2, + "Szülői értekezlet", + at(2026, 8, 31, 17, 0), + at(2026, 8, 31, 19, 0), + some("Minden évfolyam"), + "#fb8c00", + ), + event( + 3, + "Tanévnyitó", + at(2026, 9, 1, 8, 0), + at(2026, 9, 1, 10, 0), + some("Díszterem"), + "#1e88e5", + ), + event( + 4, + "Témahét", + at(2026, 9, 28, 8, 0), + at(2026, 10, 2, 14, 0), + None, + "#43a047", + ), + event( + EVENT_ON_LAST_GRID_DAY, + "Nyílt nap", + at(2026, 10, 4, 9, 0), + at(2026, 10, 4, 12, 0), + some("Leendő kilencedikeseknek"), + "#8e24aa", + ), + event( + 6, + "Pályaorientációs nap", + at(2026, 10, 15, 8, 0), + at(2026, 10, 15, 14, 0), + None, + "#e53935", + ), + ], + ) + .await; +} + +fn event( + id: u32, + title: &str, + date_from: NaiveDateTime, + date_to: NaiveDateTime, + description: Option, + color: &str, +) -> events_data::ActiveModel { + events_data::ActiveModel { + id: Set(id), + date_from: Set(date_from), + date_to: Set(date_to), + title: Set(text(title)), + description: Set(description), + color: Set(text(color)), + created_at: Set(created()), + updated_at: Set(created()), + } +} diff --git a/test-utils/src/snapshot.rs b/test-utils/src/snapshot.rs new file mode 100644 index 0000000..bc476ab --- /dev/null +++ b/test-utils/src/snapshot.rs @@ -0,0 +1,22 @@ +//! Snapshot assertions for GraphQL responses. + +/// Asserts that a GraphQL response matches its stored insta snapshot. +/// +/// Works like `insta::assert_json_snapshot!`, except that object keys are +/// sorted before the comparison. The order of sibling fields in responses is +/// not stable (a CI run serialized aliased root fields in a different order than +/// local runs), and key order carries no meaning in JSON. Array order, which +/// does, is kept. +/// +/// * `$response` - the response body, typically returned by +/// [`TestApp::graphql`](crate::TestApp::graphql). +/// +/// As with insta's own macro, the snapshot is named after the calling test. +#[macro_export] +macro_rules! assert_response_snapshot { + ($response:expr $(,)?) => { + $crate::macro_support::insta::with_settings!({ sort_maps => true }, { + $crate::macro_support::insta::assert_json_snapshot!($response); + }) + }; +} diff --git a/tests/calendar.rs b/tests/calendar.rs new file mode 100644 index 0000000..b6e3a03 --- /dev/null +++ b/tests/calendar.rs @@ -0,0 +1,85 @@ +//! Date-based queries: canteen menus by ISO week and events by month. + +use test_utils::prelude::*; + +#[tokio::test] +async fn canteen_week_spanning_new_year() { + // ISO week 1 of 2026 runs from Monday 29 December 2025 to Sunday 4 January 2026. + let app = TestApp::seeded().await; + + let response = app + .graphql("{ canteen(year: 2026, week: 1) { id date menus { id menu type } } }") + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn canteen_week_without_menus_is_empty() { + let app = TestApp::seeded().await; + + let response = app + .graphql("{ canteen(year: 2026, week: 30) { date } }") + .await; + + assert_eq!(expect_data(&response)["canteen"], json!([])); +} + +#[tokio::test] +#[ignore = "bug: a canteen week that does not exist panics and returns HTTP 500"] +async fn nonexistent_canteen_week_is_a_graphql_error() { + let app = TestApp::seeded().await; + + let response = app + .post("/graphql") + .json(&json!({ "query": "{ canteen(year: 2026, week: 0) { date } }" })) + .send() + .await + .expect("request"); + + assert_eq!(response.status(), StatusCode::OK); + let body: Value = response.json().await.expect("JSON body"); + assert!(!error_messages(&body).is_empty(), "{body:#}"); +} + +#[tokio::test] +async fn events_of_a_month_cover_its_calendar_grid() { + // September 2026's grid runs from Monday 31 August to Sunday 4 October. + let app = TestApp::seeded().await; + + let response = app + .graphql("{ events(year: 2026, month: 9) { id title dateFrom dateTo description color } }") + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +#[ignore = "bug: calendar grid end drops the last Sunday, or adds a spare week when the next month starts on a Monday"] +async fn events_include_the_last_day_of_the_calendar_grid() { + let app = TestApp::seeded().await; + + let response = app.graphql("{ events(year: 2026, month: 9) { id } }").await; + + let ids: Vec = expect_data(&response)["events"] + .as_array() + .expect("events") + .iter() + .filter_map(|event| event["id"].as_u64()) + .collect(); + assert!( + ids.contains(&u64::from(seed::EVENT_ON_LAST_GRID_DAY)), + "{ids:?}" + ); +} + +#[tokio::test] +async fn events_of_an_invalid_month_are_a_graphql_error() { + let app = TestApp::seeded().await; + + let response = app + .graphql("{ events(year: 2026, month: 13) { id } }") + .await; + + assert_eq!(error_messages(&response), ["invalid date"]); +} diff --git a/tests/content.rs b/tests/content.rs new file mode 100644 index 0000000..60034ef --- /dev/null +++ b/tests/content.rs @@ -0,0 +1,120 @@ +//! Pages, the navigation menu, colleagues, authors and labels. + +use test_utils::prelude::*; + +#[tokio::test] +async fn colleagues_are_sorted_by_name_in_hungarian_order() { + let app = TestApp::seeded().await; + + let response = app + .graphql("{ colleagues { id name jobs subjects roles awards image category } }") + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn page_is_found_by_slug() { + let app = TestApp::seeded().await; + + let response = app + .graphql_with( + "query ($slug: String!) { + page(slug: $slug) { id template name title content extras } + missing: page(slug: \"nincs-ilyen\") { id } + }", + json!({ "slug": seed::PAGE_SLUG }), + ) + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +#[ignore = "bug: soft-deleted pages are still served"] +async fn soft_deleted_page_is_not_served() { + let app = TestApp::seeded().await; + + let response = app + .graphql_with( + "query ($slug: String!) { page(slug: $slug) { id } }", + json!({ "slug": seed::DELETED_PAGE_SLUG }), + ) + .await; + + assert_eq!(expect_data(&response)["page"], Value::Null); +} + +#[tokio::test] +async fn menu_is_a_tree_in_position_order() { + let app = TestApp::seeded().await; + + let response = app + .graphql( + "{ menu { name type link slug children { name type link slug children { name } } } }", + ) + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +#[ignore = "bug: a menu item's slug is null unless its link is selected too"] +async fn menu_slug_does_not_depend_on_selecting_link() { + // "Kapcsolat" links to the page with the slug "kapcsolat". + let app = TestApp::seeded().await; + + let response = app.graphql("{ menu { name slug } }").await; + + let contact = expect_data(&response)["menu"] + .as_array() + .expect("menu") + .iter() + .find(|item| item["name"] == "Kapcsolat") + .expect("seeded menu item"); + assert_eq!(contact["slug"], "kapcsolat"); +} + +#[tokio::test] +#[ignore = "bug: soft-deleted menu items are still listed"] +async fn soft_deleted_menu_items_are_not_listed() { + let app = TestApp::seeded().await; + + let response = app.graphql("{ menu { name } }").await; + + let names: Vec<&str> = expect_data(&response)["menu"] + .as_array() + .expect("menu") + .iter() + .filter_map(|item| item["name"].as_str()) + .collect(); + assert!(!names.contains(&seed::DELETED_MENU_ITEM), "{names:?}"); +} + +#[tokio::test] +async fn authors_are_found_by_id() { + let app = TestApp::seeded().await; + + let response = app + .graphql( + "{ + with_image: author(id: 1) { id name description image } + without_image: author(id: 2) { id name description image } + missing: author(id: 99) { id } + }", + ) + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn labels_are_found_by_id() { + let app = TestApp::seeded().await; + + let response = app + .graphql("{ label(id: 1) { id name color } missing: label(id: 99) { id } }") + .await; + + assert_response_snapshot!(response); +} diff --git a/tests/frontend/canteen.graphql b/tests/frontend/canteen.graphql new file mode 100644 index 0000000..d1e92ab --- /dev/null +++ b/tests/frontend/canteen.graphql @@ -0,0 +1,18 @@ +# Copied from website_frontend2 src/data/canteen.data.ts: QUERY. + +query CanteenTwoWeeks($year1: Int!, $week1: Int!, $year2: Int!, $week2: Int!) { + week1: canteen(year: $year1, week: $week1) { + ...canteen + } + week2: canteen(year: $year2, week: $week2) { + ...canteen + } +} + +fragment canteen on Canteen { + date + menus { + menu + type + } +} diff --git a/tests/frontend/colleagues.graphql b/tests/frontend/colleagues.graphql new file mode 100644 index 0000000..c6b04e2 --- /dev/null +++ b/tests/frontend/colleagues.graphql @@ -0,0 +1,14 @@ +# Copied from website_frontend2 src/data/colleagues.data.ts: QUERY. + +query Colleagues { + colleagues { + id + name + jobs + subjects + roles + awards + image + category + } +} diff --git a/tests/frontend/home.graphql b/tests/frontend/home.graphql new file mode 100644 index 0000000..26120d5 --- /dev/null +++ b/tests/frontend/home.graphql @@ -0,0 +1,37 @@ +# Copied from website_frontend2 src/data/home.data.ts: QUERY_INITIAL with +# QUERY_POSTS_FRAGMENT inlined. + +fragment PostsFragment on PostConnection { + edges { + node { + id + title + description + color + author { + name + image + } + date + indexImage + labels { + id + name + color + } + } + } +} + +query Posts($first: Int, $numFeaturedPosts: Int) { + featuredPosts: posts(featured: true, first: $numFeaturedPosts) { + ...PostsFragment, + } + posts(first: $first) { + ...PostsFragment, + pageInfo { + endCursor + hasPreviousPage + } + } +} diff --git a/tests/frontend/information_menu.graphql b/tests/frontend/information_menu.graphql new file mode 100644 index 0000000..e84b7c0 --- /dev/null +++ b/tests/frontend/information_menu.graphql @@ -0,0 +1,16 @@ +# Copied from website_frontend2 src/data/information.data.ts: QUERY_INFORMATION_MENU. + +query InformationMenu { + menu { + name + type + link + slug + children { + name + type + link + slug + } + } +} diff --git a/tests/frontend/page.graphql b/tests/frontend/page.graphql new file mode 100644 index 0000000..5cfe979 --- /dev/null +++ b/tests/frontend/page.graphql @@ -0,0 +1,12 @@ +# Copied from website_frontend2 src/data/pages.data.ts: QUERY. + +query Page($slug: String!) { + page(slug: $slug) { + id + template + name + title + content + extras + } +} diff --git a/tests/frontend/post.graphql b/tests/frontend/post.graphql new file mode 100644 index 0000000..deab511 --- /dev/null +++ b/tests/frontend/post.graphql @@ -0,0 +1,25 @@ +# Copied from website_frontend2 src/data/post.data.ts: QUERY. + +query Post($id: Int!) { + post(id: $id) { + id + title + color + description + content + date + indexImage + images + author { + id + name + description + image + } + labels { + id + color + name + } + } +} diff --git a/tests/frontend/search_author.graphql b/tests/frontend/search_author.graphql new file mode 100644 index 0000000..df1c790 --- /dev/null +++ b/tests/frontend/search_author.graphql @@ -0,0 +1,37 @@ +# Copied from website_frontend2 src/data/search.data.ts: AUTHOR_QUERY with +# FRAGMENT inlined. + +query AuthorQuery($authorID: Int!) { + author(id: $authorID) { + posts { + ...Posts + } + } +} + +fragment Posts on PostConnection { + edges { + node { + id + title + description + color + author { + id + name + image + } + date + indexImage + labels { + id + name + color + } + } + } + pageInfo { + endCursor + hasPreviousPage + } +} diff --git a/tests/frontend/search_label.graphql b/tests/frontend/search_label.graphql new file mode 100644 index 0000000..1cad9ba --- /dev/null +++ b/tests/frontend/search_label.graphql @@ -0,0 +1,37 @@ +# Copied from website_frontend2 src/data/search.data.ts: LABEL_QUERY with +# FRAGMENT inlined. + +query LabelQuery($labelID: Int!) { + label(id: $labelID) { + posts { + ...Posts + } + } +} + +fragment Posts on PostConnection { + edges { + node { + id + title + description + color + author { + id + name + image + } + date + indexImage + labels { + id + name + color + } + } + } + pageInfo { + endCursor + hasPreviousPage + } +} diff --git a/tests/frontend/search_term.graphql b/tests/frontend/search_term.graphql new file mode 100644 index 0000000..0fe8573 --- /dev/null +++ b/tests/frontend/search_term.graphql @@ -0,0 +1,35 @@ +# Copied from website_frontend2 src/data/search.data.ts: TERM_QUERY with +# FRAGMENT inlined. + +query TermQuery($term: String!) { + search(term: $term, last: 20) { + ...Posts + } +} + +fragment Posts on PostConnection { + edges { + node { + id + title + description + color + author { + id + name + image + } + date + indexImage + labels { + id + name + color + } + } + } + pageInfo { + endCursor + hasPreviousPage + } +} diff --git a/tests/frontend_queries.rs b/tests/frontend_queries.rs new file mode 100644 index 0000000..d2ea5f6 --- /dev/null +++ b/tests/frontend_queries.rs @@ -0,0 +1,114 @@ +//! The GraphQL operations website_frontend2 sends, run against the seed data. +//! +//! The documents in `tests/frontend/` are copied from website_frontend2's +//! `src/data/*.data.ts` files with their fragments inlined, and the variables +//! below mirror what the site passes. When the frontend changes an operation, +//! update the copy so the backend stays tested against what the site requests. + +use test_utils::prelude::*; + +async fn run(document: &str, variables: Value) -> Value { + let app = TestApp::seeded().await; + + app.graphql_with(document, variables).await +} + +#[tokio::test] +async fn home_page() { + let response = run( + include_str!("frontend/home.graphql"), + json!({ "first": 21, "numFeaturedPosts": 20 }), + ) + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn post_page() { + let response = run(include_str!("frontend/post.graphql"), json!({ "id": 1 })).await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn post_page_of_an_unpublished_post_finds_nothing() { + // The frontend redirects to its 404 page when `post` is null. + let response = run( + include_str!("frontend/post.graphql"), + json!({ "id": seed::UNPUBLISHED_POST }), + ) + .await; + + assert_eq!(expect_data(&response)["post"], Value::Null); +} + +#[tokio::test] +async fn search_page_by_term() { + let response = run( + include_str!("frontend/search_term.graphql"), + json!({ "term": "ünnep" }), + ) + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn search_page_by_author() { + let response = run( + include_str!("frontend/search_author.graphql"), + json!({ "authorID": seed::AUTHOR_WITH_IMAGE }), + ) + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn search_page_by_label() { + let response = run( + include_str!("frontend/search_label.graphql"), + json!({ "labelID": seed::LABEL_NEWS }), + ) + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn canteen_page() { + // The frontend asks for the current and the next ISO week. + let response = run( + include_str!("frontend/canteen.graphql"), + json!({ "year1": 2026, "week1": 1, "year2": 2026, "week2": 2 }), + ) + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn colleagues_page() { + let response = run(include_str!("frontend/colleagues.graphql"), json!({})).await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn information_page_menu() { + let response = run(include_str!("frontend/information_menu.graphql"), json!({})).await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn page() { + let response = run( + include_str!("frontend/page.graphql"), + json!({ "slug": seed::PAGE_SLUG }), + ) + .await; + + assert_response_snapshot!(response); +} diff --git a/tests/http.rs b/tests/http.rs new file mode 100644 index 0000000..ffda28c --- /dev/null +++ b/tests/http.rs @@ -0,0 +1,163 @@ +//! HTTP behaviour around the GraphQL endpoint: health checks, metrics, CORS, +//! compression, limits and routing. + +use test_utils::prelude::*; + +#[tokio::test] +async fn health_checks_respond_ok() { + let app = TestApp::empty().await; + + for path in ["/liveness", "/readiness"] { + let response = app.get(path).send().await.expect("request"); + + assert_eq!(response.status(), StatusCode::OK, "{path}"); + } +} + +#[tokio::test] +async fn graphiql_is_served_on_get() { + let app = TestApp::empty().await; + + let response = app.get("/graphql").send().await.expect("request"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.text().await.expect("body"); + assert!(body.to_lowercase().contains("graphiql"), "{body}"); +} + +#[tokio::test] +async fn metrics_count_queries_per_resource() { + let app = TestApp::seeded().await; + + app.graphql("{ colleagues { id } }").await; + app.graphql("{ colleagues { id } }").await; + app.graphql(r#"{ page(slug: "rolunk") { id } }"#).await; + + let metrics = app + .get("/metrics") + .send() + .await + .expect("request") + .text() + .await + .expect("body"); + assert!( + metrics.contains(r#"query_req_count{resource="colleagues"} 2"#), + "{metrics}" + ); + assert!( + metrics.contains(r#"query_req_count{resource="page"} 1"#), + "{metrics}" + ); +} + +#[tokio::test] +async fn cors_preflight_allows_any_origin() { + let app = TestApp::empty().await; + + let response = app + .request(Method::OPTIONS, "/graphql") + .header(header::ORIGIN, "https://example.test") + .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST") + .send() + .await + .expect("request"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*"); +} + +#[tokio::test] +async fn responses_are_gzip_compressed_when_requested() { + let app = TestApp::seeded().await; + + let response = app + .post("/graphql") + .header(header::ACCEPT_ENCODING, "gzip") + .json(&json!({ "query": "{ colleagues { name jobs subjects } }" })) + .send() + .await + .expect("request"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_ENCODING], "gzip"); +} + +#[tokio::test] +async fn queries_above_the_complexity_limit_are_rejected() { + // Each aliased `colleagues { id name }` costs 3, so 100 of them exceed 256. + let app = TestApp::empty().await; + let selections: String = (0..100) + .map(|i| format!("c{i}: colleagues {{ id name }} ")) + .collect(); + + let response = app.graphql(&format!("{{ {selections} }}")).await; + + assert!( + error_messages(&response) + .iter() + .any(|message| message.contains("too complex")), + "{response:#}" + ); +} + +#[tokio::test] +async fn invalid_arguments_are_reported_as_graphql_errors() { + let app = TestApp::empty().await; + + let response = app.graphql(r#"{ post(id: "one") { id } }"#).await; + + assert!(!error_messages(&response).is_empty(), "{response:#}"); +} + +#[tokio::test] +async fn introspection_query_is_answered() { + let app = TestApp::empty().await; + + let response = app + .graphql_request(json!({ + "operationName": "IntrospectionQuery", + "query": "query IntrospectionQuery { __schema { queryType { name } } }", + })) + .await; + + assert_eq!( + expect_data(&response)["__schema"]["queryType"]["name"], + "Query" + ); +} + +#[tokio::test] +#[ignore = "bug: operations named IntrospectionQuery run without a database transaction and panic when they select data"] +async fn operation_named_introspection_query_can_select_data() { + let app = TestApp::seeded().await; + + let response = app + .post("/graphql") + .json(&json!({ + "operationName": "IntrospectionQuery", + "query": "query IntrospectionQuery { colleagues { id } }", + })) + .send() + .await + .expect("request"); + + assert_eq!(response.status(), StatusCode::OK); + let body: Value = response.json().await.expect("JSON body"); + expect_data(&body); +} + +#[tokio::test] +#[ignore = "bug: trailing slashes are trimmed after routing, so /graphql/ is not found"] +async fn trailing_slash_is_ignored() { + let app = TestApp::seeded().await; + + let response = app + .post("/graphql/") + .json(&json!({ "query": "{ colleagues { id } }" })) + .send() + .await + .expect("request"); + + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/tests/persisted_queries.rs b/tests/persisted_queries.rs new file mode 100644 index 0000000..2fd6e43 --- /dev/null +++ b/tests/persisted_queries.rs @@ -0,0 +1,122 @@ +//! Apollo automatic persisted queries and the Redis cache behind them. + +use async_graphql::extensions::apollo_persisted_queries::CacheStorage; +use envconfig::Envconfig; +use redis::AsyncCommands; +use sha2::{Digest, Sha256}; +use test_utils::{TestConfig, prelude::*}; +use website_backend2::graphql::RedisCache; + +/// The `extensions` object of a persisted-query request. +fn persisted_query(hash: &str) -> Value { + json!({ "persistedQuery": { "version": 1, "sha256Hash": hash } }) +} + +fn sha256_hex(text: &str) -> String { + format!("{:x}", Sha256::digest(text.as_bytes())) +} + +/// A query whose text, and therefore hash, no earlier run has sent, so Redis +/// cannot already hold it. +fn fresh_query() -> String { + format!( + "{{ {}: colleagues {{ name }} }}", + unique_name("colleagues_") + ) +} + +fn test_config() -> TestConfig { + TestConfig::init_from_env().expect("test configuration") +} + +async fn redis_connection(config: &TestConfig) -> redis::aio::MultiplexedConnection { + redis::Client::open(config.redis_url.as_str()) + .expect("Redis URL") + .get_multiplexed_async_connection() + .await + .expect("Redis connection") +} + +#[tokio::test] +async fn unknown_hash_asks_the_client_for_the_query() { + let app = TestApp::seeded().await; + + let response = app + .graphql_request(json!({ "extensions": persisted_query(&sha256_hex(&fresh_query())) })) + .await; + + assert_eq!(error_messages(&response), ["PersistedQueryNotFound"]); +} + +#[tokio::test] +async fn registered_query_can_be_run_by_its_hash() { + let app = TestApp::seeded().await; + let query = fresh_query(); + let hash = sha256_hex(&query); + + let registered = app + .graphql_request(json!({ "query": query, "extensions": persisted_query(&hash) })) + .await; + let by_hash = app + .graphql_request(json!({ "extensions": persisted_query(&hash) })) + .await; + + expect_data(®istered); + assert_eq!(by_hash, registered); +} + +#[tokio::test] +async fn query_with_a_mismatched_hash_is_rejected() { + let app = TestApp::seeded().await; + + let response = app + .graphql_request(json!({ + "query": fresh_query(), + "extensions": persisted_query(&sha256_hex("a different query")), + })) + .await; + + assert_eq!( + error_messages(&response), + ["provided sha does not match query"] + ); +} + +#[tokio::test] +async fn cache_returns_the_documents_it_stored() { + let config = test_config(); + let cache = RedisCache::new(&config.redis_url).await.expect("cache"); + let key = unique_name("website_backend2_test:"); + let document = + async_graphql_parser::parse_query("{ colleagues { name } }").expect("valid query"); + + cache.set(key.clone(), document.clone()).await; + let cached = cache.get(key.clone()).await.expect("document is cached"); + + assert_eq!( + serde_json::to_value(&cached).expect("serializable"), + serde_json::to_value(&document).expect("serializable") + ); + let () = redis_connection(&config) + .await + .del(&key) + .await + .expect("delete test key"); +} + +#[tokio::test] +async fn cache_evicts_entries_it_cannot_decode() { + let config = test_config(); + let cache = RedisCache::new(&config.redis_url).await.expect("cache"); + let mut redis = redis_connection(&config).await; + let key = unique_name("website_backend2_test:"); + let () = redis + .set(&key, b"not a document".as_slice()) + .await + .expect("store corrupt entry"); + + assert!(cache.get(key.clone()).await.is_none()); + + let exists: bool = redis.exists(&key).await.expect("check key"); + assert!(!exists, "the corrupt entry should have been deleted"); +} diff --git a/tests/posts.rs b/tests/posts.rs new file mode 100644 index 0000000..ab5b5fa --- /dev/null +++ b/tests/posts.rs @@ -0,0 +1,269 @@ +//! Listing, searching, looking up and paginating posts. + +use test_utils::prelude::*; + +/// Node ids of the connection at `data.`. +fn ids_of(response: &Value, field: &str) -> Vec { + node_ids(&expect_data(response)[field]) +} + +#[tokio::test] +async fn published_posts_are_listed_newest_first() { + let app = TestApp::seeded().await; + + let response = app + .graphql("{ posts(first: 50) { edges { node { id } } } }") + .await; + + assert_eq!( + ids_of(&response, "posts"), + seed::PUBLISHED_POSTS_NEWEST_FIRST + ); +} + +#[tokio::test] +async fn last_also_returns_the_newest_posts() { + // Unlike Relay connections, `last` does not count from the oldest post: + // website_frontend2's search asks for `last: 20` and shows the newest. + let app = TestApp::seeded().await; + + let response = app + .graphql("{ posts(last: 2) { edges { node { id } } } }") + .await; + + assert_eq!(ids_of(&response, "posts"), [1, 2]); +} + +#[tokio::test] +async fn featured_filter_lists_only_featured_posts() { + let app = TestApp::seeded().await; + + let response = app + .graphql("{ posts(featured: true, first: 50) { edges { node { id } } } }") + .await; + + assert_eq!( + ids_of(&response, "posts"), + seed::FEATURED_POSTS_NEWEST_FIRST + ); +} + +#[tokio::test] +async fn search_matches_title_description_and_content() { + // "ünnep" appears in post 1's title and content, in post 5's description + // and in the content of the unpublished post 4. + let app = TestApp::seeded().await; + + let response = app + .graphql(r#"{ search(term: "ünnep", first: 50) { edges { node { id } } } }"#) + .await; + + assert_eq!(ids_of(&response, "search"), [1, 5]); +} + +#[tokio::test] +async fn search_ignores_case_and_accents() { + // Follows from the utf8mb4_unicode_ci collation of the production tables. + let app = TestApp::seeded().await; + + let response = app + .graphql(r#"{ search(term: "UNNEP", first: 50) { edges { node { id } } } }"#) + .await; + + assert_eq!(ids_of(&response, "search"), [1, 5]); +} + +#[tokio::test] +async fn unpublished_posts_are_not_listed_anywhere() { + let app = TestApp::seeded().await; + + let response = app + .graphql( + r#"{ + posts(first: 50) { edges { node { id } } } + featured: posts(featured: true, first: 50) { edges { node { id } } } + search(term: "szalagavató", first: 50) { edges { node { id } } } + label(id: 1) { posts(first: 50) { edges { node { id } } } } + author(id: 1) { posts(first: 50) { edges { node { id } } } } + archive { posts(year: 2026, month: 9) { id } } + }"#, + ) + .await; + + let data = expect_data(&response); + for connection in [ + &data["posts"], + &data["featured"], + &data["search"], + &data["label"]["posts"], + &data["author"]["posts"], + ] { + assert!( + !node_ids(connection).contains(&seed::UNPUBLISHED_POST), + "{connection:#}" + ); + } + assert_eq!(data["archive"]["posts"], json!([{ "id": 1 }])); +} + +#[tokio::test] +async fn archive_counts_published_posts_per_month_newest_first() { + let app = TestApp::seeded().await; + + let response = app + .graphql("{ archive { info { year month count } } }") + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn archive_lists_the_published_posts_of_a_month() { + let app = TestApp::seeded().await; + + let response = app + .graphql( + "{ archive { + december: posts(year: 2025, month: 12) { id title date } + september: posts(year: 2026, month: 9) { id title date } + empty: posts(year: 2026, month: 4) { id } + } }", + ) + .await; + + assert_response_snapshot!(response); +} + +#[tokio::test] +async fn published_post_is_found_by_id() { + let app = TestApp::seeded().await; + + let response = app + .graphql("{ post(id: 1) { id title } missing: post(id: 99) { id } }") + .await; + + assert_eq!( + expect_data(&response), + &json!({ "post": { "id": 1, "title": "Tanévnyitó ünnepség" }, "missing": null }) + ); +} + +#[tokio::test] +async fn unpublished_post_is_only_found_with_its_preview_token() { + let app = TestApp::seeded().await; + let query = "query ($token: String) { post(id: 4, token: $token) { id } }"; + + for token in [Value::Null, json!("wrong-token")] { + let response = app.graphql_with(query, json!({ "token": token })).await; + + assert_eq!(expect_data(&response)["post"], Value::Null, "token {token}"); + } + + let response = app + .graphql_with(query, json!({ "token": seed::PREVIEW_TOKEN })) + .await; + + assert_eq!(expect_data(&response)["post"]["id"], seed::UNPUBLISHED_POST); +} + +#[tokio::test] +async fn preview_token_only_finds_unpublished_posts() { + // A token switches the lookup to unpublished posts, so a published post + // is not found with one, not even with a valid token. + let app = TestApp::seeded().await; + + let response = app + .graphql_with( + "query ($token: String) { post(id: 1, token: $token) { id } }", + json!({ "token": seed::PREVIEW_TOKEN }), + ) + .await; + + assert_eq!(expect_data(&response)["post"], Value::Null); +} + +#[tokio::test] +async fn author_and_label_post_lists_are_filtered() { + let app = TestApp::seeded().await; + + let response = app + .graphql( + "{ + sport: label(id: 3) { posts(first: 50) { edges { node { id } } } } + unused_label: label(id: 4) { posts(first: 50) { edges { node { id } } } } + author: author(id: 2) { posts(first: 50) { edges { node { id } } } } + author_without_posts: author(id: 3) { posts(first: 50) { edges { node { id } } } } + }", + ) + .await; + + let data = expect_data(&response); + assert_eq!(node_ids(&data["sport"]["posts"]), [2]); + assert_eq!(node_ids(&data["unused_label"]["posts"]), Vec::::new()); + assert_eq!( + node_ids(&data["author"]["posts"]), + [2, seed::BACKDATED_POST] + ); + assert_eq!( + node_ids(&data["author_without_posts"]["posts"]), + Vec::::new() + ); +} + +#[tokio::test] +#[ignore = "bug: cursor pagination skips posts with a higher id than the cursor's post but an older date"] +async fn paging_through_posts_visits_every_published_post_once() { + let app = TestApp::seeded().await; + let query = "query ($after: String) { + posts(first: 2, after: $after) { + edges { node { id } } + pageInfo { endCursor hasPreviousPage } + } + }"; + + let mut seen = Vec::new(); + let mut after = Value::Null; + for _ in 0..seed::PUBLISHED_POSTS_NEWEST_FIRST.len() { + let response = app.graphql_with(query, json!({ "after": after })).await; + let posts = &expect_data(&response)["posts"]; + seen.extend(node_ids(posts)); + + // In this API `hasPreviousPage` means "older posts exist". + if posts["pageInfo"]["hasPreviousPage"] != true { + break; + } + after = posts["pageInfo"]["endCursor"].clone(); + } + + assert_eq!(seen, seed::PUBLISHED_POSTS_NEWEST_FIRST); +} + +#[tokio::test] +#[ignore = "bug: pageInfo of filtered post lists is computed from all published posts"] +async fn page_info_of_a_filtered_list_reflects_the_filter() { + // Post 2 is the only post labelled "Sport", so there is nothing before or after it. + let app = TestApp::seeded().await; + + let response = app + .graphql( + "{ label(id: 3) { posts(first: 50) { pageInfo { hasPreviousPage hasNextPage } } } }", + ) + .await; + + assert_eq!( + expect_data(&response)["label"]["posts"]["pageInfo"], + json!({ "hasPreviousPage": false, "hasNextPage": false }) + ); +} + +#[tokio::test] +#[ignore = "bug: listing posts returns an error instead of an empty list when no post is published"] +async fn listing_posts_without_published_posts_returns_an_empty_list() { + let app = TestApp::empty().await; + + let response = app + .graphql("{ posts(first: 10) { edges { node { id } } } }") + .await; + + assert_eq!(ids_of(&response, "posts"), Vec::::new()); +} diff --git a/tests/snapshots/calendar__canteen_week_spanning_new_year.snap b/tests/snapshots/calendar__canteen_week_spanning_new_year.snap new file mode 100644 index 0000000..06f9a2f --- /dev/null +++ b/tests/snapshots/calendar__canteen_week_spanning_new_year.snap @@ -0,0 +1,53 @@ +--- +source: tests/calendar.rs +expression: response +--- +{ + "data": { + "canteen": [ + { + "date": "2025-12-29", + "id": 2, + "menus": [ + { + "id": 4, + "menu": "Zöldségleves", + "type": 0 + }, + { + "id": 5, + "menu": "Paprikás krumpli", + "type": 1 + }, + { + "id": 3, + "menu": "Túrós palacsinta", + "type": 2 + } + ] + }, + { + "date": "2026-01-02", + "id": 3, + "menus": [ + { + "id": 1, + "menu": "Gulyásleves", + "type": 0 + }, + { + "id": 5, + "menu": "Paprikás krumpli", + "type": 1 + } + ] + } + ] + }, + "extensions": { + "analyzer": { + "complexity": 7, + "depth": 3 + } + } +} diff --git a/tests/snapshots/calendar__events_of_a_month_cover_its_calendar_grid.snap b/tests/snapshots/calendar__events_of_a_month_cover_its_calendar_grid.snap new file mode 100644 index 0000000..80c177a --- /dev/null +++ b/tests/snapshots/calendar__events_of_a_month_cover_its_calendar_grid.snap @@ -0,0 +1,40 @@ +--- +source: tests/calendar.rs +expression: response +--- +{ + "data": { + "events": [ + { + "color": "#fb8c00", + "dateFrom": "2026-08-31 17:00:00", + "dateTo": "2026-08-31 19:00:00", + "description": "Minden évfolyam", + "id": 2, + "title": "Szülői értekezlet" + }, + { + "color": "#1e88e5", + "dateFrom": "2026-09-01 08:00:00", + "dateTo": "2026-09-01 10:00:00", + "description": "Díszterem", + "id": 3, + "title": "Tanévnyitó" + }, + { + "color": "#43a047", + "dateFrom": "2026-09-28 08:00:00", + "dateTo": "2026-10-02 14:00:00", + "description": null, + "id": 4, + "title": "Témahét" + } + ] + }, + "extensions": { + "analyzer": { + "complexity": 7, + "depth": 2 + } + } +} diff --git a/tests/snapshots/content__authors_are_found_by_id.snap b/tests/snapshots/content__authors_are_found_by_id.snap new file mode 100644 index 0000000..f2c91f3 --- /dev/null +++ b/tests/snapshots/content__authors_are_found_by_id.snap @@ -0,0 +1,27 @@ +--- +source: tests/content.rs +expression: response +--- +{ + "data": { + "missing": null, + "with_image": { + "description": "Magyar és történelem szakos tanár.", + "id": 1, + "image": "https://storage.test/authors_images/kovacs-anna.jpg", + "name": "Kovács Anna" + }, + "without_image": { + "description": null, + "id": 2, + "image": null, + "name": "Nagy Béla" + } + }, + "extensions": { + "analyzer": { + "complexity": 12, + "depth": 2 + } + } +} diff --git a/tests/snapshots/content__colleagues_are_sorted_by_name_in_hungarian_order.snap b/tests/snapshots/content__colleagues_are_sorted_by_name_in_hungarian_order.snap new file mode 100644 index 0000000..d960cd8 --- /dev/null +++ b/tests/snapshots/content__colleagues_are_sorted_by_name_in_hungarian_order.snap @@ -0,0 +1,96 @@ +--- +source: tests/content.rs +expression: response +--- +{ + "data": { + "colleagues": [ + { + "awards": null, + "category": 2, + "id": 4, + "image": null, + "jobs": "óraadó tanár", + "name": "Ádám Éva", + "roles": null, + "subjects": "rajz" + }, + { + "awards": null, + "category": 3, + "id": 5, + "image": null, + "jobs": "rendszergazda", + "name": "Adorján Péter", + "roles": null, + "subjects": null + }, + { + "awards": "Az év tanára (2024)", + "category": 1, + "id": 2, + "image": "https://storage.test/colleagues_images/cirko-bela.jpg", + "jobs": "tanár", + "name": "Dr. Cirkó Béla", + "roles": "munkaközösség-vezető", + "subjects": "fizika" + }, + { + "awards": null, + "category": 0, + "id": 3, + "image": "https://storage.test/colleagues_images/csizmadia-agnes.jpg", + "jobs": "igazgatóhelyettes", + "name": "Csizmadia Ágnes", + "roles": null, + "subjects": "matematika" + }, + { + "awards": null, + "category": 1, + "id": 1, + "image": null, + "jobs": "tanár", + "name": "Oláh Zsófia", + "roles": null, + "subjects": "angol nyelv" + }, + { + "awards": null, + "category": 4, + "id": 6, + "image": null, + "jobs": "karbantartó", + "name": "Őri Gábor", + "roles": null, + "subjects": null + }, + { + "awards": null, + "category": 0, + "id": 8, + "image": null, + "jobs": "tanár", + "name": "Zakariás Ottó", + "roles": null, + "subjects": "testnevelés" + }, + { + "awards": null, + "category": 1, + "id": 7, + "image": null, + "jobs": "tanár", + "name": "Zsigmond Lili", + "roles": null, + "subjects": "biológia" + } + ] + }, + "extensions": { + "analyzer": { + "complexity": 9, + "depth": 2 + } + } +} diff --git a/tests/snapshots/content__labels_are_found_by_id.snap b/tests/snapshots/content__labels_are_found_by_id.snap new file mode 100644 index 0000000..ddf63e4 --- /dev/null +++ b/tests/snapshots/content__labels_are_found_by_id.snap @@ -0,0 +1,20 @@ +--- +source: tests/content.rs +expression: response +--- +{ + "data": { + "label": { + "color": "#1e88e5", + "id": 1, + "name": "Hírek" + }, + "missing": null + }, + "extensions": { + "analyzer": { + "complexity": 6, + "depth": 2 + } + } +} diff --git a/tests/snapshots/content__menu_is_a_tree_in_position_order.snap b/tests/snapshots/content__menu_is_a_tree_in_position_order.snap new file mode 100644 index 0000000..0feb4ce --- /dev/null +++ b/tests/snapshots/content__menu_is_a_tree_in_position_order.snap @@ -0,0 +1,59 @@ +--- +source: tests/content.rs +expression: response +--- +{ + "data": { + "menu": [ + { + "children": [ + { + "children": [], + "link": null, + "name": "Rólunk", + "slug": "rolunk", + "type": "page_link" + }, + { + "children": [], + "link": null, + "name": "Régi oldal", + "slug": "regi-oldal", + "type": "page_link" + } + ], + "link": "/informaciok", + "name": "Iskolánk", + "slug": null, + "type": "internal_link" + }, + { + "children": [], + "link": null, + "name": "Kapcsolat", + "slug": "kapcsolat", + "type": "page_link" + }, + { + "children": [], + "link": "https://example.test/e-naplo", + "name": "E-napló", + "slug": null, + "type": "external_link" + }, + { + "children": [], + "link": "https://example.test/torolt", + "name": "Törölt menüpont", + "slug": null, + "type": "external_link" + } + ] + }, + "extensions": { + "analyzer": { + "complexity": 12, + "depth": 4 + } + } +} diff --git a/tests/snapshots/content__page_is_found_by_slug.snap b/tests/snapshots/content__page_is_found_by_slug.snap new file mode 100644 index 0000000..29fe73c --- /dev/null +++ b/tests/snapshots/content__page_is_found_by_slug.snap @@ -0,0 +1,25 @@ +--- +source: tests/content.rs +expression: response +--- +{ + "data": { + "missing": null, + "page": { + "content": "

Iskolánk bemutatkozása.

", + "extras": { + "meta_description": "Az iskola bemutatása" + }, + "id": 1, + "name": "rolunk", + "template": "default", + "title": "Rólunk" + } + }, + "extensions": { + "analyzer": { + "complexity": 9, + "depth": 2 + } + } +} diff --git a/tests/snapshots/frontend_queries__canteen_page.snap b/tests/snapshots/frontend_queries__canteen_page.snap new file mode 100644 index 0000000..3aee580 --- /dev/null +++ b/tests/snapshots/frontend_queries__canteen_page.snap @@ -0,0 +1,61 @@ +--- +source: tests/frontend_queries.rs +expression: response +--- +{ + "data": { + "week1": [ + { + "date": "2025-12-29", + "menus": [ + { + "menu": "Zöldségleves", + "type": 0 + }, + { + "menu": "Paprikás krumpli", + "type": 1 + }, + { + "menu": "Túrós palacsinta", + "type": 2 + } + ] + }, + { + "date": "2026-01-02", + "menus": [ + { + "menu": "Gulyásleves", + "type": 0 + }, + { + "menu": "Paprikás krumpli", + "type": 1 + } + ] + } + ], + "week2": [ + { + "date": "2026-01-05", + "menus": [ + { + "menu": "Zöldségleves", + "type": 0 + }, + { + "menu": "Rántott csirke rizzsel", + "type": 1 + } + ] + } + ] + }, + "extensions": { + "analyzer": { + "complexity": 10, + "depth": 3 + } + } +} diff --git a/tests/snapshots/frontend_queries__colleagues_page.snap b/tests/snapshots/frontend_queries__colleagues_page.snap new file mode 100644 index 0000000..b03b88a --- /dev/null +++ b/tests/snapshots/frontend_queries__colleagues_page.snap @@ -0,0 +1,96 @@ +--- +source: tests/frontend_queries.rs +expression: response +--- +{ + "data": { + "colleagues": [ + { + "awards": null, + "category": 2, + "id": 4, + "image": null, + "jobs": "óraadó tanár", + "name": "Ádám Éva", + "roles": null, + "subjects": "rajz" + }, + { + "awards": null, + "category": 3, + "id": 5, + "image": null, + "jobs": "rendszergazda", + "name": "Adorján Péter", + "roles": null, + "subjects": null + }, + { + "awards": "Az év tanára (2024)", + "category": 1, + "id": 2, + "image": "https://storage.test/colleagues_images/cirko-bela.jpg", + "jobs": "tanár", + "name": "Dr. Cirkó Béla", + "roles": "munkaközösség-vezető", + "subjects": "fizika" + }, + { + "awards": null, + "category": 0, + "id": 3, + "image": "https://storage.test/colleagues_images/csizmadia-agnes.jpg", + "jobs": "igazgatóhelyettes", + "name": "Csizmadia Ágnes", + "roles": null, + "subjects": "matematika" + }, + { + "awards": null, + "category": 1, + "id": 1, + "image": null, + "jobs": "tanár", + "name": "Oláh Zsófia", + "roles": null, + "subjects": "angol nyelv" + }, + { + "awards": null, + "category": 4, + "id": 6, + "image": null, + "jobs": "karbantartó", + "name": "Őri Gábor", + "roles": null, + "subjects": null + }, + { + "awards": null, + "category": 0, + "id": 8, + "image": null, + "jobs": "tanár", + "name": "Zakariás Ottó", + "roles": null, + "subjects": "testnevelés" + }, + { + "awards": null, + "category": 1, + "id": 7, + "image": null, + "jobs": "tanár", + "name": "Zsigmond Lili", + "roles": null, + "subjects": "biológia" + } + ] + }, + "extensions": { + "analyzer": { + "complexity": 9, + "depth": 2 + } + } +} diff --git a/tests/snapshots/frontend_queries__home_page.snap b/tests/snapshots/frontend_queries__home_page.snap new file mode 100644 index 0000000..adaf82c --- /dev/null +++ b/tests/snapshots/frontend_queries__home_page.snap @@ -0,0 +1,171 @@ +--- +source: tests/frontend_queries.rs +expression: response +--- +{ + "data": { + "featuredPosts": { + "edges": [ + { + "node": { + "author": { + "image": "https://storage.test/authors_images/kovacs-anna.jpg", + "name": "Kovács Anna" + }, + "color": "#1e88e5", + "date": "2026-09-01", + "description": "Ünnepélyesen megnyitottuk a 2026/2027-es tanévet.", + "id": 1, + "indexImage": "https://storage.test/posts_images/tanevnyito.jpg", + "labels": [ + { + "color": "#8e24aa", + "id": 2, + "name": "Ünnepségek" + }, + { + "color": "#1e88e5", + "id": 1, + "name": "Hírek" + } + ], + "title": "Tanévnyitó ünnepség" + } + }, + { + "node": { + "author": null, + "color": "#e53935", + "date": "2025-12-19", + "description": "Az iskolakórus ünnepi hangversenye.", + "id": 5, + "indexImage": "https://storage.test/posts_images/koncert.jpg", + "labels": [ + { + "color": "#8e24aa", + "id": 2, + "name": "Ünnepségek" + } + ], + "title": "Karácsonyi koncert" + } + } + ] + }, + "posts": { + "edges": [ + { + "node": { + "author": { + "image": "https://storage.test/authors_images/kovacs-anna.jpg", + "name": "Kovács Anna" + }, + "color": "#1e88e5", + "date": "2026-09-01", + "description": "Ünnepélyesen megnyitottuk a 2026/2027-es tanévet.", + "id": 1, + "indexImage": "https://storage.test/posts_images/tanevnyito.jpg", + "labels": [ + { + "color": "#8e24aa", + "id": 2, + "name": "Ünnepségek" + }, + { + "color": "#1e88e5", + "id": 1, + "name": "Hírek" + } + ], + "title": "Tanévnyitó ünnepség" + } + }, + { + "node": { + "author": { + "image": null, + "name": "Nagy Béla" + }, + "color": "#43a047", + "date": "2026-08-20", + "description": "Futóverseny és focibajnokság az udvaron.", + "id": 2, + "indexImage": "https://storage.test/posts_images/sportnap.jpg", + "labels": [ + { + "color": "#43a047", + "id": 3, + "name": "Sport" + } + ], + "title": "Őszi sportnap" + } + }, + { + "node": { + "author": { + "image": "https://storage.test/authors_images/kovacs-anna.jpg", + "name": "Kovács Anna" + }, + "color": "#fb8c00", + "date": "2026-07-10", + "description": null, + "id": 3, + "indexImage": "https://storage.test/posts_images/tabor.jpg", + "labels": [], + "title": "Nyári tábor beszámoló" + } + }, + { + "node": { + "author": { + "image": null, + "name": "Nagy Béla" + }, + "color": "#fdd835", + "date": "2026-02-13", + "description": "Utólag rögzített beszámoló.", + "id": 6, + "indexImage": "https://storage.test/posts_images/farsang.jpg", + "labels": [ + { + "color": "#1e88e5", + "id": 1, + "name": "Hírek" + } + ], + "title": "Farsangi bál" + } + }, + { + "node": { + "author": null, + "color": "#e53935", + "date": "2025-12-19", + "description": "Az iskolakórus ünnepi hangversenye.", + "id": 5, + "indexImage": "https://storage.test/posts_images/koncert.jpg", + "labels": [ + { + "color": "#8e24aa", + "id": 2, + "name": "Ünnepségek" + } + ], + "title": "Karácsonyi koncert" + } + } + ], + "pageInfo": { + "endCursor": "2025-12-19#5", + "hasPreviousPage": false + } + } + }, + "extensions": { + "analyzer": { + "complexity": 35, + "depth": 5 + } + } +} diff --git a/tests/snapshots/frontend_queries__information_page_menu.snap b/tests/snapshots/frontend_queries__information_page_menu.snap new file mode 100644 index 0000000..73215c7 --- /dev/null +++ b/tests/snapshots/frontend_queries__information_page_menu.snap @@ -0,0 +1,57 @@ +--- +source: tests/frontend_queries.rs +expression: response +--- +{ + "data": { + "menu": [ + { + "children": [ + { + "link": null, + "name": "Rólunk", + "slug": "rolunk", + "type": "page_link" + }, + { + "link": null, + "name": "Régi oldal", + "slug": "regi-oldal", + "type": "page_link" + } + ], + "link": "/informaciok", + "name": "Iskolánk", + "slug": null, + "type": "internal_link" + }, + { + "children": [], + "link": null, + "name": "Kapcsolat", + "slug": "kapcsolat", + "type": "page_link" + }, + { + "children": [], + "link": "https://example.test/e-naplo", + "name": "E-napló", + "slug": null, + "type": "external_link" + }, + { + "children": [], + "link": "https://example.test/torolt", + "name": "Törölt menüpont", + "slug": null, + "type": "external_link" + } + ] + }, + "extensions": { + "analyzer": { + "complexity": 10, + "depth": 3 + } + } +} diff --git a/tests/snapshots/frontend_queries__page.snap b/tests/snapshots/frontend_queries__page.snap new file mode 100644 index 0000000..21d1817 --- /dev/null +++ b/tests/snapshots/frontend_queries__page.snap @@ -0,0 +1,24 @@ +--- +source: tests/frontend_queries.rs +expression: response +--- +{ + "data": { + "page": { + "content": "

Iskolánk bemutatkozása.

", + "extras": { + "meta_description": "Az iskola bemutatása" + }, + "id": 1, + "name": "rolunk", + "template": "default", + "title": "Rólunk" + } + }, + "extensions": { + "analyzer": { + "complexity": 7, + "depth": 2 + } + } +} diff --git a/tests/snapshots/frontend_queries__post_page.snap b/tests/snapshots/frontend_queries__post_page.snap new file mode 100644 index 0000000..6635257 --- /dev/null +++ b/tests/snapshots/frontend_queries__post_page.snap @@ -0,0 +1,45 @@ +--- +source: tests/frontend_queries.rs +expression: response +--- +{ + "data": { + "post": { + "author": { + "description": "Magyar és történelem szakos tanár.", + "id": 1, + "image": "https://storage.test/authors_images/kovacs-anna.jpg", + "name": "Kovács Anna" + }, + "color": "#1e88e5", + "content": "

Az ünnepségen a diákok műsorral készültek.

", + "date": "2026-09-01", + "description": "Ünnepélyesen megnyitottuk a 2026/2027-es tanévet.", + "id": 1, + "images": [ + "https://storage.test/posts_images/tanevnyito-1.jpg", + "https://storage.test/posts_images/tanevnyito-2.jpg" + ], + "indexImage": "https://storage.test/posts_images/tanevnyito.jpg", + "labels": [ + { + "color": "#8e24aa", + "id": 2, + "name": "Ünnepségek" + }, + { + "color": "#1e88e5", + "id": 1, + "name": "Hírek" + } + ], + "title": "Tanévnyitó ünnepség" + } + }, + "extensions": { + "analyzer": { + "complexity": 18, + "depth": 3 + } + } +} diff --git a/tests/snapshots/frontend_queries__search_page_by_author.snap b/tests/snapshots/frontend_queries__search_page_by_author.snap new file mode 100644 index 0000000..f3a5624 --- /dev/null +++ b/tests/snapshots/frontend_queries__search_page_by_author.snap @@ -0,0 +1,67 @@ +--- +source: tests/frontend_queries.rs +expression: response +--- +{ + "data": { + "author": { + "posts": { + "edges": [ + { + "node": { + "author": { + "id": 1, + "image": "https://storage.test/authors_images/kovacs-anna.jpg", + "name": "Kovács Anna" + }, + "color": "#1e88e5", + "date": "2026-09-01", + "description": "Ünnepélyesen megnyitottuk a 2026/2027-es tanévet.", + "id": 1, + "indexImage": "https://storage.test/posts_images/tanevnyito.jpg", + "labels": [ + { + "color": "#8e24aa", + "id": 2, + "name": "Ünnepségek" + }, + { + "color": "#1e88e5", + "id": 1, + "name": "Hírek" + } + ], + "title": "Tanévnyitó ünnepség" + } + }, + { + "node": { + "author": { + "id": 1, + "image": "https://storage.test/authors_images/kovacs-anna.jpg", + "name": "Kovács Anna" + }, + "color": "#fb8c00", + "date": "2026-07-10", + "description": null, + "id": 3, + "indexImage": "https://storage.test/posts_images/tabor.jpg", + "labels": [], + "title": "Nyári tábor beszámoló" + } + } + ], + "pageInfo": { + "endCursor": "2026-07-10#3", + "hasPreviousPage": true + } + } + } + }, + "extensions": { + "analyzer": { + "complexity": 21, + "depth": 6 + } + } +} diff --git a/tests/snapshots/frontend_queries__search_page_by_label.snap b/tests/snapshots/frontend_queries__search_page_by_label.snap new file mode 100644 index 0000000..135d7bf --- /dev/null +++ b/tests/snapshots/frontend_queries__search_page_by_label.snap @@ -0,0 +1,73 @@ +--- +source: tests/frontend_queries.rs +expression: response +--- +{ + "data": { + "label": { + "posts": { + "edges": [ + { + "node": { + "author": { + "id": 1, + "image": "https://storage.test/authors_images/kovacs-anna.jpg", + "name": "Kovács Anna" + }, + "color": "#1e88e5", + "date": "2026-09-01", + "description": "Ünnepélyesen megnyitottuk a 2026/2027-es tanévet.", + "id": 1, + "indexImage": "https://storage.test/posts_images/tanevnyito.jpg", + "labels": [ + { + "color": "#8e24aa", + "id": 2, + "name": "Ünnepségek" + }, + { + "color": "#1e88e5", + "id": 1, + "name": "Hírek" + } + ], + "title": "Tanévnyitó ünnepség" + } + }, + { + "node": { + "author": { + "id": 2, + "image": null, + "name": "Nagy Béla" + }, + "color": "#fdd835", + "date": "2026-02-13", + "description": "Utólag rögzített beszámoló.", + "id": 6, + "indexImage": "https://storage.test/posts_images/farsang.jpg", + "labels": [ + { + "color": "#1e88e5", + "id": 1, + "name": "Hírek" + } + ], + "title": "Farsangi bál" + } + } + ], + "pageInfo": { + "endCursor": "2026-02-13#6", + "hasPreviousPage": true + } + } + } + }, + "extensions": { + "analyzer": { + "complexity": 21, + "depth": 6 + } + } +} diff --git a/tests/snapshots/frontend_queries__search_page_by_term.snap b/tests/snapshots/frontend_queries__search_page_by_term.snap new file mode 100644 index 0000000..66c3b19 --- /dev/null +++ b/tests/snapshots/frontend_queries__search_page_by_term.snap @@ -0,0 +1,67 @@ +--- +source: tests/frontend_queries.rs +expression: response +--- +{ + "data": { + "search": { + "edges": [ + { + "node": { + "author": { + "id": 1, + "image": "https://storage.test/authors_images/kovacs-anna.jpg", + "name": "Kovács Anna" + }, + "color": "#1e88e5", + "date": "2026-09-01", + "description": "Ünnepélyesen megnyitottuk a 2026/2027-es tanévet.", + "id": 1, + "indexImage": "https://storage.test/posts_images/tanevnyito.jpg", + "labels": [ + { + "color": "#8e24aa", + "id": 2, + "name": "Ünnepségek" + }, + { + "color": "#1e88e5", + "id": 1, + "name": "Hírek" + } + ], + "title": "Tanévnyitó ünnepség" + } + }, + { + "node": { + "author": null, + "color": "#e53935", + "date": "2025-12-19", + "description": "Az iskolakórus ünnepi hangversenye.", + "id": 5, + "indexImage": "https://storage.test/posts_images/koncert.jpg", + "labels": [ + { + "color": "#8e24aa", + "id": 2, + "name": "Ünnepségek" + } + ], + "title": "Karácsonyi koncert" + } + } + ], + "pageInfo": { + "endCursor": "2025-12-19#5", + "hasPreviousPage": false + } + } + }, + "extensions": { + "analyzer": { + "complexity": 20, + "depth": 5 + } + } +} diff --git a/tests/snapshots/posts__archive_counts_published_posts_per_month_newest_first.snap b/tests/snapshots/posts__archive_counts_published_posts_per_month_newest_first.snap new file mode 100644 index 0000000..ebc138c --- /dev/null +++ b/tests/snapshots/posts__archive_counts_published_posts_per_month_newest_first.snap @@ -0,0 +1,43 @@ +--- +source: tests/posts.rs +expression: response +--- +{ + "data": { + "archive": { + "info": [ + { + "count": 1, + "month": 9, + "year": 2026 + }, + { + "count": 1, + "month": 8, + "year": 2026 + }, + { + "count": 1, + "month": 7, + "year": 2026 + }, + { + "count": 1, + "month": 2, + "year": 2026 + }, + { + "count": 1, + "month": 12, + "year": 2025 + } + ] + } + }, + "extensions": { + "analyzer": { + "complexity": 5, + "depth": 3 + } + } +} diff --git a/tests/snapshots/posts__archive_lists_the_published_posts_of_a_month.snap b/tests/snapshots/posts__archive_lists_the_published_posts_of_a_month.snap new file mode 100644 index 0000000..f7e0163 --- /dev/null +++ b/tests/snapshots/posts__archive_lists_the_published_posts_of_a_month.snap @@ -0,0 +1,31 @@ +--- +source: tests/posts.rs +expression: response +--- +{ + "data": { + "archive": { + "december": [ + { + "date": "2025-12-19", + "id": 5, + "title": "Karácsonyi koncert" + } + ], + "empty": [], + "september": [ + { + "date": "2026-09-01", + "id": 1, + "title": "Tanévnyitó ünnepség" + } + ] + } + }, + "extensions": { + "analyzer": { + "complexity": 11, + "depth": 3 + } + } +}