use std::sync::Arc;
use async_trait::async_trait;
use jpzip::{is_valid_zipcode, Cache, Error, JpzipClient};
use serde_json::json;
use tokio::sync::Mutex;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn entry_json(pref: &str, city: &str) -> serde_json::Value {
json!({
"prefecture": pref,
"prefecture_kana": "カナガワケン",
"prefecture_roma": "Kanagawa",
"prefecture_code": "14",
"city": city,
"city_kana": "ヨコハマシナカク",
"city_roma": "Yokohama Shi Naka Ku",
"city_code": "14104",
"towns": [
{"town": "本町", "kana": "ホンチョウ", "roma": "Honcho"}
]
})
}
fn meta_json(version: &str) -> serde_json::Value {
json!({
"version": version,
"generated_at": "2026-05-01T00:00:00Z",
"spec_version": "1.0",
"total_zipcodes": 1,
"prefix_count": 1,
"by_pref": {"14": 1},
"data_source": "https://example.com",
"endpoints": {"group": "/g/{prefix1}.json", "prefix": "/p/{prefix3}.json"}
})
}
async fn make_client(server: &MockServer) -> JpzipClient {
JpzipClient::builder().base_url(server.uri()).build()
}
#[tokio::test]
async fn lookup_returns_entry() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/p/231.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"2310017": entry_json("神奈川県", "横浜市中区")
})))
.mount(&server)
.await;
let client = make_client(&server).await;
let entry = client.lookup("2310017").await.unwrap().unwrap();
assert_eq!(entry.prefecture, "神奈川県");
assert_eq!(entry.city, "横浜市中区");
}
#[tokio::test]
async fn lookup_invalid_zipcode_returns_none_without_fetch() {
let server = MockServer::start().await;
let client = make_client(&server).await;
assert!(client.lookup("abc").await.unwrap().is_none());
assert!(client.lookup("12345").await.unwrap().is_none());
assert!(client.lookup("12345678").await.unwrap().is_none());
}
#[tokio::test]
async fn lookup_404_returns_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/p/999.json"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = make_client(&server).await;
assert!(client.lookup("9990000").await.unwrap().is_none());
}
#[tokio::test]
async fn lookup_group_3digit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/p/231.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"2310017": entry_json("神奈川県", "横浜市中区")
})))
.mount(&server)
.await;
let client = make_client(&server).await;
let dict = client.lookup_group("231").await.unwrap();
assert_eq!(dict.len(), 1);
assert!(dict.contains_key("2310017"));
}
#[tokio::test]
async fn lookup_group_2digit_fan_out() {
let server = MockServer::start().await;
for i in 0..10u8 {
let p3 = format!("23{}", i);
let zip = format!("23{}0001", i);
let pref = format!("Pref{}", i);
Mock::given(method("GET"))
.and(path(format!("/p/{}.json", p3)))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
zip.clone(): entry_json(&pref, "City")
})))
.mount(&server)
.await;
}
let client = make_client(&server).await;
let dict = client.lookup_group("23").await.unwrap();
assert_eq!(dict.len(), 10);
assert!(dict.contains_key("2350001"));
assert!(dict.contains_key("2390001"));
}
#[tokio::test]
async fn lookup_all_fans_out_g() {
let server = MockServer::start().await;
for i in 0..10u8 {
let zip = format!("{}000000", i);
Mock::given(method("GET"))
.and(path(format!("/g/{}.json", i)))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
zip.clone(): entry_json("P", "C")
})))
.mount(&server)
.await;
}
let client = make_client(&server).await;
let dict = client.lookup_all().await.unwrap();
assert_eq!(dict.len(), 10);
}
#[tokio::test]
async fn refresh_clears_cache() {
let server = MockServer::start().await;
let m1 = Mock::given(method("GET"))
.and(path("/p/231.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"2310017": entry_json("First", "C1")
})))
.expect(1..)
.mount_as_scoped(&server)
.await;
let client = make_client(&server).await;
let e1 = client.lookup("2310017").await.unwrap().unwrap();
assert_eq!(e1.prefecture, "First");
drop(m1);
client.refresh().await.unwrap();
Mock::given(method("GET"))
.and(path("/p/231.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"2310017": entry_json("Second", "C2")
})))
.mount(&server)
.await;
let e2 = client.lookup("2310017").await.unwrap().unwrap();
assert_eq!(e2.prefecture, "Second");
}
#[tokio::test]
async fn meta_version_change_invalidates_cache() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/p/231.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"2310017": entry_json("X", "Y")
})))
.mount(&server)
.await;
let meta_a = Mock::given(method("GET"))
.and(path("/meta.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(meta_json("2026-05")))
.up_to_n_times(1)
.mount_as_scoped(&server)
.await;
let client = make_client(&server).await;
let _ = client.get_meta().await.unwrap();
let _ = client.lookup("2310017").await.unwrap();
drop(meta_a);
Mock::given(method("GET"))
.and(path("/meta.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(meta_json("2026-06")))
.mount(&server)
.await;
client.refresh().await.unwrap();
let m = client.get_meta().await.unwrap().unwrap();
assert_eq!(m.version, "2026-06");
}
#[tokio::test]
async fn get_meta_404_returns_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/meta.json"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = make_client(&server).await;
assert!(client.get_meta().await.unwrap().is_none());
}
#[tokio::test]
async fn is_valid_zipcode_matches_seven_digits() {
assert!(is_valid_zipcode("1234567"));
assert!(!is_valid_zipcode("123456"));
assert!(!is_valid_zipcode("12345678"));
assert!(!is_valid_zipcode("123-4567"));
assert!(!is_valid_zipcode("abcdefg"));
}
#[tokio::test]
async fn retry_on_5xx_then_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/p/231.json"))
.respond_with(ResponseTemplate::new(503))
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/p/231.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"2310017": entry_json("神奈川県", "横浜市中区")
})))
.mount(&server)
.await;
let client = make_client(&server).await;
let entry = client.lookup("2310017").await.unwrap().unwrap();
assert_eq!(entry.prefecture, "神奈川県");
}
#[tokio::test]
async fn no_retry_on_4xx() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/p/231.json"))
.respond_with(ResponseTemplate::new(403))
.expect(1)
.mount(&server)
.await;
let client = make_client(&server).await;
let err = client.lookup("2310017").await.unwrap_err();
match err {
Error::Status { status, .. } => assert_eq!(status, 403),
other => panic!("expected Status(403), got {other:?}"),
}
}
#[tokio::test]
async fn lookup_group_rejects_bad_prefix() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let err = client.lookup_group("abcd").await.unwrap_err();
assert!(matches!(err, Error::InvalidPrefix(_)));
let err = client.lookup_group("").await.unwrap_err();
assert!(matches!(err, Error::InvalidPrefix(_)));
let err = client.lookup_group("1234").await.unwrap_err();
assert!(matches!(err, Error::InvalidPrefix(_)));
}
#[tokio::test]
async fn l2_cache_round_trip() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/p/231.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"2310017": entry_json("神奈川県", "横浜市中区")
})))
.expect(1)
.mount(&server)
.await;
let store: Arc<Mutex<std::collections::HashMap<String, Vec<u8>>>> =
Arc::new(Mutex::new(std::collections::HashMap::new()));
let cache: Arc<dyn Cache> = Arc::new(MapCache {
inner: store.clone(),
});
let client = JpzipClient::builder()
.base_url(server.uri())
.cache(cache.clone())
.build();
let _ = client.lookup("2310017").await.unwrap().unwrap();
assert!(!store.lock().await.is_empty());
let client2 = JpzipClient::builder()
.base_url(server.uri())
.cache(cache)
.build();
let e = client2.lookup("2310017").await.unwrap().unwrap();
assert_eq!(e.prefecture, "神奈川県");
}
#[derive(Clone)]
struct MapCache {
inner: Arc<Mutex<std::collections::HashMap<String, Vec<u8>>>>,
}
#[async_trait]
impl Cache for MapCache {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
Ok(self.inner.lock().await.get(key).cloned())
}
async fn set(&self, key: &str, value: Vec<u8>) -> Result<(), Error> {
self.inner.lock().await.insert(key.to_string(), value);
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), Error> {
self.inner.lock().await.remove(key);
Ok(())
}
async fn clear(&self) -> Result<(), Error> {
self.inner.lock().await.clear();
Ok(())
}
}