mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-31 02:04:40 +08:00
8abca2f88c
Collapse the two error conventions (string-reason `{ok:false,reason}` outcomes
and thrown domain-error classes) onto one. Usecases now produce typed `AppError`
values via factories (`notFound()`/`quotaExceeded()`/`featureBlocked()`/…);
handlers `throw result.error`; and `jsonError` (renamed from `renderError`) is the
single place that renders any error to an AIP-193 body + access-log line, in
`app.onError`/accessLog.
Why: the previous setup had a string→code mapping (`outcomeError` + the `OUTCOME`
table) living in parallel with a type→code mapping (`mapDomainError`), plus inline
`apiError(c, <status>, …)` calls that hand-wrote the status at every site — exactly
the drift that left the same `quota_exceeded` at 400 in one handler and 422 in the
rest. Now the status/reason live once, in the factory.
- Add `server/usecases/ports/app-error.ts`: `AppError` + factories. Status/reason
are baked in per factory, so no usecase or handler writes an HTTP code or a
magic-string reason. `AppError` also carries optional response headers
(`Retry-After`) via a `rateLimited()` factory.
- Delete `apiError`, `outcomeError`, the `OUTCOME` table, and the dead `ApiError`
class. The 67 inline guard/middleware `apiError` sites became `throw <factory>()`.
- Control-flow outcomes a handler branches on (not just renders) stay discriminated
reasons (e.g. `deleteObject` `not_trashed`); internal shared sub-usecases
(traffic-metering, licensing internals) keep string reasons, mapped at the boundary.
- Regenerate the Go OpenAPI client (saveShare gained a 422 response).
BREAKING CHANGE: POST /shares/{token}/objects quota rejection now returns 422
(was an inconsistent 400); every other quota path already returned 422.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
4.5 KiB
TypeScript
112 lines
4.5 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
BackgroundJobError,
|
|
DownloadError,
|
|
NameConflictError,
|
|
ObjectUploadSessionError,
|
|
StorageQuotaExceededError,
|
|
WebDavPathError,
|
|
} from '../usecases/ports'
|
|
import { buildErrorBody, mapDomainError } from './http-errors'
|
|
|
|
describe('buildErrorBody', () => {
|
|
it('defaults reason and canonical status from the HTTP code', () => {
|
|
const body = buildErrorBody(404, 'Not found')
|
|
expect(body).toEqual({
|
|
error: {
|
|
code: 404,
|
|
message: 'Not found',
|
|
status: 'NOT_FOUND',
|
|
details: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason: 'NOT_FOUND', domain: 'zpan.dev' }],
|
|
},
|
|
})
|
|
})
|
|
|
|
it('falls back to INTERNAL for unmapped 5xx and UNKNOWN for unmapped 4xx', () => {
|
|
expect(buildErrorBody(599, 'x').error.status).toBe('INTERNAL')
|
|
expect(buildErrorBody(418, 'x').error.status).toBe('UNKNOWN')
|
|
})
|
|
|
|
it('honors explicit reason, canonical status, metadata, and domain overrides', () => {
|
|
const body = buildErrorBody(422, 'Quota exceeded', {
|
|
reason: 'QUOTA_EXCEEDED',
|
|
status: 'RESOURCE_EXHAUSTED',
|
|
metadata: { resource: 'storage_egress' },
|
|
domain: 'custom.example',
|
|
})
|
|
expect(body.error.status).toBe('RESOURCE_EXHAUSTED')
|
|
expect(body.error.details?.[0]).toEqual({
|
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
reason: 'QUOTA_EXCEEDED',
|
|
domain: 'custom.example',
|
|
metadata: { resource: 'storage_egress' },
|
|
})
|
|
})
|
|
|
|
it('omits metadata when none is given', () => {
|
|
expect(buildErrorBody(403, 'Forbidden').error.details?.[0]?.metadata).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('mapDomainError', () => {
|
|
const reasonOf = (m: ReturnType<typeof mapDomainError>) => m?.json.error.details?.[0]?.reason
|
|
|
|
it('maps StorageQuotaExceededError to 422 / RESOURCE_EXHAUSTED', () => {
|
|
const m = mapDomainError(new StorageQuotaExceededError())
|
|
expect(m?.status).toBe(422)
|
|
expect(m?.message).toBe('Quota exceeded')
|
|
expect(m?.json.error.status).toBe('RESOURCE_EXHAUSTED')
|
|
expect(reasonOf(m)).toBe('QUOTA_EXCEEDED')
|
|
})
|
|
|
|
it('maps NameConflictError to 409 / ALREADY_EXISTS with conflict metadata', () => {
|
|
const m = mapDomainError(new NameConflictError('doc.txt', 'id-1'))
|
|
expect(m?.status).toBe(409)
|
|
expect(m?.json.error.status).toBe('ALREADY_EXISTS')
|
|
expect(reasonOf(m)).toBe('NAME_CONFLICT')
|
|
expect(m?.json.error.details?.[0]?.metadata).toEqual({ conflictingName: 'doc.txt', conflictingId: 'id-1' })
|
|
})
|
|
|
|
it('omits conflictingId metadata when it is empty', () => {
|
|
const m = mapDomainError(new NameConflictError('doc.txt', ''))
|
|
expect(m?.json.error.details?.[0]?.metadata).toEqual({ conflictingName: 'doc.txt' })
|
|
})
|
|
|
|
it('maps ObjectUploadSessionError by code', () => {
|
|
expect(mapDomainError(new ObjectUploadSessionError('storage_failure', 'boom'))?.status).toBe(502)
|
|
expect(reasonOf(mapDomainError(new ObjectUploadSessionError('storage_failure', 'boom')))).toBe('STORAGE_FAILURE')
|
|
expect(mapDomainError(new ObjectUploadSessionError('not_found'))?.status).toBe(404)
|
|
const invalid = mapDomainError(new ObjectUploadSessionError('invalid_state'))
|
|
expect(invalid?.status).toBe(409)
|
|
expect(reasonOf(invalid)).toBe('INVALID_STATE')
|
|
})
|
|
|
|
it('maps WebDavPathError to its own status with the canonical default reason', () => {
|
|
const m = mapDomainError(new WebDavPathError('Bad path', 409))
|
|
expect(m?.status).toBe(409)
|
|
expect(m?.message).toBe('Bad path')
|
|
expect(m?.json.error.status).toBe('ABORTED')
|
|
expect(reasonOf(m)).toBe('ABORTED')
|
|
})
|
|
|
|
it('maps DownloadError by code with an UPPER_SNAKE reason', () => {
|
|
expect(mapDomainError(new DownloadError('not_found'))?.status).toBe(404)
|
|
expect(mapDomainError(new DownloadError('forbidden'))?.status).toBe(403)
|
|
const other = mapDomainError(new DownloadError('invalid_state', 'Task is paused'))
|
|
expect(other?.status).toBe(409)
|
|
expect(other?.message).toBe('Task is paused')
|
|
expect(reasonOf(other)).toBe('INVALID_STATE')
|
|
})
|
|
|
|
it('maps BackgroundJobError by code', () => {
|
|
expect(reasonOf(mapDomainError(new BackgroundJobError('not_cancelable')))).toBe('NOT_CANCELABLE')
|
|
expect(reasonOf(mapDomainError(new BackgroundJobError('not_retryable')))).toBe('NOT_RETRYABLE')
|
|
expect(mapDomainError(new BackgroundJobError('not_found'))?.status).toBe(404)
|
|
})
|
|
|
|
it('returns null for unrecognized errors', () => {
|
|
expect(mapDomainError(new Error('boom'))).toBeNull()
|
|
expect(mapDomainError(null)).toBeNull()
|
|
})
|
|
})
|