diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a1d8ba4..3b1f80a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,6 +10,7 @@ - [ ] `npm run lint` passes - [ ] `npm run typecheck` passes +- [ ] `npm test` passes - [ ] `npm run build` passes - [ ] Migration generated and committed (if `server/database/schema.ts` changed) - [ ] Docs updated (if behavior or configuration changed) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7d96a98..9dabb61 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ concurrency: jobs: check: - name: Lint, Typecheck and Build + name: Lint, Typecheck, Test and Build runs-on: ubuntu-latest steps: - name: Checkout Repository @@ -35,5 +35,8 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Test + run: npm test + - name: Build run: npm run build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 835b0a0..aec4c44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,7 @@ scripts/ Standalone CLI scripts (e.g. OTP setup) ```bash npm run lint npm run typecheck + npm test npm run build ``` @@ -49,7 +50,8 @@ Fix Upload Size Check - TypeScript everywhere; avoid `any` (the linter enforces this). - Match the style of the surrounding code. Run `npm run lint:fix` to auto-fix what ESLint can. -- Put types and constants used by both the client and the server in `shared/`. +- Put types, constants and validation schemas used by both the client and the server in `shared/`. +- Add or update tests for behavior changes (`test/unit/` for pure logic, `test/e2e/` for API behavior). ## License diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f5297a6..a0bff3b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -89,9 +89,20 @@ Run these before opening a pull request (CI runs them too): ```bash npm run lint # ESLint (use lint:fix to auto-fix) npm run typecheck # vue-tsc +npm test # unit tests and API tests against a production build npm run build # production build ``` +### Tests + +- `test/unit/` covers pure logic (schemas, file type detection, login throttling). Run just these with `npm run test:unit`. +- `test/e2e/` builds the app, starts it on a temporary database and exercises the API. +- Search tests are skipped unless a Meilisearch instance is available: + +```bash +MEILISEARCH_TEST_HOST=http://localhost:7700 MEILISEARCH_TEST_KEY=dev-master-key npm test +``` + ## Database Migrations Migrations in `server/database/migrations/` run automatically on startup. diff --git a/README.md b/README.md index 0dfec8c..57426a7 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ The app reads secrets from `.env` (created by `setup.sh`). | `DATABASE_PATH` | Yes | SQLite database file path. | | `FILES_PATH` | Yes | Attachments storage path. | | `MEILISEARCH_HOST` | Yes | Meilisearch HTTP endpoint. | +| `SEARCH_REINDEX` | No | Set to `true` to rebuild the search index on startup, e.g. after deleting `data/meili/`. Remove it afterwards. | ## Data Location @@ -102,7 +103,7 @@ tar czf onearctic-backup-$(date +%F).tar.gz data/ docker compose start app ``` -To restore, stop the services, replace `data/` with the contents of the archive, and start them again. Keep `data/meili/` in the backup too: without it, search only finds notes edited after the restore. +To restore, stop the services, replace `data/` with the contents of the archive, and start them again. If you leave `data/meili/` out of the backup, start the app once with `SEARCH_REINDEX=true` after restoring to rebuild search. ## Update diff --git a/app/components/attachment/FileList.vue b/app/components/attachment/FileList.vue index 3aad163..f2ab011 100644 --- a/app/components/attachment/FileList.vue +++ b/app/components/attachment/FileList.vue @@ -129,12 +129,8 @@ const { dragOverIdx, onDragStart, onDragOver, onDrop, onDragEnd, startTouchDrag const nextItems = items.map((item, position) => ({ ...item, position })) localAttachments.value = nextItems if (!props.noteId) return - try { - await reorderAttachments(props.noteId, nextItems.map((att) => att.id)) - } catch (e) { - showSnackbar(getErrorMessage(e, 'Reorder Failed'), 'error') - localAttachments.value = [...props.attachments] - } + const ok = await reorderAttachments(props.noteId, nextItems.map((att) => att.id)) + if (!ok) localAttachments.value = [...props.attachments] }, }) @@ -173,12 +169,9 @@ const saveRename = async (att: Attachment) => { showSnackbar('Filename is Required', 'error') return } - try { - await renameAttachment(props.noteId, att.id, nextName) + if (await renameAttachment(props.noteId, att.id, nextName)) { showSnackbar('Attachment Renamed') cancelRename() - } catch (e) { - showSnackbar(getErrorMessage(e, 'Rename Failed'), 'error') } } diff --git a/app/components/attachment/ImageGrid.vue b/app/components/attachment/ImageGrid.vue index b52b796..b3aed65 100644 --- a/app/components/attachment/ImageGrid.vue +++ b/app/components/attachment/ImageGrid.vue @@ -76,7 +76,7 @@ const fallbackToOriginal = (e: Event, attId: string) => { const props = defineProps<{ attachments: Attachment[] deletable?: boolean - onDelete?: (id: string) => Promise + onDelete?: (id: string) => Promise maxVisible?: number }>() const emit = defineEmits<{ delete: [id: string] }>() diff --git a/app/components/label/Manager.vue b/app/components/label/Manager.vue index 011487a..04a67a6 100644 --- a/app/components/label/Manager.vue +++ b/app/components/label/Manager.vue @@ -138,8 +138,7 @@ const editName = ref('') const addLabel = async () => { const name = newName.value.trim() if (!name || name.length > MAX_LABEL_LENGTH) return - await createLabel(name) - newName.value = '' + if (await createLabel(name)) newName.value = '' } const startEdit = (label: Label) => { diff --git a/app/components/label/Picker.vue b/app/components/label/Picker.vue index 14b5e9e..8a37962 100644 --- a/app/components/label/Picker.vue +++ b/app/components/label/Picker.vue @@ -82,6 +82,7 @@ const toggle = (id: string) => { const addLabel = async () => { if (!newName.value.trim()) return const created = await createLabel(newName.value.trim()) + if (!created) return newName.value = '' // Auto-select the newly created label localSelected.value = [...localSelected.value, created.id] diff --git a/app/components/note/EditorContent.vue b/app/components/note/EditorContent.vue index 6bbf550..589b08b 100644 --- a/app/components/note/EditorContent.vue +++ b/app/components/note/EditorContent.vue @@ -220,7 +220,7 @@ const toggleType = () => { localType.value = localType.value === 'text' ? 'check const onLabel = (ids: string[]) => { localLabelIds.value = ids; emitUpdate() } const deleteAtt = async (attId: string) => { - if (props.note) await deleteAttachment(props.note.id, attId) + if (props.note) return deleteAttachment(props.note.id, attId) } defineExpose({ showTitleError: () => { titleError.value = true } }) diff --git a/app/composables/useAuthRequest.ts b/app/composables/useAuthRequest.ts deleted file mode 100644 index bf2dfd5..0000000 --- a/app/composables/useAuthRequest.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const useAuthRequest = () => { - const nuxtApp = useNuxtApp() - - const execute = async (request: () => Promise): Promise => { - try { - return await request() - } catch (error) { - if (getErrorStatus(error) === 401) { - await nuxtApp.runWithContext(() => navigateTo('/login')) - return undefined - } - throw error - } - } - - return { execute } -} diff --git a/app/composables/useLabels.ts b/app/composables/useLabels.ts index febb646..da28d6c 100644 --- a/app/composables/useLabels.ts +++ b/app/composables/useLabels.ts @@ -8,16 +8,14 @@ export const useLabels = () => { // transferred to the client, preventing hydration mismatches. const labels = useState('labels', () => []) - // During SSR, forwards the original request's cookies so auth works on hard refresh. - const apiFetch = useRequestFetch() + const apiFetch = useNuxtApp().$api const { subscribe } = useEventSource() const realtimeBound = useState('labels-realtime-bound', () => false) const { consume: consumeLocalLabelEvent, run: runLocalLabelMutation } = useLocalRealtimeEvents('local-label-events') const fetchLabels = async () => { - const data = await apiFetch<{ labels: Label[] }>('/api/labels') - labels.value = data.labels + labels.value = await apiFetch('/api/labels') } if (import.meta.client && !realtimeBound.value) { @@ -31,23 +29,30 @@ export const useLabels = () => { event.type !== 'reconnected' && (consumeLocalLabelEvent(eventKey) || (labelId ? consumeLocalLabelEvent(event.type) : false)) ) return - fetchLabels().catch(() => {}) + fetchLabels().catch((error) => console.warn('[realtime] Failed to refresh labels:', error)) } }) } + /** Resolves to the new label, or undefined on failure (an error snackbar is shown). */ const createLabel = async (name: string) => { - const label = await runLocalLabelMutation('label.created', () => - apiFetch