quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
quality / api (push) Successful in 52s
CD / quality (push) Successful in 2m4s
CD / publish (push) Successful in 1m51s
KPI и карточка показывают живой OK, а не гистерезис unknown; DNS по-прежнему ждёт повторные успехи. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
9.0 KiB
TypeScript
337 lines
9.0 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import {
|
||
failoverEventCopy,
|
||
isFailoverEventStatus,
|
||
mergeFailoverHistory,
|
||
toFailoverEvents,
|
||
toIpAliveTransitions,
|
||
type FailoverBindingPool,
|
||
type FailoverHealthInput,
|
||
} from '@/lib/failover-events'
|
||
import type { FailoverLogEntry } from '@/lib/schemas'
|
||
import type { HealthLogProbe } from '@/lib/health-log'
|
||
|
||
function row(
|
||
overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
|
||
): FailoverHealthInput {
|
||
return {
|
||
status: 'up',
|
||
consecutive_failures: 0,
|
||
last_error: null,
|
||
last_checked_at: null,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
const mskHip: FailoverBindingPool[] = [
|
||
{
|
||
fqdn: 'gt.rkns.top',
|
||
configured: ['130.49.213.153', '93.115.203.183'],
|
||
active: ['93.115.203.183'],
|
||
},
|
||
{
|
||
fqdn: 'nsgt.rkns.top',
|
||
configured: ['130.49.213.153'],
|
||
active: ['130.49.213.153'],
|
||
},
|
||
{
|
||
fqdn: 'rutg.rkns.top',
|
||
configured: ['93.115.203.183'],
|
||
active: ['93.115.203.183'],
|
||
},
|
||
]
|
||
|
||
describe('toFailoverEvents', () => {
|
||
it('инцидент только при binding down, не при unhealthy ноды', () => {
|
||
expect(isFailoverEventStatus('down')).toBe(true)
|
||
expect(isFailoverEventStatus('up')).toBe(false)
|
||
expect(isFailoverEventStatus('degraded')).toBe(false)
|
||
expect(isFailoverEventStatus('unknown')).toBe(false)
|
||
expect(isFailoverEventStatus('unhealthy')).toBe(false)
|
||
})
|
||
|
||
it('один IP у сервиса — не инцидент пула', () => {
|
||
const events = toFailoverEvents(
|
||
[
|
||
row({
|
||
ip: '10.0.0.1',
|
||
status: 'down',
|
||
consecutive_failures: 9,
|
||
last_error: 'fetch failed',
|
||
}),
|
||
],
|
||
[
|
||
{
|
||
fqdn: 'solo.example.com',
|
||
configured: ['10.0.0.1'],
|
||
active: [],
|
||
},
|
||
],
|
||
)
|
||
expect(events).toEqual([])
|
||
})
|
||
|
||
it('Down без shared pool не событие балансировки', () => {
|
||
const events = toFailoverEvents([
|
||
row({
|
||
ip: '130.49.213.153',
|
||
status: 'down',
|
||
consecutive_failures: 9,
|
||
last_error: 'fetch failed',
|
||
}),
|
||
])
|
||
expect(events).toEqual([])
|
||
})
|
||
|
||
it('OK вне пула не инцидент (standby)', () => {
|
||
const events = toFailoverEvents(
|
||
[
|
||
row({ ip: '10.0.0.1', status: 'up' }),
|
||
row({ ip: '130.49.213.153', status: 'up' }),
|
||
],
|
||
[
|
||
{
|
||
fqdn: 'pool.example.com',
|
||
configured: ['10.0.0.1', '130.49.213.153'],
|
||
active: ['10.0.0.1'],
|
||
},
|
||
],
|
||
)
|
||
expect(events).toEqual([])
|
||
})
|
||
|
||
it('unknown и degraded вне пула не инцидент', () => {
|
||
const events = toFailoverEvents(
|
||
[
|
||
row({ ip: '10.0.0.1', status: 'up' }),
|
||
row({ ip: '10.0.0.2', status: 'unknown' }),
|
||
row({ ip: '10.0.0.3', status: 'degraded' }),
|
||
],
|
||
[
|
||
{
|
||
fqdn: 'pool.example.com',
|
||
configured: ['10.0.0.1', '10.0.0.2', '10.0.0.3'],
|
||
active: ['10.0.0.1'],
|
||
},
|
||
],
|
||
)
|
||
expect(events).toEqual([])
|
||
})
|
||
|
||
it('Down на доп. FQDN last-resort, снятая с общего пула', () => {
|
||
const events = toFailoverEvents(
|
||
[
|
||
row({
|
||
ip: '130.49.213.153',
|
||
status: 'down',
|
||
consecutive_failures: 9,
|
||
last_error: 'fetch failed',
|
||
last_checked_at: '2026-08-20 07:00:00',
|
||
}),
|
||
row({ ip: '93.115.203.183', status: 'up' }),
|
||
],
|
||
mskHip,
|
||
)
|
||
expect(events).toEqual([
|
||
{
|
||
id: '130.49.213.153',
|
||
address: '130.49.213.153',
|
||
status: 'down',
|
||
kind: 'removed',
|
||
fqdns: ['gt.rkns.top'],
|
||
consecutiveFailures: 9,
|
||
lastFailureReason: 'fetch failed',
|
||
lastCheckAt: '2026-08-20 07:00:00',
|
||
},
|
||
])
|
||
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
|
||
expect(events[0]?.fqdns).not.toContain('nsgt.rkns.top')
|
||
})
|
||
|
||
it('Down только last-resort на своём FQDN', () => {
|
||
const events = toFailoverEvents(
|
||
[row({ ip: '130.49.213.153', status: 'down' })],
|
||
[
|
||
{
|
||
fqdn: 'nsgt.rkns.top',
|
||
configured: ['130.49.213.153'],
|
||
active: ['130.49.213.153'],
|
||
},
|
||
],
|
||
)
|
||
expect(events).toEqual([])
|
||
})
|
||
|
||
it('down без A-записи на configured FQDN — снятие', () => {
|
||
const events = toFailoverEvents(
|
||
[
|
||
row({
|
||
ip: '130.49.213.153',
|
||
status: 'down',
|
||
consecutive_failures: 9,
|
||
last_error: 'fetch failed',
|
||
last_checked_at: '2026-08-20 07:00:00',
|
||
}),
|
||
],
|
||
[
|
||
{
|
||
fqdn: 'pool.example.com',
|
||
configured: ['10.0.0.1', '130.49.213.153'],
|
||
active: ['10.0.0.1'],
|
||
},
|
||
],
|
||
)
|
||
expect(events).toEqual([
|
||
{
|
||
id: '130.49.213.153',
|
||
address: '130.49.213.153',
|
||
status: 'down',
|
||
kind: 'removed',
|
||
fqdns: ['pool.example.com'],
|
||
consecutiveFailures: 9,
|
||
lastFailureReason: 'fetch failed',
|
||
lastCheckAt: '2026-08-20 07:00:00',
|
||
},
|
||
])
|
||
})
|
||
})
|
||
|
||
function probe(
|
||
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
|
||
): HealthLogProbe {
|
||
return {
|
||
ip: '130.49.213.153',
|
||
provider: 'local',
|
||
ok: overrides.status === 'up',
|
||
latency_ms: 12,
|
||
colo: null,
|
||
error: null,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function dns(
|
||
overrides: Partial<FailoverLogEntry> & Pick<FailoverLogEntry, 'id' | 'action' | 'created_at'>,
|
||
): FailoverLogEntry {
|
||
return {
|
||
service_id: 13,
|
||
binding_id: 1,
|
||
fqdn: 'gt.rkns.top',
|
||
ip: '130.49.213.153',
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
describe('toIpAliveTransitions', () => {
|
||
it('emits leave then return, not the initial up', () => {
|
||
const transitions = toIpAliveTransitions([
|
||
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||
probe({ id: 2, status: 'up', checked_at: '2026-08-20T09:01:00Z' }),
|
||
probe({ id: 3, status: 'down', checked_at: '2026-08-20T09:02:00Z' }),
|
||
probe({ id: 4, status: 'down', checked_at: '2026-08-20T09:03:00Z' }),
|
||
probe({ id: 5, status: 'up', checked_at: '2026-08-20T09:04:00Z' }),
|
||
])
|
||
expect(transitions).toEqual([
|
||
{ id: 'probe:3', ip: '130.49.213.153', alive: false, at: '2026-08-20T09:02:00Z' },
|
||
{ id: 'probe:5', ip: '130.49.213.153', alive: true, at: '2026-08-20T09:04:00Z' },
|
||
])
|
||
})
|
||
|
||
it('any-up across providers: leave only when every source is down', () => {
|
||
const transitions = toIpAliveTransitions([
|
||
probe({
|
||
id: 1,
|
||
provider: 'local',
|
||
status: 'up',
|
||
checked_at: '2026-08-20T09:00:00Z',
|
||
}),
|
||
probe({
|
||
id: 2,
|
||
provider: 'cloudflare',
|
||
status: 'down',
|
||
checked_at: '2026-08-20T09:01:00Z',
|
||
}),
|
||
probe({
|
||
id: 3,
|
||
provider: 'local',
|
||
status: 'down',
|
||
checked_at: '2026-08-20T09:02:00Z',
|
||
}),
|
||
probe({
|
||
id: 4,
|
||
provider: 'local',
|
||
status: 'up',
|
||
checked_at: '2026-08-20T09:03:00Z',
|
||
}),
|
||
])
|
||
expect(transitions.map((item) => item.id)).toEqual(['probe:3', 'probe:4'])
|
||
})
|
||
})
|
||
|
||
describe('mergeFailoverHistory', () => {
|
||
it('fills leave/return from probes when DNS has only the add', () => {
|
||
const history = mergeFailoverHistory(
|
||
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
|
||
[
|
||
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
|
||
probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' }),
|
||
],
|
||
mskHip,
|
||
)
|
||
expect(history.map((item) => `${item.action}:${item.source}`)).toEqual([
|
||
'added:dns',
|
||
'removed:probe',
|
||
])
|
||
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
|
||
expect(history[1]?.copy).toBe(
|
||
'130.49.213.153 вышла из пула (gt.rkns.top)',
|
||
)
|
||
})
|
||
|
||
it('keeps DNS over a probe return in the same 2-minute window', () => {
|
||
const history = mergeFailoverHistory(
|
||
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
|
||
[probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' })],
|
||
mskHip,
|
||
)
|
||
expect(history).toHaveLength(1)
|
||
expect(history[0]?.source).toBe('dns')
|
||
})
|
||
|
||
it('drops probe leave/return when the service has no shared pool', () => {
|
||
const history = mergeFailoverHistory(
|
||
[],
|
||
[
|
||
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
|
||
],
|
||
[
|
||
{
|
||
fqdn: 'solo.example.com',
|
||
configured: ['130.49.213.153'],
|
||
active: ['130.49.213.153'],
|
||
},
|
||
],
|
||
)
|
||
expect(history).toEqual([])
|
||
})
|
||
|
||
it('drops dedicated extra-FQDN DNS rows', () => {
|
||
const history = mergeFailoverHistory(
|
||
[
|
||
dns({
|
||
id: 11,
|
||
fqdn: 'nsgt.rkns.top',
|
||
action: 'added',
|
||
created_at: '2026-08-20 09:26:00',
|
||
}),
|
||
],
|
||
[],
|
||
mskHip,
|
||
)
|
||
expect(history).toEqual([])
|
||
})
|
||
})
|