Files
windmill/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs
hugocasa 2ec1863340 fix: scope promotion-mode debounce key per repo (#9145)
* test(git-sync): regression tests for secondary promotion repos

Adds two integration tests that reproduce the bug where a second
promotion-mode repo's deployment callback was silently dropped via
debounce-key collision, plus the EE ref bump that includes the fix.

Updates the two existing promotion-mode debounce-key tests to expect
the new repo-namespaced key shape.

* test(git-sync): drop redundant distinct-debounce-keys test

The behavior test (`test_two_promotion_repos_both_enqueue_callback`)
already covers the same regression one layer up: if the debounce keys
collide, one callback gets marked skipped, which the behavior test
catches.

* chore: update ee-repo-ref to 7a32388adaa37eb1dd1820b40e140ff1877110f2

This commit updates the EE repository reference after PR #572 was merged in windmill-ee-private.

Previous ee-repo-ref: dbf26f5e4c01c0de536f606679be46eb316aaf31

New ee-repo-ref: 7a32388adaa37eb1dd1820b40e140ff1877110f2

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-13 11:35:30 +00:00

799 lines
27 KiB
Rust

/*!
* Integration tests for workspace dependencies git sync.
*
* These tests verify that creating, archiving, and deleting workspace dependencies
* triggers deployment callback jobs with the correct arguments for git sync.
*
* Run with enterprise features:
* ```bash
* cargo test --test workspace_dependencies_git_sync --features enterprise,private
* ```
*/
use serde_json::json;
use sqlx::{Pool, Postgres};
use std::time::Duration;
#[allow(unused_imports)]
use windmill_test_utils::*;
/// Row shape for querying deployment callback jobs from v2_job_queue
#[derive(Debug)]
#[allow(dead_code)]
struct DeploymentCallbackJob {
id: uuid::Uuid,
runnable_path: Option<String>,
args: Option<serde_json::Value>,
kind: String,
}
/// Poll for deployment callback jobs in the queue for a given script path
#[allow(dead_code)]
async fn get_deployment_callback_jobs(
db: &Pool<Postgres>,
script_path: &str,
timeout: Duration,
) -> anyhow::Result<Vec<DeploymentCallbackJob>> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let rows = sqlx::query_as!(
DeploymentCallbackJob,
r#"
SELECT j.id, j.runnable_path, j.args, j.kind::text AS "kind!"
FROM v2_job j
JOIN v2_job_queue q ON j.id = q.id
WHERE j.runnable_path = $1
AND j.kind = 'deploymentcallback'
ORDER BY j.created_at DESC
"#,
script_path,
)
.fetch_all(db)
.await?;
if !rows.is_empty() {
return Ok(rows);
}
if tokio::time::Instant::now() >= deadline {
// Return empty if timeout - caller will handle assertion
return Ok(vec![]);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Configure git sync for the test workspace with workspace dependencies enabled
#[allow(dead_code)]
async fn setup_git_sync_config(db: &Pool<Postgres>, sync_script_path: &str) -> anyhow::Result<()> {
let git_sync_config = json!({
"include_type": ["workspacedependencies"],
"include_path": ["**"],
"repositories": [{
"script_path": sync_script_path,
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
"use_individual_branch": false,
"group_by_folder": false
}]
});
sqlx::query!(
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
git_sync_config,
"test-workspace"
)
.execute(db)
.await?;
Ok(())
}
/// Create a git repository resource for testing
#[allow(dead_code)]
async fn create_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', 'u/test-user/test_git_repo', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user')
ON CONFLICT (workspace_id, path) DO NOTHING
"#,
)
.bind(json!({
"url": "https://github.com/test/test.git",
"branch": "main",
"token": "test-token"
}))
.execute(db)
.await?;
Ok(())
}
/// Create a dummy sync script for testing (with version >= 28103 for debouncing support)
#[allow(dead_code)]
async fn create_sync_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
sqlx::query(
r#"
INSERT INTO script (workspace_id, hash, path, summary, description, content,
created_by, language, kind, lock)
VALUES ('test-workspace', $1, $2, 'sync script', '',
'export function main(items: any[]) { return { synced: items.length }; }',
'test-user', 'bun', 'script', '')
"#,
)
.bind(hash)
.bind(path)
.execute(db)
.await?;
Ok(hash)
}
/// Create a folder for the versioned script path
#[allow(dead_code)]
async fn create_folder(db: &Pool<Postgres>, name: &str) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by)
VALUES ('test-workspace', $1, $1, ARRAY['u/test-user'], '{}'::jsonb, 'test-user')
ON CONFLICT (workspace_id, name) DO NOTHING
"#,
)
.bind(name)
.execute(db)
.await?;
Ok(())
}
// ============================================================================
// Tests
// ============================================================================
/// Test that creating a workspace dependency triggers a git sync deployment callback
/// with the correct path_type and path arguments.
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_create_workspace_dependencies_triggers_git_sync(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
// Setup: Create folder, git repo resource, sync script, and configure git sync
create_folder(&db, "28103").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_script";
create_sync_script(&db, sync_script_path).await?;
setup_git_sync_config(&db, sync_script_path).await?;
// Start API server
let (client, _port, _server) = init_client(db.clone()).await;
// Create workspace dependency via API
let response = client
.client()
.post(format!(
"{}/w/test-workspace/workspace_dependencies/create",
client.baseurl()
))
.json(&json!({
"workspace_id": "test-workspace",
"language": "python3",
"name": "test-deps",
"content": "requests==2.28.0\nnumpy==1.24.0"
}))
.send()
.await?;
assert!(
response.status().is_success(),
"Failed to create workspace dependency: {:?}",
response.text().await
);
// Wait for deployment callback job to be created
tokio::time::sleep(Duration::from_millis(500)).await;
// Query for deployment callback jobs
let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_secs(5)).await?;
assert!(
!jobs.is_empty(),
"Expected at least one deployment callback job to be created"
);
// Verify the job arguments
let job = &jobs[0];
let args = job.args.as_ref().expect("Job should have args");
// Check that path_type is "workspace_dependencies" (or check items array)
// The exact structure depends on whether debouncing is enabled
if let Some(items) = args.get("items") {
// Debounced format: items is an array
let items_arr = items.as_array().expect("items should be an array");
assert!(!items_arr.is_empty(), "items array should not be empty");
let item = &items_arr[0];
assert_eq!(
item.get("path_type").and_then(|v| v.as_str()),
Some("workspace_dependencies"),
"path_type should be 'workspace_dependencies'"
);
// Path should be "dependencies/test-deps.requirements.in" or similar
let path = item.get("path").and_then(|v| v.as_str()).unwrap_or("");
assert!(
path.contains("dependencies") || path.contains("requirements"),
"path should contain dependencies or requirements: got {}",
path
);
} else if let Some(path_type) = args.get("path_type") {
// Non-debounced format: path_type is a direct field
assert_eq!(
path_type.as_str(),
Some("workspace_dependencies"),
"path_type should be 'workspace_dependencies'"
);
} else {
panic!(
"Job args should contain either 'items' array or 'path_type' field: {:?}",
args
);
}
Ok(())
}
/// Test that archiving a workspace dependency triggers a git sync deployment callback
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_archive_workspace_dependencies_triggers_git_sync(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
// Setup
create_folder(&db, "28103").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_script_archive";
create_sync_script(&db, sync_script_path).await?;
setup_git_sync_config(&db, sync_script_path).await?;
let (client, _port, _server) = init_client(db.clone()).await;
// First create a workspace dependency
let create_response = client
.client()
.post(format!(
"{}/w/test-workspace/workspace_dependencies/create",
client.baseurl()
))
.json(&json!({
"workspace_id": "test-workspace",
"language": "python3",
"name": "archive-test-deps",
"content": "flask==2.0.0"
}))
.send()
.await?;
assert!(create_response.status().is_success());
// Wait a bit for the create job to be processed
tokio::time::sleep(Duration::from_millis(300)).await;
// Now archive it
let archive_response = client
.client()
.post(format!(
"{}/w/test-workspace/workspace_dependencies/archive/python3?name=archive-test-deps",
client.baseurl()
))
.send()
.await?;
assert!(
archive_response.status().is_success(),
"Failed to archive workspace dependency: {:?}",
archive_response.text().await
);
// Wait for deployment callback job
tokio::time::sleep(Duration::from_millis(500)).await;
// Verify at least one deployment callback job exists
// (create test already validates create triggers git sync; this test validates archive does too)
let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_secs(5)).await?;
assert!(
!jobs.is_empty(),
"Expected at least one deployment callback job after archive"
);
Ok(())
}
/// Test that workspace dependencies are NOT synced when workspacedependencies type is excluded
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_workspace_dependencies_respects_include_type_filter(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
// Setup with git sync that EXCLUDES workspacedependencies
create_folder(&db, "28103").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_script_filter";
create_sync_script(&db, sync_script_path).await?;
// Configure git sync to only include scripts (not workspace dependencies)
let git_sync_config = json!({
"include_type": ["script"], // Note: workspacedependencies is NOT included
"include_path": ["**"],
"repositories": [{
"script_path": sync_script_path,
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
"use_individual_branch": false,
"group_by_folder": false
}]
});
sqlx::query!(
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
git_sync_config,
"test-workspace"
)
.execute(&db)
.await?;
let (client, _port, _server) = init_client(db.clone()).await;
// Create workspace dependency
let response = client
.client()
.post(format!(
"{}/w/test-workspace/workspace_dependencies/create",
client.baseurl()
))
.json(&json!({
"workspace_id": "test-workspace",
"language": "python3",
"name": "filtered-deps",
"content": "django==4.0.0"
}))
.send()
.await?;
assert!(response.status().is_success());
// Wait a bit
tokio::time::sleep(Duration::from_secs(1)).await;
// Should NOT have any deployment callback jobs because workspacedependencies is filtered out
let jobs =
get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(500)).await?;
assert!(
jobs.is_empty(),
"Expected NO deployment callback jobs when workspacedependencies is not in include_type, got {}",
jobs.len()
);
Ok(())
}
/// Test that the commit message is correctly generated for workspace dependencies
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_workspace_dependencies_commit_message(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// Setup
create_folder(&db, "28103").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_script_msg";
create_sync_script(&db, sync_script_path).await?;
setup_git_sync_config(&db, sync_script_path).await?;
let (client, _port, _server) = init_client(db.clone()).await;
// Create workspace dependency
let response = client
.client()
.post(format!(
"{}/w/test-workspace/workspace_dependencies/create",
client.baseurl()
))
.json(&json!({
"workspace_id": "test-workspace",
"language": "bun",
"name": null, // unnamed/default dependency
"content": "lodash: ^4.17.21"
}))
.send()
.await?;
assert!(response.status().is_success());
tokio::time::sleep(Duration::from_millis(500)).await;
let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_secs(5)).await?;
assert!(!jobs.is_empty());
let job = &jobs[0];
let args = job.args.as_ref().expect("Job should have args");
// Check commit message format
if let Some(items) = args.get("items") {
let items_arr = items.as_array().expect("items should be an array");
if !items_arr.is_empty() {
let commit_msg = items_arr[0]
.get("commit_msg")
.and_then(|v| v.as_str())
.unwrap_or("");
assert!(
commit_msg.contains("[WM]"),
"Commit message should contain '[WM]' prefix: {}",
commit_msg
);
assert!(
commit_msg.to_lowercase().contains("workspace")
|| commit_msg.to_lowercase().contains("dependency")
|| commit_msg.to_lowercase().contains("deployed"),
"Commit message should mention workspace dependency or deployed: {}",
commit_msg
);
}
} else if let Some(commit_msg) = args.get("commit_msg").and_then(|v| v.as_str()) {
assert!(
commit_msg.contains("[WM]"),
"Commit message should contain '[WM]' prefix: {}",
commit_msg
);
}
Ok(())
}
// ============================================================================
// Promotion-mode debounce-key tests
// ============================================================================
/// Configure git sync in promotion mode (one branch per object), with explicit
/// include_type and include_path lists so script deploys fire the callback.
#[allow(dead_code)]
async fn setup_promotion_git_sync_config(
db: &Pool<Postgres>,
sync_script_path: &str,
group_by_folder: bool,
) -> anyhow::Result<()> {
let git_sync_config = json!({
"include_type": ["script"],
"include_path": ["**"],
"repositories": [{
"script_path": sync_script_path,
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
"use_individual_branch": true,
"group_by_folder": group_by_folder
}]
});
sqlx::query!(
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
git_sync_config,
"test-workspace"
)
.execute(db)
.await?;
Ok(())
}
/// Create a script via the API (triggering handle_deployment_metadata).
#[allow(dead_code)]
async fn create_test_script(
client: &windmill_api_client::Client,
path: &str,
) -> anyhow::Result<()> {
let resp = client
.client()
.post(format!(
"{}/w/test-workspace/scripts/create",
client.baseurl()
))
.json(&json!({
"path": path,
"summary": "",
"description": "",
// bash has no lock step, so handle_deployment_metadata runs
"content": "echo hi",
"language": "bash"
}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::ensure!(
status.is_success(),
"failed to create script {}: {} {}",
path,
status,
body
);
Ok(())
}
/// Poll the `debounce_key` table until `expected` appears, or timeout.
#[allow(dead_code)]
async fn wait_for_debounce_key(
db: &Pool<Postgres>,
expected: &str,
timeout: Duration,
) -> anyhow::Result<Vec<String>> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let keys: Vec<String> =
sqlx::query_scalar!("SELECT key FROM debounce_key WHERE key LIKE 'git_sync:%'")
.fetch_all(db)
.await?;
if keys.iter().any(|k| k == expected) {
return Ok(keys);
}
if tokio::time::Instant::now() >= deadline {
return Ok(keys);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Promotion mode with one branch per object: the debounce key must scope to
/// (path_type, path) so rapid re-edits of the same script collapse while
/// different scripts run in parallel.
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_promotion_individual_branch_debounces_per_path(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
create_folder(&db, "28103").await?;
create_folder(&db, "target").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_individual_branch";
create_sync_script(&db, sync_script_path).await?;
setup_promotion_git_sync_config(&db, sync_script_path, false).await?;
let (client, _port, _server) = init_client(db.clone()).await;
create_test_script(&client, "f/target/alpha").await?;
create_test_script(&client, "f/target/beta").await?;
// Wait for both deployment callbacks to resolve a debounce key. The
// alpha key is polled first as a warm-up, then we also wait for beta
// so the assertions don't race the second spawned callback.
// Keys are namespaced by the repo's resource path so multiple promotion
// repos don't collide on the same key.
let expected_alpha = "git_sync:$res:u/test-user/test_git_repo:script:f/target/alpha";
let expected_beta = "git_sync:$res:u/test-user/test_git_repo:script:f/target/beta";
let _ = wait_for_debounce_key(&db, expected_alpha, Duration::from_secs(5)).await?;
let keys = wait_for_debounce_key(&db, expected_beta, Duration::from_secs(5)).await?;
assert!(
keys.iter().any(|k| k == expected_alpha),
"expected per-path debounce key {expected_alpha} in {keys:?}"
);
assert!(
keys.iter().any(|k| k == expected_beta),
"expected per-path debounce key {expected_beta} in {keys:?}"
);
Ok(())
}
/// Create a second git repository resource for multi-repo tests.
#[allow(dead_code)]
async fn create_second_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', 'u/test-user/test_git_repo_2', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user')
ON CONFLICT (workspace_id, path) DO NOTHING
"#,
)
.bind(json!({
"url": "https://github.com/test/test2.git",
"branch": "main",
"token": "test-token-2"
}))
.execute(db)
.await?;
Ok(())
}
/// Configure git sync with TWO promotion-mode repositories pointing at distinct
/// git repo resources. Both repos use the same sync script and the same item
/// filters — they only differ in the repo they target.
#[allow(dead_code)]
async fn setup_two_promotion_repos_config(
db: &Pool<Postgres>,
sync_script_path: &str,
group_by_folder: bool,
) -> anyhow::Result<()> {
let git_sync_config = json!({
"include_type": ["script"],
"include_path": ["**"],
"repositories": [
{
"script_path": sync_script_path,
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
"use_individual_branch": true,
"group_by_folder": group_by_folder
},
{
"script_path": sync_script_path,
"git_repo_resource_path": "$res:u/test-user/test_git_repo_2",
"use_individual_branch": true,
"group_by_folder": group_by_folder
}
]
});
sqlx::query!(
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
git_sync_config,
"test-workspace"
)
.execute(db)
.await?;
Ok(())
}
/// Regression test for: when two promotion-mode repos are configured with the
/// same `use_individual_branch=true` settings (i.e. a primary and a secondary
/// promotion repo), deploying a single script must enqueue ONE deployment
/// callback per repo. Both callbacks must remain in the queue — neither may
/// be debounced into oblivion by the other.
///
/// The bug this guards against: the debounce key for promotion mode was
/// derived only from (path_type, path) and omitted any per-repo identifier,
/// so the second repo's push hit ON CONFLICT in `upsert_debounce_key` and
/// `complete_debounced_job` flagged the first repo's job as `status='skipped'`
/// — silently dropping one of the two pushes.
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_two_promotion_repos_both_enqueue_callback(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
create_folder(&db, "28103").await?;
create_folder(&db, "target").await?;
create_git_repo_resource(&db).await?;
create_second_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_two_promotion_repos";
create_sync_script(&db, sync_script_path).await?;
setup_two_promotion_repos_config(&db, sync_script_path, false).await?;
let (client, _port, _server) = init_client(db.clone()).await;
// Deploy a single script — handle_deployment_metadata should iterate
// both repos and create one callback job per repo.
create_test_script(&client, "f/target/alpha").await?;
// Both callbacks should reach the queue. With the bug, only one survives
// (the other is moved to v2_job_completed with status='skipped').
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let mut last_jobs: Vec<DeploymentCallbackJob> = vec![];
loop {
last_jobs =
get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(200)).await?;
if last_jobs.len() >= 2 {
break;
}
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Inspect what landed in v2_job_completed so failure messages explain why.
let skipped: Vec<(uuid::Uuid, String)> = sqlx::query_as(
r#"
SELECT c.id, c.status::text
FROM v2_job_completed c
JOIN v2_job j ON j.id = c.id
WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback'
"#,
)
.bind(sync_script_path)
.fetch_all(&db)
.await?;
assert_eq!(
last_jobs.len(),
2,
"expected 2 deployment callback jobs in v2_job_queue (one per promotion repo), got {} queued + {:?} completed",
last_jobs.len(),
skipped,
);
// Per-repo args sanity check: the two jobs must target different repos.
let mut repo_paths: Vec<String> = last_jobs
.iter()
.filter_map(|j| {
j.args
.as_ref()
.and_then(|a| a.get("repo_url_resource_path"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.collect();
repo_paths.sort();
repo_paths.dedup();
assert_eq!(
repo_paths.len(),
2,
"expected callbacks to target two distinct repos, got: {:?}",
last_jobs.iter().map(|j| &j.args).collect::<Vec<_>>()
);
// No callback should have been silently skipped via debouncing collision.
assert!(
skipped.iter().all(|(_, s)| s != "skipped"),
"no deployment callback should be marked skipped, got: {:?}",
skipped,
);
Ok(())
}
/// Promotion mode with group_by_folder: items destined for the same per-folder
/// branch must share one debounce key so they accumulate into a single sync
/// job; scripts in different folders must get distinct keys.
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_promotion_group_by_folder_debounces_per_folder(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
create_folder(&db, "28103").await?;
create_folder(&db, "grouped").await?;
create_folder(&db, "other").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_group_by_folder";
create_sync_script(&db, sync_script_path).await?;
setup_promotion_git_sync_config(&db, sync_script_path, true).await?;
let (client, _port, _server) = init_client(db.clone()).await;
// Two scripts in the same folder — should share one debounce key.
create_test_script(&client, "f/grouped/alpha").await?;
create_test_script(&client, "f/grouped/beta").await?;
// One in a different folder — should get its own key.
create_test_script(&client, "f/other/gamma").await?;
// Keys are namespaced by the repo's resource path.
let expected_grouped = "git_sync:$res:u/test-user/test_git_repo:folder:f/grouped";
let expected_other = "git_sync:$res:u/test-user/test_git_repo:folder:f/other";
// Wait for BOTH folder keys to appear, not just the first one.
let keys = wait_for_debounce_key(&db, expected_other, Duration::from_secs(5)).await?;
assert!(
keys.iter().any(|k| k == expected_grouped),
"expected per-folder debounce key {expected_grouped} in {keys:?}"
);
assert!(
keys.iter().any(|k| k == expected_other),
"expected per-folder debounce key {expected_other} in {keys:?}"
);
// Paths within the same folder must NOT leak as their own keys.
assert!(
!keys
.iter()
.any(|k| k.contains(":script:f/grouped/") || k.contains(":script:f/other/")),
"group_by_folder mode should not emit per-path keys, got: {keys:?}"
);
Ok(())
}