Files
core/test/dever-tests/tests/api_declarations.rs
T

934 lines
31 KiB
Rust

#[path = "support/temp.rs"]
mod temp;
use std::fs;
use dever_core::source::SourceMap;
use dever_runtime::config::Settings;
fn sources(files: &[(&str, &str)]) -> SourceMap {
let mut sources = SourceMap::default();
for (path, source) in files {
sources.add(*path, *source);
}
sources
}
fn check(files: &[(&str, &str)]) {
let sources = sources(files);
dever_core::check(&sources).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(&sources))
.collect::<String>()
)
});
}
fn reject(files: &[(&str, &str)], expected: &str) {
let sources = sources(files);
let errors = dever_core::check(&sources).expect_err("source must be rejected");
let rendered = errors
.iter()
.map(|error| error.render(&sources))
.collect::<String>();
assert!(
rendered.contains(expected),
"expected {expected}: {rendered}"
);
}
fn auth_settings() -> (temp::TemporaryDirectory, Settings) {
let root = temp::TemporaryDirectory::new();
fs::create_dir(root.path().join("config")).unwrap();
fs::write(
root.path().join("config/setting.json"),
r#"{
"auth": {
"providers": {
"session": {
"verify": "user.account.verify",
"jwtSecret": "0123456789abcdef0123456789abcdef"
}
}
},
"sites": {
"admin": {"path": "admin", "auth": "session"}
},
"database": {
"default": {
"type": "sqlite",
"path": "data/control.db",
"tenant_directory": "data/tenants"
}
},
"tenant": {
"database": "default"
}
}"#,
)
.unwrap();
let settings = Settings::load_project(root.path()).unwrap();
(root, settings)
}
fn multi_provider_settings() -> (temp::TemporaryDirectory, Settings) {
let (root, _) = auth_settings();
let setting_path = root.path().join("config/setting.json");
let mut settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&setting_path).unwrap()).unwrap();
settings["auth"]["providers"]["other"] = serde_json::json!({
"verify": "admin.principal.verify",
"jwtSecret": "0123456789abcdef0123456789abcdef",
});
settings["sites"]["other"] = serde_json::json!({"path": "other", "auth": "other"});
fs::write(&setting_path, settings.to_string()).unwrap();
let settings = Settings::load_project(root.path()).unwrap();
(root, settings)
}
fn identity_sources() -> [(&'static str, &'static str); 3] {
[
(
"user/account/model.dever",
"global type Account { email: Text(1, 254) }",
),
(
"platform/tenant/model.dever",
"global type Tenant { name: Text(1, 64) }",
),
(
"user/account/app.dever",
r#"type Identity {
id: Text
user_id: user.account.model.id?
tenant_id: platform.tenant.model.id?
}
verify(claims: dever.auth.Claims) (identity: Identity) {
identity = Identity {
id = claims.subject
user_id = null
tenant_id = null
}
}
profile() (response: Text) {
response = dever.site.key()
}"#,
),
]
}
fn configured_check(
files: &[(&str, &str)],
settings: &Settings,
) -> Result<dever_core::hir::Program, String> {
let sources = sources(files);
dever_core::check_with_settings(&sources, settings).map_err(|errors| {
errors
.iter()
.map(|error| error.render(&sources))
.collect::<String>()
})
}
#[test]
fn api_bindings_inherit_app_signatures_without_response_name_requirement() {
check(&[
(
"user/account/app.dever",
"greet(name: Text) (greeting: Text) { greeting = text.trim(name) }",
),
(
"user/account/api.dever",
"get greeting = app.greet\npost greeting = app.greet\nput greeting = app.greet\ndelete greeting = app.greet\ncmd greeting = app.greet",
),
]);
}
#[test]
fn configured_sites_bind_nested_api_directories_to_checked_auth_hooks() {
let (_root, settings) = auth_settings();
let mut files = identity_sources().to_vec();
files.push((
"user/account/api/admin/profile.dever",
"public get profile = app.profile",
));
let source_map = sources(&files);
let program =
dever_core::check_with_settings(&source_map, &settings).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(&source_map))
.collect::<String>()
)
});
let generated = dever_core::native::emit_project(
&program,
&source_map,
dever_runtime::config::RuntimeProfile::default(),
)
.unwrap();
assert!(generated.contains("dever_runtime::auth::prepare(&[\"admin\"]"));
assert!(generated.contains("dever_runtime::auth::scope(auth_prepared, auth_identity"));
}
#[test]
fn configured_sites_reject_unmapped_api_directories_and_invalid_verify_records() {
let (_root, settings) = auth_settings();
let mut unmapped = identity_sources().to_vec();
unmapped.push((
"user/account/api/front/profile.dever",
"get profile = app.profile",
));
let error = configured_check(&unmapped, &settings).unwrap_err();
assert!(
error.contains("not assigned to a configured site"),
"{error}"
);
let invalid_app = identity_sources()[2]
.1
.replace("tenant_id: platform.tenant.model.id?", "tenant_id: Int?");
let mut invalid = identity_sources().to_vec();
invalid[2] = ("user/account/app.dever", &invalid_app);
invalid.push((
"user/account/api/admin/profile.dever",
"get profile = app.profile",
));
let error = configured_check(&invalid, &settings).unwrap_err();
assert!(
error.contains("identity must be an App-owned public record"),
"{error}"
);
}
#[test]
fn verify_is_read_only() {
let (_root, settings) = auth_settings();
let writing_verify = identity_sources()[2].1.replace(
"identity = Identity {",
"account = model.create({ email = claims.subject })\n identity = Identity {",
);
let mut writing = identity_sources().to_vec();
writing[2] = ("user/account/app.dever", &writing_verify);
writing.push((
"user/account/api/admin/profile.dever",
"get profile = app.profile",
));
let error = configured_check(&writing, &settings).unwrap_err();
assert!(
error.contains("authentication verify cannot write"),
"{error}"
);
}
#[test]
fn trusted_identity_getters_preserve_nominal_ids_and_nullable_representation() {
let (_root, settings) = auth_settings();
let mut files = identity_sources().to_vec();
files.extend([
(
"news/reader/app.dever",
r#"
type Profile {
user: user.account.model.id?
tenant: platform.tenant.model.id?
raw: Int?
}
profile() (profile: Profile) {
profile = Profile {
user = dever.auth.user_id()
tenant = dever.auth.tenant_id()
raw = dever.auth.user_id()
}
}
current_user() (user: user.account.model.id) {
user = require_user(dever.auth.user_id())
}
require_user(value: user.account.model.id) (user: user.account.model.id) {
user = value
}
require_user(value: null) (user: user.account.model.id) {
fail(dever.api.Error.Unauthorized)
}
owns(id: user.account.model.id) (owned: Bool) {
owned = dever.auth.owns_user(id)
}
"#,
),
(
"news/reader/api/admin/profile.dever",
"get profile = app.profile\nget current = app.current_user\nget owns = app.owns",
),
]);
let source_map = sources(&files);
dever_core::check(&source_map).expect("source-only checking defers provider binding");
let program =
dever_core::check_with_settings(&source_map, &settings).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(&source_map))
.collect::<String>()
)
});
let native = dever_core::native::emit_project(
&program,
&source_map,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(native.contains("dever_runtime::auth::user_id().map(|value| value.map(T"));
assert!(native.contains("dever_runtime::auth::tenant_id().map(|value| value.map(T"));
let llvm = dever_core::llvm::emit_application(&program, &source_map).unwrap();
assert!(llvm.contains("@dever_rt_v1_auth_user_id"));
assert!(llvm.contains("@dever_rt_v1_auth_tenant_id"));
}
#[test]
fn trusted_identity_getters_reject_wrong_ids_and_implicit_casts() {
let (_root, settings) = auth_settings();
for (app, message) in [
(
"profile() (value: platform.tenant.model.id?) { value = dever.auth.user_id() }",
"nominal ID must match provider",
),
(
"profile() (value: user.account.model.id?) { value = dever.auth.tenant_id() }",
"nominal ID must match provider",
),
(
"profile(id: platform.tenant.model.id) (owned: Bool) { owned = dever.auth.owns_user(id) }",
"nominal ID must match provider",
),
(
"profile() (value: user.account.model.id) { value = dever.auth.user_id() }",
"C005",
),
(
"profile(raw: Int?) (value: user.account.model.id?) { value = raw }",
"C005",
),
(
"profile() (value: user.account.model.id?) { value = 1 }",
"C005",
),
(
"profile() (value: user.account.model.id?) { raw = dever.auth.user_id()\nvalue = raw }",
"C005",
),
] {
let mut files = identity_sources().to_vec();
files.extend([
("news/reader/app.dever", app),
(
"news/reader/api/admin/profile.dever",
"get profile = app.profile",
),
]);
let error = configured_check(&files, &settings).unwrap_err();
assert!(error.contains(message), "{app}: {error}");
}
}
#[test]
fn trusted_identity_getters_require_matching_providers_at_every_route() {
let (_root, settings) = multi_provider_settings();
let other_provider = identity_sources()[2]
.1
.replace("user.account.model.id?", "admin.principal.model.id?");
for app in [
"profile() (value: user.account.model.id?) { value = dever.auth.user_id() }",
"profile(id: user.account.model.id) (owned: Bool) { owned = dever.auth.owns_user(id) }",
] {
let mut files = identity_sources().to_vec();
files.extend([
(
"admin/principal/model.dever",
"global type Principal { name: Text }",
),
("admin/principal/app.dever", other_provider.as_str()),
("news/reader/app.dever", app),
(
"news/reader/api/admin/profile.dever",
"get profile = app.profile",
),
(
"news/reader/api/other/profile.dever",
"get profile = app.profile",
),
]);
let error = configured_check(&files, &settings).unwrap_err();
assert!(
error.contains("C006") && error.contains("provider 'other'"),
"{error}"
);
}
}
#[test]
fn rest_identity_bindings_preserve_matching_nominal_ids() {
let (_root, settings) = auth_settings();
let mut files = identity_sources().to_vec();
files.extend([
(
"news/article/model.dever",
r#"type Article {
private owner author: user.account.model.id = dever.auth.user_id()
title: Text
linked_user: user.account.model.id? create = dever.auth.user_id() replace = dever.auth.user_id()
linked_tenant: platform.tenant.model.id? create = dever.auth.tenant_id() replace = dever.auth.tenant_id()
}"#,
),
("news/article/api/admin/manage.dever", "rest model"),
]);
check(&files);
let program = configured_check(&files, &settings).unwrap();
let source_map = sources(&files);
let native = dever_core::native::emit_project(
&program,
&source_map,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(native.contains("dever_runtime::auth::user_id().map(|value| value.map(T"));
assert!(native.contains("dever_runtime::auth::tenant_id().map(|value| value.map(T"));
let llvm = dever_core::llvm::emit_application(&program, &source_map).unwrap();
assert!(llvm.contains("@dever_rt_v1_auth_user_id"));
assert!(llvm.contains("@dever_rt_v1_auth_tenant_id"));
}
#[test]
fn rest_identity_bindings_reject_wrong_provider_ids() {
let (_root, settings) = auth_settings();
for field in [
"linked: platform.tenant.model.id? create = dever.auth.user_id()",
"linked: platform.tenant.model.id? replace = dever.auth.user_id()",
"linked: user.account.model.id? create = dever.auth.tenant_id()",
"linked: user.account.model.id? replace = dever.auth.tenant_id()",
"private owner linked: platform.tenant.model.id = dever.auth.user_id()",
"private owner linked: user.account.model.id = dever.auth.tenant_id()",
] {
let model = format!("type Article {{\n title: Text\n {field}\n}}");
let mut files = identity_sources().to_vec();
files.extend([
("news/article/model.dever", model.as_str()),
("news/article/api/admin/manage.dever", "rest model"),
]);
check(&files);
let error = configured_check(&files, &settings).unwrap_err();
assert!(
error.contains("C006") && error.contains("provider 'session'"),
"{field}: {error}"
);
}
}
#[test]
fn rest_identity_bindings_match_each_sites_provider() {
let (_root, settings) = multi_provider_settings();
let other_provider = identity_sources()[2]
.1
.replace("user.account.model.id?", "admin.principal.model.id?");
for field in [
"linked: user.account.model.id? create = dever.auth.user_id()",
"linked: user.account.model.id? replace = dever.auth.user_id()",
"private owner linked: user.account.model.id = dever.auth.user_id()",
] {
let model = format!("type Article {{\n title: Text\n {field}\n}}");
let mut files = identity_sources().to_vec();
files.extend([
(
"admin/principal/model.dever",
"global type Principal { name: Text }",
),
("admin/principal/app.dever", other_provider.as_str()),
("news/article/model.dever", model.as_str()),
("news/article/api/admin/manage.dever", "rest model"),
]);
configured_check(&files, &settings).expect("first site's provider matches");
files.push(("news/article/api/other/manage.dever", "rest model"));
let error = configured_check(&files, &settings).unwrap_err();
assert!(
error.contains("C006") && error.contains("provider 'other'"),
"{field}: {error}"
);
}
}
#[test]
fn nominal_identity_getters_do_not_gain_public_cmd_or_job_context() {
let (_root, settings) = auth_settings();
let app = "profile() (value: user.account.model.id?) { value = dever.auth.user_id() }";
for (path, declaration, message) in [
(
"news/reader/api/admin/profile.dever",
"public get profile = app.profile",
"requires a protected API",
),
(
"news/reader/api.dever",
"cmd profile = app.profile",
"not available from CMD or Job",
),
(
"news/reader/job.dever",
"database default\njob sample() () retry(3) timeout(1000) { value = app.profile() }",
"not available from CMD or Job",
),
] {
let mut files = identity_sources().to_vec();
files.extend([("news/reader/app.dever", app), (path, declaration)]);
let error = configured_check(&files, &settings).unwrap_err();
assert!(error.contains("C006") && error.contains(message), "{error}");
}
}
#[test]
fn nominal_identity_getters_remain_unavailable_to_ordinary_tests() {
let (_root, settings) = auth_settings();
let mut files = identity_sources().to_vec();
files.push((
"news/reader/app.dever",
"profile() (value: user.account.model.id?) { value = dever.auth.user_id() }",
));
let mut inputs = sources(&files).compilation_sources().unwrap();
inputs.push(dever_core::source::CompilationSource {
logical_path: "news/reader/probe.dever".into(),
is_test: true,
text: "probe() () { value = app.profile() }".into(),
});
let source_map = SourceMap::from_compilation_sources(&inputs).unwrap();
let errors = dever_core::check_with_settings(&source_map, &settings).unwrap_err();
let rendered = errors
.iter()
.map(|error| error.render(&source_map))
.collect::<String>();
assert!(
rendered.contains("C006") && rendered.contains("not available from tests"),
"{rendered}"
);
}
#[test]
fn protected_api_permissions_are_stable_and_unique_across_paths_and_methods() {
let (_root, settings) = auth_settings();
let mut files = identity_sources().to_vec();
files.push((
"user/account/api/admin/profile.dever",
"get profile = app.profile",
));
let program = configured_check(&files, &settings).unwrap();
assert!(program.api_snapshot().contains(
"permission user.account.admin.profile method=GET component=user domain=account site=admin action=profile"
));
files.push((
"user/account/api/admin/alternate.dever",
"post profile = app.profile",
));
let error = configured_check(&files, &settings).unwrap_err();
assert!(
error.contains("duplicate API permission 'user.account.admin.profile'"),
"{error}"
);
}
#[test]
fn public_visibility_and_identity_stay_on_http_app_bindings() {
reject(
&[
(
"user/account/app.dever",
"profile() (response: Text) { response = \"ok\" }",
),
(
"user/account/api/admin/profile.dever",
"public cmd profile = app.profile",
),
],
"application visibility is defined by the source role",
);
reject(
&[(
"user/account/domain.dever",
"profile() (response: Text) { response = dever.auth.id() }",
)],
"available only from App functions",
);
reject(
&[
(
"user/account/app.dever",
"manage() (ok: Bool) { dever.auth.disable_role(\"reader\")\nok = true }",
),
(
"user/account/api/admin/manage.dever",
"public post manage = app.manage",
),
],
"public API call chains cannot administer authorization",
);
}
#[test]
fn public_api_cannot_reach_a_tenant_component() {
let (_root, settings) = auth_settings();
let mut files = identity_sources().to_vec();
files.extend([
(
"news/article/model.dever",
"type Article { title: Text(1, 160) }",
),
(
"news/article/app.dever",
"count() (value: Int) { value = model.count() }",
),
(
"news/article/api/admin/browse.dever",
"public get count = app.count",
),
]);
let error = configured_check(&files, &settings).unwrap_err();
assert!(
error.contains("public API cannot reach tenant components [news]"),
"{error}"
);
}
#[test]
fn json_bindings_accept_wire_records_but_query_bindings_require_scalars() {
let app = "type Input { name: Text }\nupdate(input: Input) (value: Text) { value = text.trim(input.name) }";
check(&[
("user/account/app.dever", app),
(
"user/account/api.dever",
"put profile = app.update\ncmd update = app.update",
),
]);
reject(
&[
("user/account/app.dever", app),
("user/account/api.dever", "get profile = app.update"),
],
"wire-safe scalars",
);
}
#[test]
fn api_bindings_reject_legacy_bodies_cross_domain_and_ambiguous_targets() {
reject(
&[(
"user/account/api.dever",
"get_name() (response: Text) { response = \"x\" }",
)],
"handler function bodies and legacy prefixes",
);
reject(
&[("user/account/api.dever", "post save = user.profile.save")],
"same domain",
);
reject(
&[("user/account/api.dever", "post save = app.missing")],
"unambiguous same-domain",
);
reject(
&[
(
"user/account/app.dever",
"save() (value: Text) { value = \"x\" }\nsave(name: Text) (value: Text) { value = text.trim(name) }",
),
("user/account/api.dever", "post save = app.save"),
],
"unambiguous same-domain",
);
}
#[test]
fn duplicate_routes_and_commands_are_rejected() {
let app = "greet() (value: Text) { value = \"hello\" }";
for (declaration, diagnostic) in [
("get hello = app.greet", "duplicate HTTP route"),
("cmd hello = app.greet", "duplicate command"),
] {
let api = format!("{declaration}\n{declaration}");
reject(
&[
("user/account/app.dever", app),
("user/account/api.dever", &api),
],
diagnostic,
);
}
reject(
&[
("user/account/app.dever", app),
("user/account/api.dever", "cmd hello = app.greet"),
(
"user/account/api/maintenance.dever",
"cmd hello = app.greet",
),
],
"duplicate command",
);
}
fn sql_projection_files(sqlite: &str, postgres: &str, method: &str) -> [(&'static str, String); 3] {
[
(
"news/article/model.dever",
format!(
"type Article {{\n name: Text\n private draft: Bool default true\n}}\n\
sql lookup(name: Text, size: Int) (rows: List<app.View>) {{\n\
sqlite = {sqlite:?}\n postgres = {postgres:?}\n}}\n"
),
),
(
"news/article/app.dever",
"type View { name: Text }\n\
read(name: Text, size: Int) (rows: List<View>) {\n\
rows = model.lookup(name, size)\n}\n"
.into(),
),
(
"news/article/api.dever",
format!("{method} read = app.read\n"),
),
]
}
#[test]
fn get_accepts_only_proven_single_model_sql_projections() {
for sqlite in [
"SELECT name FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
"select \"name\" from \"article\" where \"name\" = ?1 AND name = ?1 order by id desc, name asc limit ?2;",
] {
let postgres = sqlite.replace('?', "$");
let files = sql_projection_files(sqlite, &postgres, "get");
check(
&files
.iter()
.map(|(path, source)| (*path, source.as_str()))
.collect::<Vec<_>>(),
);
}
}
#[test]
fn unproven_sql_keeps_its_write_effect_in_each_dialect() {
let plain = "SELECT name FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2";
for unproven in [
"SELECT name FROM other_article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
"SELECT missing FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
"SELECT lower(name) FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
"WITH changed AS (UPDATE article SET name = ?1 WHERE id = ?2 RETURNING name) SELECT name FROM changed",
"SELECT name FROM article WHERE name = ?1 LIMIT ?2; DELETE FROM article",
"UPDATE article SET name = ?1 WHERE id = ?2 RETURNING name",
"SELECT name INTO copied_article FROM article WHERE name = ?1 LIMIT ?2",
"SELECT name FROM article WHERE name = ?1 LIMIT ?2 FOR UPDATE",
"SELECT name FROM article WHERE name = ?1 LIMIT ?2 -- unproved comment",
] {
let plain_postgres = plain.replace('?', "$");
let unproven_postgres = unproven.replace('?', "$");
for (sqlite, postgres) in [
(unproven, plain_postgres.as_str()),
(plain, unproven_postgres.as_str()),
] {
let files = sql_projection_files(sqlite, postgres, "get");
reject(
&files
.iter()
.map(|(path, source)| (*path, source.as_str()))
.collect::<Vec<_>>(),
"GET App call chains cannot write",
);
let files = sql_projection_files(sqlite, postgres, "post");
check(
&files
.iter()
.map(|(path, source)| (*path, source.as_str()))
.collect::<Vec<_>>(),
);
}
}
}
#[test]
fn get_rejects_writes_reached_through_cross_domain_app_calls() {
let writer = "create(name: Text) (value: Text) { account = model.create({ name = text.trim(name) })\nvalue = account.name }";
reject(
&[
(
"user/account/model.dever",
"type Account { name: Text(1, 64) }",
),
("user/account/app.dever", writer),
(
"user/report/app.dever",
"greet(name: Text) (value: Text) { value = \"hello \" + user.account.create(name) }",
),
("user/report/api.dever", "get greeting = app.greet"),
],
"GET App call chains cannot write",
);
check(&[
(
"user/account/model.dever",
"type Account { name: Text(1, 64) }",
),
("user/account/app.dever", writer),
("user/account/api.dever", "post create = app.create"),
]);
check(&[
(
"user/account/model.dever",
"type Account { name: Text(1, 64) }",
),
(
"user/account/app.dever",
"count() (value: Text) { value = int.to_text(model.count()) }",
),
("user/account/api.dever", "get count = app.count"),
]);
reject(
&[
(
"user/account/app.dever",
"disable() (ok: Bool) { dever.auth.disable_role(\"reader\")\nok = true }",
),
("user/account/api.dever", "get disable = app.disable"),
],
"GET App call chains cannot write database or authorization state",
);
}
#[test]
fn rest_requires_unambiguous_same_domain_model_and_rejects_duplicate_registration() {
let model = "type Account { name: Text(1, 64) }";
check(&[
("user/account/model.dever", model),
("user/account/api.dever", "rest"),
]);
check(&[
("user/account/model.dever", model),
("user/account/api.dever", "rest model"),
]);
reject(
&[("user/account/api.dever", "rest")],
"requires one same-domain Model",
);
reject(
&[
("user/account/model.dever", model),
("user/account/api.dever", "rest\nrest"),
],
"duplicate HTTP route",
);
}
#[test]
fn rest_selector_disambiguates_main_and_topic_models() {
let main = (
"user/account/model.dever",
"type Account { name: Text(1, 64) }",
);
let detail = (
"user/account/model/detail.dever",
"type Detail { title: Text(1, 64) }",
);
reject(
&[main, detail, ("user/account/api.dever", "rest")],
"requires one same-domain Model",
);
check(&[
main,
detail,
("user/account/api.dever", "rest model.detail"),
]);
let alternate = (
"user/account/model/archive.dever",
"type Archive { name: Text(1, 64) }",
);
reject(
&[detail, alternate, ("user/account/api.dever", "rest")],
"requires one same-domain Model",
);
reject(
&[main, ("user/account/api.dever", "rest user.profile.model")],
"requires one same-domain Model",
);
}
#[test]
fn generated_rest_and_custom_bindings_cannot_register_the_same_route() {
reject(
&[
(
"user/account/model.dever",
"type Account { name: Text(1, 64) }",
),
(
"user/account/app.dever",
"details() (value: Text) { value = \"details\" }",
),
("user/account/api.dever", "get details = app.details"),
("user/account/api/details.dever", "rest model"),
],
"duplicate HTTP route",
);
}
#[test]
fn api_declarations_format_idempotently() {
let mut sources = SourceMap::default();
sources.add("user/account/api.dever", "# Actions\npost publish=app.publish # publish article\ncmd rebuild =app.rebuild\nrest model\n");
let source = sources.files().first().unwrap();
let formatted = dever_core::format::format(source).unwrap();
assert!(formatted.contains("post publish = app.publish"));
assert!(formatted.contains("cmd rebuild = app.rebuild"));
assert!(formatted.contains("rest model"));
assert!(formatted.contains("# Actions"));
assert!(formatted.contains("# publish article"));
let mut roundtrip = SourceMap::default();
roundtrip.add("user/account/api.dever", formatted.clone());
assert_eq!(
dever_core::format::format(roundtrip.files().first().unwrap()).unwrap(),
formatted
);
}
#[test]
fn markdown_api_declarations_share_plain_source_contracts() {
let markdown = "# 账户接口\n\n绑定账户业务能力。\n\n- 包:`user.account.api`\n- 公开类型:无\n- 公开方法:无\n- 使用:无\n\n## 问候\n\n提供账户问候接口。\n\n- 声明:`get greeting`\n\n```dever\nget greeting = app.greet\n```\n";
check(&[
(
"user/account/app.dever",
"greet() (value: Text) { value = \"hello\" }",
),
("user/account/api.dever.md", markdown),
]);
let invalid = markdown.replace("`get greeting`", "`post greeting`");
reject(
&[
(
"user/account/app.dever",
"greet() (value: Text) { value = \"hello\" }",
),
("user/account/api.dever.md", &invalid),
],
"documented Model declaration",
);
}