feat: bootstrap OSDB full-stack skeleton (story #01)

- Go backend: Echo v4 + pgxpool + embedded golang-migrate; health endpoints
  at /health and /api/v1/health; TDD health handler (httptest)
- Vue 3 frontend: Vite + TypeScript + Pinia + vue-router + Tailwind CSS v4;
  TDD HelloWorld component (@vue/test-utils + jsdom + vitest)
- Infra: docker-compose postgres:16 with env-interpolated credentials;
  Makefile with dev-db health-wait loop, migrate-up/down, run, test, fmt
- embed.FS migrations at backend/migrations/ (000001 no-op baseline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 17:04:32 +02:00
co-authored by Claude Sonnet 4.6
parent 1070ba3ac1
commit 24a368cac3
44 changed files with 4486 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+8
View File
@@ -0,0 +1,8 @@
// getHealth calls the backend health endpoint via the Vite proxy (/api → :3000).
export async function getHealth(): Promise<{ status: string }> {
const res = await fetch('/api/v1/health')
if (!res.ok) {
throw new Error(`health check failed: ${res.status}`)
}
return res.json()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import './assets/main.css'
import App from './App.vue'
createApp(App).use(createPinia()).use(router).mount('#app')
+14
View File
@@ -0,0 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
import HelloWorld from '../views/HelloWorld.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: HelloWorld,
},
],
})
export default router
+14
View File
@@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// appStore: global application state stub.
// Extended by later stories (auth, fruit, publication stores).
export const useAppStore = defineStore('app', () => {
const ready = ref(false)
function setReady(value: boolean) {
ready.value = value
}
return { ready, setReady }
})
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import HelloWorld from './HelloWorld.vue'
// Stub fetch so the component's onMounted health call doesn't hit the network.
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: 'ok' }),
}))
})
describe('HelloWorld', () => {
it('renders the OSDB greeting', async () => {
const wrapper = mount(HelloWorld, {
global: {
plugins: [createPinia()],
},
})
expect(wrapper.text()).toContain('Hello, OSDB!')
})
})
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getHealth } from '../api/health'
const backendStatus = ref<string | null>(null)
const backendError = ref<string | null>(null)
onMounted(async () => {
try {
const data = await getHealth()
backendStatus.value = data.status
} catch (err) {
backendError.value = err instanceof Error ? err.message : 'unknown error'
}
})
</script>
<template>
<main class="flex flex-col items-center justify-center min-h-screen gap-4">
<h1 class="text-4xl font-bold">Hello, OSDB!</h1>
<div v-if="backendStatus" class="text-green-600">
Backend: {{ backendStatus }}
</div>
<div v-else-if="backendError" class="text-red-600">
Backend unreachable: {{ backendError }}
</div>
<div v-else class="text-gray-400">
Checking backend
</div>
</main>
</template>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />