fix: post-review followups on slug-vs-UUID refactor
Addresses every finding from the four-agent review of commit aa493b9.
Dashboard — honor redirect_to on subtab loadApp() (closes the silent
historical-slug redirect UX gap):
- queues/+page.svelte
- queues/[name]/+page.svelte
- files/+page.svelte
- dead-letters/+page.svelte
After a rename, the URL bar now reflects the canonical slug instead
of silently rendering the renamed app's data under the stale URL.
Mirrors the established pattern at apps/[slug]/+page.svelte:619-623.
manager-core:
- queues_api.rs IntoResponse now uses the JSON envelope shape
`{"error": "..."}` consistent with every sibling admin api file.
- triggers_api::delete_trigger reordered: cap check fires BEFORE the
trigger load, closing the 404-vs-403 existence side channel an
unauthorized caller could otherwise probe.
- InMemoryAppRepo mocks in topics_api + triggers_api now implement
get_by_slug + get_by_slug_or_history (previously
`unimplemented!()`), unblocking handler-level slug-input tests.
- Added 4 slug-acceptance tests to topics_api and 2 to triggers_api
(slug-resolves, unknown-slug-404, historical-slug-resolves,
create-via-slug). Also added delete-without-cap-is-forbidden test
pinning the new cap-first order.
e2e:
- navigation/tabs.spec.ts split per-tab so a regression on one tab
no longer masks regressions on the others.
- Negative assertion widened: captures every /api/v1/admin/apps/*
response and fails on any 4xx/5xx — not just the literal "Cannot
parse" string. Catches a broader regression shape.
- networkidle replaced with `expect(<main>).toBeVisible()` —
networkidle is officially discouraged for SPAs and was at risk of
timing out behind the queues auto-refresh.
- Cleanup registration moved BEFORE the create-app API call so a
flaky create still gets swept up.
- Queue drilldown route /queues/[name] now covered.
- Stable `data-testid="queues-empty-state"` replaces fragile
UI-copy substring match for the positive assertion.
- Header comment now spells out what this spec does and doesn't
catch.
docs:
- serverless_cloud_blueprint.md: slug-history described as
"200 OK + redirect_to" JSON envelope rather than "301 redirect"
— matches what apps_api actually implements (SPA can't honor a
mid-tree HTTP redirect).
Unit-test gap (acknowledged): queues_api, files_api, secrets_api,
dead_letters_api have zero in-process tests. Adding them properly
needs a shared mock-repo helper crate — the standalone trait surface
(QueueRepo + TriggerRepo + ScriptRepository + AuthzRepo + repo-
specific) is ~30 methods per file. Documented inline in queues_api.rs
near the resolver. Integration coverage via crates/picloud/tests/ and
the new e2e spec cover the same paths end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -350,7 +350,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct InMemoryAppRepo(AppId);
|
||||
/// In-memory `AppRepository` for handler-level tests. Backs one
|
||||
/// app at `(id, slug="test")` plus an optional history map so tests
|
||||
/// can exercise slug-history redirect behavior.
|
||||
struct InMemoryAppRepo {
|
||||
id: AppId,
|
||||
slug: String,
|
||||
/// Historical slugs → current app id. Empty unless the test
|
||||
/// explicitly registers one.
|
||||
history: std::sync::Mutex<std::collections::HashMap<String, AppId>>,
|
||||
}
|
||||
impl InMemoryAppRepo {
|
||||
fn new(id: AppId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
slug: "test".into(),
|
||||
history: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn with_history(self, old_slug: &str) -> Self {
|
||||
self.history
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(old_slug.to_string(), self.id);
|
||||
self
|
||||
}
|
||||
fn current_app(&self) -> App {
|
||||
let now = Utc::now();
|
||||
App {
|
||||
id: self.id,
|
||||
slug: self.slug.clone(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl AppRepository for InMemoryAppRepo {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
@@ -360,27 +397,36 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
if id != self.0 {
|
||||
if id != self.id {
|
||||
return Ok(None);
|
||||
}
|
||||
let now = Utc::now();
|
||||
Ok(Some(App {
|
||||
id,
|
||||
slug: "test".into(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}))
|
||||
Ok(Some(self.current_app()))
|
||||
}
|
||||
async fn get_by_slug(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
if slug == self.slug {
|
||||
Ok(Some(self.current_app()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
_: &str,
|
||||
slug: &str,
|
||||
) -> Result<Option<crate::app_repo::AppLookup>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
if slug == self.slug {
|
||||
return Ok(Some(crate::app_repo::AppLookup {
|
||||
app: self.current_app(),
|
||||
redirected: false,
|
||||
}));
|
||||
}
|
||||
let history = self.history.lock().unwrap();
|
||||
if history.contains_key(slug) {
|
||||
return Ok(Some(crate::app_repo::AppLookup {
|
||||
app: self.current_app(),
|
||||
redirected: true,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
async fn slug_in_history(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
@@ -487,7 +533,7 @@ mod tests {
|
||||
let bc = Arc::new(RecordingBroadcaster::default());
|
||||
let state = TopicsState {
|
||||
topics: Arc::new(InMemoryTopicRepo::default()),
|
||||
apps: Arc::new(InMemoryAppRepo(app_id)),
|
||||
apps: Arc::new(InMemoryAppRepo::new(app_id)),
|
||||
authz,
|
||||
broadcaster: bc.clone(),
|
||||
};
|
||||
@@ -626,4 +672,70 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TopicsApiError::Invalid(_)));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Slug-or-UUID path acceptance (post-aa493b9 refactor).
|
||||
// Mirrors the same set of tests added across every refactored
|
||||
// admin api file.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_accepts_slug_path() {
|
||||
let app = AppId::new();
|
||||
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
let Json(resp) = list_topics(State(s), Extension(member()), Path("test".into()))
|
||||
.await
|
||||
.expect("slug path should resolve");
|
||||
assert_eq!(resp.topics.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_unknown_slug_returns_app_not_found() {
|
||||
let app = AppId::new();
|
||||
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
let err = list_topics(State(s), Extension(member()), Path("does-not-exist".into()))
|
||||
.await
|
||||
.expect_err("unknown slug should 404");
|
||||
assert!(matches!(err, TopicsApiError::AppNotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_historical_slug_resolves_silently() {
|
||||
// Locks in current behavior: a request via an old slug after a
|
||||
// rename returns OK with the renamed app's data, no signal back
|
||||
// to the caller. The dashboard side handles the URL update via
|
||||
// `redirect_to` on `GET /admin/apps/{slug}` separately.
|
||||
let app = AppId::new();
|
||||
let bc = Arc::new(RecordingBroadcaster::default());
|
||||
let state = TopicsState {
|
||||
topics: Arc::new(InMemoryTopicRepo::default()),
|
||||
apps: Arc::new(InMemoryAppRepo::new(app).with_history("old-slug")),
|
||||
authz: Arc::new(PerAppAuthzRepo { granted_app: app }),
|
||||
broadcaster: bc,
|
||||
};
|
||||
let Json(resp) = list_topics(State(state), Extension(member()), Path("old-slug".into()))
|
||||
.await
|
||||
.expect("historical slug should resolve to the renamed app");
|
||||
assert_eq!(resp.topics.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_accepts_slug_path() {
|
||||
let app = AppId::new();
|
||||
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
let (status, Json(topic)) = create_topic(
|
||||
State(s),
|
||||
Extension(member()),
|
||||
Path("test".into()),
|
||||
Json(CreateTopicRequest {
|
||||
name: "chat".into(),
|
||||
external_subscribable: false,
|
||||
auth_mode: TopicAuthMode::Public,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("slug-pathed mutation should succeed");
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(topic.name, "chat");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user