···11+use dioxus::prelude::*;
22+33+use crate::Route;
44+55+#[component]
66+pub(crate) fn Foundation() -> Element {
77+ rsx! {
88+ main { class: "browse-page product-shell",
99+ header { class: "product-header",
1010+ div { class: "product-kicker", "PM-38 foundation" }
1111+ div { class: "product-header-row",
1212+ div {
1313+ h1 { "Foundation" }
1414+ p { class: "product-lede", "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." }
1515+ }
1616+ nav { class: "product-nav", aria_label: "Foundation navigation",
1717+ Link { to: Route::Browse {}, "Back to home" }
1818+ a { href: "#ui-states", "UI states" }
1919+ }
2020+ }
2121+ }
2222+2323+ section { id: "browse-foundation", class: "foundation-hero blueprint-panel",
2424+ div { class: "foundation-hero-copy",
2525+ p { class: "eyebrow", "Token and asset check" }
2626+ h2 { "Blueprint styling foundation" }
2727+ p { "This route keeps PM-38 primitives visible without making implementation scaffolding the product home." }
2828+ div { class: "button-row",
2929+ button { class: "button button-primary", "Primary action" }
3030+ button { class: "button button-ghost", "Ghost action" }
3131+ }
3232+ }
3333+ div { class: "blueprint-preview", aria_label: "Blueprint placeholder preview",
3434+ div { class: "blueprint-preview-grid" }
3535+ span { class: "blueprint-axis blueprint-axis-x", "X" }
3636+ span { class: "blueprint-axis blueprint-axis-y", "Y" }
3737+ span { class: "blueprint-dimension", "120 × 86 × 42 mm" }
3838+ }
3939+ }
4040+4141+ section { class: "foundation-grid", aria_label: "Foundation primitives",
4242+ for item in foundation_items() { FoundationCard { item } }
4343+ }
4444+4545+ section { id: "ui-states", class: "state-grid", aria_label: "Reusable states",
4646+ div { class: "state-card loading-state", span { class: "status-pill", "Loading" } div { class: "skeleton-line skeleton-wide" } div { class: "skeleton-line" } div { class: "skeleton-box" } }
4747+ div { class: "state-card empty-state", span { class: "status-pill status-muted", "Empty" } h2 { "No projects yet" } p { "Empty browse states can use this panel before live feeds are connected." } }
4848+ div { class: "state-card error-state", span { class: "status-pill status-error", "Error" } h2 { "Feed unavailable" } p { "Error states keep the browse surface useful without inventing data plumbing in this slice." } }
4949+ }
5050+ }
5151+ }
5252+}
5353+5454+#[derive(Clone, Copy, PartialEq)]
5555+struct FoundationItem {
5656+ label: &'static str,
5757+ title: &'static str,
5858+ body: &'static str,
5959+ meta: &'static str,
6060+}
6161+6262+fn foundation_items() -> [FoundationItem; 3] {
6363+ [
6464+ FoundationItem { label: "Panel", title: "Project card surface", body: "A crisp bordered card for future model summaries, previews, and maker metadata.", meta: "primitive/card" },
6565+ FoundationItem { label: "Input", title: "Search and filter shell", body: "Reusable input styling for browse, profile, and publish flows without binding to a data source yet.", meta: "primitive/input" },
6666+ FoundationItem { label: "Status", title: "Metadata and chips", body: "Compact mono labels for license, dimensions, file state, and feed status.", meta: "primitive/status" },
6767+ ]
6868+}
6969+7070+#[component]
7171+fn FoundationCard(item: FoundationItem) -> Element {
7272+ rsx! {
7373+ article { class: "foundation-card card-surface",
7474+ div { class: "card-media blueprint-media", span { class: "status-pill", "{item.label}" } }
7575+ div { class: "card-body", h2 { class: "card-title", "{item.title}" } p { "{item.body}" } span { class: "card-meta", "{item.meta}" } }
7676+ }
7777+ }
7878+}
7979+8080+#[cfg(test)]
8181+mod tests {
8282+ use super::*;
8383+8484+ #[test]
8585+ fn foundation_items_cover_base_primitives() {
8686+ let items = foundation_items();
8787+ assert!(items.iter().any(|item| item.label == "Panel"));
8888+ assert!(items.iter().any(|item| item.label == "Input"));
8989+ assert!(items.iter().any(|item| item.label == "Status"));
9090+ }
9191+9292+ #[test]
9393+ fn foundation_copy_avoids_workflow_marketing() {
9494+ let combined = foundation_items().iter().map(|item| item.body).collect::<Vec<_>>().join(" ");
9595+ assert!(!combined.contains("Jira"));
9696+ assert!(!combined.contains("Polytoken"));
9797+ }
9898+}
+61
src/indexing/config.rs
···2424 /// defaults the OAuth client origin to `https://polymodel.space`, a functional
2525 /// hosted client; set `POLYMODEL_BASE_URL` for local or alternate origins.
2626 pub base_url: Option<String>,
2727+ /// Local/demo sample-data mode. `auto` seeds conservative local SQLite DBs
2828+ /// only when empty; `force` seeds any SQLite DB when empty; `off` disables it.
2929+ pub sample_data: SampleDataMode,
3030+}
3131+3232+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3333+pub enum SampleDataMode {
3434+ Auto,
3535+ Off,
3636+ Force,
3737+}
3838+3939+impl SampleDataMode {
4040+ fn from_env(value: Option<String>) -> Self {
4141+ match value.as_deref().map(str::trim).map(str::to_ascii_lowercase) {
4242+ Some(value) if value == "off" => Self::Off,
4343+ Some(value) if value == "force" => Self::Force,
4444+ Some(value) if value == "auto" => Self::Auto,
4545+ Some(value) => {
4646+ tracing::warn!(%value, "unknown POLYMODEL_SAMPLE_DATA value; falling back to off");
4747+ Self::Off
4848+ }
4949+ None => Self::Auto,
5050+ }
5151+ }
5252+5353+ pub fn should_seed(self, database_url: &str) -> bool {
5454+ match self {
5555+ Self::Off => false,
5656+ Self::Force => database_url.starts_with("sqlite:"),
5757+ Self::Auto => is_local_sqlite(database_url),
5858+ }
5959+ }
6060+}
6161+6262+fn is_local_sqlite(database_url: &str) -> bool {
6363+ database_url == "sqlite::memory:"
6464+ || database_url.starts_with("sqlite:./")
6565+ || database_url.starts_with("sqlite:../")
6666+ || database_url.starts_with("sqlite:/")
2767}
28682969impl ServerConfig {
···3878 .ok()
3979 .map(|s| s.trim().to_owned())
4080 .filter(|s| !s.is_empty());
8181+ let sample_data = SampleDataMode::from_env(env::var("POLYMODEL_SAMPLE_DATA").ok());
4182 Self {
4283 database_url,
4384 base_url,
8585+ sample_data,
4486 }
4587 }
4688}
8989+9090+#[cfg(test)]
9191+mod tests {
9292+ use super::*;
9393+9494+ #[test]
9595+ fn sample_data_auto_is_limited_to_local_sqlite_urls() {
9696+ assert!(SampleDataMode::Auto.should_seed("sqlite:./data/polymodel.db"));
9797+ assert!(SampleDataMode::Auto.should_seed("sqlite:/tmp/polymodel.db"));
9898+ assert!(!SampleDataMode::Auto.should_seed("postgres://example"));
9999+ }
100100+101101+ #[test]
102102+ fn sample_data_env_parses_known_values() {
103103+ assert_eq!(SampleDataMode::from_env(Some("off".into())), SampleDataMode::Off);
104104+ assert_eq!(SampleDataMode::from_env(Some("force".into())), SampleDataMode::Force);
105105+ assert_eq!(SampleDataMode::from_env(Some("auto".into())), SampleDataMode::Auto);
106106+ }
107107+}
+3
src/indexing/mod.rs
···99//! 1. Hydrant's fjall store (`HYDRANT_DATABASE_PATH`) for raw ATProto records.
1010//! 2. The SQLite projection database (`DATABASE_URL`) for app-specific views.
11111212+#![cfg_attr(test, allow(dead_code))]
1313+1214pub mod config;
1315pub mod db;
1416pub mod projection;
1717+pub mod sample_data;
1518pub mod setup;
16191720use std::time::Duration;
+390
src/indexing/sample_data.rs
···11+//! Deterministic local/demo sample data for appview read paths.
22+//!
33+//! This module writes projection-shaped SQLite rows only when the local/demo
44+//! database has no Polymodel things. It lets ordinary local `just serve` and e2e
55+//! exercise the same `getFeed`, `getThing`, and `getModel` appview contracts as
66+//! production reads without UI-only fixtures.
77+88+use jacquard_common::types::blob::BlobRef;
99+use polymodel_api::space_polymodel::{
1010+ actor::profile::Profile,
1111+ library::{File, model::Model, part::Part, thing::Thing},
1212+};
1313+use serde::{Serialize, de::DeserializeOwned};
1414+use serde_json::{Value, json};
1515+use sqlx::{SqliteConnection, SqlitePool};
1616+1717+pub const SAMPLE_DID: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa";
1818+pub const SAMPLE_HANDLE: &str = "ari.model.tools";
1919+pub const SAMPLE_THING_RKEY: &str = "parametric-enclosure";
2020+pub const SAMPLE_THING_URI: &str =
2121+ "at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.thing/parametric-enclosure";
2222+2323+const SAMPLE_TIME: &str = "2026-06-21T12:00:00.000Z";
2424+const SAMPLE_TIME_MILLIS: i64 = 1_750_507_200_000;
2525+2626+pub async fn seed_if_empty(pool: &SqlitePool) -> anyhow::Result<()> {
2727+ let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM things")
2828+ .fetch_one(pool)
2929+ .await?;
3030+ if count > 0 {
3131+ tracing::debug!(count, "sample data skipped because things table is populated");
3232+ return Ok(());
3333+ }
3434+3535+ seed(pool).await?;
3636+ tracing::info!(thing_uri = SAMPLE_THING_URI, "seeded local/demo Polymodel sample data");
3737+ Ok(())
3838+}
3939+4040+async fn seed(pool: &SqlitePool) -> anyhow::Result<()> {
4141+ let mut tx = pool.begin().await?;
4242+4343+ insert_identity(&mut tx, SAMPLE_DID, SAMPLE_HANDLE).await?;
4444+ insert_profile(&mut tx).await?;
4545+4646+ let model_uris = vec![
4747+ model_uri("enclosure-v1"),
4848+ model_uri("panel-variant"),
4949+ model_uri("ruggedized-lid"),
5050+ ];
5151+ let parts = sample_parts();
5252+ for part in &parts {
5353+ insert_part(&mut tx, part).await?;
5454+ }
5555+5656+ insert_model(
5757+ &mut tx,
5858+ "enclosure-v1",
5959+ "Snap-fit electronics enclosure",
6060+ "Main printable enclosure with lid, body, buttons, and mounting hardware.",
6161+ &parts[0..6],
6262+ )
6363+ .await?;
6464+ insert_model(
6565+ &mut tx,
6666+ "panel-variant",
6767+ "Panel-mount variant",
6868+ "Alternate front panel for flush mounting sensors and displays.",
6969+ &parts[6..12],
7070+ )
7171+ .await?;
7272+ insert_model(
7373+ &mut tx,
7474+ "ruggedized-lid",
7575+ "Ruggedized outdoor lid",
7676+ "Weather-resistant lid and gasket parts for workshop deployments.",
7777+ &parts[12..18],
7878+ )
7979+ .await?;
8080+8181+ let instructions = vec![
8282+ "Print the body and lid in PETG or PLA+ with 0.2 mm layers.".to_string(),
8383+ "Use the model selector to choose the standard, panel-mount, or ruggedized variant.".to_string(),
8484+ "Press brass inserts into the corner bosses before final assembly.".to_string(),
8585+ ];
8686+ let tags = vec!["enclosure", "parametric", "print-in-place"];
8787+ let thing_record = json!({
8888+ "name": "Parametric enclosure kit",
8989+ "summary": "A configurable electronics enclosure kit with three printable model variants and detailed part files.",
9090+ "instructions": instructions,
9191+ "license": "CC-BY-4.0",
9292+ "tags": tags,
9393+ "cover": [image("bafkreiathingcover", "Rendered preview of a parametric electronics enclosure")],
9494+ "previews": [image("bafkreiathingpreview", "Exploded blueprint preview of the enclosure kit")],
9595+ "models": model_uris.iter().map(|uri| strong_ref(uri)).collect::<Vec<_>>(),
9696+ "createdAt": SAMPLE_TIME
9797+ });
9898+9999+ let thing_record_json = typed_json::<Thing>(thing_record.clone())?;
100100+101101+ sqlx::query(
102102+ r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json,
103103+ tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json)
104104+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)"#,
105105+ )
106106+ .bind(SAMPLE_DID)
107107+ .bind(SAMPLE_THING_RKEY)
108108+ .bind(SAMPLE_THING_URI)
109109+ .bind("bafyreipolymodelsamplething")
110110+ .bind("Parametric enclosure kit")
111111+ .bind("A configurable electronics enclosure kit with three printable model variants and detailed part files.")
112112+ .bind("CC-BY-4.0")
113113+ .bind(serde_json::to_string(&tags)?)
114114+ .bind(tags.join(" "))
115115+ .bind(instructions.join("\n"))
116116+ .bind(serde_json::to_string(&thing_record["cover"])? )
117117+ .bind(SAMPLE_TIME_MILLIS)
118118+ .bind(SAMPLE_TIME_MILLIS)
119119+ .bind(thing_record_json)
120120+ .execute(&mut *tx)
121121+ .await?;
122122+123123+ for (position, model_uri) in model_uris.iter().enumerate() {
124124+ sqlx::query(
125125+ "INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)",
126126+ )
127127+ .bind(SAMPLE_THING_URI)
128128+ .bind(model_uri)
129129+ .bind(position as i64)
130130+ .execute(&mut *tx)
131131+ .await?;
132132+ }
133133+134134+ sqlx::query(
135135+ "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, 42, 12, 3)",
136136+ )
137137+ .bind(SAMPLE_THING_URI)
138138+ .execute(&mut *tx)
139139+ .await?;
140140+141141+ tx.commit().await?;
142142+ Ok(())
143143+}
144144+145145+async fn insert_identity(conn: &mut SqliteConnection, did: &str, handle: &str) -> anyhow::Result<()> {
146146+ sqlx::query("INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?)")
147147+ .bind(did)
148148+ .bind(handle)
149149+ .bind(SAMPLE_TIME_MILLIS)
150150+ .execute(&mut *conn)
151151+ .await?;
152152+ Ok(())
153153+}
154154+155155+async fn insert_profile(conn: &mut SqliteConnection) -> anyhow::Result<()> {
156156+ let avatar = typed_json::<BlobRef>(blob("bafkreiariavatar", "image/jpeg", 2048))?;
157157+ let profile_record = typed_json::<Profile>(json!({
158158+ "displayName": "Ari Chen",
159159+ "description": "Maker of practical parametric fixtures and electronics housings.",
160160+ "avatar": serde_json::from_str::<Value>(&avatar)?,
161161+ "pronouns": "she/her",
162162+ "defaultLicense": "CC-BY-4.0"
163163+ }))?;
164164+ sqlx::query(
165165+ r#"INSERT INTO profiles (did, display_name, description, avatar_json, default_license,
166166+ pronouns, printers_json, links_json, record_json, indexed_at)
167167+ VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)"#,
168168+ )
169169+ .bind(SAMPLE_DID)
170170+ .bind("Ari Chen")
171171+ .bind("Maker of practical parametric fixtures and electronics housings.")
172172+ .bind(&avatar)
173173+ .bind("CC-BY-4.0")
174174+ .bind("she/her")
175175+ .bind(profile_record)
176176+ .bind(SAMPLE_TIME_MILLIS)
177177+ .execute(&mut *conn)
178178+ .await?;
179179+ Ok(())
180180+}
181181+182182+async fn insert_model(
183183+ conn: &mut SqliteConnection,
184184+ rkey: &str,
185185+ name: &str,
186186+ summary: &str,
187187+ parts: &[SamplePart],
188188+) -> anyhow::Result<()> {
189189+ let uri = model_uri(rkey);
190190+ let record = typed_json::<Model>(json!({
191191+ "name": name,
192192+ "summary": summary,
193193+ "instructions": ["Review the ordered part list before printing.", "Print fit-critical clips slowly for best tolerances."],
194194+ "license": "CC-BY-4.0",
195195+ "tags": ["variant", "enclosure"],
196196+ "cover": [image("bafkreimodelcover", &format!("Preview of {name}"))],
197197+ "previews": [image("bafkreimodelpreview", &format!("Blueprint preview of {name}"))],
198198+ "parts": parts.iter().map(|part| strong_ref(&part_uri(part.rkey))).collect::<Vec<_>>(),
199199+ "createdAt": SAMPLE_TIME
200200+ }))?;
201201+202202+ sqlx::query(
203203+ r#"INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json)
204204+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
205205+ )
206206+ .bind(SAMPLE_DID)
207207+ .bind(rkey)
208208+ .bind(&uri)
209209+ .bind(format!("bafyreimodel{rkey}"))
210210+ .bind(name)
211211+ .bind(summary)
212212+ .bind(SAMPLE_TIME_MILLIS)
213213+ .bind(SAMPLE_TIME_MILLIS)
214214+ .bind(record)
215215+ .execute(&mut *conn)
216216+ .await?;
217217+218218+ for (position, part) in parts.iter().enumerate() {
219219+ sqlx::query("INSERT INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)")
220220+ .bind(&uri)
221221+ .bind(part_uri(part.rkey))
222222+ .bind(position as i64)
223223+ .execute(&mut *conn)
224224+ .await?;
225225+ }
226226+ Ok(())
227227+}
228228+229229+async fn insert_part(conn: &mut SqliteConnection, part: &SamplePart) -> anyhow::Result<()> {
230230+ let file = file_manifest(part.file_size);
231231+ let file_json = typed_json::<File>(file.clone())?;
232232+ let record = typed_json::<Part>(json!({
233233+ "name": part.name,
234234+ "file": file,
235235+ "format": "STL",
236236+ "dimensions": { "x": part.dimensions.0, "y": part.dimensions.1, "z": part.dimensions.2, "unit": "mm" },
237237+ "units": "mm",
238238+ "notes": part.notes,
239239+ "printSettings": ["0.2 mm layer height", "15% gyroid infill", "No supports required unless noted"],
240240+ "previews": [image("bafkreipartpreview", &format!("Preview of {}", part.name))],
241241+ "createdAt": SAMPLE_TIME
242242+ }))?;
243243+244244+ sqlx::query(
245245+ r#"INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json)
246246+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
247247+ )
248248+ .bind(SAMPLE_DID)
249249+ .bind(part.rkey)
250250+ .bind(part_uri(part.rkey))
251251+ .bind(format!("bafyreipart{}", part.rkey))
252252+ .bind(part.name)
253253+ .bind("STL")
254254+ .bind(file_json)
255255+ .bind(SAMPLE_TIME_MILLIS)
256256+ .bind(SAMPLE_TIME_MILLIS)
257257+ .bind(record)
258258+ .execute(&mut *conn)
259259+ .await?;
260260+ Ok(())
261261+}
262262+263263+#[derive(Clone, Copy)]
264264+struct SamplePart {
265265+ rkey: &'static str,
266266+ name: &'static str,
267267+ file_size: i64,
268268+ dimensions: (&'static str, &'static str, &'static str),
269269+ notes: &'static str,
270270+}
271271+272272+fn sample_parts() -> Vec<SamplePart> {
273273+ [
274274+ ("part-01", "Main enclosure body"),
275275+ ("part-02", "Snap-fit lid"),
276276+ ("part-03", "Button cap set"),
277277+ ("part-04", "USB-C bezel"),
278278+ ("part-05", "PCB mounting tray"),
279279+ ("part-06", "Corner screw boss"),
280280+ ("part-07", "Front sensor panel"),
281281+ ("part-08", "Display window frame"),
282282+ ("part-09", "Panel latch pair"),
283283+ ("part-10", "Flush wall bracket"),
284284+ ("part-11", "Cable strain relief"),
285285+ ("part-12", "Label plate"),
286286+ ("part-13", "Ruggedized lid shell"),
287287+ ("part-14", "TPU gasket guide"),
288288+ ("part-15", "Outdoor cable gland"),
289289+ ("part-16", "Hinged dust cover"),
290290+ ("part-17", "Drain slot insert"),
291291+ ("part-18", "Mounting foot set"),
292292+ ]
293293+ .into_iter()
294294+ .enumerate()
295295+ .map(|(index, (rkey, name))| SamplePart {
296296+ rkey,
297297+ name,
298298+ file_size: 48_000 + ((index + 1) as i64 * 2_048),
299299+ dimensions: ("86.0", "54.0", "18.5"),
300300+ notes: "Orient the visible face upward and verify first-layer adhesion around clips.",
301301+ })
302302+ .collect()
303303+}
304304+305305+fn model_uri(rkey: &str) -> String {
306306+ format!("at://{SAMPLE_DID}/space.polymodel.library.model/{rkey}")
307307+}
308308+309309+fn part_uri(rkey: &str) -> String {
310310+ format!("at://{SAMPLE_DID}/space.polymodel.library.part/{rkey}")
311311+}
312312+313313+fn strong_ref(uri: &str) -> Value {
314314+ json!({ "uri": uri, "cid": "bafyreicid" })
315315+}
316316+317317+fn image(cid: &str, alt: &str) -> Value {
318318+ json!({
319319+ "alt": alt,
320320+ "aspectRatio": { "width": 4, "height": 3 },
321321+ "image": blob(cid, "image/jpeg", 1234)
322322+ })
323323+}
324324+325325+fn file_manifest(size: i64) -> Value {
326326+ json!({
327327+ "mimeType": "model/stl",
328328+ "size": size,
329329+ "digest": "sha256:sample-polymodel-fixture",
330330+ "chunks": [{ "blob": blob("bafkreistlchunk", "model/stl", size), "offset": 0, "size": size }]
331331+ })
332332+}
333333+334334+fn blob(cid: &str, mime_type: &str, size: i64) -> Value {
335335+ json!({ "$type": "blob", "ref": { "$link": cid }, "mimeType": mime_type, "size": size })
336336+}
337337+338338+fn typed_json<T>(value: Value) -> anyhow::Result<String>
339339+where
340340+ T: DeserializeOwned + Serialize,
341341+{
342342+ let typed: T = serde_json::from_value(value)?;
343343+ Ok(serde_json::to_string(&typed)?)
344344+}
345345+346346+#[cfg(test)]
347347+mod tests {
348348+ use std::str::FromStr;
349349+ use std::sync::Arc;
350350+351351+ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
352352+353353+ use super::*;
354354+ use crate::appview::{state::AppState, views};
355355+356356+ async fn test_pool() -> SqlitePool {
357357+ let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
358358+ let pool = SqlitePoolOptions::new()
359359+ .max_connections(1)
360360+ .connect_with(options)
361361+ .await
362362+ .unwrap();
363363+ sqlx::migrate!("./migrations").run(&pool).await.unwrap();
364364+ pool
365365+ }
366366+367367+ #[tokio::test]
368368+ async fn seed_populates_feed_thing_and_model_read_paths() {
369369+ let pool = test_pool().await;
370370+ seed_if_empty(&pool).await.unwrap();
371371+ seed_if_empty(&pool).await.unwrap();
372372+ let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost"))
373373+ .expect("ephemeral OAuth bootstrap for tests");
374374+ let state = Arc::new(AppState::new(pool, bootstrap));
375375+376376+ let feed = views::feed_recent(&state, 10, None, None).await.unwrap();
377377+ assert_eq!(feed.items.len(), 1);
378378+ assert_eq!(feed.items[0].thing.name.as_str(), "Parametric enclosure kit");
379379+ assert_eq!(feed.items[0].thing.model_count, 3);
380380+ assert_eq!(feed.items[0].thing.part_count, 18);
381381+382382+ let (thing, models) = views::get_thing(&state, SAMPLE_THING_URI, None).await.unwrap();
383383+ assert_eq!(thing.author.display_name.as_ref().unwrap().as_str(), "Ari Chen");
384384+ assert_eq!(models.len(), 3);
385385+386386+ let detail = views::get_model(&state, models[0].uri.as_ref(), None).await.unwrap();
387387+ assert_eq!(detail.parts.len(), 6);
388388+ assert_eq!(detail.parts[0].name.as_str(), "Main enclosure body");
389389+ }
390390+}