diff --git a/.devcontainer/cspell-dict/mountix-words.txt b/.devcontainer/cspell-dict/mountix-words.txt index 551ffdb..b05c9a8 100644 --- a/.devcontainer/cspell-dict/mountix-words.txt +++ b/.devcontainer/cspell-dict/mountix-words.txt @@ -1,7 +1,18 @@ +2dsphere +binstall clippy defaultauthdb +dotenv +dotenvy +dtolnay +geosearch +geospatial INITDB lldb +mockall mongoimport +mongosh mountix +robbyrussell rustup +untap diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml new file mode 100644 index 0000000..3d61bae --- /dev/null +++ b/.github/workflows/test-and-lint.yml @@ -0,0 +1,28 @@ +name: Test and Lint + +on: + push: + branches-ignore: + - main + pull_request: + branches: + - '*' + workflow_dispatch: + workflow_call: + +jobs: + test-and-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt -- --check + + - name: Run Clippy + run: cargo clippy -- -D warnings diff --git a/.gitignore b/.gitignore index b1d8f33..ef9c354 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ Temporary Items # mountix .env + +# Claude +.claude/*.local.json diff --git a/.zshrc b/.zshrc index 51668f5..001de07 100644 --- a/.zshrc +++ b/.zshrc @@ -1,10 +1,18 @@ # shellcheck disable=all + +# Activate Mise +eval "$(mise activate zsh)" +export PATH="$HOME/.local/share/mise/shims:$PATH" + # If you come from bash you might have to change your $PATH. # export PATH=$HOME/bin:$HOME/.local/bin:/usr/local/bin:$PATH # Path to your Oh My Zsh installation. export ZSH="$HOME/.oh-my-zsh" export ZSH_CUSTOM="$ZSH/custom" +# Settings to persist history file +export HISTFILE=/root/.history/.zsh_history + # Clone zsh-autosuggestions plugin if it doesn't exist if [ ! -d $ZSH_CUSTOM/plugins/zsh-autosuggestions ]; then git clone https://github.com/zsh-users/zsh-autosuggestions $ZSH_CUSTOM/plugins/zsh-autosuggestions diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..779a48f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +このファイルは、このリポジトリでコードを扱う際にClaude Codeにガイダンスを提供します。 + +## プロジェクト概要 + +Mountixは、クリーンアーキテクチャパターンを使用してRustで構築された日本の山岳データAPIです。百名山データを含む山岳情報のRESTエンドポイントを提供し、MongoDBをデータストアとして使用しています。 + +## 開発コマンド + +### ビルドと実行 + +- **ビルド**: `cargo build` +- **実行**: `cargo run` +- **チェック**: `cargo check` +- **フォーマット**: `cargo fmt` +- **リント**: `cargo clippy` +- **テスト**: `cargo test` +- **カバレッジ**: `cargo llvm-cov` + +### 環境設定 + +`sample.env`を`.env`にコピーして設定: +- `DATABASE_URL`にMongoDB接続文字列 +- `DATABASE_NAME=mountix_db` +- サーバーのホスト/ポート設定 + +### データベースセットアップ + +開発環境(devcontainer)の場合、起動時にマイグレーションが完了します。 + +```bash +cd ./migrations/ +./migrate.sh +cd ../ +``` + +## インフラストラクチャ + +インフラストラクチャは[Render](https://render.com/)を採用しています。 + +## アーキテクチャ + +このプロジェクトは依存性逆転を伴う階層化クリーンアーキテクチャに従います: + +### レイヤー構造(上位から下位) +1. **mountix-driver** (Controller/Presentation) + - Axum Webフレームワークのセットアップとルーティング + - HTTPハンドラーとJSONシリアライゼーション + - エントリーポイント: `startup/mod.rs`にサーバー設定 + - ルート: `/api/v1/mountains`, `/api/v1/hc` (ヘルスチェック) + +2. **mountix-app** (Use Case/Application) + - ビジネスロジックとアプリケーションワークフロー + - kernelとadapterレイヤー間の調整 + - ユースケース実装を含む + +3. **mountix-kernel** (Domain/Core) + - ドメインモデルとビジネスルール + - リポジトリトレイト定義(adapterで実装) + - 外部依存性のないコアビジネスロジック + +4. **mountix-adapter** (Infrastructure) + - MongoDB永続化実装 + - 外部サービス統合 + - kernelのリポジトリトレイトを実装 + +### 依存関係ルール + +- 上位レイヤーは下位レイヤーに依存可能 +- 下位レイヤーは上位レイヤーに依存不可 +- kernelとadapterはDIP(依存性逆転の原則)を使用 + +## 主要技術 + +- **Webフレームワーク**: Axum with tokio async runtime +- **データベース**: MongoDB(`DATABASE_URL`経由で接続) +- **ログ**: tracingによるJSON出力 +- **環境**: dotenvy(.envファイル読み込み) +- **CORS**: API アクセス用に設定済み + +## APIエンドポイント + +- `GET /api/v1/mountains` - フィルタリング付き山岳リスト +- `GET /api/v1/mountains/{id}` - 特定の山岳取得 +- `GET /api/v1/mountains/{id}/surroundings` - 周辺の山岳取得 +- `GET /api/v1/mountains/geosearch` - 地理的検索 +- `GET /api/v1/hc` - ヘルスチェック +- `GET /api/v1/hc/mongo` - MongoDBヘルスチェック + +## 開発ノート + +- 4つのクレートからなるRustワークスペースを使用 +- 日本の山岳データドメイン(百名山)に従う +- 実行前に環境設定が必要 +- クラウドデプロイにはRenderとMongoDB Atlasを推奨 diff --git a/Dockerfile b/Dockerfile index 58071dd..9eec8d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,15 @@ # Rust development environment for devcontainer FROM rust:1.87.0-slim-bullseye +ENV LANG=ja_JP.UTF-8 +ENV LANGUAGE=ja_JP:ja +ENV LC_ALL=ja_JP.UTF-8 +ENV MISE_GLOBAL_CONFIG_FILE=/workspace/mise.toml + # Install packages required for development RUN apt-get update && apt-get install -y \ + # Locales + locales \ # Basic tools git \ curl \ @@ -10,6 +17,7 @@ RUN apt-get update && apt-get install -y \ vim \ nano \ zsh \ + unzip \ # Build tools build-essential \ pkg-config \ @@ -19,11 +27,21 @@ RUN apt-get update && apt-get install -y \ lldb \ # Network tools net-tools \ + # Generate Japanese locale + && echo "ja_JP.UTF-8 UTF-8" >> /etc/locale.gen \ + && locale-gen \ + # Clean up + && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Optimize Rust settings RUN rustup component add rustfmt clippy llvm-tools-preview -RUN cargo install cargo-llvm-cov +RUN cargo install cargo-llvm-cov cargo-binstall + +# Install Mise +COPY mise.toml /workspace/mise.toml +RUN cargo binstall mise +RUN mise install node # Oh My Zsh RUN sh -c "$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" diff --git a/migrations/devcontainer/migrate-in-docker.sh b/migrations/devcontainer/migrate-in-docker.sh index 349f517..9721156 100755 --- a/migrations/devcontainer/migrate-in-docker.sh +++ b/migrations/devcontainer/migrate-in-docker.sh @@ -10,4 +10,13 @@ mongoimport \ --collection=mountains \ --file="/docker-entrypoint-initdb.d/mountix_db-mountains.json" +echo "Creating geospatial index on mountains collection..." + +mongosh \ + --username="$MONGO_INITDB_ROOT_USERNAME" \ + --password="$MONGO_INITDB_ROOT_PASSWORD" \ + --authenticationDatabase=admin \ + mountix_db \ + --eval "db.mountains.createIndex({ location: '2dsphere' });" + echo "Completed migration" diff --git a/migrations/migrate.sh b/migrations/migrate.sh index a2e6078..a9684c5 100755 --- a/migrations/migrate.sh +++ b/migrations/migrate.sh @@ -5,10 +5,23 @@ source ./.env brew tap mongodb/brew brew install mongodb-database-tools +echo "Migrating data into MongoDB..." + mongoimport $DATABASE_URL \ ---collection=mountains \ ---db=mountix_db \ ---file="./data/mountix_db-mountains.json" + --collection=mountains \ + --db=mountix_db \ + --file="./data/mountix_db-mountains.json" + +echo "Creating geospatial index on mountains collection..." + +mongosh \ + --username="$MONGO_INITDB_ROOT_USERNAME" \ + --password="$MONGO_INITDB_ROOT_PASSWORD" \ + --authenticationDatabase=admin \ + mountix_db \ + --eval "db.mountains.createIndex({ location: '2dsphere' });" + +echo "Completed migration" brew remove mongodb-database-tools brew untap mongodb/brew diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..a4123da --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tools] +node = "22.16.0" +gh = "latest" +"npm:@anthropic-ai/claude-code" = "latest" diff --git a/mountix-adapter/Cargo.toml b/mountix-adapter/Cargo.toml index 510466a..36febfa 100644 --- a/mountix-adapter/Cargo.toml +++ b/mountix-adapter/Cargo.toml @@ -12,3 +12,7 @@ tokio = { version = "1.45.1", features = ["full"] } serde = { version = "1.0.219", features = ["derive"] } futures = "0.3.31" mongodb = "3.2.3" + +[dev-dependencies] +tokio-test = "0.4.4" +mockall = "0.13.0" diff --git a/mountix-adapter/src/model/mountain.rs b/mountix-adapter/src/model/mountain.rs index 17f9525..5c01bef 100644 --- a/mountix-adapter/src/model/mountain.rs +++ b/mountix-adapter/src/model/mountain.rs @@ -1,7 +1,7 @@ use mongodb::bson::{doc, Document}; use mongodb::options::FindOptions; use mountix_kernel::model::mountain::{ - Mountain, MountainBoxSearchCondition, MountainLocation, MountainSearchCondition, + Mountain, MountainBoxSearchCondition, MountainData, MountainLocation, MountainSearchCondition, }; use mountix_kernel::model::Id; use serde::{Deserialize, Serialize}; @@ -31,22 +31,24 @@ impl TryFrom for Mountain { fn try_from(mountain_doc: MountainDocument) -> Result { let mountain_id: Id = mountain_doc.id.into(); + // MongoDBの地理的データ形式: [longitude, latitude] + // Rustの座標形式: (latitude, longitude) let mountain_location = MountainLocation::new( - mountain_doc.location.coordinates[1], - mountain_doc.location.coordinates[0], + mountain_doc.location.coordinates[1], // latitude (緯度) + mountain_doc.location.coordinates[0], // longitude (経度) mountain_doc.gsi_url, ); - Ok(Mountain::new( - mountain_id, - mountain_doc.name, - mountain_doc.name_kana, - mountain_doc.area, - mountain_doc.prefectures, - mountain_doc.elevation, - mountain_location, - mountain_doc.tags, - )) + let data = MountainData { + name: mountain_doc.name, + name_kana: mountain_doc.name_kana, + area: mountain_doc.area, + prefectures: mountain_doc.prefectures, + elevation: mountain_doc.elevation, + location: mountain_location, + tags: mountain_doc.tags, + }; + Ok(Mountain::new(mountain_id, data)) } } @@ -76,7 +78,7 @@ impl TryFrom for MountainFindCommand { and_doc.push(doc! {"tags": &tag_name}); } - if and_doc.len() > 0 { + if !and_doc.is_empty() { filter.insert("$and", and_doc); } @@ -104,6 +106,7 @@ impl TryFrom for MountainFindBoxCommand { fn try_from(sc: MountainBoxSearchCondition) -> Result { let mut filter = Document::new(); + // MongoDBの地理的ボックス検索では [longitude, latitude] の順序が必要 let mut and_doc = vec![ doc! {"location": {"$geoWithin": {"$box": [[sc.box_coordinates.bottom_left.0,sc.box_coordinates.bottom_left.1], [sc.box_coordinates.upper_right.0,sc.box_coordinates.upper_right.1]]}}}, ]; diff --git a/mountix-adapter/src/model/surrounding_mountain.rs b/mountix-adapter/src/model/surrounding_mountain.rs index 2af47fa..430405c 100644 --- a/mountix-adapter/src/model/surrounding_mountain.rs +++ b/mountix-adapter/src/model/surrounding_mountain.rs @@ -1,6 +1,7 @@ use mongodb::bson::{doc, Document}; use mountix_kernel::model::surrounding_mountain::{ - SurroundingMountain, SurroundingMountainLocation, SurroundingMountainSearchCondition, + SurroundingMountain, SurroundingMountainData, SurroundingMountainLocation, + SurroundingMountainSearchCondition, }; use mountix_kernel::model::Id; use serde::{Deserialize, Serialize}; @@ -30,22 +31,24 @@ impl TryFrom for SurroundingMountain { fn try_from(mountain_doc: SurroundingMountainDocument) -> Result { let mountain_id: Id = mountain_doc.id.into(); + // MongoDBの地理的データ形式: [longitude, latitude] + // Rustの座標形式: (latitude, longitude) let mountain_location = SurroundingMountainLocation::new( - mountain_doc.location.coordinates[1], - mountain_doc.location.coordinates[0], + mountain_doc.location.coordinates[1], // latitude (緯度) + mountain_doc.location.coordinates[0], // longitude (経度) mountain_doc.gsi_url, ); - Ok(SurroundingMountain::new( - mountain_id, - mountain_doc.name, - mountain_doc.name_kana, - mountain_doc.area, - mountain_doc.prefectures, - mountain_doc.elevation, - mountain_location, - mountain_doc.tags, - )) + let data = SurroundingMountainData { + name: mountain_doc.name, + name_kana: mountain_doc.name_kana, + area: mountain_doc.area, + prefectures: mountain_doc.prefectures, + elevation: mountain_doc.elevation, + location: mountain_location, + tags: mountain_doc.tags, + }; + Ok(SurroundingMountain::new(mountain_id, data)) } } @@ -57,9 +60,10 @@ impl TryFrom for SurroundingMountainFindComm type Error = anyhow::Error; fn try_from(sc: SurroundingMountainSearchCondition) -> Result { + // MongoDBの地理的クエリでは [longitude, latitude] の順序が必要 let coordinates = ( - sc.mountain.location.longitude, - sc.mountain.location.latitude, + sc.mountain.location.longitude, // longitude (経度) + sc.mountain.location.latitude, // latitude (緯度) ); let filter = doc! {"$and": [{"location":{"$nearSphere": {"$geometry": { "type": "Point", "coordinates": [coordinates.0, coordinates.1]},"$minDistance": 0,"$maxDistance": sc.distance.0}}}, {"_id": {"$ne": &sc.mountain.id.value}}]}; diff --git a/mountix-adapter/src/repository/mountain.rs b/mountix-adapter/src/repository/mountain.rs index ac0df23..9567989 100644 --- a/mountix-adapter/src/repository/mountain.rs +++ b/mountix-adapter/src/repository/mountain.rs @@ -69,3 +69,138 @@ impl MountainRepository for MongoDBRepositoryImpl { Ok(mountains) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::mountain::MountainLocationDocument; + use mountix_kernel::model::mountain::{ + MountainPrefecture, MountainSearchCondition, MountainSortCondition, MountainTag, + }; + + fn create_test_mountain_document() -> MountainDocument { + MountainDocument { + id: 1, + name: "富士山".to_string(), + name_kana: "ふじさん".to_string(), + area: "関東地方".to_string(), + prefectures: vec!["静岡県".to_string(), "山梨県".to_string()], + elevation: 3776, + tags: vec!["百名山".to_string()], + location: MountainLocationDocument { + r#type: "Point".to_string(), + coordinates: [138.727778, 35.360556], // [longitude, latitude] - MongoDB形式 + }, + gsi_url: "https://maps.gsi.go.jp/fuji".to_string(), + } + } + + #[test] + fn test_mountain_document_to_mountain_conversion() { + let mountain_doc = create_test_mountain_document(); + let result = Mountain::try_from(mountain_doc); + + assert!(result.is_ok()); + let mountain = result.unwrap(); + assert_eq!(mountain.id.value, 1); + assert_eq!(mountain.name, "富士山"); + assert_eq!(mountain.name_kana, "ふじさん"); + assert_eq!(mountain.area, "関東地方"); + assert_eq!(mountain.prefectures, vec!["静岡県", "山梨県"]); + assert_eq!(mountain.elevation, 3776); + assert_eq!(mountain.location.latitude, 35.360556); + assert_eq!(mountain.location.longitude, 138.727778); + assert_eq!(mountain.location.gsi_url, "https://maps.gsi.go.jp/fuji"); + assert_eq!(mountain.tags, vec!["百名山"]); + } + + #[test] + fn test_mountain_search_condition_to_find_command() { + let search_condition = MountainSearchCondition { + name: Some("富士".to_string()), + prefecture: Some(MountainPrefecture::try_from("19".to_string()).unwrap()), + tag: Some(MountainTag::try_from("1".to_string()).unwrap()), + skip: 10, + limit: Some(5), + sort: MountainSortCondition::default(), + }; + + let result = MountainFindCommand::try_from(search_condition); + assert!(result.is_ok()); + + let command = result.unwrap(); + assert!(command.filter.contains_key("$and")); + assert_eq!(command.options.skip, Some(10)); + assert_eq!(command.options.limit, Some(5)); + } + + #[test] + fn test_mountain_box_search_condition_to_find_command() { + let box_coords = mountix_kernel::model::mountain::MountainBoxCoordinates::try_from( + "(138.0,35.0),(139.0,36.0)".to_string(), + ) + .unwrap(); + let search_condition = mountix_kernel::model::mountain::MountainBoxSearchCondition { + box_coordinates: box_coords, + name: Some("富士".to_string()), + tag: Some(MountainTag::try_from("1".to_string()).unwrap()), + sort: MountainSortCondition::default(), + }; + + let result = MountainFindBoxCommand::try_from(search_condition); + assert!(result.is_ok()); + + let command = result.unwrap(); + assert!(command.filter.contains_key("$and")); + assert!( + command + .filter + .get("$and") + .unwrap() + .as_array() + .unwrap() + .len() + >= 2 + ); + } + + #[test] + fn test_mountain_search_condition_empty_filters() { + let search_condition = MountainSearchCondition { + name: None, + prefecture: None, + tag: None, + skip: 0, + limit: None, + sort: MountainSortCondition::default(), + }; + + let result = MountainFindCommand::try_from(search_condition); + assert!(result.is_ok()); + + let command = result.unwrap(); + assert!(!command.filter.contains_key("$and")); + assert_eq!(command.options.skip, Some(0)); + assert_eq!(command.options.limit, None); + } + + #[test] + fn test_mountain_sort_condition_elevation_desc() { + let sort_condition = MountainSortCondition::try_from("elevation.desc".to_string()).unwrap(); + let search_condition = MountainSearchCondition { + name: None, + prefecture: None, + tag: None, + skip: 0, + limit: None, + sort: sort_condition, + }; + + let result = MountainFindCommand::try_from(search_condition); + assert!(result.is_ok()); + + let command = result.unwrap(); + let sort_doc = command.options.sort.unwrap(); + assert_eq!(sort_doc.get("elevation").unwrap().as_i64().unwrap(), -1); + } +} diff --git a/mountix-adapter/src/repository/surrounding_mountain.rs b/mountix-adapter/src/repository/surrounding_mountain.rs index e0d1300..f8b29a3 100644 --- a/mountix-adapter/src/repository/surrounding_mountain.rs +++ b/mountix-adapter/src/repository/surrounding_mountain.rs @@ -31,3 +31,137 @@ impl SurroundingMountainRepository for MongoDBRepositoryImpl SurroundingMountainDocument { + SurroundingMountainDocument { + id: 2, + name: "周辺の山".to_string(), + name_kana: "しゅうへんのやま".to_string(), + area: "関東地方".to_string(), + prefectures: vec!["静岡県".to_string()], + elevation: 2500, + tags: vec!["二百名山".to_string()], + location: SurroundingMountainLocationDocument { + r#type: "Point".to_string(), + coordinates: [138.800000, 35.300000], // [longitude, latitude] - MongoDB形式 + }, + gsi_url: "https://maps.gsi.go.jp/surrounding".to_string(), + } + } + + fn create_test_mountain() -> Mountain { + let id = Id::new(1); + let location = + MountainLocation::new(35.360556, 138.727778, "https://maps.gsi.go.jp".to_string()); + let data = MountainData { + name: "富士山".to_string(), + name_kana: "ふじさん".to_string(), + area: "関東地方".to_string(), + prefectures: vec!["静岡県".to_string(), "山梨県".to_string()], + elevation: 3776, + location, + tags: vec!["百名山".to_string()], + }; + Mountain::new(id, data) + } + + #[test] + fn test_surrounding_mountain_document_to_surrounding_mountain_conversion() { + let surrounding_mountain_doc = create_test_surrounding_mountain_document(); + let result = SurroundingMountain::try_from(surrounding_mountain_doc); + + assert!(result.is_ok()); + let surrounding_mountain = result.unwrap(); + assert_eq!(surrounding_mountain.id.value, 2); + assert_eq!(surrounding_mountain.name, "周辺の山"); + assert_eq!(surrounding_mountain.name_kana, "しゅうへんのやま"); + assert_eq!(surrounding_mountain.area, "関東地方"); + assert_eq!(surrounding_mountain.prefectures, vec!["静岡県"]); + assert_eq!(surrounding_mountain.elevation, 2500); + assert_eq!(surrounding_mountain.location.latitude, 35.300000); + assert_eq!(surrounding_mountain.location.longitude, 138.800000); + assert_eq!( + surrounding_mountain.location.gsi_url, + "https://maps.gsi.go.jp/surrounding" + ); + assert_eq!(surrounding_mountain.tags, vec!["二百名山"]); + } + + #[test] + fn test_surrounding_mountain_search_condition_to_find_command() { + let mountain = create_test_mountain(); + let distance = SurroundingMountainSearchDistance::new(10000); + let search_condition = SurroundingMountainSearchCondition::new(mountain, distance); + + let result = SurroundingMountainFindCommand::try_from(search_condition); + assert!(result.is_ok()); + + let command = result.unwrap(); + // Test that the filter contains geospatial query structure + assert!(command.filter.contains_key("$and")); + } + + #[test] + fn test_surrounding_mountain_search_condition_with_default_distance() { + let mountain = create_test_mountain(); + let distance = SurroundingMountainSearchDistance::default(); + let search_condition = SurroundingMountainSearchCondition::new(mountain, distance); + + let result = SurroundingMountainFindCommand::try_from(search_condition); + assert!(result.is_ok()); + + let command = result.unwrap(); + // Test that the filter contains geospatial query structure with default distance + assert!(command.filter.contains_key("$and")); + } + + #[test] + fn test_surrounding_mountain_search_condition_with_large_distance() { + let mountain = create_test_mountain(); + let distance = SurroundingMountainSearchDistance::new(50000); // 50km + let search_condition = SurroundingMountainSearchCondition::new(mountain, distance); + + let result = SurroundingMountainFindCommand::try_from(search_condition); + assert!(result.is_ok()); + + let command = result.unwrap(); + // Test that the filter contains geospatial query structure with large distance + assert!(command.filter.contains_key("$and")); + } + + #[test] + fn test_surrounding_mountain_location_document_structure() { + let location_doc = SurroundingMountainLocationDocument { + r#type: "Point".to_string(), + coordinates: [138.727778, 35.360556], // [longitude, latitude] - MongoDB形式 + }; + + assert_eq!(location_doc.r#type, "Point"); + assert_eq!(location_doc.coordinates[0], 138.727778); // longitude (経度) + assert_eq!(location_doc.coordinates[1], 35.360556); // latitude (緯度) + } + + #[test] + fn test_surrounding_mountain_document_structure() { + let doc = create_test_surrounding_mountain_document(); + + assert_eq!(doc.id, 2); + assert_eq!(doc.name, "周辺の山"); + assert_eq!(doc.elevation, 2500); + assert_eq!(doc.location.r#type, "Point"); + assert_eq!(doc.location.coordinates[0], 138.800000); // longitude (経度) + assert_eq!(doc.location.coordinates[1], 35.300000); // latitude (緯度) + } +} diff --git a/mountix-app/Cargo.toml b/mountix-app/Cargo.toml index a1fcc06..93c30fd 100644 --- a/mountix-app/Cargo.toml +++ b/mountix-app/Cargo.toml @@ -9,3 +9,8 @@ mountix-adapter = { path = "../mountix-adapter" } anyhow = "1.0.98" tokio = { version = "1.45.1", features = ["full"] } num = "0.4.3" +async-trait = "0.1.88" + +[dev-dependencies] +tokio-test = "0.4.4" +mockall = "0.13.0" diff --git a/mountix-app/src/model/mountain.rs b/mountix-app/src/model/mountain.rs index a2d159e..4728760 100644 --- a/mountix-app/src/model/mountain.rs +++ b/mountix-app/src/model/mountain.rs @@ -4,6 +4,7 @@ use mountix_kernel::model::mountain::{ MountainPrefecture, MountainSearchCondition, MountainSortCondition, MountainTag, }; +#[derive(Debug)] pub struct SearchedMountain { pub id: i32, pub name: String, @@ -30,6 +31,7 @@ impl From for SearchedMountain { } } +#[derive(Debug)] pub struct SearchedMountainLocation { pub latitude: f64, pub longitude: f64, @@ -46,6 +48,7 @@ impl From for SearchedMountainLocation { } } +#[derive(Debug)] pub struct SearchedMountainResult { pub mountains: Vec, pub total: u64, @@ -115,7 +118,7 @@ impl TryFrom for MountainSearchCondition { } } - if errors.len() > 0 { + if !errors.is_empty() { return Err(errors); } @@ -130,6 +133,7 @@ impl TryFrom for MountainSearchCondition { } } +#[derive(Debug)] pub struct SearchedBoxMountainResult { pub mountains: Vec, pub total: u64, @@ -175,7 +179,7 @@ impl TryFrom for MountainBoxSearchCondition { } } - if errors.len() > 0 { + if !errors.is_empty() { return Err(errors); } diff --git a/mountix-app/src/model/surrounding_mountain.rs b/mountix-app/src/model/surrounding_mountain.rs index 09b4b71..b757749 100644 --- a/mountix-app/src/model/surrounding_mountain.rs +++ b/mountix-app/src/model/surrounding_mountain.rs @@ -5,6 +5,7 @@ use mountix_kernel::model::surrounding_mountain::{ use std::env; use std::ffi::OsString; +#[derive(Debug)] pub struct SearchedSurroundingMountain { pub id: i32, pub name: String, @@ -31,6 +32,7 @@ impl From for SearchedSurroundingMountain { } } +#[derive(Debug)] pub struct SearchedSurroundingMountainLocation { pub latitude: f64, pub longitude: f64, @@ -47,6 +49,7 @@ impl From for SearchedSurroundingMountainLocation { } } +#[derive(Debug)] pub struct SearchedSurroundingMountainResult { pub mountains: Vec, pub distance: u32, diff --git a/mountix-app/src/usecase/mountain.rs b/mountix-app/src/usecase/mountain.rs index e67c3ea..826e78b 100644 --- a/mountix-app/src/usecase/mountain.rs +++ b/mountix-app/src/usecase/mountain.rs @@ -40,8 +40,8 @@ impl MountainUseCase { ) -> Result { match MountainSearchCondition::try_from(search_query) { Ok(condition) => { - let offset = condition.skip.clone(); - let condition_limit = condition.limit.clone(); + let offset = condition.skip; + let condition_limit = condition.limit; let mut total = 0u64; if let Ok(count) = self @@ -123,3 +123,326 @@ impl MountainUseCase { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::mountain::{MountainBoxSearchQuery, MountainSearchQuery}; + use mockall::mock; + use mountix_kernel::model::mountain::{ + Mountain, MountainBoxSearchCondition, MountainLocation, MountainSearchCondition, + }; + use mountix_kernel::model::{ErrorCode, Id}; + use mountix_kernel::repository::mountain::MountainRepository; + use std::sync::Arc; + + mock! { + TestMountainRepository {} + + #[async_trait::async_trait] + impl MountainRepository for TestMountainRepository { + async fn get(&self, id: Id) -> anyhow::Result>; + async fn get_count(&self, search_condition: MountainSearchCondition) -> anyhow::Result; + async fn find(&self, search_condition: MountainSearchCondition) -> anyhow::Result>; + async fn find_box(&self, search_condition: MountainBoxSearchCondition) -> anyhow::Result>; + } + } + + mock! { + TestSurroundingMountainRepository {} + + #[async_trait::async_trait] + impl mountix_kernel::repository::surrounding_mountain::SurroundingMountainRepository for TestSurroundingMountainRepository { + async fn find(&self, condition: mountix_kernel::model::surrounding_mountain::SurroundingMountainSearchCondition) -> anyhow::Result>; + } + } + + struct MockRepositoriesModule { + mountain_repository: MockTestMountainRepository, + surrounding_mountain_repository: MockTestSurroundingMountainRepository, + } + + impl RepositoriesModuleExt for MockRepositoriesModule { + type MountainRepo = MockTestMountainRepository; + type SurroundingMountainRepo = MockTestSurroundingMountainRepository; + + fn mountain_repository(&self) -> &Self::MountainRepo { + &self.mountain_repository + } + + fn surrounding_mountain_repository(&self) -> &Self::SurroundingMountainRepo { + &self.surrounding_mountain_repository + } + } + + fn create_test_mountain() -> Mountain { + let id = Id::new(1); + let location = + MountainLocation::new(35.360556, 138.727778, "https://maps.gsi.go.jp".to_string()); + let data = mountix_kernel::model::mountain::MountainData { + name: "富士山".to_string(), + name_kana: "ふじさん".to_string(), + area: "関東地方".to_string(), + prefectures: vec!["静岡県".to_string(), "山梨県".to_string()], + elevation: 3776, + location, + tags: vec!["百名山".to_string()], + }; + Mountain::new(id, data) + } + + #[tokio::test] + async fn test_mountain_use_case_get_success() { + let mut mock_repo = MockTestMountainRepository::new(); + mock_repo + .expect_get() + .with(mockall::predicate::function(|id: &Id| { + id.value == 1 + })) + .times(1) + .returning(|_| Ok(Some(create_test_mountain()))); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let result = use_case.get("1".to_string()).await; + + assert!(result.is_ok()); + let mountain = result.unwrap(); + assert!(mountain.is_some()); + let mountain = mountain.unwrap(); + assert_eq!(mountain.id, 1); + assert_eq!(mountain.name, "富士山"); + } + + #[tokio::test] + async fn test_mountain_use_case_get_not_found() { + let mut mock_repo = MockTestMountainRepository::new(); + mock_repo + .expect_get() + .with(mockall::predicate::function(|id: &Id| { + id.value == 999 + })) + .times(1) + .returning(|_| Ok(None)); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let result = use_case.get("999".to_string()).await; + + assert!(result.is_ok()); + let mountain = result.unwrap(); + assert!(mountain.is_none()); + } + + #[tokio::test] + async fn test_mountain_use_case_get_invalid_id() { + let mock_repo = MockTestMountainRepository::new(); + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let result = use_case.get("invalid".to_string()).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::InvalidId); + } + + #[tokio::test] + async fn test_mountain_use_case_get_repository_error() { + let mut mock_repo = MockTestMountainRepository::new(); + mock_repo + .expect_get() + .with(mockall::predicate::function(|id: &Id| { + id.value == 1 + })) + .times(1) + .returning(|_| Err(anyhow::anyhow!("Database error"))); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let result = use_case.get("1".to_string()).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::ServerError); + } + + #[tokio::test] + async fn test_mountain_use_case_find_success() { + let mut mock_repo = MockTestMountainRepository::new(); + mock_repo.expect_get_count().times(1).returning(|_| Ok(1)); + mock_repo + .expect_find() + .times(1) + .returning(|_| Ok(vec![create_test_mountain()])); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let search_query = MountainSearchQuery { + name: None, + prefecture: None, + tag: None, + offset: None, + limit: None, + sort: None, + }; + let result = use_case.find(search_query).await; + + assert!(result.is_ok()); + let search_result = result.unwrap(); + assert_eq!(search_result.mountains.len(), 1); + assert_eq!(search_result.total, 1); + assert_eq!(search_result.mountains[0].name, "富士山"); + } + + #[tokio::test] + async fn test_mountain_use_case_find_with_invalid_params() { + let mock_repo = MockTestMountainRepository::new(); + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let search_query = MountainSearchQuery { + name: None, + prefecture: Some("invalid".to_string()), + tag: None, + offset: None, + limit: None, + sort: None, + }; + let result = use_case.find(search_query).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::InvalidQueryParam); + } + + #[tokio::test] + async fn test_mountain_use_case_find_repository_error() { + let mut mock_repo = MockTestMountainRepository::new(); + mock_repo.expect_get_count().times(1).returning(|_| Ok(1)); + mock_repo + .expect_find() + .times(1) + .returning(|_| Err(anyhow::anyhow!("Database error"))); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let search_query = MountainSearchQuery { + name: None, + prefecture: None, + tag: None, + offset: None, + limit: None, + sort: None, + }; + let result = use_case.find(search_query).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::ServerError); + } + + #[tokio::test] + async fn test_mountain_use_case_find_box_success() { + let mut mock_repo = MockTestMountainRepository::new(); + mock_repo + .expect_find_box() + .times(1) + .returning(|_| Ok(vec![create_test_mountain()])); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let search_query = MountainBoxSearchQuery { + box_coordinates: "(139.0,35.0),(140.0,36.0)".to_string(), + name: None, + tag: None, + sort: None, + }; + let result = use_case.find_box(search_query).await; + + assert!(result.is_ok()); + let search_result = result.unwrap(); + assert_eq!(search_result.mountains.len(), 1); + assert_eq!(search_result.total, 1); + assert_eq!(search_result.mountains[0].name, "富士山"); + } + + #[tokio::test] + async fn test_mountain_use_case_find_box_with_invalid_coordinates() { + let mock_repo = MockTestMountainRepository::new(); + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let search_query = MountainBoxSearchQuery { + box_coordinates: "invalid_format".to_string(), + name: None, + tag: None, + sort: None, + }; + let result = use_case.find_box(search_query).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::InvalidQueryParam); + } + + #[tokio::test] + async fn test_mountain_use_case_find_box_repository_error() { + let mut mock_repo = MockTestMountainRepository::new(); + mock_repo + .expect_find_box() + .times(1) + .returning(|_| Err(anyhow::anyhow!("Database error"))); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_repo, + surrounding_mountain_repository: MockTestSurroundingMountainRepository::new(), + }; + + let use_case = MountainUseCase::new(Arc::new(mock_module)); + let search_query = MountainBoxSearchQuery { + box_coordinates: "(139.0,35.0),(140.0,36.0)".to_string(), + name: None, + tag: None, + sort: None, + }; + let result = use_case.find_box(search_query).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::ServerError); + } +} diff --git a/mountix-app/src/usecase/surrounding_mountain.rs b/mountix-app/src/usecase/surrounding_mountain.rs index ca1b840..67d3f14 100644 --- a/mountix-app/src/usecase/surrounding_mountain.rs +++ b/mountix-app/src/usecase/surrounding_mountain.rs @@ -31,7 +31,7 @@ impl SurroundingMountainUseCase { Some(mountain) => { match SurroundingMountainSearchDistance::try_from(search_query) { Ok(search_distance) => { - let distance = search_distance.0.clone(); + let distance = search_distance.0; let condition = SurroundingMountainSearchCondition::new( mountain, @@ -80,3 +80,307 @@ impl SurroundingMountainUseCase { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::surrounding_mountain::SurroundingMountainSearchQuery; + use mockall::mock; + use mountix_kernel::model::mountain::{Mountain, MountainLocation}; + use mountix_kernel::model::surrounding_mountain::{ + SurroundingMountain, SurroundingMountainLocation, SurroundingMountainSearchCondition, + }; + use mountix_kernel::model::{ErrorCode, Id}; + use mountix_kernel::repository::mountain::MountainRepository; + use mountix_kernel::repository::surrounding_mountain::SurroundingMountainRepository; + use std::sync::Arc; + + mock! { + TestSurroundingMountainRepository {} + + #[async_trait::async_trait] + impl SurroundingMountainRepository for TestSurroundingMountainRepository { + async fn find(&self, condition: SurroundingMountainSearchCondition) -> anyhow::Result>; + } + } + + mock! { + TestMountainRepository {} + + #[async_trait::async_trait] + impl MountainRepository for TestMountainRepository { + async fn get(&self, id: Id) -> anyhow::Result>; + async fn get_count(&self, search_condition: mountix_kernel::model::mountain::MountainSearchCondition) -> anyhow::Result; + async fn find(&self, search_condition: mountix_kernel::model::mountain::MountainSearchCondition) -> anyhow::Result>; + async fn find_box(&self, search_condition: mountix_kernel::model::mountain::MountainBoxSearchCondition) -> anyhow::Result>; + } + } + + struct MockRepositoriesModule { + mountain_repository: MockTestMountainRepository, + surrounding_mountain_repository: MockTestSurroundingMountainRepository, + } + + impl RepositoriesModuleExt for MockRepositoriesModule { + type MountainRepo = MockTestMountainRepository; + type SurroundingMountainRepo = MockTestSurroundingMountainRepository; + + fn mountain_repository(&self) -> &Self::MountainRepo { + &self.mountain_repository + } + + fn surrounding_mountain_repository(&self) -> &Self::SurroundingMountainRepo { + &self.surrounding_mountain_repository + } + } + + fn create_test_mountain() -> Mountain { + let id = Id::new(1); + let location = + MountainLocation::new(35.360556, 138.727778, "https://maps.gsi.go.jp".to_string()); + let data = mountix_kernel::model::mountain::MountainData { + name: "富士山".to_string(), + name_kana: "ふじさん".to_string(), + area: "関東地方".to_string(), + prefectures: vec!["静岡県".to_string(), "山梨県".to_string()], + elevation: 3776, + location, + tags: vec!["百名山".to_string()], + }; + Mountain::new(id, data) + } + + fn create_test_surrounding_mountain() -> SurroundingMountain { + let id = Id::new(2); + let location = SurroundingMountainLocation::new( + 35.300000, + 138.800000, + "https://maps.gsi.go.jp/surrounding".to_string(), + ); + let data = mountix_kernel::model::surrounding_mountain::SurroundingMountainData { + name: "周辺の山".to_string(), + name_kana: "しゅうへんのやま".to_string(), + area: "関東地方".to_string(), + prefectures: vec!["静岡県".to_string()], + elevation: 2500, + location, + tags: vec!["二百名山".to_string()], + }; + SurroundingMountain::new(id, data) + } + + #[tokio::test] + async fn test_surrounding_mountain_use_case_find_success() { + let mut mock_mountain_repo = MockTestMountainRepository::new(); + mock_mountain_repo + .expect_get() + .with(mockall::predicate::function(|id: &Id| { + id.value == 1 + })) + .times(1) + .returning(|_| Ok(Some(create_test_mountain()))); + + let mut mock_surrounding_repo = MockTestSurroundingMountainRepository::new(); + mock_surrounding_repo + .expect_find() + .times(1) + .returning(|_| Ok(vec![create_test_surrounding_mountain()])); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_mountain_repo, + surrounding_mountain_repository: mock_surrounding_repo, + }; + + let use_case = SurroundingMountainUseCase::new(Arc::new(mock_module)); + let search_query = SurroundingMountainSearchQuery { + distance: Some("10000".to_string()), + }; + let result = use_case.find("1".to_string(), search_query).await; + + assert!(result.is_ok()); + let search_result = result.unwrap(); + assert_eq!(search_result.mountains.len(), 1); + assert_eq!(search_result.distance, 10000); + assert_eq!(search_result.mountains[0].name, "周辺の山"); + } + + #[tokio::test] + async fn test_surrounding_mountain_use_case_find_mountain_not_found() { + let mut mock_mountain_repo = MockTestMountainRepository::new(); + mock_mountain_repo + .expect_get() + .with(mockall::predicate::function(|id: &Id| { + id.value == 999 + })) + .times(1) + .returning(|_| Ok(None)); + + let mock_surrounding_repo = MockTestSurroundingMountainRepository::new(); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_mountain_repo, + surrounding_mountain_repository: mock_surrounding_repo, + }; + + let use_case = SurroundingMountainUseCase::new(Arc::new(mock_module)); + let search_query = SurroundingMountainSearchQuery { + distance: Some("5000".to_string()), + }; + let result = use_case.find("999".to_string(), search_query).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::ServerError); + } + + #[tokio::test] + async fn test_surrounding_mountain_use_case_find_invalid_id() { + let mock_mountain_repo = MockTestMountainRepository::new(); + let mock_surrounding_repo = MockTestSurroundingMountainRepository::new(); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_mountain_repo, + surrounding_mountain_repository: mock_surrounding_repo, + }; + + let use_case = SurroundingMountainUseCase::new(Arc::new(mock_module)); + let search_query = SurroundingMountainSearchQuery { + distance: Some("5000".to_string()), + }; + let result = use_case.find("invalid".to_string(), search_query).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::ServerError); + } + + #[tokio::test] + async fn test_surrounding_mountain_use_case_find_repository_error() { + let mut mock_mountain_repo = MockTestMountainRepository::new(); + mock_mountain_repo + .expect_get() + .with(mockall::predicate::function(|id: &Id| { + id.value == 1 + })) + .times(1) + .returning(|_| Err(anyhow::anyhow!("Database error"))); + + let mock_surrounding_repo = MockTestSurroundingMountainRepository::new(); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_mountain_repo, + surrounding_mountain_repository: mock_surrounding_repo, + }; + + let use_case = SurroundingMountainUseCase::new(Arc::new(mock_module)); + let search_query = SurroundingMountainSearchQuery { + distance: Some("5000".to_string()), + }; + let result = use_case.find("1".to_string(), search_query).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::ServerError); + } + + #[tokio::test] + async fn test_surrounding_mountain_use_case_find_surrounding_repository_error() { + let mut mock_mountain_repo = MockTestMountainRepository::new(); + mock_mountain_repo + .expect_get() + .with(mockall::predicate::function(|id: &Id| { + id.value == 1 + })) + .times(1) + .returning(|_| Ok(Some(create_test_mountain()))); + + let mut mock_surrounding_repo = MockTestSurroundingMountainRepository::new(); + mock_surrounding_repo + .expect_find() + .times(1) + .returning(|_| Err(anyhow::anyhow!("Surrounding database error"))); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_mountain_repo, + surrounding_mountain_repository: mock_surrounding_repo, + }; + + let use_case = SurroundingMountainUseCase::new(Arc::new(mock_module)); + let search_query = SurroundingMountainSearchQuery { + distance: Some("5000".to_string()), + }; + let result = use_case.find("1".to_string(), search_query).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code, ErrorCode::ServerError); + } + + #[tokio::test] + async fn test_surrounding_mountain_use_case_find_with_default_distance() { + let mut mock_mountain_repo = MockTestMountainRepository::new(); + mock_mountain_repo + .expect_get() + .with(mockall::predicate::function(|id: &Id| { + id.value == 1 + })) + .times(1) + .returning(|_| Ok(Some(create_test_mountain()))); + + let mut mock_surrounding_repo = MockTestSurroundingMountainRepository::new(); + mock_surrounding_repo + .expect_find() + .times(1) + .returning(|_| Ok(vec![create_test_surrounding_mountain()])); + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_mountain_repo, + surrounding_mountain_repository: mock_surrounding_repo, + }; + + let use_case = SurroundingMountainUseCase::new(Arc::new(mock_module)); + let search_query = SurroundingMountainSearchQuery { + distance: None, // Use default distance + }; + let result = use_case.find("1".to_string(), search_query).await; + + assert!(result.is_ok()); + let search_result = result.unwrap(); + assert_eq!(search_result.mountains.len(), 1); + assert_eq!(search_result.distance, 5000); // Default distance + } + + #[tokio::test] + async fn test_surrounding_mountain_use_case_find_empty_results() { + let mut mock_mountain_repo = MockTestMountainRepository::new(); + mock_mountain_repo + .expect_get() + .with(mockall::predicate::function(|id: &Id| { + id.value == 1 + })) + .times(1) + .returning(|_| Ok(Some(create_test_mountain()))); + + let mut mock_surrounding_repo = MockTestSurroundingMountainRepository::new(); + mock_surrounding_repo + .expect_find() + .times(1) + .returning(|_| Ok(vec![])); // No surrounding mountains found + + let mock_module = MockRepositoriesModule { + mountain_repository: mock_mountain_repo, + surrounding_mountain_repository: mock_surrounding_repo, + }; + + let use_case = SurroundingMountainUseCase::new(Arc::new(mock_module)); + let search_query = SurroundingMountainSearchQuery { + distance: Some("1000".to_string()), + }; + let result = use_case.find("1".to_string(), search_query).await; + + assert!(result.is_ok()); + let search_result = result.unwrap(); + assert_eq!(search_result.mountains.len(), 0); + assert_eq!(search_result.distance, 1000); + } +} diff --git a/mountix-driver/Cargo.toml b/mountix-driver/Cargo.toml index 9122355..a002007 100644 --- a/mountix-driver/Cargo.toml +++ b/mountix-driver/Cargo.toml @@ -16,3 +16,10 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] } dotenvy = "0.15.7" tower = "0.5.2" tower-http = { version = "0.6.6", features = ["cors", "trace"] } + +[dev-dependencies] +tokio-test = "0.4.4" +mockall = "0.13.0" +tower = "0.5.2" +hyper = "1.6.0" +serde_json = "1.0.140" diff --git a/mountix-driver/src/model/information.rs b/mountix-driver/src/model/information.rs index fcf141a..09b7417 100644 --- a/mountix-driver/src/model/information.rs +++ b/mountix-driver/src/model/information.rs @@ -50,3 +50,86 @@ impl JsonEndpoint { Self { resource, url } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_json_endpoint_new() { + let resource = "mountains".to_string(); + let url = "https://api.example.com/mountains".to_string(); + let endpoint = JsonEndpoint::new(resource.clone(), url.clone()); + + assert_eq!(endpoint.resource, resource); + assert_eq!(endpoint.url, url); + } + + #[test] + fn test_json_endpoint_structure() { + let endpoint = JsonEndpoint { + resource: "test".to_string(), + url: "https://test.com".to_string(), + }; + + assert_eq!(endpoint.resource, "test"); + assert_eq!(endpoint.url, "https://test.com"); + } + + #[test] + fn test_json_information_response_with_env_vars() { + // Set test environment variables + std::env::set_var("MOUNTAINS_URL", "https://test.example.com/mountains"); + std::env::set_var("DOCUMENTS_URL", "https://test.example.com/docs"); + + let response = JsonInformationResponse::default(); + + assert_eq!( + response.about, + "日本の主な山岳をJSON形式で提供するAPIです。" + ); + assert_eq!(response.endpoints.len(), 1); + assert_eq!(response.endpoints[0].resource, "mountains"); + assert_eq!( + response.endpoints[0].url, + "https://test.example.com/mountains" + ); + assert_eq!(response.documents, "https://test.example.com/docs"); + + // Clean up environment variables + std::env::remove_var("MOUNTAINS_URL"); + std::env::remove_var("DOCUMENTS_URL"); + } + + #[test] + fn test_json_endpoint_multiple_resources() { + let endpoints = vec![ + JsonEndpoint::new( + "mountains".to_string(), + "https://api.com/mountains".to_string(), + ), + JsonEndpoint::new("health".to_string(), "https://api.com/health".to_string()), + ]; + + assert_eq!(endpoints.len(), 2); + assert_eq!(endpoints[0].resource, "mountains"); + assert_eq!(endpoints[1].resource, "health"); + } + + #[test] + fn test_json_information_response_structure() { + // Test structure without environment dependencies + std::env::set_var("MOUNTAINS_URL", "https://example.com/mountains"); + std::env::set_var("DOCUMENTS_URL", "https://example.com/docs"); + + let response = JsonInformationResponse::default(); + + // Test that all required fields are present + assert!(!response.about.is_empty()); + assert!(!response.endpoints.is_empty()); + assert!(!response.documents.is_empty()); + + std::env::remove_var("MOUNTAINS_URL"); + std::env::remove_var("DOCUMENTS_URL"); + } +} diff --git a/mountix-driver/src/model/surrounding_mountain.rs b/mountix-driver/src/model/surrounding_mountain.rs index 71ecef7..bb0db9a 100644 --- a/mountix-driver/src/model/surrounding_mountain.rs +++ b/mountix-driver/src/model/surrounding_mountain.rs @@ -74,7 +74,7 @@ impl From for JsonSurroundingMountainResponse #[derive(Debug, Deserialize)] pub struct SurroundingMountainSearchQueryParam { - distance: Option, + pub distance: Option, } impl From for SurroundingMountainSearchQuery { diff --git a/mountix-driver/src/routes/health.rs b/mountix-driver/src/routes/health.rs index 5a4464b..39967d6 100644 --- a/mountix-driver/src/routes/health.rs +++ b/mountix-driver/src/routes/health.rs @@ -26,3 +26,19 @@ pub async fn hc_mongodb( StatusCode::SERVICE_UNAVAILABLE }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_hc_endpoint() { + let response = hc().await; + + // Test that health check returns NO_CONTENT status + assert_eq!(response.into_response().status(), StatusCode::NO_CONTENT); + } + + // Note: MongoDB health check tests would require actual database connection + // The hc_mongodb function tests are omitted as they need real infrastructure +} diff --git a/mountix-driver/src/routes/information.rs b/mountix-driver/src/routes/information.rs index f6b12ae..cc3f6e8 100644 --- a/mountix-driver/src/routes/information.rs +++ b/mountix-driver/src/routes/information.rs @@ -9,3 +9,25 @@ pub async fn info() -> impl IntoResponse { let json: JsonInformationResponse = Default::default(); (StatusCode::OK, Json(json)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_info_endpoint() { + // Set test environment variables + std::env::set_var("MOUNTAINS_URL", "https://test.example.com/mountains"); + std::env::set_var("DOCUMENTS_URL", "https://test.example.com/docs"); + + let response = info().await; + let response = response.into_response(); + + // Test that information endpoint returns OK status + assert_eq!(response.status(), StatusCode::OK); + + // Clean up environment variables + std::env::remove_var("MOUNTAINS_URL"); + std::env::remove_var("DOCUMENTS_URL"); + } +} diff --git a/mountix-driver/src/routes/mountain.rs b/mountix-driver/src/routes/mountain.rs index f20b724..b25d953 100644 --- a/mountix-driver/src/routes/mountain.rs +++ b/mountix-driver/src/routes/mountain.rs @@ -19,20 +19,18 @@ pub async fn get_mountain( ) -> Result { let res = modules.mountain_use_case().get(mountain_id).await; match res { - Ok(sm) => { - return match sm { - Some(sm) => { - tracing::info!("Succeeded to get mountain by id ({}).", &sm.id); + Ok(sm) => match sm { + Some(sm) => { + tracing::info!("Succeeded to get mountain by id ({}).", &sm.id); - let json: JsonMountain = sm.into(); - Ok((StatusCode::OK, Json(json))) - } - None => { - tracing::info!("Succeeded to get mountain by id (None)."); - Err(MountainError::NotFound) - } + let json: JsonMountain = sm.into(); + Ok((StatusCode::OK, Json(json))) } - } + None => { + tracing::info!("Succeeded to get mountain by id (None)."); + Err(MountainError::NotFound) + } + }, Err(get_ex) => { error!("{:?}", get_ex); if get_ex.error_code == ErrorCode::InvalidId { @@ -108,3 +106,108 @@ pub async fn find_mountains_by_box( } } } + +#[cfg(test)] +mod tests { + use crate::model::mountain::JsonMountain; + use crate::model::JsonErrorResponse; + use mountix_app::model::mountain::{SearchedMountain, SearchedMountainLocation}; + use mountix_kernel::model::mountain::{MountainFindException, MountainGetException}; + use mountix_kernel::model::ErrorCode; + + fn create_test_searched_mountain() -> SearchedMountain { + SearchedMountain { + id: 1, + name: "富士山".to_string(), + name_kana: "ふじさん".to_string(), + area: "関東地方".to_string(), + prefectures: vec!["静岡県".to_string(), "山梨県".to_string()], + elevation: 3776, + location: SearchedMountainLocation { + latitude: 35.360556, + longitude: 138.727778, + gsi_url: "https://maps.gsi.go.jp/fuji".to_string(), + }, + tags: vec!["百名山".to_string()], + } + } + + #[test] + fn test_json_mountain_conversion() { + let searched_mountain = create_test_searched_mountain(); + let json_mountain: JsonMountain = searched_mountain.into(); + + assert_eq!(json_mountain.id, 1); + assert_eq!(json_mountain.name, "富士山"); + assert_eq!(json_mountain.name_kana, "ふじさん"); + assert_eq!(json_mountain.elevation, 3776); + assert_eq!(json_mountain.location.latitude, 35.360556); + assert_eq!(json_mountain.location.longitude, 138.727778); + } + + #[test] + fn test_mountain_get_exception_not_found() { + let exception = MountainGetException::new(ErrorCode::InvalidId); + assert_eq!(exception.error_code, ErrorCode::InvalidId); + } + + #[test] + fn test_mountain_get_exception_server_error() { + let exception = MountainGetException::new(ErrorCode::ServerError); + assert_eq!(exception.error_code, ErrorCode::ServerError); + } + + #[test] + fn test_mountain_find_exception_invalid_params() { + let messages = vec!["Invalid parameter".to_string()]; + let exception = MountainFindException::new(ErrorCode::InvalidQueryParam, messages.clone()); + assert_eq!(exception.error_code, ErrorCode::InvalidQueryParam); + assert_eq!(exception.messages, messages); + } + + #[test] + fn test_mountain_find_exception_with_error_code() { + let exception = MountainFindException::new_with_error_code(ErrorCode::ServerError); + assert_eq!(exception.error_code, ErrorCode::ServerError); + assert!(!exception.messages.is_empty()); + } + + #[test] + fn test_json_error_response_creation() { + let messages = vec!["Error message".to_string()]; + let _response = JsonErrorResponse::new(messages.clone()); + // Cannot test private field directly, but test that the object was created + assert!(true); // JsonErrorResponse was created successfully + } + + #[test] + fn test_json_mountains_response_structure() { + let searched_mountain = create_test_searched_mountain(); + let mountains = vec![searched_mountain]; + + // Test that the conversion would work for JsonMountainsResponse + let json_mountains: Vec = mountains.into_iter().map(|m| m.into()).collect(); + assert_eq!(json_mountains.len(), 1); + assert_eq!(json_mountains[0].name, "富士山"); + } + + // Unit tests for model conversions and error handling + // These don't require database connections or HTTP servers + #[test] + fn test_mountain_error_handling() { + // Test InvalidId error + let invalid_id_error = MountainGetException::new(ErrorCode::InvalidId); + assert_eq!(invalid_id_error.error_code, ErrorCode::InvalidId); + + // Test ServerError + let server_error = MountainGetException::new(ErrorCode::ServerError); + assert_eq!(server_error.error_code, ErrorCode::ServerError); + } + + #[test] + fn test_mountain_find_error_handling() { + let find_error = MountainFindException::new_with_error_code(ErrorCode::ServerError); + assert_eq!(find_error.error_code, ErrorCode::ServerError); + assert!(!find_error.messages.is_empty()); + } +} diff --git a/mountix-driver/src/routes/surrounding_mountain.rs b/mountix-driver/src/routes/surrounding_mountain.rs index d854439..6999374 100644 --- a/mountix-driver/src/routes/surrounding_mountain.rs +++ b/mountix-driver/src/routes/surrounding_mountain.rs @@ -45,3 +45,145 @@ pub async fn find_surroundings( } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::surrounding_mountain::JsonSurroundingMountain; + use mountix_app::model::surrounding_mountain::{ + SearchedSurroundingMountain, SearchedSurroundingMountainLocation, + }; + use mountix_kernel::model::surrounding_mountain::SurroundingMountainFindException; + use mountix_kernel::model::ErrorCode; + + fn create_test_searched_surrounding_mountain() -> SearchedSurroundingMountain { + SearchedSurroundingMountain { + id: 2, + name: "周辺の山".to_string(), + name_kana: "しゅうへんのやま".to_string(), + area: "関東地方".to_string(), + prefectures: vec!["静岡県".to_string()], + elevation: 2500, + location: SearchedSurroundingMountainLocation { + latitude: 35.300000, + longitude: 138.800000, + gsi_url: "https://maps.gsi.go.jp/surrounding".to_string(), + }, + tags: vec!["二百名山".to_string()], + } + } + + #[test] + fn test_json_surrounding_mountain_conversion() { + let searched_surrounding_mountain = create_test_searched_surrounding_mountain(); + let json_mountain: JsonSurroundingMountain = searched_surrounding_mountain.into(); + + assert_eq!(json_mountain.id, 2); + assert_eq!(json_mountain.name, "周辺の山"); + assert_eq!(json_mountain.name_kana, "しゅうへんのやま"); + assert_eq!(json_mountain.elevation, 2500); + assert_eq!(json_mountain.location.latitude, 35.300000); + assert_eq!(json_mountain.location.longitude, 138.800000); + } + + #[test] + fn test_surrounding_mountain_find_exception_server_error() { + let exception = + SurroundingMountainFindException::new_with_error_code(ErrorCode::ServerError); + assert_eq!(exception.error_code, ErrorCode::ServerError); + assert!(!exception.messages.is_empty()); + } + + #[test] + fn test_surrounding_mountain_find_exception_invalid_params() { + let messages = vec!["Invalid distance parameter".to_string()]; + let exception = + SurroundingMountainFindException::new(ErrorCode::InvalidQueryParam, messages.clone()); + assert_eq!(exception.error_code, ErrorCode::InvalidQueryParam); + assert_eq!(exception.messages, messages); + } + + #[test] + fn test_json_surrounding_mountain_response_structure() { + let searched_mountain = create_test_searched_surrounding_mountain(); + let mountains = vec![searched_mountain]; + + // Test that the conversion would work for JsonSurroundingMountainResponse + let json_mountains: Vec = + mountains.into_iter().map(|m| m.into()).collect(); + assert_eq!(json_mountains.len(), 1); + assert_eq!(json_mountains[0].name, "周辺の山"); + assert_eq!(json_mountains[0].elevation, 2500); + } + + #[test] + fn test_surrounding_mountain_error_handling() { + // Test ServerError + let server_error = + SurroundingMountainFindException::new_with_error_code(ErrorCode::ServerError); + assert_eq!(server_error.error_code, ErrorCode::ServerError); + + // Test InvalidQueryParam error + let param_messages = vec!["Distance out of range".to_string()]; + let param_error = SurroundingMountainFindException::new( + ErrorCode::InvalidQueryParam, + param_messages.clone(), + ); + assert_eq!(param_error.error_code, ErrorCode::InvalidQueryParam); + assert_eq!(param_error.messages, param_messages); + } + + #[test] + fn test_surrounding_mountain_location_json_conversion() { + let location = SearchedSurroundingMountainLocation { + latitude: 35.678, + longitude: 139.765, + gsi_url: "https://example.com".to_string(), + }; + + // Test individual field access for JSON conversion + assert_eq!(location.latitude, 35.678); + assert_eq!(location.longitude, 139.765); + assert_eq!(location.gsi_url, "https://example.com"); + } + + #[test] + fn test_surrounding_mountain_search_query_param_structure() { + // Test that query parameter structure is handled correctly + use crate::model::surrounding_mountain::SurroundingMountainSearchQueryParam; + + // Test with distance parameter + let query_param = SurroundingMountainSearchQueryParam { + distance: Some("10000".to_string()), + }; + let _search_query: SurroundingMountainSearchQuery = query_param.into(); + // The actual conversion logic would be tested here in a real implementation + assert!(true); // Placeholder - tests the structure exists + } + + #[test] + fn test_surrounding_mountain_error_messages() { + let exception = + SurroundingMountainFindException::new_with_error_code(ErrorCode::ServerError); + assert!(!exception.messages.is_empty()); + // Test that error messages contain meaningful content + assert!(exception.messages[0].contains("周辺の山岳情報を検索中にエラーが発生しました")); + } + + #[test] + fn test_surrounding_mountain_multiple_results() { + let mountain1 = create_test_searched_surrounding_mountain(); + let mut mountain2 = create_test_searched_surrounding_mountain(); + mountain2.id = 3; + mountain2.name = "別の周辺の山".to_string(); + + let mountains = vec![mountain1, mountain2]; + let json_mountains: Vec = + mountains.into_iter().map(|m| m.into()).collect(); + + assert_eq!(json_mountains.len(), 2); + assert_eq!(json_mountains[0].id, 2); + assert_eq!(json_mountains[1].id, 3); + assert_eq!(json_mountains[1].name, "別の周辺の山"); + } +} diff --git a/mountix-driver/src/startup/mod.rs b/mountix-driver/src/startup/mod.rs index 7e49efc..70156b6 100644 --- a/mountix-driver/src/startup/mod.rs +++ b/mountix-driver/src/startup/mod.rs @@ -97,3 +97,78 @@ fn init_addr() -> (IpAddr, u16) { tracing::debug!("Init ip address."); (ip_addr, port) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Shared lock to ensure environment variable tests run sequentially + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn test_init_addr_with_valid_env_vars() { + let _lock = ENV_LOCK.lock().unwrap(); + + // Set test environment variables + std::env::set_var("HOST", "127.0.0.1"); + std::env::set_var("PORT", "8080"); + + let (ip_addr, port) = init_addr(); + + assert_eq!(ip_addr.to_string(), "127.0.0.1"); + assert_eq!(port, 8080); + + // Clean up environment variables + std::env::remove_var("HOST"); + std::env::remove_var("PORT"); + } + + #[test] + fn test_init_addr_with_different_host() { + let _lock = ENV_LOCK.lock().unwrap(); + + std::env::set_var("HOST", "0.0.0.0"); + std::env::set_var("PORT", "3000"); + + let (ip_addr, port) = init_addr(); + + assert_eq!(ip_addr.to_string(), "0.0.0.0"); + assert_eq!(port, 3000); + + std::env::remove_var("HOST"); + std::env::remove_var("PORT"); + } + + #[test] + fn test_socket_addr_creation() { + // Test SocketAddr creation from IP and port using manual construction + // This avoids relying on environment variables that might be in an inconsistent state + let ip_addr: IpAddr = "192.168.1.1".parse().unwrap(); + let port: u16 = 9000; + let socket_addr = SocketAddr::from((ip_addr, port)); + + assert_eq!(socket_addr.ip().to_string(), "192.168.1.1"); + assert_eq!(socket_addr.port(), 9000); + } + + #[test] + fn test_env_var_parsing() { + let _lock = ENV_LOCK.lock().unwrap(); + + // Test environment variable parsing for different scenarios + std::env::set_var("HOST", "::1"); // IPv6 localhost + std::env::set_var("PORT", "8443"); + + let (ip_addr, port) = init_addr(); + + assert_eq!(ip_addr.to_string(), "::1"); + assert_eq!(port, 8443); + + std::env::remove_var("HOST"); + std::env::remove_var("PORT"); + } + + // Note: Full startup tests would require actual server initialization + // These tests focus on individual components and configuration +} diff --git a/mountix-kernel/Cargo.toml b/mountix-kernel/Cargo.toml index 5e58e0f..5446ddb 100644 --- a/mountix-kernel/Cargo.toml +++ b/mountix-kernel/Cargo.toml @@ -8,3 +8,6 @@ anyhow = "1.0.98" async-trait = "0.1.88" serde = { version = "1.0.219", features = ["derive"] } regex = "1.11.1" + +[dev-dependencies] +tokio-test = "0.4.4" diff --git a/mountix-kernel/src/model/mod.rs b/mountix-kernel/src/model/mod.rs index f4a2026..993ed77 100644 --- a/mountix-kernel/src/model/mod.rs +++ b/mountix-kernel/src/model/mod.rs @@ -3,7 +3,7 @@ use std::marker::PhantomData; pub mod mountain; pub mod surrounding_mountain; -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct Id { pub value: i32, _marker: PhantomData, diff --git a/mountix-kernel/src/model/mountain.rs b/mountix-kernel/src/model/mountain.rs index 30ff5c4..a1ae524 100644 --- a/mountix-kernel/src/model/mountain.rs +++ b/mountix-kernel/src/model/mountain.rs @@ -22,26 +22,27 @@ pub struct MountainLocation { pub gsi_url: String, } +pub struct MountainData { + pub name: String, + pub name_kana: String, + pub area: String, + pub prefectures: Vec, + pub elevation: u32, + pub location: MountainLocation, + pub tags: Vec, +} + impl Mountain { - pub fn new( - id: Id, - name: String, - name_kana: String, - area: String, - prefectures: Vec, - elevation: u32, - location: MountainLocation, - tags: Vec, - ) -> Self { + pub fn new(id: Id, data: MountainData) -> Self { Self { id, - name, - name_kana, - area, - prefectures, - elevation, - location, - tags, + name: data.name, + name_kana: data.name_kana, + area: data.area, + prefectures: data.prefectures, + elevation: data.elevation, + location: data.location, + tags: data.tags, } } } @@ -266,6 +267,7 @@ pub struct MountainBoxSearchCondition { pub sort: MountainSortCondition, } +#[derive(Debug)] pub struct MountainBoxCoordinates { pub bottom_left: (f64, f64), pub upper_right: (f64, f64), @@ -288,7 +290,7 @@ impl TryFrom for MountainBoxCoordinates { .ok_or(Self::Error::msg("Invalid bottom left longitude."))? .as_str() .parse::()?; - if bottom_left_lng > 180.0 || bottom_left_lng < -180.0 { + if !(-180.0..=180.0).contains(&bottom_left_lng) { return Err(Self::Error::msg("Invalid bottom left longitude.")); } @@ -297,7 +299,7 @@ impl TryFrom for MountainBoxCoordinates { .ok_or(Self::Error::msg("Invalid bottom left latitude."))? .as_str() .parse::()?; - if bottom_left_lat > 90.0 || bottom_left_lat < -90.0 { + if !(-90.0..=90.0).contains(&bottom_left_lat) { return Err(Self::Error::msg("Invalid bottom left latitude.")); } @@ -306,7 +308,7 @@ impl TryFrom for MountainBoxCoordinates { .ok_or(Self::Error::msg("Invalid upper right longitude."))? .as_str() .parse::()?; - if upper_right_lng > 180.0 || upper_right_lng < -180.0 { + if !(-180.0..=180.0).contains(&upper_right_lng) { return Err(Self::Error::msg("Invalid upper right longitude.")); } @@ -315,7 +317,7 @@ impl TryFrom for MountainBoxCoordinates { .ok_or(Self::Error::msg("Invalid upper right latitude."))? .as_str() .parse::()?; - if upper_right_lat > 90.0 || upper_right_lat < -90.0 { + if !(-90.0..=90.0).contains(&upper_right_lat) { return Err(Self::Error::msg("Invalid upper right latitude.")); } @@ -366,3 +368,213 @@ impl MountainFindException { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mountain_prefecture_try_from_valid_id() { + let result = MountainPrefecture::try_from("1".to_string()); + assert!(result.is_ok()); + let prefecture = result.unwrap(); + assert_eq!(prefecture.id, 1); + assert_eq!(prefecture.name, "北海道"); + } + + #[test] + fn test_mountain_prefecture_try_from_valid_tokyo() { + let result = MountainPrefecture::try_from("13".to_string()); + assert!(result.is_ok()); + let prefecture = result.unwrap(); + assert_eq!(prefecture.id, 13); + assert_eq!(prefecture.name, "東京都"); + } + + #[test] + fn test_mountain_prefecture_try_from_invalid_id() { + let result = MountainPrefecture::try_from("48".to_string()); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Invalid prefecture value."); + } + + #[test] + fn test_mountain_prefecture_try_from_invalid_string() { + let result = MountainPrefecture::try_from("invalid".to_string()); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Invalid prefecture value."); + } + + #[test] + fn test_mountain_prefecture_try_from_zero() { + let result = MountainPrefecture::try_from("0".to_string()); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Invalid prefecture value."); + } + + #[test] + fn test_mountain_tag_try_from_valid_hyakumeizan() { + let result = MountainTag::try_from("1".to_string()); + assert!(result.is_ok()); + let tag = result.unwrap(); + assert_eq!(tag.id, 1); + assert_eq!(tag.name, "百名山"); + } + + #[test] + fn test_mountain_tag_try_from_valid_nihyakumeizan() { + let result = MountainTag::try_from("2".to_string()); + assert!(result.is_ok()); + let tag = result.unwrap(); + assert_eq!(tag.id, 2); + assert_eq!(tag.name, "二百名山"); + } + + #[test] + fn test_mountain_tag_try_from_invalid_id() { + let result = MountainTag::try_from("3".to_string()); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Invalid tag value."); + } + + #[test] + fn test_mountain_tag_try_from_invalid_string() { + let result = MountainTag::try_from("invalid".to_string()); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Invalid tag value."); + } + + #[test] + fn test_mountain_tag_try_from_zero() { + let result = MountainTag::try_from("0".to_string()); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Invalid tag value."); + } + + #[test] + fn test_mountain_sort_condition_try_from_id_asc() { + let result = MountainSortCondition::try_from("id.asc".to_string()); + assert!(result.is_ok()); + let sort = result.unwrap(); + assert_eq!(sort.key.to_key(), "_id"); + assert_eq!(sort.order.to_value(), 1); + } + + #[test] + fn test_mountain_sort_condition_try_from_id_desc() { + let result = MountainSortCondition::try_from("id.desc".to_string()); + assert!(result.is_ok()); + let sort = result.unwrap(); + assert_eq!(sort.key.to_key(), "_id"); + assert_eq!(sort.order.to_value(), -1); + } + + #[test] + fn test_mountain_sort_condition_try_from_elevation_asc() { + let result = MountainSortCondition::try_from("elevation.asc".to_string()); + assert!(result.is_ok()); + let sort = result.unwrap(); + assert_eq!(sort.key.to_key(), "elevation"); + assert_eq!(sort.order.to_value(), 1); + } + + #[test] + fn test_mountain_sort_condition_try_from_name_desc() { + let result = MountainSortCondition::try_from("name.desc".to_string()); + assert!(result.is_ok()); + let sort = result.unwrap(); + assert_eq!(sort.key.to_key(), "name_kana"); + assert_eq!(sort.order.to_value(), -1); + } + + #[test] + fn test_mountain_sort_condition_try_from_invalid() { + let result = MountainSortCondition::try_from("invalid".to_string()); + assert!(result.is_err()); + } + + #[test] + fn test_mountain_sort_condition_default() { + let sort = MountainSortCondition::default(); + assert_eq!(sort.key.to_key(), "_id"); + assert_eq!(sort.order.to_value(), 1); + } + + #[test] + fn test_mountain_box_coordinates_try_from_valid() { + let box_param = "(139.0,35.0),(140.0,36.0)".to_string(); + let result = MountainBoxCoordinates::try_from(box_param); + assert!(result.is_ok()); + let coords = result.unwrap(); + assert_eq!(coords.bottom_left, (139.0, 35.0)); + assert_eq!(coords.upper_right, (140.0, 36.0)); + } + + #[test] + fn test_mountain_box_coordinates_try_from_negative_coords() { + let box_param = "(-139.5,-35.5),(-138.5,-34.5)".to_string(); + let result = MountainBoxCoordinates::try_from(box_param); + assert!(result.is_ok()); + let coords = result.unwrap(); + assert_eq!(coords.bottom_left, (-139.5, -35.5)); + assert_eq!(coords.upper_right, (-138.5, -34.5)); + } + + #[test] + fn test_mountain_box_coordinates_try_from_boundary_longitude() { + let box_param = "(-180.0,35.0),(180.0,36.0)".to_string(); + let result = MountainBoxCoordinates::try_from(box_param); + assert!(result.is_ok()); + let coords = result.unwrap(); + assert_eq!(coords.bottom_left, (-180.0, 35.0)); + assert_eq!(coords.upper_right, (180.0, 36.0)); + } + + #[test] + fn test_mountain_box_coordinates_try_from_boundary_latitude() { + let box_param = "(139.0,-90.0),(140.0,90.0)".to_string(); + let result = MountainBoxCoordinates::try_from(box_param); + assert!(result.is_ok()); + let coords = result.unwrap(); + assert_eq!(coords.bottom_left, (139.0, -90.0)); + assert_eq!(coords.upper_right, (140.0, 90.0)); + } + + #[test] + fn test_mountain_box_coordinates_try_from_invalid_longitude() { + let box_param = "(181.0,35.0),(140.0,36.0)".to_string(); + let result = MountainBoxCoordinates::try_from(box_param); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Invalid bottom left longitude." + ); + } + + #[test] + fn test_mountain_box_coordinates_try_from_invalid_latitude() { + let box_param = "(139.0,91.0),(140.0,36.0)".to_string(); + let result = MountainBoxCoordinates::try_from(box_param); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Invalid bottom left latitude." + ); + } + + #[test] + fn test_mountain_box_coordinates_try_from_invalid_format() { + let box_param = "invalid_format".to_string(); + let result = MountainBoxCoordinates::try_from(box_param); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Invalid box parameter."); + } + + #[test] + fn test_mountain_box_coordinates_try_from_missing_coordinates() { + let box_param = "(139.0,35.0)".to_string(); + let result = MountainBoxCoordinates::try_from(box_param); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Invalid box parameter."); + } +} diff --git a/mountix-kernel/src/model/surrounding_mountain.rs b/mountix-kernel/src/model/surrounding_mountain.rs index 24008aa..520b1fd 100644 --- a/mountix-kernel/src/model/surrounding_mountain.rs +++ b/mountix-kernel/src/model/surrounding_mountain.rs @@ -25,26 +25,27 @@ pub struct SurroundingMountainLocation { pub gsi_url: String, } +pub struct SurroundingMountainData { + pub name: String, + pub name_kana: String, + pub area: String, + pub prefectures: Vec, + pub elevation: u32, + pub location: SurroundingMountainLocation, + pub tags: Vec, +} + impl SurroundingMountain { - pub fn new( - id: Id, - name: String, - name_kana: String, - area: String, - prefectures: Vec, - elevation: u32, - location: SurroundingMountainLocation, - tags: Vec, - ) -> Self { + pub fn new(id: Id, data: SurroundingMountainData) -> Self { Self { id, - name, - name_kana, - area, - prefectures, - elevation, - location, - tags, + name: data.name, + name_kana: data.name_kana, + area: data.area, + prefectures: data.prefectures, + elevation: data.elevation, + location: data.location, + tags: data.tags, } } } @@ -121,3 +122,69 @@ impl SurroundingMountainFindException { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::mountain::{Mountain, MountainLocation}; + + fn create_test_mountain() -> Mountain { + let id = Id::new(1); + let location = + MountainLocation::new(35.360556, 138.727778, "https://maps.gsi.go.jp".to_string()); + let data = crate::model::mountain::MountainData { + name: "富士山".to_string(), + name_kana: "ふじさん".to_string(), + area: "関東地方".to_string(), + prefectures: vec!["静岡県".to_string(), "山梨県".to_string()], + elevation: 3776, + location, + tags: vec!["百名山".to_string()], + }; + Mountain::new(id, data) + } + + #[test] + fn test_surrounding_mountain_search_distance_default() { + let distance = SurroundingMountainSearchDistance::default(); + assert_eq!(distance.0, 5000); + } + + #[test] + fn test_surrounding_mountain_search_distance_with_env_var() { + std::env::set_var("DEFAULT_DISTANCE", "8000"); + let distance = SurroundingMountainSearchDistance::default(); + assert_eq!(distance.0, 8000); + std::env::remove_var("DEFAULT_DISTANCE"); + } + + #[test] + fn test_surrounding_mountain_search_distance_with_invalid_env_var() { + std::env::set_var("DEFAULT_DISTANCE", "invalid"); + let distance = SurroundingMountainSearchDistance::default(); + assert_eq!(distance.0, 5000); + std::env::remove_var("DEFAULT_DISTANCE"); + } + + #[test] + fn test_surrounding_mountain_search_condition_new() { + let mountain = create_test_mountain(); + let distance = SurroundingMountainSearchDistance::new(15000); + let condition = SurroundingMountainSearchCondition::new(mountain, distance); + + assert_eq!(condition.mountain.id.value, 1); + assert_eq!(condition.distance.0, 15000); + } + + #[test] + fn test_surrounding_mountain_find_exception_new_with_multiple_messages() { + let messages = vec!["First error".to_string(), "Second error".to_string()]; + let exception = + SurroundingMountainFindException::new(ErrorCode::InvalidQueryParam, messages.clone()); + + assert_eq!(exception.error_code, ErrorCode::InvalidQueryParam); + assert_eq!(exception.messages.len(), 2); + assert_eq!(exception.messages[0], "First error"); + assert_eq!(exception.messages[1], "Second error"); + } +}