mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 04:59:47 +08:00
[codex] separate billing configuration (#479)
* feat(admin): separate billing configuration Add dedicated storage egress and downloader credit billing contracts, usecases, RPC wrappers, drawers, generated client updates, and coverage. Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0 * fix(billing): preserve not found ordering Check storage and downloader existence before quota_store gating in dedicated billing usecases, and cover enabled missing-resource requests at usecase and route levels. Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0 --------- Co-authored-by: Jordan Park <jordan-park@mails.agent-kanban.dev>
This commit is contained in:
co-authored by
Jordan Park
parent
82c5452782
commit
f41ed27bba
@@ -1875,4 +1875,68 @@ describe('Downloaders — free plan limit', () => {
|
||||
expect((await postDownloader(app, admin, 'first')).status).toBe(201)
|
||||
expect((await postDownloader(app, admin, 'second')).status).toBe(201)
|
||||
})
|
||||
|
||||
it('updates downloader credit billing through the dedicated route [spec: downloaders/credit-billing]', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await seedBusinessLicense(db)
|
||||
const admin = await adminHeaders(app)
|
||||
const createRes = await postDownloader(app, admin, 'billable')
|
||||
const created = (await createRes.json()) as { downloader: { id: string } }
|
||||
|
||||
const res = await app.request(`/api/downloads/downloaders/${created.downloader.id}/credit-billing`, {
|
||||
method: 'PUT',
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, unitBytes: 2048, creditsPerUnit: 3 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Downloader
|
||||
expect(body.remoteDownloadCreditBillingEnabled).toBe(true)
|
||||
expect(body.remoteDownloadCreditUnitBytes).toBe(2048)
|
||||
expect(body.remoteDownloadCreditPerUnit).toBe(3)
|
||||
})
|
||||
|
||||
it('returns 402 when enabling downloader credit billing without quota_store', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const admin = await adminHeaders(app)
|
||||
const createRes = await postDownloader(app, admin, 'blocked-billing')
|
||||
const created = (await createRes.json()) as { downloader: { id: string } }
|
||||
|
||||
const res = await app.request(`/api/downloads/downloaders/${created.downloader.id}/credit-billing`, {
|
||||
method: 'PUT',
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, unitBytes: 2048, creditsPerUnit: 3 }),
|
||||
})
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as {
|
||||
error: { message: string; details: { reason: string; metadata: Record<string, string> }[] }
|
||||
}
|
||||
expect(body.error.message).toBe('Feature not available')
|
||||
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0].metadata.feature).toBe('quota_store')
|
||||
})
|
||||
|
||||
it('returns 404 from downloader credit billing when disabled for a missing downloader', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
const res = await app.request('/api/downloads/downloaders/missing/credit-billing', {
|
||||
method: 'PUT',
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: false, unitBytes: 2048, creditsPerUnit: 3 }),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 from downloader credit billing when enabled for a missing downloader without quota_store', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await seedProLicense(db)
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
const res = await app.request('/api/downloads/downloaders/missing/credit-billing', {
|
||||
method: 'PUT',
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, unitBytes: 2048, creditsPerUnit: 3 }),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
downloaderHeartbeatSchema,
|
||||
downloaderSchema,
|
||||
pageSchema,
|
||||
updateDownloaderCreditBillingSchema,
|
||||
updateDownloaderSchema,
|
||||
} from '@shared/schemas'
|
||||
import { FREE_DOWNLOADER_LIMIT } from '../../../shared/constants'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
listDownloaders,
|
||||
recordDownloaderHeartbeat,
|
||||
updateDownloader,
|
||||
updateDownloaderCreditBilling,
|
||||
} from '../../usecases/downloads/downloads'
|
||||
import { featureBlocked, unauthorized } from '../../usecases/ports'
|
||||
import { loadBindingState } from '../../usecases/site/licensing'
|
||||
@@ -67,6 +69,21 @@ const updateRoute = createRoute({
|
||||
},
|
||||
})
|
||||
|
||||
const updateCreditBillingRoute = createRoute({
|
||||
operationId: 'updateDownloaderCreditBilling',
|
||||
summary: 'Update downloader credit billing',
|
||||
tags: ['Downloaders'],
|
||||
method: 'put',
|
||||
path: '/{id}/credit-billing',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderCreditBillingSchema) },
|
||||
responses: {
|
||||
200: jsonContent(downloaderSchema, 'Updated downloader'),
|
||||
402: errorResponse('Feature not available'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
})
|
||||
|
||||
const deleteRoute = createRoute({
|
||||
operationId: 'deleteDownloader',
|
||||
summary: 'Delete downloader',
|
||||
@@ -134,6 +151,10 @@ const downloadersRoute = new OpenAPIHono<Env>()
|
||||
}
|
||||
return c.json(await updateDownloader(c.get('deps'), id, input), 200)
|
||||
})
|
||||
.openapi(updateCreditBillingRoute, async (c) => {
|
||||
const { id } = c.req.valid('param')
|
||||
return c.json(await updateDownloaderCreditBilling(c.get('deps'), id, c.req.valid('json')), 200)
|
||||
})
|
||||
.openapi(deleteRoute, async (c) => {
|
||||
const { id } = c.req.valid('param')
|
||||
await deleteDownloader(c.get('deps'), id)
|
||||
|
||||
@@ -152,6 +152,29 @@ describe('[CF] Admin Storages API', () => {
|
||||
expect(body.title).toBe('Updated CF S3')
|
||||
})
|
||||
|
||||
it('PUT /api/site/storages/:id/egress-billing enforces quota_store for enabling', async () => {
|
||||
const app = await buildApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const platform = createCloudflarePlatform(env)
|
||||
const created = await createStorageRepo(platform.db).create({
|
||||
...validStorage,
|
||||
title: `CF Egress Billing ${Date.now()}`,
|
||||
bucket: `cf-egress-billing-${Date.now()}`,
|
||||
})
|
||||
|
||||
const res = await app.request(`/api/site/storages/${created.id}/egress-billing`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }),
|
||||
})
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as {
|
||||
error: { details: Array<{ reason: string; metadata: Record<string, string> }> }
|
||||
}
|
||||
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0].metadata.feature).toBe('quota_store')
|
||||
})
|
||||
|
||||
it('DELETE /api/site/storages/:id deletes a storage', async () => {
|
||||
const app = await buildApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FREE_STORAGE_LIMIT } from '@shared/constants'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createStorageRepo } from '../../adapters/repos/storage.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../../test/setup.js'
|
||||
|
||||
const validStorage = {
|
||||
title: 'Test S3',
|
||||
@@ -189,6 +189,78 @@ describe('Admin Storages API', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PUT /:id/egress-billing updates storage credits billing [spec: storages/egress-billing]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const createRes = await app.request('/api/site/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const res = await app.request(`/api/site/storages/${created.id}/egress-billing`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.egressCreditBillingEnabled).toBe(true)
|
||||
expect(body.egressCreditUnitBytes).toBe(1024)
|
||||
expect(body.egressCreditPerUnit).toBe(2)
|
||||
})
|
||||
|
||||
it('PUT /:id/egress-billing returns 402 when quota_store is unavailable', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const createRes = await app.request('/api/site/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const res = await app.request(`/api/site/storages/${created.id}/egress-billing`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }),
|
||||
})
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as {
|
||||
error: { message: string; details: Array<{ reason: string; metadata: Record<string, string> }> }
|
||||
}
|
||||
expect(body.error.message).toBe('Feature not available')
|
||||
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0].metadata.feature).toBe('quota_store')
|
||||
})
|
||||
|
||||
it('PUT /:id/egress-billing returns 404 for missing storage when disabled', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/site/storages/nonexistent/egress-billing', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: false, unitBytes: 1024, creditsPerUnit: 2 }),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PUT /:id/egress-billing returns 404 for missing storage when enabled without quota_store', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/site/storages/nonexistent/egress-billing', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('DELETE /:id deletes a storage [spec: storages/delete]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { createStorageSchema, pageSchema, updateStorageSchema } from '@shared/schemas'
|
||||
import { createStorageSchema, pageSchema, updateStorageEgressBillingSchema, updateStorageSchema } from '@shared/schemas'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import { type StorageRecord, storageNotFound } from '../../usecases/ports'
|
||||
import { createStorage, deleteStorage, getStorage, listStorages, updateStorage } from '../../usecases/site/storage'
|
||||
import {
|
||||
createStorage,
|
||||
deleteStorage,
|
||||
getStorage,
|
||||
listStorages,
|
||||
updateStorage,
|
||||
updateStorageEgressBilling,
|
||||
} from '../../usecases/site/storage'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
// Admin storage config. The response intentionally includes the S3 credentials
|
||||
@@ -93,6 +100,21 @@ const updateStorageRoute = createRoute({
|
||||
},
|
||||
})
|
||||
|
||||
const updateStorageEgressBillingRoute = createRoute({
|
||||
operationId: 'updateStorageEgressBilling',
|
||||
summary: 'Update storage egress billing',
|
||||
tags: ['Storages'],
|
||||
method: 'put',
|
||||
path: '/{id}/egress-billing',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ id: z.string() }), ...jsonBody(updateStorageEgressBillingSchema) },
|
||||
responses: {
|
||||
200: jsonContent(storageSchema, 'Updated storage'),
|
||||
402: errorResponse('Feature not available'),
|
||||
404: errorResponse('Storage not found'),
|
||||
},
|
||||
})
|
||||
|
||||
const deleteStorageRoute = createRoute({
|
||||
operationId: 'deleteStorage',
|
||||
summary: 'Delete storage',
|
||||
@@ -138,6 +160,16 @@ const storages = new OpenAPIHono<Env>()
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(toStorageDTO(result.storage), 200)
|
||||
})
|
||||
.openapi(updateStorageEgressBillingRoute, async (c) => {
|
||||
const result = await updateStorageEgressBilling(c.get('deps'), {
|
||||
userId: c.get('userId')!,
|
||||
orgId: c.get('orgId')!,
|
||||
id: c.req.valid('param').id,
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(toStorageDTO(result.storage), 200)
|
||||
})
|
||||
.openapi(deleteStorageRoute, async (c) => {
|
||||
const id = c.req.valid('param').id
|
||||
const result = await deleteStorage(c.get('deps'), { userId: c.get('userId')!, orgId: c.get('orgId')!, id })
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { BindingState, Downloader } from '@shared/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DownloaderRecord, DownloaderRepo } from '../ports'
|
||||
import { type AppError, DownloadError } from '../ports'
|
||||
import { loadBindingState } from '../site/licensing'
|
||||
import { type DownloadsDeps, updateDownloaderCreditBilling } from './downloads'
|
||||
|
||||
vi.mock('../site/licensing', () => ({ loadBindingState: vi.fn() }))
|
||||
|
||||
const PRO: BindingState = { bound: true, active: true, edition: 'pro' }
|
||||
const BUSINESS: BindingState = { bound: true, active: true, edition: 'business' }
|
||||
|
||||
const downloader: Downloader = {
|
||||
id: 'downloader-1',
|
||||
name: 'Edge worker',
|
||||
status: 'offline',
|
||||
enabled: true,
|
||||
version: '1.0.0',
|
||||
hostname: 'edge-1',
|
||||
platform: 'linux',
|
||||
arch: 'amd64',
|
||||
engine: 'aria2',
|
||||
capabilities: ['http'],
|
||||
maxConcurrentTasks: 2,
|
||||
currentTasks: 0,
|
||||
downloadBps: 0,
|
||||
uploadBps: 0,
|
||||
freeDiskBytes: 1024,
|
||||
remoteDownloadCreditBillingEnabled: true,
|
||||
remoteDownloadCreditUnitBytes: 1024,
|
||||
remoteDownloadCreditPerUnit: 2,
|
||||
lastHeartbeatAt: null,
|
||||
createdBy: 'user-1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
const downloaderRecord = {
|
||||
...downloader,
|
||||
tokenHash: 'hash',
|
||||
tokenJti: 'jti',
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: new Date(downloader.createdAt),
|
||||
updatedAt: new Date(downloader.updatedAt),
|
||||
} satisfies DownloaderRecord
|
||||
|
||||
function makeDeps(downloaders: Partial<DownloaderRepo> = {}) {
|
||||
const update = vi.fn(async () => {})
|
||||
const repo: DownloaderRepo = {
|
||||
insert: async () => {},
|
||||
list: async () => [],
|
||||
get: async () => downloader,
|
||||
getRecord: async () => downloaderRecord,
|
||||
findRecord: async () => downloaderRecord,
|
||||
update,
|
||||
recordHeartbeat: async () => {},
|
||||
delete: async () => {},
|
||||
listAssignmentCandidates: async () => [],
|
||||
listStaleIds: async () => [],
|
||||
listUnreachableIds: async () => [],
|
||||
markStaleOffline: async () => {},
|
||||
...downloaders,
|
||||
}
|
||||
return {
|
||||
deps: {
|
||||
downloaders: repo,
|
||||
downloadTasks: {},
|
||||
downloadTokens: {},
|
||||
licenseBinding: {},
|
||||
licensingCloud: {},
|
||||
remoteDownloadUsage: {},
|
||||
} as DownloadsDeps,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('updateDownloaderCreditBilling', () => {
|
||||
it('updates credit billing fields through the downloader repo', async () => {
|
||||
vi.mocked(loadBindingState).mockResolvedValue(BUSINESS)
|
||||
const { deps, update } = makeDeps()
|
||||
|
||||
const out = await updateDownloaderCreditBilling(deps, 'downloader-1', {
|
||||
enabled: true,
|
||||
unitBytes: 2048,
|
||||
creditsPerUnit: 3,
|
||||
})
|
||||
|
||||
expect(out).toBe(downloader)
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'downloader-1',
|
||||
{
|
||||
remoteDownloadCreditBillingEnabled: true,
|
||||
remoteDownloadCreditUnitBytes: 2048,
|
||||
remoteDownloadCreditPerUnit: 3,
|
||||
},
|
||||
expect.any(Date),
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks enabling credit billing when quota_store is unavailable', async () => {
|
||||
vi.mocked(loadBindingState).mockResolvedValue(PRO)
|
||||
const { deps, update } = makeDeps()
|
||||
|
||||
await expect(
|
||||
updateDownloaderCreditBilling(deps, 'downloader-1', {
|
||||
enabled: true,
|
||||
unitBytes: 2048,
|
||||
creditsPerUnit: 3,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AppError',
|
||||
httpStatus: 402,
|
||||
meta: { reason: 'FEATURE_NOT_AVAILABLE', metadata: { feature: 'quota_store' } },
|
||||
} satisfies Partial<AppError>)
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves not_found when credit billing is disabled for a missing downloader', async () => {
|
||||
vi.mocked(loadBindingState).mockResolvedValue(PRO)
|
||||
const { deps, update } = makeDeps({
|
||||
getRecord: async () => {
|
||||
throw new DownloadError('not_found')
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
updateDownloaderCreditBilling(deps, 'missing', {
|
||||
enabled: false,
|
||||
unitBytes: 2048,
|
||||
creditsPerUnit: 3,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: 'DownloadError', code: 'not_found' })
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves not_found before quota_store gating for a missing downloader when credit billing is enabled', async () => {
|
||||
vi.mocked(loadBindingState).mockResolvedValue(PRO)
|
||||
const { deps, update } = makeDeps({
|
||||
getRecord: async () => {
|
||||
throw new DownloadError('not_found')
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
updateDownloaderCreditBilling(deps, 'missing', {
|
||||
enabled: true,
|
||||
unitBytes: 2048,
|
||||
creditsPerUnit: 3,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: 'DownloadError', code: 'not_found' })
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CreateDownloadTaskInput,
|
||||
DownloaderHeartbeatInput,
|
||||
DownloadTaskActionInput,
|
||||
UpdateDownloaderCreditBillingInput,
|
||||
UpdateDownloaderInput,
|
||||
UpdateDownloadTaskInput,
|
||||
} from '@shared/schemas'
|
||||
@@ -10,6 +11,7 @@ import { downloadTaskRuntimeSchema } from '@shared/schemas'
|
||||
import type { Downloader, DownloadTask, DownloadTaskRuntime } from '@shared/types'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
|
||||
import { hasFeature } from '../../domain/licensing'
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import type {
|
||||
DownloaderRecord,
|
||||
@@ -22,7 +24,8 @@ import type {
|
||||
ListDownloadTasksFilters,
|
||||
RemoteDownloadUsageRepo,
|
||||
} from '../ports'
|
||||
import { DownloadError } from '../ports'
|
||||
import { DownloadError, featureBlocked } from '../ports'
|
||||
import { loadBindingState } from '../site/licensing'
|
||||
import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './remote-download-usage'
|
||||
|
||||
// Pure orchestration over the downloader / download-task repos: registration,
|
||||
@@ -142,6 +145,29 @@ export async function updateDownloader(
|
||||
return deps.downloaders.get(id)
|
||||
}
|
||||
|
||||
export async function updateDownloaderCreditBilling(
|
||||
deps: DownloadsDeps,
|
||||
id: string,
|
||||
input: UpdateDownloaderCreditBillingInput,
|
||||
): Promise<Downloader> {
|
||||
await deps.downloaders.getRecord(id) // throws not_found
|
||||
if (input.enabled && !hasFeature('quota_store', await loadBindingState(deps))) {
|
||||
throw featureBlocked('Feature not available', {
|
||||
metadata: { feature: 'quota_store' },
|
||||
})
|
||||
}
|
||||
await deps.downloaders.update(
|
||||
id,
|
||||
{
|
||||
remoteDownloadCreditBillingEnabled: input.enabled,
|
||||
remoteDownloadCreditUnitBytes: input.unitBytes,
|
||||
remoteDownloadCreditPerUnit: input.creditsPerUnit,
|
||||
},
|
||||
new Date(),
|
||||
)
|
||||
return deps.downloaders.get(id)
|
||||
}
|
||||
|
||||
export async function deleteDownloader(deps: DownloadsDeps, id: string): Promise<{ id: string; deleted: true }> {
|
||||
await deps.downloaders.getRecord(id) // throws not_found
|
||||
const now = new Date()
|
||||
|
||||
@@ -5,7 +5,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ActivityRepo, LicenseBindingRepo, StorageRecord, StorageRepo } from '../ports'
|
||||
import { AppError } from '../ports'
|
||||
import { loadBindingState } from './licensing'
|
||||
import { createStorage, deleteStorage, getStorage, listStorages, type StorageDeps, updateStorage } from './storage'
|
||||
import {
|
||||
createStorage,
|
||||
deleteStorage,
|
||||
getStorage,
|
||||
listStorages,
|
||||
type StorageDeps,
|
||||
updateStorage,
|
||||
updateStorageEgressBilling,
|
||||
} from './storage'
|
||||
|
||||
// loadBindingState derives features from a signed certificate — out of scope for
|
||||
// a usecase unit test. Mock it so each case feeds a chosen edition; the real
|
||||
@@ -184,6 +192,84 @@ describe('storage usecase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateStorageEgressBilling', () => {
|
||||
it('updates egress billing fields and records activity', async () => {
|
||||
edition(BUSINESS)
|
||||
const update = vi.fn(async () => sampleStorage)
|
||||
const { deps, record } = makeDeps({ get: async () => sampleStorage, update })
|
||||
const out = await updateStorageEgressBilling(deps, {
|
||||
userId: 'u1',
|
||||
orgId: 'o1',
|
||||
id: 'st-1',
|
||||
input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 },
|
||||
})
|
||||
expect(out).toEqual({ ok: true, storage: sampleStorage })
|
||||
expect(update).toHaveBeenCalledWith('st-1', {
|
||||
egressCreditBillingEnabled: true,
|
||||
egressCreditUnitBytes: 1024,
|
||||
egressCreditPerUnit: 2,
|
||||
})
|
||||
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'storage_update', targetId: 'st-1' }))
|
||||
})
|
||||
|
||||
it('blocks enabling egress billing without quota_store', async () => {
|
||||
edition(PRO)
|
||||
const update = vi.fn(async () => sampleStorage)
|
||||
const { deps, record } = makeDeps({ get: async () => sampleStorage, update })
|
||||
const out = await updateStorageEgressBilling(deps, {
|
||||
userId: 'u1',
|
||||
orgId: 'o1',
|
||||
id: 'st-1',
|
||||
input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 },
|
||||
})
|
||||
expect(out.ok).toBe(false)
|
||||
if (!out.ok) {
|
||||
expect(out.error).toBeInstanceOf(AppError)
|
||||
expect(out.error.httpStatus).toBe(402)
|
||||
expect(out.error.meta.reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(out.error.meta.metadata).toEqual({ feature: 'quota_store' })
|
||||
}
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(record).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns not_found for a missing storage when billing is disabled', async () => {
|
||||
edition(PRO)
|
||||
const { deps, record } = makeDeps({ update: async () => null })
|
||||
const out = await updateStorageEgressBilling(deps, {
|
||||
userId: 'u1',
|
||||
orgId: 'o1',
|
||||
id: 'missing',
|
||||
input: { enabled: false, unitBytes: 1024, creditsPerUnit: 2 },
|
||||
})
|
||||
expect(out.ok).toBe(false)
|
||||
if (!out.ok) {
|
||||
expect(out.error.httpStatus).toBe(404)
|
||||
expect(out.error.message).toBe('Storage not found')
|
||||
}
|
||||
expect(record).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns not_found before quota_store gating for a missing storage when billing is enabled', async () => {
|
||||
edition(PRO)
|
||||
const update = vi.fn(async () => null)
|
||||
const { deps, record } = makeDeps({ get: async () => null, update })
|
||||
const out = await updateStorageEgressBilling(deps, {
|
||||
userId: 'u1',
|
||||
orgId: 'o1',
|
||||
id: 'missing',
|
||||
input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 },
|
||||
})
|
||||
expect(out.ok).toBe(false)
|
||||
if (!out.ok) {
|
||||
expect(out.error.httpStatus).toBe(404)
|
||||
expect(out.error.message).toBe('Storage not found')
|
||||
}
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(record).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteStorage', () => {
|
||||
it('deletes and records activity with the storage name', async () => {
|
||||
const del = vi.fn(async () => 'ok' as const)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// the CRUD resource; that one is a cross-resource operation.
|
||||
|
||||
import { FREE_STORAGE_LIMIT } from '@shared/constants'
|
||||
import type { CreateStorageInput, UpdateStorageInput } from '@shared/schemas'
|
||||
import type { CreateStorageInput, UpdateStorageEgressBillingInput, UpdateStorageInput } from '@shared/schemas'
|
||||
import { hasFeature } from '../../domain/licensing'
|
||||
import {
|
||||
type ActivityRepo,
|
||||
@@ -121,6 +121,33 @@ export async function updateStorage(
|
||||
return { ok: true, storage }
|
||||
}
|
||||
|
||||
export async function updateStorageEgressBilling(
|
||||
deps: StorageDeps,
|
||||
params: { userId: string; orgId: string; id: string; input: UpdateStorageEgressBillingInput },
|
||||
): Promise<UpdateStorageOutcome> {
|
||||
const { userId, orgId, id, input } = params
|
||||
const existing = await deps.storages.get(id)
|
||||
if (!existing) return { ok: false, error: storageNotFound() }
|
||||
if (input.enabled && !hasFeature('quota_store', await loadBindingState({ licenseBinding: deps.licenseBinding }))) {
|
||||
return { ok: false, error: featureBlockError({ feature: 'quota_store' }) }
|
||||
}
|
||||
const storage = await deps.storages.update(id, {
|
||||
egressCreditBillingEnabled: input.enabled,
|
||||
egressCreditUnitBytes: input.unitBytes,
|
||||
egressCreditPerUnit: input.creditsPerUnit,
|
||||
})
|
||||
if (!storage) return { ok: false, error: storageNotFound() }
|
||||
await deps.activity.record({
|
||||
orgId,
|
||||
userId,
|
||||
action: 'storage_update',
|
||||
targetType: 'storage',
|
||||
targetId: storage.id,
|
||||
targetName: storage.title,
|
||||
})
|
||||
return { ok: true, storage }
|
||||
}
|
||||
|
||||
export async function deleteStorage(
|
||||
deps: StorageDeps,
|
||||
params: { userId: string; orgId: string; id: string },
|
||||
|
||||
Reference in New Issue
Block a user