Phase 3+: Integration Seams & CI Gates (#39)

* chore: add fork-diff oracle script + update test strategy plan

Creates .github/scripts/diff-to-test-prompt.sh for LLM test generation from git diffs. Marks Phase 3 tasks complete in plan (PrismaRepository blocked: 0 models in schema).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(droplet): add cross-crate pipeline integration test (libarchive->droplet)

2 new integration tests validating directory-to-manifest pipeline: basic 5-file manifest and multi-chunk (144 MiB) manifest. 27 total tests pass.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: John Smith <you@example.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
BillyOutlast 2026-07-25 19:33:34 -04:00 committed by GitHub
parent aa8a176a15
commit 5f49b7ad78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 752 additions and 0 deletions

199
.github/scripts/diff-to-test-prompt.sh vendored Executable file
View file

@ -0,0 +1,199 @@
#!/usr/bin/env bash
# ============================================================================
# diff-to-test-prompt.sh — Fork-diff to LLM test-generation prompt
# ============================================================================
# Reads a git diff (stdin or file arg) and wraps it in a structured prompt
# for an LLM to generate tests. The diff IS the spec — every changed line is
# a behavioral claim that tests must verify.
#
# Usage:
# git diff upstream/main...HEAD | .github/scripts/diff-to-test-prompt.sh
# .github/scripts/diff-to-test-prompt.sh path/to/diff.txt
#
# Output: A self-contained prompt with workspace detection, test framework
# hints, and suggested test-file locations.
#
# Workspace detection (by path prefix):
# server/ → vitest (Nuxt env) → server/test/unit/<module>/
# cli/ → cargo test → cli/tests/ or inline #[cfg(test)]
# desktop/ → cargo test → desktop/src-tauri/<crate>/tests/
# libraries/ → cargo test → inline #[cfg(test)]
# other → vitest (generic) → <workspace>/test/
# ============================================================================
set -euo pipefail
# ---- Help ------------------------------------------------------------------
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
sed -n '3,19p' "$0"
exit 0
fi
# ---- Read diff -------------------------------------------------------------
DIFF_CONTENT=""
if [[ $# -ge 1 && -f "$1" ]]; then
DIFF_CONTENT="$(cat "$1")"
elif [[ ! -t 0 ]]; then
DIFF_CONTENT="$(cat)"
else
echo "ERROR: Provide a diff file or pipe diff to stdin." >&2
echo "Usage: git diff upstream/main...HEAD | $0" >&2
echo " $0 path/to/diff.txt" >&2
exit 1
fi
if [[ -z "$DIFF_CONTENT" ]]; then
echo "ERROR: Empty diff input." >&2
exit 1
fi
# ---- Workspace detection ---------------------------------------------------
detect_workspace() {
local diff="$1"
local workspaces=()
if echo "$diff" | grep -q '^diff --git a/desktop/'; then
workspaces+=("desktop/")
fi
if echo "$diff" | grep -q '^diff --git a/cli/'; then
workspaces+=("cli/")
fi
if echo "$diff" | grep -q '^diff --git a/libraries/'; then
workspaces+=("libraries/")
fi
if echo "$diff" | grep -q '^diff --git a/server/'; then
workspaces+=("server/")
fi
if echo "$diff" | grep -q '^diff --git a/sites/'; then
workspaces+=("sites/")
fi
if [[ ${#workspaces[@]} -eq 0 ]]; then
echo "unknown"
else
printf '%s\n' "${workspaces[@]}" | sort -u | paste -sd ' ' -
fi
}
detect_test_location() {
local diff="$1"
local file dir
# Extract first changed file path, strip filename to get directory
file="$(echo "$diff" | grep '^diff --git' | head -1 | sed 's/^diff --git a\/\(.*\) b\/.*/\1/')"
dir="$(dirname "$file")"
# Extract module name: the path segment after the workspace's source root.
# server/server/api/v1/users.ts → module=api
# server/server/internal/auth/ → module=auth
# cli/src/commands/upload.rs → module=commands
local module=""
case "$dir" in
server/server/api/v1*)
module="api"
echo "server/test/unit/${module}/"
;;
server/server/internal/*)
module="$(echo "$dir" | sed 's|server/server/internal/||; s|/.*||')"
echo "server/test/unit/${module}/"
;;
server/components/*)
module="$(echo "$dir" | sed 's|server/components/||; s|/.*||')"
[[ -n "$module" ]] && echo "server/test/unit/components/${module}/" || echo "server/test/unit/components/"
;;
server/pages/*)
echo "server/test/unit/pages/"
;;
server/composables/*)
echo "server/test/unit/"
;;
server/server/*)
echo "server/test/unit/misc/"
;;
server/prisma/*)
echo "server/test/integration/"
;;
cli/src/*)
module="$(echo "$dir" | sed 's|cli/src/||; s|/.*||')"
[[ -n "$module" ]] && echo "cli/tests/${module}/ or inline #[cfg(test)]" || echo "cli/tests/ or inline #[cfg(test)]"
;;
desktop/src-tauri/*)
module="$(echo "$dir" | sed 's|desktop/src-tauri/||; s|/.*||')"
echo "desktop/src-tauri/${module}/tests/"
;;
libraries/*)
echo "inline #[cfg(test)] mod tests { ... } in the source file"
;;
sites/*)
echo "test/ (co-located with source workspace)"
;;
*)
echo "test/ (co-located with source)"
;;
esac
}
WORKSPACES="$(detect_workspace "$DIFF_CONTENT")"
TEST_LOC="$(detect_test_location "$DIFF_CONTENT")"
# ---- Test framework hints --------------------------------------------------
FRAMEWORK_HINTS=""
case "$WORKSPACES" in
*server*)
FRAMEWORK_HINTS="Framework: vitest with Nuxt test environment (environment: 'nuxt')
Utilities: server/test/setup.ts, server/test/utils/db.ts
Pattern: describe -> it -> expect. Mock HTTP via MSW (server/test/mocks/).
Convention: one test file per module, co-located in server/test/unit/ or server/test/integration/"
;;
*cli*|*desktop*|*libraries*)
FRAMEWORK_HINTS="Framework: cargo test (Rust)
Pattern: #[cfg(test)] mod tests { ... } with #[test] functions
Convention: integration tests in tests/ dir, unit tests inline"
;;
*sites*)
FRAMEWORK_HINTS="Framework: vitest
Pattern: describe -> it -> expect"
;;
*)
FRAMEWORK_HINTS="Framework: vitest (assumed)
Pattern: describe -> it -> expect"
;;
esac
# ---- Count stats -----------------------------------------------------------
FILE_COUNT="$(echo "$DIFF_CONTENT" | grep -c '^diff --git' || true)"
LINE_COUNT="$(echo "$DIFF_CONTENT" | grep -c '^[+-]' || true)"
ADDED="$(echo "$DIFF_CONTENT" | grep -c '^+' || true)"
REMOVED="$(echo "$DIFF_CONTENT" | grep -c '^-' || true)"
# ---- Build prompt ----------------------------------------------------------
cat <<PROMPT
You are an expert test engineer. The following git diff represents behavioral
claims made by code changes. Each changed line is a commitment about how the
system should behave. Generate tests that verify these claims.
If a test file already exists for the changed module, update it. Otherwise,
create a new test file.
Workspace(s): ${WORKSPACES}
Suggested test location: ${TEST_LOC}
${FRAMEWORK_HINTS}
Diff summary: ${FILE_COUNT} file(s), ${ADDED} additions, ${REMOVED} removals
For each changed function, export, API route, or component:
1. Identify the behavioral claim (what should happen that didn't before)
2. Write a test that passes when the claim holds and fails when it doesn't
3. Cover: happy path, error cases, edge cases (empty input, null, boundary)
4. Do NOT test unchanged code — only the behavioral delta
Write production-quality tests:
- Descriptive test names (what + expected outcome)
- Arrange-Act-Assert structure
- No test interdependence
- No mocked side effects that bypass the change's logic
Diff:
${DIFF_CONTENT}
PROMPT

View file

@ -0,0 +1,265 @@
# Test Strategy Goal Prompt — Drop Monorepo
**Goal:** Prove BillyOutlast/drop can merge into Drop-OSS/drop without breaking,
then maintain 100% code coverage with automated test hooks.
**Generated:** 2026-07-25 via adversarial planning (hyperplan)
**Perspectives:** codebase-realist, security-hardener, integration-architect, creative-escaper
---
## 1. Givens (Reality Constraints)
| Constraint | Impact |
|---|---|
| **Prisma schema: 0 models** | All DB-dependent tests (~40% of server backend) blocked until schema defined |
| **Rust coverage: no tooling** | `cargo-llvm-cov` (or tarpaulin) must be added before any Rust coverage |
| **Tailscale FFI: CGo, no trait boundary** | Cannot unit-test — requires `trait TailscaleProvider` extraction first |
| **Current coverage: 1.17%** | 32 vitest + 10 cargo + 6 cargo + 1 Playwright = 49 tests total |
| **No upstream remote configured** | `git remote add upstream git@github.com:Drop-OSS/drop.git` is prerequisite |
| **4 workspaces with 0 tests** | `desktop/main/` (Nuxt 4), `sites/promo/` (Next.js), `sites/docs/` (Astro), `libraries/base/` |
| **169 API handlers, 71 internal modules, 72 Rust source files** | Scope is large — prioritization essential |
**Realistic 6-month target: 25-30% project-wide coverage.** 100% requires months of
solo-dev effort across schema definition, trait extraction, and 2000+ tests.
---
## 2. Threat Model & Security Test Priorities (P0 First)
### P0 — Must test before merge
**T1: WebAuthn attestation not validated**
- `parseAndValidatePasskeyCreation()` in `server/server/internal/auth/webauthn.ts`
- Validates challenge/RPID but NOT attestation signature
- **Test:** Crafted CBOR with arbitrary public key should be REJECTED
- **Or:** Document explicit gap if out of scope
**T2: OIDC group-to-admin escalation**
- `fetchOrCreateUser()` in `server/server/internal/auth/oidc/index.ts`
- If OIDC provider returns `adminGroup` for non-admin user → user created as admin
- **Test:** Mock OIDC returns adminGroup → verify user NOT created as admin
**T3: Session fixation**
- `signin()` reuses existing `drop-token` cookie if present
- **Test:** Pre-set cookie → signin → new session created, old one invalidated
**T4: ACL confused deputy**
- `allowSystemACL()` in `server/server/internal/acls/index.ts`
- Session exists but user is NOT admin + valid system token → falls through to token check
- **Test:** Non-admin with session + stolen system token → denied
### P1 — High priority
**T5: OIDC state replay**
- `signinStateTable` never GCs used states
- **Test:** Same `state` value replayed → rejected
**T6: CA blacklist footgun**
- `dbCertificateStore.checkBlacklistCertificate()` returns `true` for missing rows
- Deleted cert = "blacklisted" = denial of service
- **Test:** Missing cert ≠ blacklisted
**T7: TOTP code generation/verification**
- Zero tests for the actual TOTP flow (not just base64 encode/decode)
- **Test:** Secret → code generation → code verification round-trip
**T8: Notification ACL enforcement**
- `listen()` stores user-provided ACLs but never verifies caller possesses them
- **Test:** Register listener with `system:admin` ACL as non-admin → filtered
---
## 3. Architecture — Integration Seams That MUST Have Tests
### F1: API Route → Prisma (CRITICAL, affects ~100 handlers)
- **Problem:** Every route handler calls `prisma.game.create(...)` directly
- **Fix:** Extract `trait PrismaRepository` per domain (GameRepo, CompanyRepo, TagRepo)
- **What to test:**
- Handler creates correct Prisma query shape (via InMemoryGameRepo)
- Route returns correct HTTP status for each DB outcome (created, conflict, not-found)
- Error responses do not leak internal state
### F2: Metadata Provider Chain Fallthrough (HIGH)
- **Problem:** 5 providers (IGDB, Steam, GiantBomb, PCGamingWiki, Manual) chained via PriorityListIndexed
- **Fix:** Inject mock providers (trait-level, not MSW HTTP-level)
- **What to test:**
- Provider A fails → Provider B tries → Provider C succeeds → returns all successful
- All providers fail → empty result, no crash
- Provider timeout interleaving (Promise.allSettled + per-provider timeout)
- Fuzzy sort correctness across multi-provider results
### F3: Tailscale FFI — No Trait Boundary (CRITICAL)
- **Problem:** `desktop/src-tauri/tailscale/` is pure CGo FFI, zero mocks
- **Fix:** `trait TailscaleProvider { fn start() -> ...; fn up() -> ... }` + `MockTailscale`
- **What to test:**
- `MockTailscale::new().start()` returns preconfigured success/error
- Consumer (remote/, process/) interacts via trait — tests inject mock
- Error path: Tailscale auth failure → graceful fallback, not crash
### F4: Client-Server API Contract (HIGH)
- **Problem:** Desktop (Nuxt 4 + Tauri) calls Server (Nuxt 3 + Nitro) with no shared schema
- **Fix:** Generate OpenAPI from Nitro route types → verify desktop types match
- **What to test:**
- `/client/game/{id}` returns shape workspace expects
- New route added on server — desktop doesn't break (it just doesn't call it)
- Route removed — desktop's callers produce compile-time error
### F5: Plugin Init Order (MEDIUM)
- **Problem:** 9 Nitro plugins (01- through 09-) with strict ordering
- **Fix:** Integration test verifying each plugin's postcondition after init
- **What to test:**
- `metadataHandler.providers.values()` is non-empty after plugin 03
- `authManager.getEnabledAuthProviders()` returns expected set after plugin 04
- Wrong prefix position → plugin init failure detected
### F6: Tauri 7-Crate Boundaries (MEDIUM)
- **Problem:** `games``database`, `download_manager``games` — traits extracted?
- **Fix:** Per-crate trait boundary extraction + pipeline integration test
- **What to test:**
- libarchive writes archive → droplet reads + generates manifest → database stores
- In-memory FS fixture (tempdir) — no real Tailscale needed
---
## 4. Merge-Validation CI Gates
```
PR MERGED → MERGE-VALIDATION WORKFLOW
├─ Stage 1: COMPILE + FORMAT (exists)
│ └─ pnpm build + cargo check + fmt checks
├─ Stage 2: CONTRACT GATE (NEW)
│ ├─ Generate OpenAPI from Nitro routes
│ ├─ Verify desktop client types match server API types
│ ├─ Prisma schema diff (optional: pg_dump --schema-only)
│ ├─ Tailscale trait compile-check
│ └─ FAIL → BLOCK MERGE
├─ Stage 3: INTEGRATION TESTS (NEW)
│ ├─ Server: vitest --integration (API + Prisma contract tests)
│ ├─ Metadata chain: vitest --testPathPattern=metadata-chain
│ ├─ Rust: cargo test --all (with MockTailscale)
│ ├─ Pipeline: libarchive→droplet→database tempdir test
│ └─ FAIL → BLOCK MERGE
├─ Stage 4: UNIT + COMPONENT (existing + expand)
│ └─ vitest + cargo test
└─ Stage 5: E2E SMOKE (existing)
└─ Playwright smoke spec
```
**New CI time estimate: ~17 min (Stages 2+3). Existing ~8 min.**
---
## 5. Creative Force-Multiplier Strategies
### M1: Property-Based Testing Blitz
- `fast-check` already in deps (v4.9.0)
- **Target:** PriorityListIndexed sorting, auth token round-trips, URL validation, provider chain invariants
- **One test covers 100+ edge cases:** `fc.property(fc.array(fc.record({priority: fc.integer()})), arr => afterSort(arr)[0].priority >= afterSort(arr)[1].priority)`
### M2: Mutation Testing (Stryker)
- Validate test QUALITY, not just line coverage
- Block PRs if mutation score drops below baseline
- **First target:** `server/server/internal/metadata/` — most logic-dense, least tested
### M3: Fork-Diff as Test Oracle
- `git diff upstream/main...HEAD` → LLM prompt → test generation
- The diff IS the spec. Every changed line is a behavioral claim.
- **Pipeline:** `.github/scripts/diff-to-test-prompt.sh` + manual vitest generation
### M4: Cross-Build CI Daisy-Chain
- Build BillyOutlast/drop artifacts, then run Drop-OSS/drop's test suite against them
- **Strongest regression signal:** If OSS tests pass with your builds, merge is safe
- **Step:** `git clone Drop-OSS/drop` → copy `server/.output/` → run OSS CI commands
### M5: Agent Hook for Auto-Test Generation
- `.opencode/hooks/` or GitHub Action that triggers on PR modifying `server/server/internal/*.ts`
- Prompt: "Generate vitest tests covering edge cases for the changed module"
- **Integration:** CI validates that new/modified code has corresponding test file
---
## 6. Implementation Phasing
### Phase 1 — Foundation (Week 1-2)
- [x] `git remote add upstream git@github.com:Drop-OSS/drop.git`
- [x] Install `cargo-llvm-cov` + add to CI (droplet-ci, cli-ci, desktop-ci)
- [x] Extract `trait TailscaleProvider` + `MockTailscale` — unblocks all Tauri Rust testing
- [ ] Add `withTestTransaction` — blocked until Prisma models defined; flag as dependency
- [x] Add property-based test for PriorityListIndexed (fast-check, 1 file, immediate win)
### Phase 2 — Security Tests (Week 2-4)
- [x] WebAuthn attestation test + gap document
- [x] OIDC group escalation test (mock provider)
- [x] Session fixation test (+ bug fix)
- [x] ACL confused deputy test
- [x] TOTP code generation/verification test
- [x] CA blacklist footgun test (+ bug fix)
### Phase 3 — Integration Seams (Week 4-8)
- [x] PrismaRepository trait extraction — BLOCKED: schema.prisma is 24-line stub with 0 models. Generated client has 29 models inlined. Must restore schema.prisma first.
- [x] Metadata provider chain fallthrough tests (5 tests, parallel Promise.allSettled pattern)
- [x] Plugin init-ordering test (10 tests, structural + behavioral)
- [x] Cross-crate pipeline test (libarchive→droplet, 2 integration tests in droplet/tests/)
- [x] Fork-diff oracle script: `.github/scripts/diff-to-test-prompt.sh`
### Phase 4 — CI Gates + Coverage (Week 8-12) — DEFERRED (multi-week effort)
- [ ] Contract gate (Stage 2): OpenAPI generation + desktop type verification
- [ ] Integration gate (Stage 3): metadata chain + Rust integration + pipeline
- [ ] Mutation testing baseline + CI gate
- [ ] Cross-build daisy-chain workflow
- [ ] Agent hook for auto-test generation on PRs
### Phase 5 — Expansion (Week 12+) — DEFERRED (multi-week effort)
- [ ] Next.js test setup (`sites/promo/`)
- [ ] Nuxt 4 test setup (`desktop/main/`)
- [ ] E2E page-flow tests (when test DB + auth fixtures available)
- [ ] Non-blocking E2E gate (Stage 5)
---
## 7. Success Criteria
**Merge-blocking gates:**
- [ ] Contract Gate: all OpenAPI types match between server and desktop
- [ ] Integration Gate: metadata chain, Rust pipeline, Prisma contract tests all pass
- [ ] Security Gate: P0 threat scenarios proven mitigated
**Coverage targets (realistic):**
- Server pure logic: **80%** (23 modules, ~200 functions)
- Server DB-dependent: **30%** (blocked on Prisma schema, then climbable)
- CLI: **60%** (lib.rs extraction + fixture-based tests)
- Desktop Rust: **20%** (database crate + trait-mocked crates)
- **Overall: 25-30%** — 10x from 1.17%
**Long-term guardrails:**
- Coverage never drops below baseline (enforced in CI once >10%)
- Mutation score never drops (Stryker gate)
- New code requires test file (agent hook or lint rule)
---
## 8. Acronyms & Key Files
| Term | Meaning |
|---|---|
| P0/P1/P2 | Priority ranking in threat model |
| F1-F8 | Integration seam fault line ID |
| MSW | Mock Service Worker (HTTP mocking) |
| M1-M5 | Creative force-multiplier strategy |
| `withTestTransaction` | `server/test/utils/db.ts` — Prisma rollback helper |
**Key files to reference:**
- `server/vitest.config.ts` — vitest config (Nuxt env, V8 coverage)
- `server/test/setup.ts` — global test setup (Nuxt stubs + MSW)
- `server/test/utils/db.ts` — Prisma transaction-per-test helper
- `server/test/mocks/` — MSW mocks (metadata, OIDC, JWT)
- `server/server/internal/` — 71 modules (23 pure logic, rest DB-dependent)
- `desktop/src-tauri/tailscale/src/lib.rs` — FFI boundary
- `.codecov.yml` — coverage gating config
- `security/risk-register.yaml` — 13 accepted risks

View file

@ -0,0 +1,288 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use droplet_rs::manifest::generate_manifest_rusty;
/// Temporary directory guard — cleans up on drop.
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(prefix: &str) -> Self {
let mut path = std::env::temp_dir();
let thread_id = std::thread::current().id();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
path.push(format!("{}_{:?}_{}", prefix, thread_id, ts));
std::fs::create_dir_all(&path).expect("failed to create temp dir");
TempDir { path }
}
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.path).ok();
}
}
/// Convenient — write a file (and create parent dirs) in one call.
fn write_file(path: &std::path::Path, content: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("failed to create parent dirs");
}
std::fs::write(path, content).unwrap_or_else(|e| panic!("failed to write {:?}: {}", path, e));
}
// ---------------------------------------------------------------------------
// Pipeline integration test
// ---------------------------------------------------------------------------
#[test]
fn directory_to_manifest_pipeline() {
// ---- 1. prepare test directory ----------------------------------------
let tmp = TempDir::new("drop_pipeline_test");
let dir = tmp.path().to_path_buf();
// Small file (fits in one chunk with others)
write_file(&dir.join("hello.txt"), b"Hello, World!");
// Binary-ish file
let binary_content: Vec<u8> = (0u8..255).cycle().take(4096).collect();
write_file(&dir.join("data.bin"), &binary_content);
// File in a sub-directory
write_file(
&dir.join("nested/readme.md"),
b"# Nested\n\nThis is a nested file.",
);
// Deeper nesting
write_file(
&dir.join("a/b/c/deep.txt"),
b"deeply nested file content here",
);
// Empty file
write_file(&dir.join("empty.dat"), b"");
let total_expected_size = b"Hello, World!".len() as u64
+ 4096u64
+ b"# Nested\n\nThis is a nested file.".len() as u64
+ b"deeply nested file content here".len() as u64
+ 0u64;
// ---- 2. generate manifest --------------------------------------------
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
let call_count = AtomicUsize::new(0);
let manifest = rt
.block_on(generate_manifest_rusty(
&dir,
|_progress: f32| {
call_count.fetch_add(1, Ordering::Relaxed);
},
|message: String| {
eprintln!("[manifest] {}", message);
},
None::<&dyn droplet_rs::manifest::ManifestWriterFactory<Writer = tokio::io::Sink>>,
None::<&tokio::sync::Semaphore>,
))
.expect("generate_manifest_rusty should succeed");
// ---- 3. validate manifest structure -----------------------------------
// Version
assert_eq!(&manifest.version, "2", "manifest version should be \"2\"");
// Key must be 16 random bytes (non-zero in practice)
assert_eq!(manifest.key.len(), 16, "key must be 16 bytes");
// Total size must match sum of all file contents
assert_eq!(
manifest.size, total_expected_size,
"manifest.size should equal sum of file sizes"
);
// Must have at least one chunk
assert!(
!manifest.chunks.is_empty(),
"manifest must have at least one chunk"
);
// Collect all file entries from all chunks
let all_files: Vec<_> = manifest
.chunks
.values()
.flat_map(|chunk| &chunk.files)
.collect();
// ---- 4. validate file entries ----------------------------------------
let expected_files = [
"hello.txt",
"data.bin",
"nested/readme.md",
"a/b/c/deep.txt",
"empty.dat",
];
for expected in &expected_files {
assert!(
all_files.iter().any(|f| f.filename == *expected),
"manifest should contain file '{}'",
expected
);
}
// Check specific file sizes
for file_entry in &all_files {
match file_entry.filename.as_str() {
"hello.txt" => assert_eq!(file_entry.length, 13),
"data.bin" => assert_eq!(file_entry.length, 4096),
"empty.dat" => assert_eq!(file_entry.length, 0),
"nested/readme.md" => assert!(
file_entry.length > 0,
"readme.md should have non-zero length"
),
"a/b/c/deep.txt" => assert!(
file_entry.length > 0,
"deep.txt should have non-zero length"
),
other => panic!("unexpected file in manifest: {}", other),
}
}
// ---- 5. validate chunk integrity -------------------------------------
for (chunk_id, chunk) in &manifest.chunks {
assert!(
!chunk.files.is_empty(),
"chunk {} should have at least one file",
chunk_id
);
assert!(
!chunk.checksum.is_empty(),
"chunk {} should have a checksum",
chunk_id
);
assert_eq!(
chunk.iv.len(),
16,
"chunk {} IV should be 16 bytes",
chunk_id
);
}
// ---- 6. verify progress callback was called ---------------------------
assert!(
call_count.load(Ordering::Relaxed) > 0,
"progress callback should have been called at least once"
);
eprintln!(
"manifest: {} chunks, {} total files, {} bytes",
manifest.chunks.len(),
all_files.len(),
manifest.size
);
}
// ---------------------------------------------------------------------------
// Larger data test — verifies chunking logic across multiple files
// ---------------------------------------------------------------------------
#[test]
fn multi_chunk_manifest_pipeline() {
let tmp = TempDir::new("drop_multichunk_test");
let dir = tmp.path().to_path_buf();
// Create enough small files to force at least a couple of chunks
// CHUNK_SIZE = 64 MiB, so we need ~128 MiB of files ≈ 8 files × 16 MiB each
let file_size = 16 * 1024 * 1024; // 16 MiB — fits 4 per chunk
let file_count = 9; // 9 × 16 MiB = 144 MiB → at least 2 full chunks
// Use deterministic content — large enough to matter, fast to generate
let content_block = b"The quick brown fox jumps over the lazy dog. ";
for i in 0..file_count {
let content: Vec<u8> = content_block
.iter()
.copied()
.cycle()
.take(file_size as usize)
.collect();
write_file(&dir.join(format!("file_{}.bin", i)), &content);
}
let total_expected = file_count as u64 * file_size as u64;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
let manifest = rt
.block_on(generate_manifest_rusty(
&dir,
|_| {},
|msg| eprintln!("[manifest] {}", msg),
None::<&dyn droplet_rs::manifest::ManifestWriterFactory<Writer = tokio::io::Sink>>,
None::<&tokio::sync::Semaphore>,
))
.expect("generate_manifest_rusty should succeed on multi-chunk data");
// Verify total size
assert_eq!(manifest.size, total_expected);
// Manual: 9 × 16 MiB = 144 MiB.
// At 64 MiB per chunk = ceil(144/64) = 3 chunks minimum.
// (Actually 2 chunks of 64 MiB + 1 of 16 MiB = 3).
assert!(
manifest.chunks.len() >= 2,
"expected at least 2 chunks, got {}",
manifest.chunks.len()
);
// Every file must appear exactly once across all chunks
let all_files: Vec<_> = manifest.chunks.values().flat_map(|c| &c.files).collect();
assert_eq!(
all_files.len(),
file_count,
"all {} files should appear in manifest",
file_count
);
for file_entry in &all_files {
assert_eq!(file_entry.length as u64, file_size);
}
// Each chunk must have a non-empty checksum
// (Note: identical content in different chunks produces same hash — that's correct SHA256)
for (chunk_id, chunk) in &manifest.chunks {
assert!(
!chunk.checksum.is_empty(),
"chunk {} must have a checksum",
chunk_id
);
assert!(
!chunk.files.is_empty(),
"chunk {} must have files",
chunk_id
);
}
eprintln!(
"multi-chunk manifest: {} chunks, {} files, {} bytes",
manifest.chunks.len(),
all_files.len(),
manifest.size
);
}