use serde::{Deserialize, Serialize};
use umbral::migrate::ModelMeta;
use umbral_openapi::client_gen::GeneratedClient;
#[derive(
Debug, Default, Clone, Copy, PartialEq, Eq, umbral::orm::Choices, Serialize, Deserialize,
)]
#[choices(rename_all = "lowercase")]
pub enum CgStatus {
#[default]
Draft,
Published,
Archived,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "cg_author")]
pub struct CgAuthor {
#[umbral(primary_key)]
pub slug: String,
pub name: String,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "cg_post")]
pub struct CgPost {
pub id: i64,
pub title: String,
pub body: Option<String>,
#[umbral(choices)]
pub status: CgStatus,
pub views: i32,
#[umbral(no_reverse)]
pub author: umbral::orm::ForeignKey<CgAuthor>,
#[umbral(noedit)]
pub slug: String,
#[umbral(noform)]
pub internal: String,
#[umbral(auto_now_add)]
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "cg_ticket")]
pub struct CgTicket {
pub id: uuid::Uuid,
pub subject: String,
}
fn generated() -> GeneratedClient {
umbral_openapi::client_gen::generate_for(&[
ModelMeta::for_::<CgAuthor>(),
ModelMeta::for_::<CgPost>(),
ModelMeta::for_::<CgTicket>(),
])
}
fn dts() -> String {
generated().dts
}
fn js() -> String {
generated().js
}
#[track_caller]
fn assert_has(haystack: &str, needle: &str) {
assert!(
haystack.contains(needle),
"expected to find:\n {needle}\nin:\n{haystack}",
);
}
#[test]
fn dts_has_the_row_types() {
let d = dts();
assert_has(&d, "export interface CgPost {");
assert_has(
&d,
r#"export type CgPostStatus = "draft" | "published" | "archived";"#,
);
assert_has(&d, "export interface CgAuthor {");
}
#[test]
fn filters_type_lists_every_lookup_typed_to_the_field() {
let d = dts();
assert_has(&d, "export interface CgPostFilters {");
assert_has(&d, r#" "status"?: CgPostStatus;"#);
assert_has(&d, r#" "status__in"?: CgPostStatus[];"#);
assert_has(&d, r#" "views__gte"?: number;"#);
assert_has(&d, r#" "views__lt"?: number;"#);
assert_has(&d, r#" "title__contains"?: string;"#);
assert_has(&d, r#" "body__isnull"?: boolean;"#);
}
#[test]
fn foreign_key_filter_value_is_the_targets_pk_type() {
let d = dts();
assert_has(&d, r#" "author"?: string;"#);
assert!(
!d.contains(r#" "author"?: number;"#),
"an FK to a String-PK model must filter by string, not number:\n{d}",
);
}
#[test]
fn primary_key_is_not_filterable() {
let d = dts();
let filters = d
.split("export interface CgPostFilters {")
.nth(1)
.and_then(|s| s.split("}\n").next())
.expect("CgPostFilters block");
assert!(
!filters.contains(r#""id""#) && !filters.contains(r#""id__"#),
"the PK must not be a filter key; got:\n{filters}",
);
}
#[test]
fn resource_map_and_client_are_present() {
let d = dts();
assert_has(
&d,
r#""cg_post": { row: CgPost; filters: CgPostFilters; ordering: CgPostOrdering; create: CgPostCreate; update: CgPostUpdate; id: number };"#,
);
assert_has(&d, "export declare class Umbral {");
assert_has(&d, "from<K extends keyof UmbralResources>");
assert_has(&d, "export interface Paginated<T> {");
assert_has(&d, " results: T[];");
assert_has(&d, " count: number;");
let j = js();
assert_has(&j, "export class Umbral {");
assert_has(&j, "export class Query {");
assert_has(&j, "export class UmbralError extends Error {");
}
#[test]
fn ordering_type_covers_columns_both_directions() {
let d = dts();
assert_has(&d, "export type CgPostOrdering =");
assert_has(&d, r#""title""#);
assert_has(&d, r#""-title""#);
}
fn block<'a>(dts: &'a str, decl: &str) -> &'a str {
dts.split(decl)
.nth(1)
.and_then(|s| s.split("}\n").next())
.unwrap_or_else(|| panic!("no `{decl}` block in:\n{dts}"))
}
#[test]
fn create_dto_includes_noedit_and_omits_server_managed() {
let d = dts();
let create = block(&d, "export interface CgPostCreate {");
assert!(create.contains("title: string;"), "got:\n{create}");
assert!(
create.contains("author: string;"),
"FK required; got:\n{create}"
);
assert!(
create.contains("slug: string;"),
"noedit must be creatable; got:\n{create}"
);
assert!(create.contains("body?: string | null;"), "got:\n{create}");
for gone in ["id", "internal", "created_at"] {
assert!(
!create.contains(&format!("{gone}:")) && !create.contains(&format!("{gone}?:")),
"`{gone}` must be omitted from Create; got:\n{create}",
);
}
}
#[test]
fn update_dto_is_partial_and_drops_noedit() {
let d = dts();
let update = block(&d, "export interface CgPostUpdate {");
assert!(
update.contains("title?: string;"),
"partial; got:\n{update}"
);
assert!(update.contains("author?: string;"), "got:\n{update}");
assert!(
!update.contains("slug"),
"a noedit column must not be updatable; got:\n{update}",
);
for gone in ["id", "internal", "created_at"] {
assert!(
!update.contains(gone),
"`{gone}` must be omitted from Update; got:\n{update}"
);
}
}
#[test]
fn client_exposes_write_operations() {
let d = dts();
assert_has(&d, "create: CgPostCreate; update: CgPostUpdate");
assert_has(&d, "create<K extends keyof UmbralResources>");
assert_has(&d, "update<K extends keyof UmbralResources>");
assert_has(&d, "delete<K extends keyof UmbralResources>");
let j = js();
assert_has(&j, "create(table, data)");
assert_has(&j, "update(table, id, data)");
assert_has(&j, "async delete(table, id)");
}
#[test]
fn each_resource_has_its_own_id_type() {
let d = dts();
assert!(
d.lines()
.any(|l| l.contains("\"cg_post\":") && l.contains("id: number }")),
"an i64 PK must type as number; got:\n{d}",
);
assert!(
d.lines()
.any(|l| l.contains("\"cg_ticket\":") && l.contains("id: string }")),
"a Uuid PK must type as string; got:\n{d}",
);
assert!(
d.lines()
.any(|l| l.contains("\"cg_author\":") && l.contains("id: string }")),
"a String PK must type as string; got:\n{d}",
);
assert!(
!d.contains("UmbralId"),
"the global id union must be gone; got:\n{d}"
);
assert_has(&d, "id: UmbralResources[K][\"id\"]");
}
#[test]
fn realtime_on_is_typed() {
let d = dts();
assert_has(&d, "export interface Subscription {");
assert_has(&d, "export interface ModelEvents<Row> {");
assert_has(&d, "on<K extends keyof UmbralResources>");
assert_has(&d, "ModelEvents<Partial<UmbralResources[K][\"row\"]>>");
}
#[test]
fn realtime_delegates_and_never_opens_its_own_eventsource() {
let j = js();
assert_has(&j, "/client.js`");
assert_has(&j, "rt.model(String(table)");
assert_has(&j, "g.umbral && g.umbral.realtime");
assert!(
!j.contains("new EventSource") && !j.contains("new WebSocket"),
"the generated client must delegate to umbral.realtime, not open its own \
connection (one per subscription exhausts the browser's per-origin cap); \
got:\n{j}",
);
}
#[test]
fn js_is_a_self_contained_es_module() {
let j = js();
assert!(
!j.contains("import ") && !j.contains("from \"./"),
"client.js must be self-contained — no imports; got:\n{j}",
);
assert_has(&j, "export class Umbral {");
assert!(
!j.contains("): Promise<") && !j.contains("?: string;"),
"client.js must be plain JS — no TypeScript annotations leaked in; got:\n{j}",
);
}
#[test]
fn detail_urls_have_no_trailing_slash() {
let j = js();
for method in [
r#"get(table, id) { return this._request("GET", `/api/${table}/${id}`); }"#,
r#"update(table, id, data) { return this._request("PATCH", `/api/${table}/${id}`, data); }"#,
] {
assert_has(&j, method);
}
assert!(
!j.contains("${id}/`"),
"a detail URL must not end in a slash — the REST detail route does not \
serve one and the request 404s; got:\n{j}",
);
assert_has(&j, "`/api/${this.table}/`");
}