|
1 | | -import { describe, test, expect, afterEach, mock } from "bun:test"; |
2 | | -import { guessContentType, putToSignedUrl, pollSource, type SourceAttributes } from "../src/commands/ingest.ts"; |
| 1 | +import { describe, test, expect, afterEach, mock, spyOn } from "bun:test"; |
| 2 | +import { |
| 3 | + guessContentType, |
| 4 | + putToSignedUrl, |
| 5 | + pollSource, |
| 6 | + html5Steps, |
| 7 | + requestSteps, |
| 8 | + type SourceAttributes, |
| 9 | +} from "../src/commands/ingest.ts"; |
3 | 10 | import type { Client } from "../src/http/client.ts"; |
4 | 11 | import { ApiError } from "../src/http/client.ts"; |
5 | 12 |
|
@@ -102,3 +109,106 @@ describe("pollSource", () => { |
102 | 109 | expect(result.status).toBe("failed"); |
103 | 110 | }); |
104 | 111 | }); |
| 112 | + |
| 113 | +const SRC = "https://shotstack-ingest-api-stage-sources.s3.ap-southeast-2.amazonaws.com/owner/src-1/source"; |
| 114 | + |
| 115 | +function readySource(ext: string, width?: number, height?: number): SourceAttributes { |
| 116 | + const source: SourceAttributes = { id: "src-1", status: "ready", source: `${SRC}.${ext}` }; |
| 117 | + if (width) source.width = width; |
| 118 | + if (height) source.height = height; |
| 119 | + return source; |
| 120 | +} |
| 121 | + |
| 122 | +describe("html5Steps", () => { |
| 123 | + test("sizes a landscape image's steps by width, below its long edge", () => { |
| 124 | + expect(html5Steps(readySource("jpg", 3000, 2000))).toEqual([ |
| 125 | + { format: "webp", size: { width: 512 }, filename: "html5-512" }, |
| 126 | + { format: "webp", size: { width: 1024 }, filename: "html5-1024" }, |
| 127 | + { format: "webp", size: { width: 2048 }, filename: "html5-2048" }, |
| 128 | + ]); |
| 129 | + }); |
| 130 | + |
| 131 | + test("sizes a portrait image's steps by height", () => { |
| 132 | + expect(html5Steps(readySource("PNG", 1200, 1600))).toEqual([ |
| 133 | + { format: "webp", size: { height: 512 }, filename: "html5-512" }, |
| 134 | + { format: "webp", size: { height: 1024 }, filename: "html5-1024" }, |
| 135 | + ]); |
| 136 | + }); |
| 137 | + |
| 138 | + test("never requests a step as large as the source", () => { |
| 139 | + expect(html5Steps(readySource("webp", 1024, 768)).map((s) => s.filename)).toEqual(["html5-512"]); |
| 140 | + expect(html5Steps(readySource("jpeg", 400, 300))).toEqual([]); |
| 141 | + }); |
| 142 | + |
| 143 | + test("skips sources that aren't raster images, aren't ready or have no size", () => { |
| 144 | + for (const ext of ["mp4", "gif", "svg", "woff2"]) expect(html5Steps(readySource(ext, 3000, 2000))).toEqual([]); |
| 145 | + expect(html5Steps({ ...readySource("jpg", 3000, 2000), status: "importing" })).toEqual([]); |
| 146 | + expect(html5Steps(readySource("jpg"))).toEqual([]); |
| 147 | + }); |
| 148 | +}); |
| 149 | + |
| 150 | +describe("requestSteps", () => { |
| 151 | + type Step = { status: string; url: string }; |
| 152 | + function stepsClient(rounds: Step[][], postError?: Error) { |
| 153 | + let i = 0; |
| 154 | + const post = mock(async () => { |
| 155 | + if (postError) throw postError; |
| 156 | + return {}; |
| 157 | + }); |
| 158 | + const get = mock(async () => { |
| 159 | + const renditions = rounds[Math.min(i, rounds.length - 1)]!; |
| 160 | + i += 1; |
| 161 | + return { data: { type: "source", id: "src-1", attributes: { ...readySource("jpg", 1600, 900), outputs: { renditions } } } }; |
| 162 | + }); |
| 163 | + return { client: { post, get } as unknown as Client, post, get }; |
| 164 | + } |
| 165 | + const step = (status: string, s: number) => ({ status, url: `https://renditions.example.com/owner/src-1/html5-${s}.webp` }); |
| 166 | + |
| 167 | + test("requests the steps once, then waits until each one is done", async () => { |
| 168 | + const { client, post, get } = stepsClient([ |
| 169 | + [step("processing", 512), step("queued", 1024)], |
| 170 | + [step("ready", 512), step("failed", 1024)], |
| 171 | + ]); |
| 172 | + const result = await requestSteps(client, readySource("jpg", 1600, 900), "json", { timeoutMs: 60_000, intervalMs: 0 }); |
| 173 | + |
| 174 | + expect(post.mock.calls).toEqual([ |
| 175 | + ["/sources/src-1/renditions", { outputs: { renditions: html5Steps(readySource("jpg", 1600, 900)) } }], |
| 176 | + ]); |
| 177 | + expect(get.mock.calls.length).toBe(2); |
| 178 | + expect(result.outputs?.renditions?.map((r) => r.status)).toEqual(["ready", "failed"]); |
| 179 | + }); |
| 180 | + |
| 181 | + test("returns the steps still processing when the wait runs out", async () => { |
| 182 | + const { client } = stepsClient([[step("processing", 512), step("processing", 1024)]]); |
| 183 | + const result = await requestSteps(client, readySource("jpg", 1600, 900), "json", { timeoutMs: 0, intervalMs: 0 }); |
| 184 | + expect(result.status).toBe("ready"); |
| 185 | + expect(result.outputs?.renditions?.map((r) => r.status)).toEqual(["processing", "processing"]); |
| 186 | + }); |
| 187 | + |
| 188 | + test("names a step by its size in text output until it has a URL", async () => { |
| 189 | + const pending = { status: "processing", transformation: { format: "webp", size: { width: 1024 } } }; |
| 190 | + const { client } = stepsClient([[step("ready", 512), pending as unknown as Step]]); |
| 191 | + const lines: string[] = []; |
| 192 | + const log = spyOn(console, "log").mockImplementation((line: string) => void lines.push(line)); |
| 193 | + try { |
| 194 | + await requestSteps(client, readySource("jpg", 1600, 900), "text", { timeoutMs: 0, intervalMs: 0 }); |
| 195 | + } finally { |
| 196 | + log.mockRestore(); |
| 197 | + } |
| 198 | + expect(lines.at(-1)).toEndWith("\n ready https://renditions.example.com/owner/src-1/html5-512.webp\n processing 1024px"); |
| 199 | + }); |
| 200 | + |
| 201 | + test("leaves a source with no steps alone", async () => { |
| 202 | + const { client, post, get } = stepsClient([[]]); |
| 203 | + const source = readySource("mp4", 1920, 1080); |
| 204 | + expect(await requestSteps(client, source, "json", { timeoutMs: 0, intervalMs: 0 })).toBe(source); |
| 205 | + expect(post.mock.calls.length + get.mock.calls.length).toBe(0); |
| 206 | + }); |
| 207 | + |
| 208 | + test("returns the source when the steps can't be requested", async () => { |
| 209 | + const { client, get } = stepsClient([[]], new ApiError(400, undefined, "POST /sources/src-1/renditions → 400")); |
| 210 | + const source = readySource("jpg", 1600, 900); |
| 211 | + expect(await requestSteps(client, source, "json", { timeoutMs: 60_000, intervalMs: 0 })).toBe(source); |
| 212 | + expect(get.mock.calls.length).toBe(0); |
| 213 | + }); |
| 214 | +}); |
0 commit comments