Files
bitvid/tests/services/s3-upload-publish-cleanup-integration.test.mjs
thePR0M3TH3ANandClaude Opus 4.8 93b3d6da22 test(s3): integration smoke for the upload -> publish -> cleanup round-trip
Drives the real s3UploadService.uploadVideo for a generic (vhost-style) S3
connection with only the S3 I/O mocked, then feeds the published note's URLs
into r2Service.deleteVideoStorage. Asserts the upload writes video + .thumb +
.torrent, the note is valid (URL + magnet with info-hash), and cleanup deletes
exactly those objects using a forcePathStyle-aware S3 client — i.e. the upload
key and the cleanup-derived key agree (the parity property).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 06:23:06 -04:00

126 lines
5.0 KiB
JavaScript

// Scenario (SCN-s3-publish-cleanup-roundtrip):
// End-to-end parity smoke for GENERIC S3 (virtual-hosted style). Drives the
// real s3UploadService.uploadVideo with only the S3 I/O boundary mocked
// (makeS3Client + multipartUpload), using the REAL key/URL/magnet/note
// builders, then feeds the resulting published note's URLs into
// r2Service.deleteVideoStorage. Asserts:
// - the upload writes the expected objects (video + .thumb + .torrent),
// - the published note is valid (has the URL + a magnet with the info-hash),
// - cleanup deletes EXACTLY the objects the upload wrote (the upload key and
// the cleanup-derived key agree) — using a forcePathStyle-aware S3 client.
// This is the round-trip that makes generic-S3 delete/edit cleanup actually
// work, the parity gap that previously broke for vhost-style buckets.
import "../test-helpers/setup-localstorage.mjs";
import test from "node:test";
import assert from "node:assert/strict";
globalThis.__BITVID_DISABLE_NETWORK_IMPORTS__ = true;
const { S3UploadService } = await import("../../js/services/s3UploadService.js");
const { R2Service } = await import("../../js/services/r2Service.js");
const PUBLIC_BASE = "https://cdn.example";
const ENDPOINT = "https://s3.us-east-1.amazonaws.com";
const NPUB = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsx5h2j";
const INFO_HASH = "a".repeat(40);
const S3_SETTINGS = {
provider: "s3",
endpoint: ENDPOINT,
region: "us-east-1",
accessKeyId: "AK",
secretAccessKey: "SK",
bucket: "my-bucket",
forcePathStyle: false, // virtual-hosted-style — the case that used to break
publicBaseUrl: PUBLIC_BASE,
};
const fileLike = (name, type) => ({ name, type, size: 1024, slice() { return this; } });
test("a generic-S3 upload publishes a valid note whose objects deleteVideoStorage can clean up", async () => {
// --- 1. Upload via the real s3UploadService (only S3 I/O mocked) ---
const uploadedKeys = [];
let publishedPayload = null;
const svc = new S3UploadService({
ensureS3SdkLoaded: async () => {},
makeS3Client: () => ({ __mock: "s3" }),
multipartUpload: async ({ key }) => {
uploadedKeys.push(key);
},
calculateTorrentInfoHash: async () => INFO_HASH,
userLogger: { warn() {}, error() {} },
// buildR2Key, buildS3ObjectUrl, normalizeVideoNotePayload,
// buildStoragePointer*, getVideoNoteErrorMessage stay REAL (defaultDeps).
});
const ok = await svc.uploadVideo({
npub: NPUB,
file: fileLike("clip.mp4", "video/mp4"),
thumbnailFile: fileLike("clip.jpg", "image/jpeg"),
torrentFile: fileLike("clip.torrent", "application/x-bittorrent"),
metadata: { title: "Parity Smoke" },
infoHash: INFO_HASH,
settings: S3_SETTINGS,
publishVideoNote: async (payload) => {
publishedPayload = payload;
return true;
},
});
assert.equal(ok, true, "upload+publish should succeed");
assert.equal(uploadedKeys.length, 3, "uploads video + thumbnail + torrent");
const note = publishedPayload?.legacyFormData;
assert.ok(note, "published a normalized video note");
assert.ok(
note.url.startsWith(`${PUBLIC_BASE}/`),
`note URL should be under the bucket base: ${note.url}`,
);
assert.match(note.magnet, new RegExp(`btih:${INFO_HASH}`), "magnet carries the info-hash");
assert.match(note.magnet, /ws=/, "magnet has a web-seed (the hosted URL)");
assert.ok(note.thumbnail.startsWith(`${PUBLIC_BASE}/`), "thumbnail URL under the base");
// --- 2. Clean up via the real r2Service.deleteVideoStorage, S3 connection ---
const deletedKeys = [];
const clients = [];
const r2 = new R2Service({
makeR2Client: (args) => { clients.push({ kind: "r2", args }); return {}; },
makeS3Client: (args) => { clients.push({ kind: "s3", args }); return {}; },
deleteObject: async ({ key }) => { deletedKeys.push(key); },
});
r2.resolveConnection = async () => S3_SETTINGS;
const cleanup = await r2.deleteVideoStorage({
npub: NPUB,
videos: [{ url: note.url, thumbnail: note.thumbnail }],
});
assert.equal(cleanup.skipped, false);
// Generic S3 => S3 client honoring forcePathStyle: false (not the R2 path-style).
assert.equal(clients[0]?.kind, "s3");
assert.equal(clients[0]?.args.forcePathStyle, false);
// The round-trip property: cleanup deletes exactly the objects we uploaded
// (video + its .torrent + thumbnail). The video + thumbnail keys must match
// what the upload wrote; the .torrent is derived from the video key.
const uploadedSet = new Set(uploadedKeys);
const torrentKey = uploadedKeys
.find((k) => /\.mp4$/.test(k))
.replace(/\.[^/.]+$/, ".torrent");
for (const k of deletedKeys) {
assert.ok(
uploadedSet.has(k) || k === torrentKey,
`cleanup deleted ${k}, which was not uploaded`,
);
}
// Specifically: the video object and the thumbnail object are both removed.
for (const uploaded of uploadedKeys) {
if (uploaded === torrentKey) continue; // torrent re-derived; covered above
assert.ok(
deletedKeys.includes(uploaded),
`uploaded object ${uploaded} should be cleaned up`,
);
}
});