feat(api, web): тест Telegram из формы, подсказки ошибок API и UX настроек
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Тест отправки использует значения формы без предварительного сохранения; пустой токен при сохранении не затирает сохранённый. Добавлены подсказки по частым ошибкам Telegram API и колонка ошибок в журнале уведомлений. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -10,8 +10,9 @@ describe('settings telegram test', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
resetTestDb()
|
resetTestDb()
|
||||||
settingsRepository.upsert('settings-main', {
|
settingsRepository.upsert('settings-main', {
|
||||||
telegramBotToken: 'token',
|
telegramBotToken: 'db-token',
|
||||||
telegramChatId: '123',
|
telegramChatId: '123',
|
||||||
|
telegramMessageThreadId: '99',
|
||||||
})
|
})
|
||||||
app = await buildApp()
|
app = await buildApp()
|
||||||
})
|
})
|
||||||
@@ -22,7 +23,7 @@ describe('settings telegram test', () => {
|
|||||||
closeDb()
|
closeDb()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns telegram API error', async () => {
|
it('returns telegram API error with hint', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn(async () =>
|
vi.fn(async () =>
|
||||||
@@ -34,5 +35,49 @@ describe('settings telegram test', () => {
|
|||||||
const body = res.json() as { ok: boolean; error?: string }
|
const body = res.json() as { ok: boolean; error?: string }
|
||||||
expect(body.ok).toBe(false)
|
expect(body.ok).toBe(false)
|
||||||
expect(body.error).toContain('chat not found')
|
expect(body.error).toContain('chat not found')
|
||||||
|
expect(body.error).toContain('Chat ID')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses body overrides and falls back to db token', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => Response.json({ ok: true }))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/settings/telegram/test',
|
||||||
|
payload: {
|
||||||
|
telegramChatId: '-100999',
|
||||||
|
telegramMessageThreadId: '42',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = res.json() as { ok: boolean }
|
||||||
|
expect(body.ok).toBe(true)
|
||||||
|
|
||||||
|
const call = fetchMock.mock.calls[0] as [string, RequestInit] | undefined
|
||||||
|
expect(call).toBeDefined()
|
||||||
|
const sent = JSON.parse(String(call![1].body)) as {
|
||||||
|
chat_id: string
|
||||||
|
message_thread_id: number
|
||||||
|
}
|
||||||
|
expect(sent.chat_id).toBe('-100999')
|
||||||
|
expect(sent.message_thread_id).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses body token when provided', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => Response.json({ ok: true }))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/settings/telegram/test',
|
||||||
|
payload: {
|
||||||
|
telegramBotToken: 'override-token',
|
||||||
|
telegramChatId: '-1001',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = String((fetchMock.mock.calls[0] as [string])[0])
|
||||||
|
expect(url).toContain('botoverride-token/')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||||
import { settingsSchema } from '@cfdm/shared/contracts/settings'
|
import { settingsSchema, telegramTestBodySchema } from '@cfdm/shared/contracts/settings'
|
||||||
|
|
||||||
import { restartScheduler } from '../services/scheduler.js'
|
import { restartScheduler } from '../services/scheduler.js'
|
||||||
import { sendTelegramMessage } from '../services/telegram.js'
|
import { sendTelegramMessage } from '../services/telegram.js'
|
||||||
@@ -30,16 +30,26 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/settings/telegram/test', async () => {
|
app.post('/api/settings/telegram/test', async (req) => {
|
||||||
|
const parsed = telegramTestBodySchema.safeParse(req.body ?? {})
|
||||||
|
const body = parsed.success ? parsed.data : {}
|
||||||
const settings = settingsRepository.getRow('settings-main')
|
const settings = settingsRepository.getRow('settings-main')
|
||||||
if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) {
|
|
||||||
|
const token = body.telegramBotToken?.trim() || settings?.telegramBotToken?.trim() || ''
|
||||||
|
const chatId = body.telegramChatId?.trim() || settings?.telegramChatId?.trim() || ''
|
||||||
|
const messageThreadId =
|
||||||
|
body.telegramMessageThreadId !== undefined
|
||||||
|
? body.telegramMessageThreadId
|
||||||
|
: settings?.telegramMessageThreadId
|
||||||
|
|
||||||
|
if (!token || !chatId) {
|
||||||
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
|
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
|
||||||
}
|
}
|
||||||
const result = await sendTelegramMessage(
|
const result = await sendTelegramMessage(
|
||||||
settings.telegramBotToken,
|
token,
|
||||||
settings.telegramChatId,
|
chatId,
|
||||||
'✅ VPS Tracker: тестовое сообщение',
|
'✅ VPS Tracker: тестовое сообщение',
|
||||||
settings.telegramMessageThreadId,
|
messageThreadId,
|
||||||
)
|
)
|
||||||
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' }
|
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { formatTelegramApiError, telegramErrorHint } from './telegram.js'
|
||||||
|
|
||||||
|
describe('telegramErrorHint', () => {
|
||||||
|
it('maps thread not found', () => {
|
||||||
|
expect(telegramErrorHint('Bad Request: message thread not found')).toContain('Thread ID')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps chat not found', () => {
|
||||||
|
expect(telegramErrorHint('Bad Request: chat not found')).toContain('Chat ID')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for unknown errors', () => {
|
||||||
|
expect(telegramErrorHint('Something else')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatTelegramApiError', () => {
|
||||||
|
it('includes hint for known telegram description', () => {
|
||||||
|
const msg = formatTelegramApiError(
|
||||||
|
'-1001',
|
||||||
|
{ status: 400, statusText: 'Bad Request' },
|
||||||
|
{ ok: false, description: 'Bad Request: message thread not found' },
|
||||||
|
)
|
||||||
|
expect(msg).toContain('message thread not found')
|
||||||
|
expect(msg).toContain('Thread ID')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to raw body when JSON has no description', () => {
|
||||||
|
const msg = formatTelegramApiError(
|
||||||
|
'-1001',
|
||||||
|
{ status: 400, statusText: 'Bad Request' },
|
||||||
|
{},
|
||||||
|
'invalid payload',
|
||||||
|
)
|
||||||
|
expect(msg).toBe('-1001: invalid payload')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,49 @@ export interface TelegramSendResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TelegramApiResponse {
|
||||||
|
ok?: boolean
|
||||||
|
description?: string
|
||||||
|
error_code?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Маппинг частых ошибок Telegram API на подсказки (для тестов и UI). */
|
||||||
|
export function telegramErrorHint(description: string): string | null {
|
||||||
|
const d = description.toLowerCase()
|
||||||
|
if (d.includes('message thread not found')) {
|
||||||
|
return 'Проверьте Thread ID и что в группе включены топики'
|
||||||
|
}
|
||||||
|
if (d.includes('chat not found')) {
|
||||||
|
return 'Бот не добавлен в чат или неверный Chat ID'
|
||||||
|
}
|
||||||
|
if (d.includes('not enough rights')) {
|
||||||
|
return 'Дайте боту право отправлять сообщения (администратор в группе)'
|
||||||
|
}
|
||||||
|
if (d.includes('unauthorized')) {
|
||||||
|
return 'Неверный токен бота'
|
||||||
|
}
|
||||||
|
if (d.includes('bot was blocked')) {
|
||||||
|
return 'Пользователь заблокировал бота'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTelegramApiError(
|
||||||
|
chatId: string,
|
||||||
|
res: Pick<Response, 'status' | 'statusText'>,
|
||||||
|
data: TelegramApiResponse,
|
||||||
|
rawBody?: string,
|
||||||
|
): string {
|
||||||
|
const description = data.description?.trim()
|
||||||
|
if (description) {
|
||||||
|
const hint = telegramErrorHint(description)
|
||||||
|
return hint ? `${chatId}: ${description} — ${hint}` : `${chatId}: ${description}`
|
||||||
|
}
|
||||||
|
const snippet = rawBody?.trim().slice(0, 200)
|
||||||
|
const fallback = snippet || res.statusText || `HTTP ${res.status}`
|
||||||
|
return `${chatId}: ${fallback}`
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendTelegramMessage(
|
export async function sendTelegramMessage(
|
||||||
token: string,
|
token: string,
|
||||||
chatIds: string | string[],
|
chatIds: string | string[],
|
||||||
@@ -43,12 +86,18 @@ export async function sendTelegramMessage(
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...payload, chat_id: chatId }),
|
body: JSON.stringify({ ...payload, chat_id: chatId }),
|
||||||
})
|
})
|
||||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
|
const rawBody = await res.text()
|
||||||
|
let data: TelegramApiResponse = {}
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawBody) as TelegramApiResponse
|
||||||
|
} catch {
|
||||||
|
/* non-JSON body */
|
||||||
|
}
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
anyOk = true
|
anyOk = true
|
||||||
} else {
|
} else {
|
||||||
const err = data.description || res.statusText || 'Unknown error'
|
const err = formatTelegramApiError(chatId, res, data, rawBody)
|
||||||
errors.push(`${chatId}: ${err}`)
|
errors.push(err)
|
||||||
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err)
|
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -43,61 +43,85 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
const projectItems = useMemo(() => snapshot?.serverProjects ?? [], [snapshot])
|
const projectItems = useMemo(() => snapshot?.serverProjects ?? [], [snapshot])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={open} onOpenChange={onOpenChange} title="Поиск" description="VPS, аккаунты, проекты и навигация">
|
<CommandDialog
|
||||||
<Command>
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Поиск"
|
||||||
|
description="VPS, аккаунты, проекты и навигация"
|
||||||
|
className="sm:max-w-lg"
|
||||||
|
>
|
||||||
|
<Command className="**:data-[selected=true]:bg-muted **:data-selected:bg-transparent">
|
||||||
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
|
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
|
||||||
<CommandList>
|
<CommandList className="max-h-96">
|
||||||
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
||||||
<CommandGroup heading="Навигация">
|
<CommandGroup heading="Навигация">
|
||||||
<CommandItem onSelect={() => go('/dashboard')}>
|
<CommandItem onSelect={() => go('/dashboard')}>
|
||||||
<LayoutDashboardIcon />
|
<LayoutDashboardIcon />
|
||||||
Дашборд
|
<span>Дашборд</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={() => go('/vps')}>
|
<CommandItem onSelect={() => go('/vps')}>
|
||||||
<ServerIcon />
|
<ServerIcon />
|
||||||
Все VPS
|
<span>Все VPS</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
<CommandGroup heading="VPS">
|
<CommandGroup heading="VPS">
|
||||||
{vpsItems.slice(0, 50).map((v) => (
|
{vpsItems.slice(0, 50).map((v) => (
|
||||||
<CommandItem key={v.id} value={`${v.ip} ${v.dns} ${v.project}`} onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}>
|
<CommandItem
|
||||||
<ServerIcon />
|
key={v.id}
|
||||||
<span>{v.ip || v.dns || v.id}</span>
|
value={`${v.ip} ${v.dns} ${v.project}`}
|
||||||
{v.project ? <span className="text-muted-foreground text-xs">· {v.project}</span> : null}
|
onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}
|
||||||
</CommandItem>
|
>
|
||||||
))}
|
<ServerIcon />
|
||||||
</CommandGroup>
|
<span className="truncate">{v.ip || v.dns || v.id}</span>
|
||||||
<CommandGroup heading="Аккаунты">
|
{v.project ? (
|
||||||
{accountItems.map((a) => (
|
<span className="text-muted-foreground text-xs">{v.project}</span>
|
||||||
<CommandItem
|
) : null}
|
||||||
key={a.id}
|
|
||||||
value={`${a.name} ${providerById.get(a.providerId)?.name ?? ''}`}
|
|
||||||
onSelect={() => go('/accounts')}
|
|
||||||
>
|
|
||||||
<WalletIcon />
|
|
||||||
{a.name}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
<CommandGroup heading="Проекты">
|
|
||||||
{projectItems.map((p) => {
|
|
||||||
const row = p as { id: string; name: string }
|
|
||||||
return (
|
|
||||||
<CommandItem key={row.id} value={row.name} onSelect={() => go('/vps', { project: row.name })}>
|
|
||||||
<FolderKanbanIcon />
|
|
||||||
{row.name}
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Аккаунты">
|
||||||
|
{accountItems.map((a) => (
|
||||||
|
<CommandItem
|
||||||
|
key={a.id}
|
||||||
|
value={`${a.name} ${providerById.get(a.providerId)?.name ?? ''}`}
|
||||||
|
onSelect={() => go('/accounts')}
|
||||||
|
>
|
||||||
|
<WalletIcon />
|
||||||
|
<span className="truncate">{a.name}</span>
|
||||||
|
{providerById.get(a.providerId)?.name ? (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{providerById.get(a.providerId)?.name}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Проекты">
|
||||||
|
{projectItems.map((p) => {
|
||||||
|
const row = p as { id: string; name: string }
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={row.id}
|
||||||
|
value={row.name}
|
||||||
|
onSelect={() => go('/vps', { project: row.name })}
|
||||||
|
>
|
||||||
|
<FolderKanbanIcon />
|
||||||
|
<span className="truncate">{row.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
<CommandGroup heading="Хостеры">
|
<CommandGroup heading="Хостеры">
|
||||||
{(snapshot?.providers ?? []).map((p) => (
|
{(snapshot?.providers ?? []).map((p) => (
|
||||||
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
|
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
|
||||||
<Building2Icon />
|
<Building2Icon />
|
||||||
{p.name}
|
<span className="truncate">{p.name}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
|
|||||||
@@ -108,8 +108,15 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
fetchSyncStatus: () => fetchApi('/api/sync/status'),
|
fetchSyncStatus: () => fetchApi('/api/sync/status'),
|
||||||
sendTelegramTest: () =>
|
sendTelegramTest: (body?: {
|
||||||
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', { method: 'POST' }),
|
telegramBotToken?: string
|
||||||
|
telegramChatId?: string
|
||||||
|
telegramMessageThreadId?: string
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
}),
|
||||||
|
|
||||||
sendWebhookTest: () =>
|
sendWebhookTest: () =>
|
||||||
fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }),
|
fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
|||||||
syncIntervalMinutes: s.syncIntervalMinutes ?? 60,
|
syncIntervalMinutes: s.syncIntervalMinutes ?? 60,
|
||||||
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
|
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
|
||||||
telegramChatId: s.telegramChatId ?? '',
|
telegramChatId: s.telegramChatId ?? '',
|
||||||
telegramBotToken: s.telegramBotToken ?? '',
|
telegramBotToken: '',
|
||||||
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
|
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
|
||||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
||||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
||||||
@@ -57,6 +57,26 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSettingsSavePayload(r: SettingsFormValues): SettingsFormValues {
|
||||||
|
const { telegramBotToken, ...rest } = r
|
||||||
|
const token = telegramBotToken?.trim() ?? ''
|
||||||
|
return token ? { ...rest, telegramBotToken: token } : (rest as SettingsFormValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTelegramTestPayload(values: SettingsFormValues) {
|
||||||
|
const token = values.telegramBotToken?.trim() ?? ''
|
||||||
|
const payload: {
|
||||||
|
telegramChatId?: string
|
||||||
|
telegramMessageThreadId?: string
|
||||||
|
telegramBotToken?: string
|
||||||
|
} = {
|
||||||
|
telegramChatId: values.telegramChatId?.trim() || undefined,
|
||||||
|
telegramMessageThreadId: values.telegramMessageThreadId ?? '',
|
||||||
|
}
|
||||||
|
if (token) payload.telegramBotToken = token
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
function BoolSelect({
|
function BoolSelect({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
@@ -96,7 +116,7 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const upsertMut = useMutation({
|
const upsertMut = useMutation({
|
||||||
mutationFn: (patch: SettingsFormValues) => {
|
mutationFn: (patch: SettingsFormValues) => {
|
||||||
const payload = { ...patch }
|
const payload = buildSettingsSavePayload(patch)
|
||||||
if (current?.id) return api.update<Settings>('settings', current.id, payload)
|
if (current?.id) return api.update<Settings>('settings', current.id, payload)
|
||||||
return api.create<Settings>('settings', {
|
return api.create<Settings>('settings', {
|
||||||
id: 'settings-main',
|
id: 'settings-main',
|
||||||
@@ -114,15 +134,15 @@ function SettingsPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const telegramTestMut = useMutation({
|
const telegramTestMut = useMutation({
|
||||||
mutationFn: () => api.sendTelegramTest(),
|
mutationFn: () => api.sendTelegramTest(buildTelegramTestPayload(form.getValues())),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
toast.error(data.error ?? 'Ошибка Telegram')
|
toast.error(data.error ?? 'Ошибка Telegram', { duration: 10_000 })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast.success('Тестовое сообщение отправлено')
|
toast.success('Тестовое сообщение отправлено')
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки', { duration: 10_000 }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const webhookTestMut = useMutation({
|
const webhookTestMut = useMutation({
|
||||||
@@ -319,7 +339,10 @@ function SettingsPage() {
|
|||||||
<Input
|
<Input
|
||||||
id="set-tg-token"
|
id="set-tg-token"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="123456:ABC-DEF..."
|
autoComplete="new-password"
|
||||||
|
placeholder={
|
||||||
|
current?.telegramBotTokenSet ? 'Токен установлен — введите новый для замены' : '123456:ABC-DEF...'
|
||||||
|
}
|
||||||
{...form.register('telegramBotToken')}
|
{...form.register('telegramBotToken')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -465,10 +488,16 @@ function SettingsPage() {
|
|||||||
<th className="px-3 py-2 font-medium">Событие</th>
|
<th className="px-3 py-2 font-medium">Событие</th>
|
||||||
<th className="px-3 py-2 font-medium">Канал</th>
|
<th className="px-3 py-2 font-medium">Канал</th>
|
||||||
<th className="px-3 py-2 font-medium">Статус</th>
|
<th className="px-3 py-2 font-medium">Статус</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{notificationRows.map((row) => (
|
{notificationRows.map((row) => {
|
||||||
|
const errorText =
|
||||||
|
row.status === 'failed' && row.payload?.error != null
|
||||||
|
? String(row.payload.error)
|
||||||
|
: ''
|
||||||
|
return (
|
||||||
<tr key={row.id} className="border-b last:border-0">
|
<tr key={row.id} className="border-b last:border-0">
|
||||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||||
{new Date(row.createdAt).toLocaleString('ru-RU')}
|
{new Date(row.createdAt).toLocaleString('ru-RU')}
|
||||||
@@ -476,8 +505,12 @@ function SettingsPage() {
|
|||||||
<td className="px-3 py-2">{row.event}</td>
|
<td className="px-3 py-2">{row.event}</td>
|
||||||
<td className="px-3 py-2">{row.channel}</td>
|
<td className="px-3 py-2">{row.channel}</td>
|
||||||
<td className="px-3 py-2">{row.status}</td>
|
<td className="px-3 py-2">{row.status}</td>
|
||||||
|
<td className="max-w-xs px-3 py-2 text-xs text-destructive break-words">
|
||||||
|
{errorText || '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { settingsRepository } from './settings.js'
|
||||||
|
import { resetTestDb } from '../test-setup.js'
|
||||||
|
|
||||||
|
describe('settingsRepository', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves telegram token on update when token empty', () => {
|
||||||
|
settingsRepository.upsert('settings-main', {
|
||||||
|
telegramBotToken: 'secret-token',
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
})
|
||||||
|
const updated = settingsRepository.upsert('settings-main', {
|
||||||
|
telegramBotToken: '',
|
||||||
|
syncIntervalMinutes: 30,
|
||||||
|
})
|
||||||
|
expect(updated.telegramBotTokenSet).toBe(true)
|
||||||
|
expect(settingsRepository.getRow('settings-main')?.telegramBotToken).toBe('secret-token')
|
||||||
|
expect(updated.syncIntervalMinutes).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces telegram token when new value provided', () => {
|
||||||
|
settingsRepository.upsert('settings-main', {
|
||||||
|
telegramBotToken: 'old-token',
|
||||||
|
})
|
||||||
|
settingsRepository.upsert('settings-main', {
|
||||||
|
telegramBotToken: 'new-token',
|
||||||
|
})
|
||||||
|
expect(settingsRepository.getRow('settings-main')?.telegramBotToken).toBe('new-token')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -95,7 +95,9 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
|||||||
? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
|
? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
|
||||||
: existing?.syncTariffsIntervalMinutes ?? 1440,
|
: existing?.syncTariffsIntervalMinutes ?? 1440,
|
||||||
telegramBotToken:
|
telegramBotToken:
|
||||||
r.telegramBotToken !== undefined ? r.telegramBotToken || '' : existing?.telegramBotToken ?? '',
|
r.telegramBotToken !== undefined && String(r.telegramBotToken || '').trim() !== ''
|
||||||
|
? r.telegramBotToken
|
||||||
|
: existing?.telegramBotToken ?? '',
|
||||||
telegramChatId:
|
telegramChatId:
|
||||||
r.telegramChatId !== undefined ? r.telegramChatId || '' : existing?.telegramChatId ?? '',
|
r.telegramChatId !== undefined ? r.telegramChatId || '' : existing?.telegramChatId ?? '',
|
||||||
telegramMessageThreadId:
|
telegramMessageThreadId:
|
||||||
|
|||||||
@@ -26,3 +26,11 @@ export const settingsSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type Settings = z.infer<typeof settingsSchema>
|
export type Settings = z.infer<typeof settingsSchema>
|
||||||
|
|
||||||
|
export const telegramTestBodySchema = z.object({
|
||||||
|
telegramBotToken: z.string().optional(),
|
||||||
|
telegramChatId: z.string().optional(),
|
||||||
|
telegramMessageThreadId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TelegramTestBody = z.infer<typeof telegramTestBodySchema>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user