приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное вшивается через go:embed и раздаётся корневым маршрутом: разметка на неизвестном пути вне корней сервиса, отказ контракта внутри корня - перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов, а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь - шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка входят в гейт, а в Dockerfile появилась ступень приложения
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
// Единственное место, где приложение читает код ответа и разбирает тело отказа.
|
||||
// Экран получает готовый текст, а не `Response`: второй разборщик кодов на
|
||||
// клиенте разошёлся бы с первым.
|
||||
|
||||
const appRoot = '/app'
|
||||
|
||||
/** Ответ сервиса на вопрос «кто вошёл». */
|
||||
export interface Me {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Единая форма тела отказа на адресах приложения. */
|
||||
interface ErrorBody {
|
||||
error_code?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Исход запроса. Отсутствие сессии — свой исход, а не разновидность неудачи:
|
||||
* экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт,
|
||||
* иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение →
|
||||
* вход → приложение».
|
||||
*/
|
||||
export type ApiResult<T> =
|
||||
| { status: 'ok'; value: T }
|
||||
| { status: 'unauthorized' }
|
||||
| { status: 'failed'; message: string }
|
||||
|
||||
const noConnectionMessage = 'Связи нет'
|
||||
const unknownFailureMessage = 'Сервис не отвечает как надо'
|
||||
|
||||
async function failureMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as ErrorBody
|
||||
if (body.message) {
|
||||
return body.message
|
||||
}
|
||||
} catch {
|
||||
// Тело не разобралось — своего текста под код ответа не сочиняем.
|
||||
}
|
||||
return unknownFailureMessage
|
||||
}
|
||||
|
||||
async function request<T>(path: string): Promise<ApiResult<T>> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(appRoot + path, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
} catch {
|
||||
// Сорванный запрос — состояние, а не ошибка: показываем строкой.
|
||||
return { status: 'failed', message: noConnectionMessage }
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return { status: 'unauthorized' }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { status: 'failed', message: await failureMessage(response) }
|
||||
}
|
||||
|
||||
try {
|
||||
return { status: 'ok', value: (await response.json()) as T }
|
||||
} catch {
|
||||
return { status: 'failed', message: unknownFailureMessage }
|
||||
}
|
||||
}
|
||||
|
||||
/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */
|
||||
export function fetchMe(): Promise<ApiResult<Me>> {
|
||||
return request<Me>('/me')
|
||||
}
|
||||
|
||||
/** Адрес, которым сервис уводит человека ко входу. */
|
||||
export const loginPath = '/auth/login'
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeScreen from './screens/HomeScreen.vue'
|
||||
|
||||
// Одна таблица маршрутов. Маршруты по файлам не включаем: сборочная надстройка
|
||||
// роутера на нашем числе экранов не окупается.
|
||||
//
|
||||
// Адреса обычные, а не после решётки. Отсюда требование к серверу: неизвестный
|
||||
// путь вне корней сервиса отдаёт разметку, а не отказ.
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [{ path: '/', name: 'home', component: HomeScreen }],
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import HomeScreen from './HomeScreen.vue'
|
||||
|
||||
// Экран судится по тому, что видит человек, а не по внутреннему состоянию:
|
||||
// переписанная реализация обязана остаться зелёной.
|
||||
|
||||
const assign = vi.fn()
|
||||
|
||||
vi.stubGlobal('window', { location: { assign } })
|
||||
|
||||
function answerWith(response: Partial<Response> & { json?: () => Promise<unknown> }) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
||||
}
|
||||
|
||||
function failWith(error: Error) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
assign.mockReset()
|
||||
})
|
||||
|
||||
describe('экран показывает вошедшего', () => {
|
||||
it('показывает имя, когда сессия есть', async () => {
|
||||
answerWith({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ id: 'u1', name: 'Антон' }),
|
||||
})
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Антон')
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ведёт ко входу, когда сессии нет', async () => {
|
||||
answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) })
|
||||
|
||||
mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(assign).toHaveBeenCalledWith('/auth/login')
|
||||
})
|
||||
|
||||
it('на прочий отказ показывает строку и ко входу не ведёт', async () => {
|
||||
answerWith({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ error_code: 'internal', message: 'Внутренняя ошибка сервиса' }),
|
||||
})
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Внутренняя ошибка сервиса')
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('на сорванный запрос показывает, что связи нет', async () => {
|
||||
failWith(new Error('network down'))
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Связи нет')
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('учётная запись без имени: вход выполнен, почты на экране нет', async () => {
|
||||
answerWith({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ id: 'u1', name: '' }),
|
||||
})
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Вы вошли')
|
||||
expect(screen.text()).not.toContain('@')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchMe, loginPath } from '../api'
|
||||
|
||||
// Состояние экрана живёт в экране: общего между экранами пока нет.
|
||||
const state = ref<'loading' | 'signed-in' | 'failed'>('loading')
|
||||
const name = ref('')
|
||||
const failure = ref('')
|
||||
|
||||
async function load() {
|
||||
const result = await fetchMe()
|
||||
|
||||
if (result.status === 'unauthorized') {
|
||||
// Единственная ветка, уводящая ко входу. Прочие отказы ведут сюда же
|
||||
// только через круг «приложение → вход → приложение», поэтому их здесь нет.
|
||||
window.location.assign(loginPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
failure.value = result.message
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
|
||||
name.value = result.value.name
|
||||
state.value = 'signed-in'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<h1>Расшифровка записей</h1>
|
||||
|
||||
<p v-if="state === 'loading'">Загружается…</p>
|
||||
|
||||
<p v-else-if="state === 'failed'">{{ failure }}</p>
|
||||
|
||||
<!-- Имени у учётной записи может не быть вовсе: тогда говорим, что вход
|
||||
выполнен, и не подставляем вместо имени адрес почты — его в ответе нет. -->
|
||||
<p v-else-if="name">Вы вошли как {{ name }}</p>
|
||||
<p v-else>Вы вошли</p>
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user