diff --git a/tests/services/s3-upload-publish-cleanup-integration.test.mjs b/tests/services/s3-upload-publish-cleanup-integration.test.mjs new file mode 100644 index 00000000..56f21ee5 --- /dev/null +++ b/tests/services/s3-upload-publish-cleanup-integration.test.mjs @@ -0,0 +1,125 @@ +// 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`, + ); + } +});