Dev #9

Merged
sandrodach merged 4 commits from dev into main 2026-10-28 10:38:28 +01:00
11 changed files with 1466 additions and 73 deletions
Showing only changes of commit 106a34d260 - Show all commits

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm install --no-audit --no-fund RUN npm install --no-audit --no-fund
COPY frontend/ ./ COPY frontend/ ./
RUN npx tsc --noEmit && npm run build RUN npx tsc --noEmit && npm run test:run && npm run build
# ============================================================================== # ==============================================================================
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer) # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
+1 -1
View File
@@ -34,7 +34,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
go vet ./... && go test ./... go vet ./... && go test ./...
# Frontend (Node 22) # Frontend (Node 22)
cd frontend && npm ci && npx tsc --noEmit && npm run build cd frontend && npm ci && npx tsc --noEmit && npm run test:run && npm run build
``` ```
Das Frontend wird für das Binary nach `cmd/server/dist` gebaut (siehe `Dockerfile`). Die CI läuft unter `.gitea/workflows/build-and-publish.yaml`. Das Frontend wird für das Binary nach `cmd/server/dist` gebaut (siehe `Dockerfile`). Die CI läuft unter `.gitea/workflows/build-and-publish.yaml`.
+2 -2
View File
@@ -79,9 +79,9 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
## Build ## Build
``` ```
Stage 1 node:22-alpine npm ci && npm run build → frontend/dist Stage 1 node:22-alpine tsc, vitest, vite build → frontend/dist
Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet) Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet)
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
``` ```
Die Tests laufen beim Image-Build (`go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests existieren nicht ([FLAW-19](flaws.md)). Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
+5 -5
View File
@@ -28,7 +28,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel | | FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
| FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig | | FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig |
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig | | FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
| FLAW-19 | Qualität | Keine Frontend-Tests, `lint` und `test:run` sind Platzhalter | Niedrig | | FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig | | FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig | | FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig | | FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
@@ -139,10 +139,10 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz. - **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen. - **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
### FLAW-19: Keine automatisierten Frontend-Prüfungen ### FLAW-19: Geringe Frontend-Testabdeckung
- **Fundstelle:** [`frontend/package.json`](../frontend/package.json): `"lint": "echo 'Linting passed'"`, `"test:run": "echo 'Frontend checks passed'"`. - **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
- **Problem:** Die Skripte melden immer Erfolg. Die CI (`.gitea/workflows/build-and-publish.yaml`) prüft daher nur Go-Tests und den Vite-Build. Regressionen im UI (z. B. Berechtigungsanzeigen) fallen nicht auf. - **Problem:** Aufgaben, Listen, Freigabe-Dialoge und Berechtigungsanzeigen sind ungetestet. `"lint"` in [`frontend/package.json`](../frontend/package.json) ist weiterhin ein Platzhalter (`echo`).
- **Maßnahme:** `tsc --noEmit` und ESLint als echte Skripte, Komponententests für kritische Pfade. - **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
### FLAW-20: Dateigröße und Duplikate ### FLAW-20: Dateigröße und Duplikate
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500). - `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
+2 -1
View File
@@ -167,10 +167,11 @@ cd frontend
npm ci npm ci
npm run dev # Vite-Entwicklungsserver npm run dev # Vite-Entwicklungsserver
npx tsc --noEmit # Typprüfung npx tsc --noEmit # Typprüfung
npm run test:run # Komponententests (Vitest, jsdom)
npm run build # Produktions-Build npm run build # Produktions-Build
``` ```
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Vet und Tests beim Image-Build aus. Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind. **Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
+1286 -61
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -8,7 +8,8 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "echo 'Linting passed'", "lint": "echo 'Linting passed'",
"test:run": "echo 'Frontend checks passed'" "test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@types/prismjs": "^1.26.6", "@types/prismjs": "^1.26.6",
@@ -19,13 +20,17 @@
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11" "vite": "^5.4.11",
"vitest": "^2.1.9"
} }
} }
@@ -0,0 +1,64 @@
import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock';
import { DocumentBlock } from '../../../lib/types';
describe('mapRenderedOffsetToRaw', () => {
it('is the identity for plain text', () => {
expect(mapRenderedOffsetToRaw('Hallo Welt', 'Hallo Welt', 4)).toBe(4);
});
it('skips bold markers', () => {
expect(mapRenderedOffsetToRaw('Hallo **Welt** du', 'Hallo Welt du', 8)).toBe(10);
});
it('maps behind closing markers', () => {
expect(mapRenderedOffsetToRaw('a **b** c', 'a b c', 4)).toBe(8);
});
it('maps into a link label', () => {
expect(mapRenderedOffsetToRaw('x [Seite](https://a.de) y', 'x Seite y', 4)).toBe(5);
});
it('handles the empty placeholder', () => {
expect(mapRenderedOffsetToRaw('', '​', 1)).toBe(0);
});
});
describe('clicking into an unfocused bullet', () => {
afterEach(() => {
delete (document as any).caretRangeFromPoint;
});
it('places the caret at the click position instead of the end', () => {
const block = { id: 'b1', type: 'bullet', content: 'Hallo **Welt** du', level: 0, position: 1 } as DocumentBlock;
const { container } = render(
<OutlinerBlock
docId="d"
block={block}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={() => {}}
onDeleteBlock={() => {}}
onIndentBlock={() => {}}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
const strong = container.querySelector('strong') as HTMLElement; // rendered "Welt"
// jsdom has no layout: pretend the click hit between "We" and "lt"
(document as any).caretRangeFromPoint = () => {
const r = document.createRange();
r.setStart(strong.firstChild!, 2);
return r;
};
fireEvent.click(strong);
expect(document.activeElement).toBe(ta);
expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du"
});
});
@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { describe, it, expect } from 'vitest';
import { render, fireEvent, screen, act } from '@testing-library/react';
import { OutlinerTree } from '../OutlinerTree';
import { DocumentBlock } from '../../../lib/types';
// Holds the latest block state rendered by the harness
let live: DocumentBlock[] = [];
const Harness: React.FC<{ initial: DocumentBlock[] }> = ({ initial }) => {
const [blocks, setBlocks] = useState(initial);
live = blocks;
return (
<OutlinerTree
docId="d1"
blocks={blocks}
canEdit
onUpdateBlock={(b) => setBlocks((p) => p.map((x) => (x.id === b.id ? b : x)))}
onUpdateBlocks={(u) => setBlocks((p) => p.map((x) => u.find((y) => y.id === x.id) || x))}
onCreateBlock={() => {}}
onDeleteBlock={(id) => setBlocks((p) => p.filter((x) => x.id !== id))}
onMoveBlock={() => {}}
/>
);
};
const mk = (id: string, content: string, level = 0, type: any = 'bullet'): DocumentBlock =>
({ id, type, content, level, position: 1 }) as DocumentBlock;
const area = (text: string) => screen.getAllByDisplayValue(text)[0] as HTMLTextAreaElement;
const backspaceAt = async (ta: HTMLTextAreaElement, pos = 0) => {
ta.focus();
ta.setSelectionRange(pos, pos);
await act(async () => {
fireEvent.keyDown(ta, { key: 'Backspace' });
});
};
describe('Backspace at the start of a bullet', () => {
it('appends its text to the previous bullet', async () => {
render(<Harness initial={[mk('a', 'Hallo '), mk('b', 'Welt')]} />);
await backspaceAt(area('Welt'));
expect(live.map((b) => b.content)).toEqual(['Hallo Welt']);
});
it('works for legacy blocks without a type', async () => {
render(<Harness initial={[mk('a', 'A', 0, undefined), mk('b', 'B', 0, '')]} />);
await backspaceAt(area('B'));
expect(live.map((b) => b.content)).toEqual(['AB']);
});
it('moves children up under the previous bullet', async () => {
render(<Harness initial={[mk('a', 'A'), mk('b', 'B'), mk('c', 'C', 1)]} />);
await backspaceAt(area('B'));
expect(live.map((b) => [b.content, b.level])).toEqual([
['AB', 0],
['C', 1],
]);
});
it('does nothing when the caret is not at the start', async () => {
render(<Harness initial={[mk('a', 'A'), mk('b', 'BB')]} />);
await backspaceAt(area('BB'), 1);
expect(live).toHaveLength(2);
});
it('does not merge the first bullet', async () => {
render(<Harness initial={[mk('a', 'A'), mk('b', 'B')]} />);
await backspaceAt(area('A'));
expect(live).toHaveLength(2);
});
it('does not merge into a code block', async () => {
render(<Harness initial={[mk('a', 'x = 1', 0, 'code'), mk('b', 'B')]} />);
await backspaceAt(area('B'));
expect(live).toHaveLength(2);
});
});
+9
View File
@@ -0,0 +1,9 @@
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
// jsdom has no layout engine, so scrolling is a no-op here
Element.prototype.scrollIntoView = () => {};
afterEach(() => {
cleanup();
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
});