mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 00:21:34 +00:00
Admins often recreate monitors with the same endpoint, model, and request settings. A server-side duplicate keeps the stored API key out of the browser, creates a disabled copy for review, and uses stable operation identity to recover ambiguous retries without creating extra rows. Constraint: Stored monitor API keys must never be returned to the browser Constraint: Applying a request template must preserve internal duplicate recovery metadata Rejected: Rebuild the monitor from list data | list responses only contain a masked API key Rejected: Copy runtime state and history | a duplicate should start as an unverified configuration Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep duplicated monitors disabled until an administrator reviews and enables them Tested: Go unit tests for repository, service, and admin handler; integration-tag compile; go vet; golangci-lint v2.9; frontend Vitest, ESLint, typecheck, production build; Playwright duplicate flow Not-tested: PostgreSQL container integration locally because Docker is unavailable; CI will execute the database-backed suite
104 lines
3.9 KiB
TypeScript
104 lines
3.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { post } = vi.hoisted(() => ({
|
|
post: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/api/client', () => ({
|
|
apiClient: { post },
|
|
}))
|
|
|
|
import { duplicate } from '@/api/admin/channelMonitor'
|
|
|
|
describe('admin channel monitor duplicate API', () => {
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
sessionStorage.clear()
|
|
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
|
|
post.mockReset()
|
|
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } })
|
|
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('sends a stable idempotency key with the duplicate request', async () => {
|
|
const monitor = await duplicate(42)
|
|
|
|
expect(post).toHaveBeenCalledWith('/admin/channel-monitors/42/duplicate', undefined, {
|
|
headers: {
|
|
'Idempotency-Key': 'channel-monitor-duplicate-7-42-11111111-1111-4111-8111-111111111111',
|
|
},
|
|
})
|
|
expect(monitor).toEqual({ id: 43, name: 'primary (Copy)' })
|
|
expect(sessionStorage.length).toBe(0)
|
|
})
|
|
|
|
it('reuses the operation key after an ambiguous failed request', async () => {
|
|
post.mockRejectedValueOnce(new Error('network timeout'))
|
|
await expect(duplicate(99)).rejects.toThrow('network timeout')
|
|
|
|
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
|
|
await duplicate(99)
|
|
|
|
expect(post).toHaveBeenCalledTimes(2)
|
|
expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers)
|
|
expect(sessionStorage.length).toBe(0)
|
|
})
|
|
|
|
it('reuses the operation key after a page reload', async () => {
|
|
post.mockRejectedValueOnce(new Error('network timeout'))
|
|
await expect(duplicate(77)).rejects.toThrow('network timeout')
|
|
const firstHeaders = post.mock.calls[0][2].headers
|
|
|
|
vi.resetModules()
|
|
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
|
|
const { duplicate: duplicateAfterReload } = await import('@/api/admin/channelMonitor')
|
|
await duplicateAfterReload(77)
|
|
|
|
expect(post).toHaveBeenCalledTimes(2)
|
|
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
|
|
expect(sessionStorage.length).toBe(0)
|
|
})
|
|
|
|
it('does not reuse an operation key across administrators for the same monitor', async () => {
|
|
post.mockRejectedValueOnce(new Error('first admin timeout'))
|
|
await expect(duplicate(55)).rejects.toThrow('first admin timeout')
|
|
const firstAdminHeaders = post.mock.calls[0][2].headers
|
|
|
|
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
|
|
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
|
'22222222-2222-4222-8222-222222222222'
|
|
)
|
|
post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } })
|
|
await duplicate(55)
|
|
|
|
expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders)
|
|
expect(post.mock.calls[1][2].headers).toEqual({
|
|
'Idempotency-Key': 'channel-monitor-duplicate-8-55-22222222-2222-4222-8222-222222222222',
|
|
})
|
|
expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:7:55')).toBe(
|
|
firstAdminHeaders['Idempotency-Key']
|
|
)
|
|
expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:8:55')).toBeNull()
|
|
})
|
|
|
|
it('does not persist or reuse keys when the current user cannot be parsed', async () => {
|
|
localStorage.setItem('auth_user', '{invalid json')
|
|
post.mockRejectedValueOnce(new Error('network timeout'))
|
|
await expect(duplicate(66)).rejects.toThrow('network timeout')
|
|
const firstHeaders = post.mock.calls[0][2].headers
|
|
|
|
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
|
'33333333-3333-4333-8333-333333333333'
|
|
)
|
|
post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } })
|
|
await duplicate(66)
|
|
|
|
expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders)
|
|
expect(sessionStorage.length).toBe(0)
|
|
})
|
|
})
|