|
| 1 | +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
| 2 | +import { NextRequest } from "next/server"; |
| 3 | +import { GET, OPTIONS } from "./route"; |
| 4 | +import { StorageOperations } from "@/lib/storage-operations"; |
| 5 | +import { ApiErrors } from "@/lib/api-errors"; |
| 6 | +import type { GistMetadata } from "@/types/models"; |
| 7 | +import type { ApiErrorResponse } from "@/types/api"; |
| 8 | + |
| 9 | +// Mock StorageOperations |
| 10 | +vi.mock("@/lib/storage-operations", () => ({ |
| 11 | + StorageOperations: { |
| 12 | + getGist: vi.fn(), |
| 13 | + deleteIfNeeded: vi.fn(), |
| 14 | + }, |
| 15 | +})); |
| 16 | + |
| 17 | +// Mock logger |
| 18 | +vi.mock("@/lib/logger", () => ({ |
| 19 | + createLogger: vi.fn(() => ({ |
| 20 | + error: vi.fn(), |
| 21 | + warn: vi.fn(), |
| 22 | + info: vi.fn(), |
| 23 | + debug: vi.fn(), |
| 24 | + })), |
| 25 | +})); |
| 26 | + |
| 27 | +describe("GET /api/blobs/[id]", () => { |
| 28 | + const mockMetadata: GistMetadata = { |
| 29 | + id: "test-gist-id", |
| 30 | + created_at: "2025-06-07T10:00:00.000Z", |
| 31 | + updated_at: "2025-06-07T10:00:00.000Z", |
| 32 | + expires_at: undefined, |
| 33 | + one_time_view: false, |
| 34 | + edit_pin_hash: "hash123", |
| 35 | + edit_pin_salt: "salt123", |
| 36 | + total_size: 1024, |
| 37 | + blob_count: 1, |
| 38 | + version: 1, |
| 39 | + current_version: "1733568000000", |
| 40 | + indent_mode: "spaces", |
| 41 | + indent_size: 2, |
| 42 | + wrap_mode: "soft", |
| 43 | + theme: "dark", |
| 44 | + encrypted_metadata: { iv: "iv123", data: "encrypted" }, |
| 45 | + }; |
| 46 | + |
| 47 | + const mockBlob = new Uint8Array([1, 2, 3, 4, 5]); |
| 48 | + |
| 49 | + const createRequest = () => { |
| 50 | + return new NextRequest("https://test.com/api/blobs/test-gist-id"); |
| 51 | + }; |
| 52 | + |
| 53 | + const createContext = (id = "test-gist-id") => ({ |
| 54 | + params: Promise.resolve({ id }), |
| 55 | + }); |
| 56 | + |
| 57 | + beforeEach(() => { |
| 58 | + vi.clearAllMocks(); |
| 59 | + }); |
| 60 | + |
| 61 | + afterEach(() => { |
| 62 | + vi.restoreAllMocks(); |
| 63 | + }); |
| 64 | + |
| 65 | + describe("successful retrieval", () => { |
| 66 | + it("should return blob data for valid gist", async () => { |
| 67 | + const mockGist = { metadata: mockMetadata, blob: mockBlob }; |
| 68 | + vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist); |
| 69 | + |
| 70 | + const request = createRequest(); |
| 71 | + const context = createContext(); |
| 72 | + const response = await GET(request, context); |
| 73 | + |
| 74 | + expect(response.status).toBe(200); |
| 75 | + |
| 76 | + const data = await response.arrayBuffer(); |
| 77 | + const uint8Array = new Uint8Array(data); |
| 78 | + expect(uint8Array).toEqual(mockBlob); |
| 79 | + }); |
| 80 | + |
| 81 | + it("should set appropriate headers for blob response", async () => { |
| 82 | + const mockGist = { metadata: mockMetadata, blob: mockBlob }; |
| 83 | + vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist); |
| 84 | + |
| 85 | + const request = createRequest(); |
| 86 | + const context = createContext(); |
| 87 | + const response = await GET(request, context); |
| 88 | + |
| 89 | + expect(response.headers.get("Content-Type")).toBe( |
| 90 | + "application/octet-stream" |
| 91 | + ); |
| 92 | + expect(response.headers.get("Content-Length")).toBe( |
| 93 | + mockBlob.length.toString() |
| 94 | + ); |
| 95 | + expect(response.headers.get("Cache-Control")).toBe( |
| 96 | + "private, max-age=3600" |
| 97 | + ); |
| 98 | + expect(response.headers.get("Content-Disposition")).toBe( |
| 99 | + 'attachment; filename="gist-test-gist-id.bin"' |
| 100 | + ); |
| 101 | + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); |
| 102 | + expect(response.headers.get("X-Frame-Options")).toBe("DENY"); |
| 103 | + }); |
| 104 | + |
| 105 | + it("should handle one-time view gists correctly", async () => { |
| 106 | + const oneTimeMetadata = { ...mockMetadata, one_time_view: true }; |
| 107 | + const mockGist = { metadata: oneTimeMetadata, blob: mockBlob }; |
| 108 | + vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist); |
| 109 | + vi.mocked(StorageOperations.deleteIfNeeded).mockResolvedValue(true); |
| 110 | + |
| 111 | + const request = createRequest(); |
| 112 | + const context = createContext(); |
| 113 | + const response = await GET(request, context); |
| 114 | + |
| 115 | + expect(response.status).toBe(200); |
| 116 | + expect(response.headers.get("Cache-Control")).toBe( |
| 117 | + "no-store, no-cache, must-revalidate" |
| 118 | + ); |
| 119 | + expect(StorageOperations.deleteIfNeeded).toHaveBeenCalledWith( |
| 120 | + oneTimeMetadata |
| 121 | + ); |
| 122 | + }); |
| 123 | + |
| 124 | + it("should handle large blob data", async () => { |
| 125 | + const largeMockBlob = new Uint8Array(1024 * 1024); // 1MB |
| 126 | + largeMockBlob.fill(42); // Fill with some data |
| 127 | + const mockGist = { metadata: mockMetadata, blob: largeMockBlob }; |
| 128 | + vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist); |
| 129 | + |
| 130 | + const request = createRequest(); |
| 131 | + const context = createContext(); |
| 132 | + const response = await GET(request, context); |
| 133 | + |
| 134 | + expect(response.status).toBe(200); |
| 135 | + expect(response.headers.get("Content-Length")).toBe( |
| 136 | + largeMockBlob.length.toString() |
| 137 | + ); |
| 138 | + |
| 139 | + const data = await response.arrayBuffer(); |
| 140 | + expect(data.byteLength).toBe(1024 * 1024); |
| 141 | + }); |
| 142 | + }); |
| 143 | + |
| 144 | + describe("error handling", () => { |
| 145 | + it("should return 400 for invalid gist ID", async () => { |
| 146 | + const request = createRequest(); |
| 147 | + const context = createContext(""); |
| 148 | + const response = await GET(request, context); |
| 149 | + |
| 150 | + expect(response.status).toBe(400); |
| 151 | + const data: ApiErrorResponse = await response.json(); |
| 152 | + expect(data.message).toBe("Invalid gist ID"); |
| 153 | + }); |
| 154 | + |
| 155 | + it("should return 404 for non-existent gist", async () => { |
| 156 | + const notFoundError = ApiErrors.notFound("Gist"); |
| 157 | + vi.mocked(StorageOperations.getGist).mockRejectedValue(notFoundError); |
| 158 | + |
| 159 | + const request = createRequest(); |
| 160 | + const context = createContext(); |
| 161 | + const response = await GET(request, context); |
| 162 | + |
| 163 | + expect(response.status).toBe(404); |
| 164 | + const data: ApiErrorResponse = await response.json(); |
| 165 | + expect(data.message).toBe("Gist not found"); |
| 166 | + }); |
| 167 | + |
| 168 | + it("should return 410 for expired gist based on expires_at", async () => { |
| 169 | + const expiredMetadata = { |
| 170 | + ...mockMetadata, |
| 171 | + expires_at: "2020-01-01T00:00:00.000Z", // Past date |
| 172 | + }; |
| 173 | + const mockGist = { metadata: expiredMetadata, blob: mockBlob }; |
| 174 | + vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist); |
| 175 | + |
| 176 | + const request = createRequest(); |
| 177 | + const context = createContext(); |
| 178 | + const response = await GET(request, context); |
| 179 | + |
| 180 | + expect(response.status).toBe(410); |
| 181 | + const data: ApiErrorResponse = await response.json(); |
| 182 | + expect(data.message).toBe("Gist has expired"); |
| 183 | + }); |
| 184 | + |
| 185 | + it("should handle storage errors gracefully", async () => { |
| 186 | + const storageError = new Error("Storage connection failed"); |
| 187 | + vi.mocked(StorageOperations.getGist).mockRejectedValue(storageError); |
| 188 | + |
| 189 | + const request = createRequest(); |
| 190 | + const context = createContext(); |
| 191 | + const response = await GET(request, context); |
| 192 | + |
| 193 | + expect(response.status).toBe(500); |
| 194 | + // Note: Logger mock calls can't be easily tested with current mock setup |
| 195 | + }); |
| 196 | + |
| 197 | + it("should continue if one-time deletion fails", async () => { |
| 198 | + const oneTimeMetadata = { ...mockMetadata, one_time_view: true }; |
| 199 | + const mockGist = { metadata: oneTimeMetadata, blob: mockBlob }; |
| 200 | + vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist); |
| 201 | + vi.mocked(StorageOperations.deleteIfNeeded).mockRejectedValue( |
| 202 | + new Error("Delete failed") |
| 203 | + ); |
| 204 | + |
| 205 | + const request = createRequest(); |
| 206 | + const context = createContext(); |
| 207 | + const response = await GET(request, context); |
| 208 | + |
| 209 | + expect(response.status).toBe(200); // Should still succeed |
| 210 | + // Note: Logger mock calls can't be easily tested with current mock setup |
| 211 | + }); |
| 212 | + |
| 213 | + it("should handle unexpected errors", async () => { |
| 214 | + const unexpectedError = new TypeError("Something went wrong"); |
| 215 | + vi.mocked(StorageOperations.getGist).mockRejectedValue(unexpectedError); |
| 216 | + |
| 217 | + const request = createRequest(); |
| 218 | + const context = createContext(); |
| 219 | + const response = await GET(request, context); |
| 220 | + |
| 221 | + expect(response.status).toBe(500); |
| 222 | + // Note: Logger mock calls can't be easily tested with current mock setup |
| 223 | + }); |
| 224 | + |
| 225 | + it("should handle empty blob data", async () => { |
| 226 | + const emptyBlob = new Uint8Array(0); |
| 227 | + const mockGist = { metadata: mockMetadata, blob: emptyBlob }; |
| 228 | + vi.mocked(StorageOperations.getGist).mockResolvedValue(mockGist); |
| 229 | + |
| 230 | + const request = createRequest(); |
| 231 | + const context = createContext(); |
| 232 | + const response = await GET(request, context); |
| 233 | + |
| 234 | + expect(response.status).toBe(200); |
| 235 | + expect(response.headers.get("Content-Length")).toBe("0"); |
| 236 | + |
| 237 | + const data = await response.arrayBuffer(); |
| 238 | + expect(data.byteLength).toBe(0); |
| 239 | + }); |
| 240 | + }); |
| 241 | +}); |
| 242 | + |
| 243 | +describe("OPTIONS /api/blobs/[id]", () => { |
| 244 | + it("should return correct CORS headers", async () => { |
| 245 | + const response = await OPTIONS(); |
| 246 | + |
| 247 | + expect(response.status).toBe(200); |
| 248 | + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( |
| 249 | + "https://ghostpaste.dev" |
| 250 | + ); |
| 251 | + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( |
| 252 | + "GET, OPTIONS" |
| 253 | + ); |
| 254 | + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( |
| 255 | + "Content-Type" |
| 256 | + ); |
| 257 | + expect(response.headers.get("Access-Control-Max-Age")).toBe("86400"); |
| 258 | + }); |
| 259 | +}); |
0 commit comments