diff --git a/src/context.ts b/src/context.ts index d55ccfb..5d13d5d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -134,7 +134,10 @@ export class Context { await writer.write(chunk) } catch (err) { await fsPromises.unlink(tempPath).catch(noop) - throw err + throw new errors.MAP_WRITE_ERROR({ + message: err instanceof Error ? err.message : undefined, + cause: err, + }) } }, close: async () => { @@ -143,7 +146,10 @@ export class Context { await writer.close() } catch (err) { await fsPromises.unlink(tempPath).catch(noop) - throw err + throw new errors.MAP_WRITE_ERROR({ + message: err instanceof Error ? err.message : undefined, + cause: err, + }) } // Validate the uploaded map file BEFORE replacing the existing one diff --git a/src/lib/errors.ts b/src/lib/errors.ts index ffbd7e1..a231558 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -98,6 +98,11 @@ const errorsList = [ message: 'Invalid map file', status: 400, }, + { + code: 'MAP_WRITE_ERROR', + message: 'Failed to write map data', + status: 500, + }, // Generic errors { diff --git a/test/map-shares.test.ts b/test/map-shares.test.ts index e218e81..8e49745 100644 --- a/test/map-shares.test.ts +++ b/test/map-shares.test.ts @@ -12,7 +12,7 @@ import { fetch as secretStreamFetch, Agent as SecretStreamAgent, } from 'secret-stream-http' -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import type { MapShareState } from '../src/types.js' import { @@ -150,7 +150,9 @@ describe('Map Shares and Downloads', () => { // Create a third device (another receiver) const receiver2KeyPair = SecretStreamAgent.keyPair(Buffer.alloc(32, 2)) - const receiver2DeviceId = Buffer.from(receiver2KeyPair.publicKey).toString('hex') + const receiver2DeviceId = Buffer.from( + receiver2KeyPair.publicKey, + ).toString('hex') // Create share for first receiver const share1 = await createShare().json() @@ -1104,7 +1106,9 @@ describe('Map Shares and Downloads', () => { // Create a third device with different keys const wrongKeyPair = SecretStreamAgent.keyPair() - const wrongDeviceId = Buffer.from(wrongKeyPair.publicKey).toString('hex') + const wrongDeviceId = Buffer.from(wrongKeyPair.publicKey).toString( + 'hex', + ) // Create a share for a different device const { shareId: shareId2 } = await sender @@ -1134,7 +1138,9 @@ describe('Map Shares and Downloads', () => { // Create a third device with different keys const wrongKeyPair = SecretStreamAgent.keyPair() - const wrongDeviceId = Buffer.from(wrongKeyPair.publicKey).toString('hex') + const wrongDeviceId = Buffer.from(wrongKeyPair.publicKey).toString( + 'hex', + ) // Create a share for wrongDeviceId const { shareId } = await sender @@ -1187,7 +1193,9 @@ describe('Map Shares and Downloads', () => { // Create a third device const wrongKeyPair = SecretStreamAgent.keyPair() - const wrongDeviceId = Buffer.from(wrongKeyPair.publicKey).toString('hex') + const wrongDeviceId = Buffer.from(wrongKeyPair.publicKey).toString( + 'hex', + ) // Create a share for wrongDeviceId const { shareId } = await sender @@ -1638,6 +1646,51 @@ describe('Map Shares and Downloads', () => { expect(tempFiles).toHaveLength(0) }) + it('should report MAP_WRITE_ERROR when write fails during download', async (t) => { + const { createShare, createDownload, receiver } = await startServers(t) + const receiverDir = path.dirname(receiver.customMapPath) + const receiverBasename = path.basename(receiver.customMapPath) + + // Mock fs.createWriteStream on the receiver to simulate disk-full + const originalCreateWriteStream = fs.createWriteStream + const spy = vi + .spyOn(fs, 'createWriteStream') + .mockImplementation((...args: any[]) => { + const stream = originalCreateWriteStream.apply(fs, args as any) + stream._write = ( + _chunk: any, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ) => { + callback(new Error('ENOSPC: no space left on device')) + } + return stream + }) + t.onTestFinished(() => spy.mockRestore()) + + const share = await createShare().json() + const download = await createDownload(share).json() + + // Wait for download to fail + await eventsUntil(receiver, download.downloadId, 'error') + + // Check download state has MAP_WRITE_ERROR + const downloadStatus = await receiver + .get(`downloads/${download.downloadId}`) + .json() + expect(downloadStatus.status).toBe('error') + expect(downloadStatus).toHaveProperty('error.code', 'MAP_WRITE_ERROR') + + await delay(100) + + // Verify temp files are cleaned up + const files = fs.readdirSync(receiverDir) + const tempFiles = files.filter( + (f) => f.startsWith(receiverBasename) && f.includes('.download-'), + ) + expect(tempFiles).toHaveLength(0) + }) + it('should try next URL when first mapShareUrl fails during download', async (t) => { const { createShare, sender, receiver } = await startServers(t) diff --git a/test/maps.test.ts b/test/maps.test.ts index 11c2d3c..9e77397 100644 --- a/test/maps.test.ts +++ b/test/maps.test.ts @@ -603,6 +603,8 @@ describe('Map Upload', () => { }) expect(response.status).toBe(500) + const error = await response.json() + expect(error).toHaveProperty('code', 'MAP_WRITE_ERROR') // Allow time for async cleanup to complete await delay(100) @@ -646,6 +648,8 @@ describe('Map Upload', () => { }) expect(response.status).toBe(500) + const error = await response.json() + expect(error).toHaveProperty('code', 'MAP_WRITE_ERROR') // Allow time for async cleanup to complete await delay(100)