Initial commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
* text=auto
|
||||
|
||||
Dockerfile text eol=lf
|
||||
docker-compose.yml text eol=lf
|
||||
|
||||
*.sh text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.conf text eol=lf
|
||||
Generated
-72
@@ -1,72 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="183cc890-1dbc-48d5-8436-4d83b3671444" name="Changes" comment="" />
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="CopilotPersistence">
|
||||
<persistenceIdMap>
|
||||
<entry key="_//wsl.localhost/Ubuntu-20.04/home/francescod/dev/Chat-App" value="3Aw7oEkK1nQjM1LVTrOnDRFvCjP" />
|
||||
</persistenceIdMap>
|
||||
</component>
|
||||
<component name="ProjectColorInfo"><![CDATA[{
|
||||
"associatedIndex": 3
|
||||
}]]></component>
|
||||
<component name="ProjectId" id="3Aw7oEkK1nQjM1LVTrOnDRFvCjP" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"javascript.preferred.runtime.type.id": "node",
|
||||
"last_opened_file_path": "//wsl.localhost/Ubuntu-20.04/home/francescod/dev/Chat-App",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}]]></component>
|
||||
<component name="RecentsManager">
|
||||
<key name="MoveFile.RECENT_KEYS">
|
||||
<recent name="\\wsl.localhost\Ubuntu-20.04\home\francescod\dev\Chat-App\Backend" />
|
||||
</key>
|
||||
</component>
|
||||
<component name="SharedIndexes">
|
||||
<attachedChunks>
|
||||
<set>
|
||||
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-WS-253.31033.133" />
|
||||
</set>
|
||||
</attachedChunks>
|
||||
</component>
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
<changelist id="183cc890-1dbc-48d5-8436-4d83b3671444" name="Changes" comment="" />
|
||||
<created>1751619297246</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1751619297246</updated>
|
||||
<workItem from="1773491953643" duration="2831000" />
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
<component name="github-copilot-workspace">
|
||||
<instructionFileLocations>
|
||||
<option value=".github/instructions" />
|
||||
</instructionFileLocations>
|
||||
<promptFileLocations>
|
||||
<option value=".github/prompts" />
|
||||
</promptFileLocations>
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,15 +1,32 @@
|
||||
# Docker lädt ein fertiges Image mit Linux Alpine und Node.js Version 20.
|
||||
# Alpine ist eine sehr kleine Linux-Distribution — kleineres Image, schnellere Downloads.
|
||||
FROM node:20-alpine
|
||||
|
||||
# Docker erstellt Verzeichnis /app falls noch nicht vorhanden.
|
||||
# Alle folgenden Befehle laufen in diesem Verzeichnis.
|
||||
WORKDIR /app
|
||||
|
||||
# Zuerst nur package.json kopieren — noch nicht den ganzen Code.
|
||||
# Warum: Docker cached jeden Schritt. Wenn sich nur der Code ändert aber nicht die
|
||||
# Dependencies, muss npm ci nicht nochmal laufen — Build wird schneller (Layer Caching).
|
||||
COPY package*.json ./
|
||||
|
||||
# npm ci installiert exakt die Versionen aus package-lock.json — reproduzierbarer Build.
|
||||
# --omit=dev: Keine Entwicklungs-Dependencies in Produktion — kleineres und sichereres Image.
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Jetzt erst den restlichen Code kopieren — nach npm ci damit der Cache optimal genutzt wird.
|
||||
COPY . .
|
||||
|
||||
# NODE_ENV=production aktiviert Optimierungen in Express (z.B. bessere Performance, weniger Logs).
|
||||
# PORT=4000 definiert auf welchem Port der Server lauscht.
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=4000
|
||||
|
||||
# Dokumentiert welchen Port der Container verwendet — öffnet ihn aber nicht wirklich.
|
||||
# Das passiert in docker-compose.yml.
|
||||
EXPOSE 4000
|
||||
|
||||
# Startbefehl wenn der Container läuft.
|
||||
# Array-Form statt String damit Node.js direkt als Prozess gestartet wird — kein Shell-Prozess dazwischen.
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -9,6 +9,11 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
|
||||
},
|
||||
})
|
||||
|
||||
const typingByRoom = new Map()
|
||||
|
||||
// io.use: Middleware die bei jeder eingehenden WebSocket-Verbindung läuft.
|
||||
// Prüft ob room und userId im Handshake vorhanden sind und ob der User in der DB existiert.
|
||||
// Wer vorher nicht POST /join aufgerufen hat, hat keine gültige userId und wird abgelehnt.
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const auth = socket.handshake.auth || {}
|
||||
@@ -25,6 +30,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
|
||||
return next(new Error('User not authorized for this room'))
|
||||
}
|
||||
|
||||
// room und userId in socket.data speichern — so sind sie in registerConnection verfügbar.
|
||||
socket.data.room = room
|
||||
socket.data.userId = userId
|
||||
next()
|
||||
@@ -34,7 +40,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
|
||||
})
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
registerConnection(io, socket, database, onError).catch((error) => {
|
||||
registerConnection(io, socket, database, onError, typingByRoom).catch((error) => {
|
||||
onError('Socket connection setup failed', error)
|
||||
socket.disconnect(true)
|
||||
})
|
||||
@@ -43,8 +49,10 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
|
||||
return io
|
||||
}
|
||||
|
||||
async function registerConnection(io, socket, database, onError) {
|
||||
async function registerConnection(io, socket, database, onError, typingByRoom) {
|
||||
const { room, userId } = socket.data
|
||||
|
||||
// socket.join: Fügt den Socket einem Room-Kanal hinzu — io.to(room).emit() erreicht nur diese Sockets.
|
||||
socket.join(room)
|
||||
|
||||
const user = await database.setUserOnline(userId, room)
|
||||
@@ -53,10 +61,25 @@ async function registerConnection(io, socket, database, onError) {
|
||||
return
|
||||
}
|
||||
|
||||
socket.data.username = user.username
|
||||
|
||||
socket.emit('connection:ready', { userId, room })
|
||||
socket.emit('history:init', await database.getMessages(room))
|
||||
|
||||
// Teilnehmerliste an alle im Room senden — User sieht sofort wer online ist.
|
||||
await broadcastParticipants(io, database, room)
|
||||
|
||||
// typingByRoom: Map pro Room — speichert wer gerade tippt { userId → { userId, username } }.
|
||||
socket.on('typing:start', () => {
|
||||
if (!typingByRoom.has(room)) typingByRoom.set(room, new Map())
|
||||
typingByRoom.get(room).set(userId, { userId, username: socket.data.username })
|
||||
io.to(room).emit('typing:update', [...typingByRoom.get(room).values()])
|
||||
})
|
||||
|
||||
socket.on('typing:stop', () => {
|
||||
typingByRoom.get(room)?.delete(userId)
|
||||
io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
|
||||
})
|
||||
|
||||
socket.on('message:send', async (payload) => {
|
||||
try {
|
||||
const text =
|
||||
@@ -65,11 +88,16 @@ async function registerConnection(io, socket, database, onError) {
|
||||
return
|
||||
}
|
||||
|
||||
// Nochmal prüfen ob der User noch im richtigen Room ist — könnte sich seit dem Verbinden geändert haben.
|
||||
const activeUser = await database.getUser(userId)
|
||||
if (!activeUser || activeUser.room !== room) {
|
||||
return
|
||||
}
|
||||
|
||||
// Tipp-Indikator sofort entfernen wenn eine Nachricht gesendet wird.
|
||||
typingByRoom.get(room)?.delete(userId)
|
||||
io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
|
||||
|
||||
const message = await database.addMessage({
|
||||
id: randomUUID(),
|
||||
room,
|
||||
@@ -77,6 +105,7 @@ async function registerConnection(io, socket, database, onError) {
|
||||
username: activeUser.username,
|
||||
text: text.trim(),
|
||||
})
|
||||
// Nachricht an alle im Room senden — inkl. Sender selbst.
|
||||
io.to(room).emit('message:new', message)
|
||||
} catch (error) {
|
||||
onError('Failed to handle socket message', error)
|
||||
@@ -84,6 +113,9 @@ async function registerConnection(io, socket, database, onError) {
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
typingByRoom.get(room)?.delete(userId)
|
||||
io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
|
||||
// User als offline markieren und Teilnehmerliste aktualisieren.
|
||||
database
|
||||
.setUserOffline(userId)
|
||||
.then(() => broadcastParticipants(io, database, room))
|
||||
@@ -100,6 +132,8 @@ async function broadcastParticipants(io, database, room) {
|
||||
io.to(room).emit('participants:update', participants)
|
||||
}
|
||||
|
||||
// Liest einen Parameter zuerst aus der primären Quelle (auth), dann aus dem Fallback (query).
|
||||
// Schützt gegen leere Strings — nur echte Werte werden akzeptiert.
|
||||
function readParam(primary, fallback) {
|
||||
if (typeof primary === 'string' && primary.trim()) return primary
|
||||
if (typeof fallback === 'string' && fallback.trim()) return fallback
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// JsonStore: Dateibasierter Fallback-Store wenn kein MongoDB vorhanden ist.
|
||||
// Alle Daten liegen als JSON-Datei auf der Festplatte — kein externer Datenbankserver nötig.
|
||||
// Nachteil gegenüber MongoDB: Kein Concurrency-Schutz bei mehreren gleichzeitigen Schreiboperationen.
|
||||
class JsonStore {
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath
|
||||
this.state = { users: [], messages: [] }
|
||||
this.state = { users: [], messages: [] } // In-Memory-State — alle Operationen laufen darauf.
|
||||
this._ensureStore()
|
||||
}
|
||||
|
||||
// Stellt sicher dass die JSON-Datei und das Verzeichnis existieren.
|
||||
// Wenn die Datei beschädigt ist (kein gültiges JSON), wird sie neu erstellt.
|
||||
_ensureStore() {
|
||||
const dir = path.dirname(this.filePath)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.mkdirSync(dir, { recursive: true }) // Verzeichnis anlegen falls nicht vorhanden.
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
this._persist()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const contents = fs.readFileSync(this.filePath, 'utf-8')
|
||||
this.state = JSON.parse(contents)
|
||||
this.state = JSON.parse(contents) // Datei in den In-Memory-State laden.
|
||||
} catch (error) {
|
||||
// Datei vorhanden aber nicht lesbar oder kein gültiges JSON — neu erstellen.
|
||||
console.warn('JSON store damaged, recreating', error)
|
||||
this._persist()
|
||||
}
|
||||
}
|
||||
|
||||
// Schreibt den aktuellen In-Memory-State in die JSON-Datei.
|
||||
// null, 2 formatiert das JSON mit Einrückung — besser lesbar beim Debugging.
|
||||
_persist() {
|
||||
fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
// Upsert: User aktualisieren wenn vorhanden, sonst neu anlegen.
|
||||
// Spread-Operator {...this.state.users[index], ...} übernimmt alle bestehenden Felder
|
||||
// und überschreibt nur die geänderten — joinedAt bleibt beim Update erhalten.
|
||||
async upsertUser({ id, username, room }) {
|
||||
const trimmed = username?.trim()
|
||||
if (!trimmed) {
|
||||
@@ -60,6 +71,7 @@ class JsonStore {
|
||||
return user
|
||||
}
|
||||
|
||||
// Object.assign mutiert das gefundene Objekt direkt im Array — kein Index-Lookup nötig.
|
||||
async setUserOnline(userId, room) {
|
||||
const user = this.state.users.find((entry) => entry.id === userId)
|
||||
if (!user) {
|
||||
@@ -74,6 +86,7 @@ class JsonStore {
|
||||
return user
|
||||
}
|
||||
|
||||
// lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war.
|
||||
async setUserOffline(userId) {
|
||||
const user = this.state.users.find((entry) => entry.id === userId)
|
||||
if (!user) {
|
||||
@@ -96,6 +109,8 @@ class JsonStore {
|
||||
return user
|
||||
}
|
||||
|
||||
// splice(index, 1) entfernt genau ein Element an der gefundenen Position.
|
||||
// Destructuring [removed] holt das gelöschte Element aus dem zurückgegebenen Array.
|
||||
async removeUser(userId) {
|
||||
const index = this.state.users.findIndex((entry) => entry.id === userId)
|
||||
if (index === -1) return null
|
||||
@@ -108,6 +123,8 @@ class JsonStore {
|
||||
return this.state.users.find((entry) => entry.id === userId) || null
|
||||
}
|
||||
|
||||
// .map() gibt nur die öffentlichen Felder zurück — interne Felder werden nicht nach außen gegeben.
|
||||
// Sortierung: Online-User zuerst, dann nach letzter Aktivität absteigend.
|
||||
async getParticipants(room) {
|
||||
return this.state.users
|
||||
.filter((entry) => entry.room === room)
|
||||
@@ -126,6 +143,7 @@ class JsonStore {
|
||||
})
|
||||
}
|
||||
|
||||
// createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut.
|
||||
async addMessage({ id, room, userId, username, text }) {
|
||||
const message = {
|
||||
id,
|
||||
@@ -140,11 +158,25 @@ class JsonStore {
|
||||
return message
|
||||
}
|
||||
|
||||
// slice(-limit) gibt die letzten N Nachrichten zurück — neueste am Ende, älteste zuerst.
|
||||
async getMessages(room, limit = 100) {
|
||||
return this.state.messages
|
||||
.filter((message) => message.room === room)
|
||||
.slice(-limit)
|
||||
}
|
||||
|
||||
// roomMap: Objekt das pro Room die Teilnehmerzahl zählt — schneller als mehrere filter()-Aufrufe.
|
||||
// localeCompare: Alphabetische Sortierung mit Sprachunterstützung (Umlaute etc.).
|
||||
async getRooms() {
|
||||
const roomMap = {}
|
||||
for (const user of this.state.users) {
|
||||
if (!roomMap[user.room]) {
|
||||
roomMap[user.room] = { name: user.room, participantCount: 0 }
|
||||
}
|
||||
if (user.isOnline) roomMap[user.room].participantCount++
|
||||
}
|
||||
return Object.values(roomMap).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = JsonStore
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const config = require('../../config/database')
|
||||
|
||||
|
||||
// Sicheres Laden des MongoDB-Treibers — wenn das Paket nicht installiert ist,
|
||||
// wird der Fehler erst in createMongoStore() mit einer klaren Meldung geworfen.
|
||||
let MongoClient
|
||||
try {
|
||||
;({ MongoClient } = require('mongodb'))
|
||||
@@ -8,12 +9,14 @@ try {
|
||||
MongoClient = null
|
||||
}
|
||||
|
||||
|
||||
// Collection-Namen als Konstanten — wenn sich ein Name ändert, muss er nur hier angepasst werden.
|
||||
const collectionNames = {
|
||||
users: 'users',
|
||||
messages: 'messages',
|
||||
}
|
||||
|
||||
// MongoDB speichert den Primärschlüssel intern als "_id".
|
||||
// Diese Funktion übersetzt das in "id" damit der Rest der App nichts von MongoDB-Interna wissen muss.
|
||||
function normalizeUser(doc) {
|
||||
if (!doc) return null
|
||||
const {
|
||||
@@ -34,6 +37,8 @@ function normalizeUser(doc) {
|
||||
}
|
||||
}
|
||||
|
||||
// Gleiche Logik wie normalizeUser — username wird redundant in der Nachricht gespeichert
|
||||
// damit beim Laden keine extra DB-Abfrage für den Usernamen nötig ist.
|
||||
function normalizeMessage(doc) {
|
||||
if (!doc) return null
|
||||
const {
|
||||
@@ -54,6 +59,8 @@ function normalizeMessage(doc) {
|
||||
}
|
||||
}
|
||||
|
||||
// Kompatibilitätsfunktion für ältere MongoDB-Treiber (< 4.0).
|
||||
// findOneAndUpdate gibt dort das Ergebnis in einem "value"-Wrapper zurück — ab 4.0 direkt.
|
||||
function extractDocument(result) {
|
||||
if (!result) return null
|
||||
return Object.prototype.hasOwnProperty.call(result, 'value')
|
||||
@@ -61,8 +68,9 @@ function extractDocument(result) {
|
||||
: result
|
||||
}
|
||||
|
||||
// async damit der Start der App nicht blockiert während MongoDB-Verbindung und Indizes aufgebaut werden.
|
||||
async function createMongoStore() {
|
||||
// Guard: fail fast with an actionable message if the driver is missing.
|
||||
|
||||
if (!MongoClient) {
|
||||
const message =
|
||||
'MongoDB driver is not installed. Run `npm install mongodb` in Backend/.'
|
||||
@@ -73,18 +81,20 @@ async function createMongoStore() {
|
||||
|
||||
const client = new MongoClient(config.uri, {
|
||||
...config.options,
|
||||
appName: 'chat-app',
|
||||
appName: 'chat-app', // Wird in MongoDB Atlas angezeigt um zu sehen welche App verbunden ist.
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
const db = client.db(config.dbName)
|
||||
const db = client.db(config.dbName) // Entspricht "USE chatapp" in SQL.
|
||||
|
||||
const users = db.collection(collectionNames.users)
|
||||
const messages = db.collection(collectionNames.messages)
|
||||
|
||||
// Indizes werden einmalig beim Start angelegt — ohne Index durchsucht MongoDB jede Abfrage
|
||||
// alle Dokumente (Full Collection Scan). Promise.all legt beide parallel an.
|
||||
await Promise.all([
|
||||
users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }),
|
||||
messages.createIndex({ room: 1, createdAt: 1 }),
|
||||
users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }), // Für getParticipants()
|
||||
messages.createIndex({ room: 1, createdAt: 1 }), // Für getMessages()
|
||||
])
|
||||
|
||||
console.log(
|
||||
@@ -93,7 +103,12 @@ async function createMongoStore() {
|
||||
}/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`,
|
||||
)
|
||||
|
||||
// Öffentliches Interface des Stores — der Rest der App kennt keine MongoDB-Details (Repository-Pattern).
|
||||
return {
|
||||
|
||||
// Upsert = Update + Insert in einem Schritt (atomare Operation).
|
||||
// Wenn User existiert: Felder aktualisieren. Wenn nicht: neu anlegen.
|
||||
// $setOnInsert stellt sicher dass joinedAt nur beim ersten Anlegen gesetzt wird, nicht bei Updates.
|
||||
async upsertUser({ id, username, room }) {
|
||||
const now = new Date()
|
||||
const result = await users.findOneAndUpdate(
|
||||
@@ -112,7 +127,7 @@ async function createMongoStore() {
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
returnDocument: 'after',
|
||||
returnDocument: 'after', // Aktualisiertes Dokument zurückgeben, nicht das alte.
|
||||
},
|
||||
)
|
||||
const document = extractDocument(result)
|
||||
@@ -120,11 +135,12 @@ async function createMongoStore() {
|
||||
return normalizeUser(document)
|
||||
}
|
||||
|
||||
|
||||
// Fallback: Manche MongoDB-Versionen geben bei einem Upsert-Insert kein Dokument zurück.
|
||||
const inserted = await users.findOne({ _id: id })
|
||||
return normalizeUser(inserted)
|
||||
},
|
||||
|
||||
// $set aktualisiert nur die angegebenen Felder — der Rest (z.B. username) bleibt unberührt.
|
||||
async setUserOnline(userId, room) {
|
||||
const result = await users.findOneAndUpdate(
|
||||
{ _id: userId },
|
||||
@@ -140,6 +156,7 @@ async function createMongoStore() {
|
||||
return normalizeUser(extractDocument(result))
|
||||
},
|
||||
|
||||
// lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war.
|
||||
async setUserOffline(userId) {
|
||||
const result = await users.findOneAndUpdate(
|
||||
{ _id: userId },
|
||||
@@ -154,6 +171,7 @@ async function createMongoStore() {
|
||||
return normalizeUser(extractDocument(result))
|
||||
},
|
||||
|
||||
// trim() auf Datenbankebene — auch wenn die Frontend-Validierung fehlt, landen keine schmutzigen Daten in der DB.
|
||||
async updateUsername(userId, username) {
|
||||
const result = await users.findOneAndUpdate(
|
||||
{ _id: userId },
|
||||
@@ -168,6 +186,7 @@ async function createMongoStore() {
|
||||
return normalizeUser(extractDocument(result))
|
||||
},
|
||||
|
||||
// findOneAndDelete löscht und gibt das Dokument in einem Schritt zurück — kein extra findOne() nötig.
|
||||
async removeUser(userId) {
|
||||
const result = await users.findOneAndDelete({ _id: userId })
|
||||
return normalizeUser(extractDocument(result))
|
||||
@@ -178,14 +197,19 @@ async function createMongoStore() {
|
||||
return normalizeUser(doc)
|
||||
},
|
||||
|
||||
// Online-User zuerst, dann nach letzter Aktivität sortiert.
|
||||
// toArray() lädt alle Cursor-Ergebnisse in den Speicher — bei sehr großen Datenmengen wäre ein Cursor besser.
|
||||
async getParticipants(room) {
|
||||
const docs = await users
|
||||
.find({ room })
|
||||
.sort({ isOnline: -1, lastActiveAt: -1 })
|
||||
.sort({ isOnline: -1, lastActiveAt: -1 }) // -1 = absteigend, 1 = aufsteigend
|
||||
.toArray()
|
||||
return docs.map(normalizeUser)
|
||||
},
|
||||
|
||||
// _id wird von außen als UUID übergeben statt von MongoDB generiert —
|
||||
// so ist die ID bereits bekannt bevor die DB antwortet (z.B. für den WebSocket-Broadcast).
|
||||
// createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut.
|
||||
async addMessage({ id, room, userId, username, text }) {
|
||||
const message = {
|
||||
_id: id,
|
||||
@@ -196,9 +220,11 @@ async function createMongoStore() {
|
||||
createdAt: new Date(),
|
||||
}
|
||||
await messages.insertOne(message)
|
||||
// Lokales Objekt zurückgeben statt nochmal aus der DB lesen — spart einen Query.
|
||||
return normalizeMessage(message)
|
||||
},
|
||||
|
||||
// limit = 100 verhindert dass bei einem langen Chatverlauf alles auf einmal geladen wird.
|
||||
async getMessages(room, limit = 100) {
|
||||
const docs = await messages
|
||||
.find({ room })
|
||||
@@ -208,6 +234,27 @@ async function createMongoStore() {
|
||||
return docs.map(normalizeMessage)
|
||||
},
|
||||
|
||||
// Aggregation Pipeline: MongoDB berechnet das Ergebnis serverseitig — effizienter als
|
||||
// alle Daten laden und in JavaScript auswerten.
|
||||
// $group: Gruppiert User nach Room (wie GROUP BY in SQL).
|
||||
// $cond: Zählt nur Online-User pro Room.
|
||||
// $project: Blendet _id aus und benennt es in "name" um.
|
||||
async getRooms() {
|
||||
const result = await users.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$room',
|
||||
participantCount: { $sum: { $cond: ['$isOnline', 1, 0] } },
|
||||
},
|
||||
},
|
||||
{ $project: { _id: 0, name: '$_id', participantCount: 1 } },
|
||||
{ $sort: { name: 1 } },
|
||||
]).toArray()
|
||||
return result
|
||||
},
|
||||
|
||||
// Verbindung sauber trennen beim Herunterfahren der App — ohne close() bleiben
|
||||
// offene Verbindungen auf dem MongoDB-Server hängen.
|
||||
async disconnect() {
|
||||
await client.close()
|
||||
},
|
||||
|
||||
@@ -5,6 +5,14 @@ const asyncHandler = require('../utils/asyncHandler')
|
||||
function createRoomsRouter({ database, io }) {
|
||||
const router = express.Router()
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const rooms = await database.getRooms()
|
||||
res.json({ rooms })
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:room/join',
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
## Docker Setup
|
||||
|
||||
Run the full stack with:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Services:
|
||||
|
||||
- Frontend: `http://localhost:5173`
|
||||
- Backend: `http://localhost:4000`
|
||||
- MongoDB: `mongodb://localhost:27017/chatapp`
|
||||
|
||||
The Compose file starts all required services together:
|
||||
|
||||
- `frontend` as a Vite container
|
||||
- `backend` as a Node/Express container
|
||||
- `mongo` with a persistent `mongo-data` volume
|
||||
|
||||
Stop everything with:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Remove containers and database volume with:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Local Development Without Docker
|
||||
|
||||
1. Copy the backend environment file:
|
||||
|
||||
```bash
|
||||
cp Backend/.env.example Backend/.env
|
||||
```
|
||||
|
||||
2. Start MongoDB locally or with Docker.
|
||||
|
||||
3. Start the backend:
|
||||
|
||||
```bash
|
||||
cd Backend && npm run dev
|
||||
```
|
||||
|
||||
The backend tries MongoDB at `mongodb://127.0.0.1:27017/chatapp` first and falls back to `Backend/data/db.json` only if MongoDB is unavailable. Use `DATA_STORE=mongo` to fail fast on MongoDB errors, or `DATA_STORE=json` to force the JSON store.
|
||||
+94
-55
@@ -1,59 +1,98 @@
|
||||
services:
|
||||
frontend:
|
||||
image: francesco448/chat-app-frontend:latest
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_API_URL: ""
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "80:80"
|
||||
networks:
|
||||
- chat-network
|
||||
|
||||
backend:
|
||||
image: francesco448/chat-app-backend:latest
|
||||
build:
|
||||
context: ./Backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PORT=4000
|
||||
- CLIENT_ORIGIN=http://localhost
|
||||
- DATA_STORE=mongo
|
||||
- MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:-chatpass}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin
|
||||
- MONGO_DB=${MONGO_DB:-chatapp}
|
||||
networks:
|
||||
- chat-network
|
||||
|
||||
mongo:
|
||||
image: mongo:7
|
||||
container_name: chat-app-mongo
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
|
||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:-chatpass}
|
||||
- MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp}
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
ports:
|
||||
- "27017:27017"
|
||||
networks:
|
||||
- chat-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
chat-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
driver: local
|
||||
services:
|
||||
frontend:
|
||||
# Fertiges Frontend-Image von Docker Hub.
|
||||
image: francesco448/chat-app-frontend:latest
|
||||
container_name: chat-app-frontend
|
||||
restart: unless-stopped
|
||||
|
||||
# Frontend startet erst, nachdem der Backend-Container gestartet wurde.
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
# Nur das Frontend wird nach außen veröffentlicht.
|
||||
# Standardmäßig TrueNAS-Port 9004 -> Nginx-Port 80.
|
||||
# FRONTEND_PORT kann optional über .env geändert werden.
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-9004}:80"
|
||||
|
||||
networks:
|
||||
- chat-network
|
||||
|
||||
|
||||
backend:
|
||||
image: francesco448/chat-app-backend:latest
|
||||
container_name: chat-app-backend
|
||||
restart: unless-stopped
|
||||
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
|
||||
environment:
|
||||
- PORT=4000
|
||||
- NODE_ENV=production
|
||||
- CLIENT_ORIGIN=${CLIENT_ORIGIN:?CLIENT_ORIGIN muss gesetzt werden}
|
||||
- DATA_STORE=mongo
|
||||
- MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss gesetzt werden}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin
|
||||
- MONGO_DB=${MONGO_DB:-chatapp}
|
||||
|
||||
networks:
|
||||
- chat-network
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- "fetch('http://localhost:4000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
mongo:
|
||||
# Feste Major-Version verwenden statt "latest".
|
||||
image: mongo:7
|
||||
container_name: chat-app-mongo
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
# Benutzer kann über .env überschrieben werden.
|
||||
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
|
||||
|
||||
# Für Production kein unsicheres Default-Passwort wie "chatpass".
|
||||
# Der Stack startet nicht, solange das Passwort in .env fehlt.
|
||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss in .env gesetzt werden}
|
||||
|
||||
# Datenbankname kann über .env überschrieben werden.
|
||||
- MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp}
|
||||
|
||||
# Persistenz direkt auf deinem TrueNAS-Dataset.
|
||||
volumes:
|
||||
- /mnt/PoolHDD/AppData/Chat-App/mongodb:/data/db
|
||||
|
||||
# Kein Port 27017 nach außen.
|
||||
# MongoDB ist ausschließlich im internen Docker-Netzwerk erreichbar.
|
||||
networks:
|
||||
- chat-network
|
||||
|
||||
# Authentifizierter MongoDB-Healthcheck.
|
||||
# $$ verhindert, dass Docker Compose die Variablen bereits auf dem Host ersetzt.
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- >-
|
||||
mongosh --quiet
|
||||
--host 127.0.0.1
|
||||
--username "$$MONGO_INITDB_ROOT_USERNAME"
|
||||
--password "$$MONGO_INITDB_ROOT_PASSWORD"
|
||||
--authenticationDatabase admin
|
||||
--eval "quit(db.adminCommand('ping').ok ? 0 : 2)"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
+25
-1
@@ -1,23 +1,47 @@
|
||||
# Multi-Stage Build: Zwei separate Phasen in einem Dockerfile.
|
||||
# Vorteil: Der finale Container enthält nur das Minimum — kein Node.js, kein Source-Code, keine node_modules.
|
||||
# Kleineres und sichereres Image als wenn alles in einem Stage wäre.
|
||||
|
||||
# Stage 1: Builder — zuständig für das Bauen der React-App.
|
||||
# "AS builder" gibt diesem Stage einen Namen damit Stage 2 darauf zugreifen kann.
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Arbeitsverzeichnis setzen — alle folgenden Befehle laufen hier.
|
||||
WORKDIR /app
|
||||
|
||||
# Zuerst nur package.json kopieren für optimales Layer Caching.
|
||||
# npm ci läuft nur neu wenn sich die Dependencies ändern, nicht bei jedem Code-Change.
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Code kopieren — nach npm ci damit der Cache optimal genutzt wird.
|
||||
COPY . .
|
||||
|
||||
|
||||
# VITE_API_URL als Build-Argument — wird von docker-compose.yml übergeben.
|
||||
# ARG ist nur während des Builds verfügbar, ENV macht sie für Vite lesbar.
|
||||
# Warum beide: Vite läuft als Node.js-Prozess und liest ENV-Variablen — nicht ARG direkt.
|
||||
# Leerer Default "" bedeutet relative URLs werden verwendet — Nginx leitet intern weiter.
|
||||
ARG VITE_API_URL=""
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# Vite baut die React-App in den /app/dist Ordner — optimiertes, minifiziertes Bundle.
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Nginx — zuständig für das Ausliefern der gebauten Dateien.
|
||||
# Node.js aus Stage 1 wird komplett weggeworfen — nur der dist-Ordner wird übernommen.
|
||||
FROM nginx:alpine
|
||||
|
||||
# Nur den fertigen dist-Ordner aus Stage 1 kopieren — kein Source-Code, kein Node.js.
|
||||
# --from=builder verweist auf Stage 1 per Name.
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Eigene Nginx-Konfiguration kopieren — überschreibt die Standard-Konfiguration.
|
||||
# Enthält Reverse Proxy Regeln für /rooms und /socket.io sowie SPA-Fallback.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Port 80 dokumentieren — Standard HTTP-Port.
|
||||
EXPOSE 80
|
||||
|
||||
# Nginx im Vordergrund starten — ohne "daemon off" würde Nginx in den Hintergrund gehen
|
||||
# und Docker würde denken der Container ist fertig und ihn stoppen.
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
+28
-5
@@ -1,37 +1,60 @@
|
||||
server {
|
||||
# Nginx lauscht auf Port 80 (Standard HTTP-Port).
|
||||
# Der Browser spricht Nginx an — Nginx entscheidet dann wohin die Anfrage geht (Reverse Proxy).
|
||||
listen 80;
|
||||
|
||||
# Serve the static React build
|
||||
# Statische React-Dateien ausliefern (HTML, CSS, JS).
|
||||
# Vite baut die App in einen dist-Ordner — dieser wird beim Docker-Build nach /usr/share/nginx/html kopiert.
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
# Fallback to index.html for client-side routing
|
||||
|
||||
# try_files: Nginx sucht zuerst nach einer echten Datei ($uri),
|
||||
# dann nach einem Ordner ($uri/), und fällt schliesslich auf index.html zurück.
|
||||
# Warum nötig: React ist eine Single Page Application (SPA) — alle Routen
|
||||
# existieren nur im Browser, nicht als echte Dateien auf dem Server.
|
||||
# Ohne diesen Fallback würde ein direkter Aufruf von z.B. /chat einen 404 liefern.
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy REST API requests to the backend
|
||||
# Reverse Proxy: Alle Anfragen an /rooms werden an das Backend weitergeleitet.
|
||||
# Warum Reverse Proxy statt direkter Backend-URL: Der Browser kennt nur Nginx auf Port 80.
|
||||
# Das Backend ist im Docker-Netzwerk unter dem Hostnamen "backend" erreichbar — nicht von aussen.
|
||||
# Vorteil: Backend muss keinen öffentlichen Port exponieren, alles läuft über Nginx.
|
||||
location /rooms {
|
||||
proxy_pass http://backend:4000;
|
||||
proxy_http_version 1.1;
|
||||
# Host-Header weiterleiten damit das Backend weiss von welcher Domain die Anfrage kommt.
|
||||
proxy_set_header Host $host;
|
||||
# X-Real-IP: Echte IP-Adresse des Clients weitergeben — sonst sieht das Backend nur Nginx als Absender.
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# X-Forwarded-For: Liste aller Proxys durch die die Anfrage gelaufen ist (Audit Trail).
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Proxy the health check endpoint to the backend
|
||||
# Health-Check-Endpoint wird ebenfalls ans Backend weitergeleitet.
|
||||
# Wird von Docker genutzt um zu prüfen ob das Backend läuft (siehe healthcheck in docker-compose.yml).
|
||||
location /health {
|
||||
proxy_pass http://backend:4000;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
# Proxy Socket.IO — requires WebSocket upgrade headers
|
||||
# WebSocket-Proxy für Socket.IO — braucht spezielle Upgrade-Header.
|
||||
# HTTP und WebSocket sind unterschiedliche Protokolle. Ein WebSocket startet als HTTP-Anfrage
|
||||
# und wird dann per "Upgrade" auf eine dauerhafte bidirektionale Verbindung umgestellt.
|
||||
# Ohne diese Header würde Nginx die Verbindung als normale HTTP-Anfrage behandeln
|
||||
# und der WebSocket-Handshake würde fehlschlagen.
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:4000;
|
||||
proxy_http_version 1.1;
|
||||
# Siehe Zeile 72 in App.jsx
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_cache_bypass: Verhindert dass Nginx WebSocket-Verbindungen cached —
|
||||
# eine dauerhafte Verbindung darf nicht gecacht werden.
|
||||
# da websocket keine fixe Antwort gibt und es ein laufender Kommunikationskanal ist, kann man es nicht sinnvoll cachen
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,65 @@ button:disabled {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.rooms-list {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.rooms-list__label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.rooms-list__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.room-chip {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.room-chip:hover {
|
||||
border-color: rgba(94, 234, 212, 0.6);
|
||||
}
|
||||
|
||||
.room-chip--active {
|
||||
border-color: rgba(94, 234, 212, 0.8);
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
.room-chip__count {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
padding: 0.4rem 1.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
min-height: 1.6rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Centralised error style classes */
|
||||
|
||||
/* Inline text error (e.g. below a field) */
|
||||
|
||||
+164
-16
@@ -5,32 +5,47 @@ import { ERROR_MESSAGES } from './constants/errorMessages'
|
||||
import { validateJoinForm } from './utils/validators'
|
||||
import './App.css'
|
||||
|
||||
// VITE_API_URL wird beim Build von Vite eingebettet — so muss die URL nicht hardcoded stehen.
|
||||
// ?? (Nullish Coalescing): Fallback auf localhost:4000 nur wenn die Variable nicht gesetzt ist.
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
||||
|
||||
|
||||
function App() {
|
||||
// t('key') gibt den übersetzten Text aus der aktiven Sprachdatei zurück (z.B. de/translation.json).
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Jede State-Variable ist einzeln deklariert statt in einem Objekt.
|
||||
// So rendert React nur neu wenn sich der jeweilige Wert ändert — nicht bei jeder State-Änderung.
|
||||
const [username, setUsername] = useState('')
|
||||
const [room, setRoom] = useState('general')
|
||||
const [session, setSession] = useState(null)
|
||||
const [status, setStatus] = useState('idle')
|
||||
const [room, setRoom] = useState('general') // Standardroom beim ersten Laden
|
||||
const [session, setSession] = useState(null) // null = nicht eingeloggt; sonst { id, username, room }
|
||||
const [status, setStatus] = useState('idle') // idle | connecting | connected | reconnecting | error
|
||||
const [messages, setMessages] = useState([])
|
||||
const [participants, setParticipants] = useState([])
|
||||
const [messageInput, setMessageInput] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [typingUsers, setTypingUsers] = useState([]) // Usernamen der User die gerade tippen
|
||||
const [rooms, setRooms] = useState([]) // Alle aktiven Rooms für die Chip-Auswahl
|
||||
|
||||
const socketRef = useRef(null)
|
||||
const messagesEndRef = useRef(null)
|
||||
// useRef speichert Werte ohne ein Re-Render auszulösen — ideal für Socket, DOM-Elemente und Timer.
|
||||
const socketRef = useRef(null) // Aktive Socket.IO-Verbindung
|
||||
const messagesEndRef = useRef(null) // Leeres div am Ende der Nachrichtenliste für Auto-Scroll
|
||||
const typingTimerRef = useRef(null) // Timer-ID für den typing:stop Debounce
|
||||
|
||||
// Abgeleitete Werte direkt aus State berechnet — kein eigener useState nötig.
|
||||
const connectionLabel = t(`status.${status}`, { defaultValue: status })
|
||||
const isConnected = status === 'connected'
|
||||
|
||||
// Dieser Effekt verwaltet den gesamten WebSocket-Lebenszyklus.
|
||||
// Dependency-Array [session?.id, session?.room]: Effekt läuft neu wenn User oder Room wechselt.
|
||||
useEffect(() => {
|
||||
// Kein Session → Verbindung trennen. Die Cleanup-Funktion am Ende erledigt das.
|
||||
if (!session?.id || !session?.room) {
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
// isCancelled verhindert State-Updates nach dem Unmount der Komponente (Memory Leak).
|
||||
let isCancelled = false
|
||||
let retryCount = 0
|
||||
let reconnectTimer
|
||||
@@ -38,6 +53,11 @@ function App() {
|
||||
const connect = () => {
|
||||
if (isCancelled) return
|
||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting')
|
||||
|
||||
// auth wird beim WebSocket-Handshake mitgeschickt — der Server prüft ob userId in der DB existiert
|
||||
// und ob sie zum angegebenen Room gehört. Wer vorher nicht POST /join aufgerufen hat,
|
||||
// hat keine gültige userId und wird mit "User not authorized for this room" abgelehnt.
|
||||
// transports: ['websocket'] erzwingt reines WebSocket, kein HTTP-Polling als Fallback.
|
||||
const socket = io(API_URL, {
|
||||
auth: {
|
||||
room: session.room,
|
||||
@@ -46,6 +66,8 @@ function App() {
|
||||
reconnectionAttempts: 5,
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
// Socket in Ref speichern damit handleSendMessage und andere Handler darauf zugreifen können.
|
||||
socketRef.current = socket
|
||||
|
||||
socket.on('connect', () => {
|
||||
@@ -53,16 +75,15 @@ function App() {
|
||||
setStatus('connected')
|
||||
})
|
||||
|
||||
// socket.io ist der Manager (Verbindungsebene), socket der Namespace (Anwendungsebene).
|
||||
// Reconnect-Events kommen vom Manager, nicht vom Socket selbst.
|
||||
socket.io.on('reconnect_attempt', () => {
|
||||
if (!isCancelled) {
|
||||
setStatus('reconnecting')
|
||||
}
|
||||
if (!isCancelled) setStatus('reconnecting')
|
||||
})
|
||||
|
||||
// Alle 5 Reconnect-Versuche gescheitert — User muss manuell neu verbinden.
|
||||
socket.io.on('reconnect_failed', () => {
|
||||
if (!isCancelled) {
|
||||
setStatus('error')
|
||||
}
|
||||
if (!isCancelled) setStatus('error')
|
||||
})
|
||||
|
||||
socket.on('connect_error', (socketError) => {
|
||||
@@ -73,6 +94,9 @@ function App() {
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
if (isCancelled) return
|
||||
setTypingUsers([])
|
||||
// 'io client disconnect': Der Client hat selbst getrennt (handleLeave) → kein Reconnect.
|
||||
// Bei allen anderen Gründen (Netzwerkfehler, Server-Neustart) → erneut versuchen.
|
||||
if (reason !== 'io client disconnect' && retryCount < 5) {
|
||||
retryCount += 1
|
||||
setStatus('reconnecting')
|
||||
@@ -81,25 +105,36 @@ function App() {
|
||||
}
|
||||
})
|
||||
|
||||
// Server schickt beim Verbinden die letzten N Nachrichten des Rooms.
|
||||
socket.on('history:init', (payload) => {
|
||||
setMessages(Array.isArray(payload) ? payload : [])
|
||||
})
|
||||
|
||||
// [...prev, payload] erstellt ein neues Array — React erkennt Änderungen nur per Referenzvergleich.
|
||||
// prev.push() würde dieselbe Referenz behalten → kein Re-Render.
|
||||
socket.on('message:new', (payload) => {
|
||||
if (payload) {
|
||||
setMessages((prev) => [...prev, payload])
|
||||
}
|
||||
})
|
||||
|
||||
// Wird bei jedem Join/Leave eines Users im Room gesendet.
|
||||
socket.on('participants:update', (payload) => {
|
||||
setParticipants(Array.isArray(payload) ? payload : [])
|
||||
})
|
||||
|
||||
// Eigener User wird herausgefiltert — man sieht sich selbst nicht als "tippt gerade".
|
||||
socket.on('typing:update', (payload) => {
|
||||
if (!Array.isArray(payload)) return
|
||||
setTypingUsers(payload.filter((u) => u.userId !== session.id).map((u) => u.username))
|
||||
})
|
||||
|
||||
socket.on('connection:ready', () => {
|
||||
setError('')
|
||||
})
|
||||
}
|
||||
|
||||
// setTimeout 0ms: Gibt React Zeit das Rendering abzuschliessen bevor der Status gesetzt wird.
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (!socketRef.current?.connected && !isCancelled) {
|
||||
setStatus('connecting')
|
||||
@@ -108,22 +143,55 @@ function App() {
|
||||
|
||||
connect()
|
||||
|
||||
// Cleanup: Wird ausgeführt wenn die Komponente unmountet oder der Effekt neu startet.
|
||||
// Ohne Cleanup würden mehrere Sockets gleichzeitig aktiv sein → Memory Leak.
|
||||
return () => {
|
||||
isCancelled = true
|
||||
clearTimeout(reconnectTimer)
|
||||
clearTimeout(typingTimerRef.current)
|
||||
socketRef.current?.disconnect()
|
||||
socketRef.current = null
|
||||
setTypingUsers([])
|
||||
}
|
||||
}, [session?.id, session?.room])
|
||||
|
||||
// Scrollt nach jeder neuen Nachricht ans Ende der Liste — smooth für sanfte Animation.
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Leeres Dependency-Array []: Läuft nur einmal beim ersten Laden der Seite.
|
||||
useEffect(() => {
|
||||
fetchRooms()
|
||||
}, [])
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/rooms`)
|
||||
if (res.ok) {
|
||||
const { rooms: list } = await res.json()
|
||||
setRooms(list)
|
||||
}
|
||||
} catch {
|
||||
// Rooms sind nicht kritisch — App funktioniert auch ohne die Chip-Navigation.
|
||||
}
|
||||
}
|
||||
|
||||
const participantCount = participants.length
|
||||
|
||||
// useMemo: Text wird nur neu berechnet wenn sich typingUsers ändert — nicht bei jedem Render.
|
||||
const typingLabel = useMemo(() => {
|
||||
if (typingUsers.length === 0) return ''
|
||||
if (typingUsers.length === 1) return `${typingUsers[0]} schreibt…`
|
||||
if (typingUsers.length === 2) return `${typingUsers[0]} und ${typingUsers[1]} schreiben…`
|
||||
return `${typingUsers.length} Personen schreiben…`
|
||||
}, [typingUsers])
|
||||
|
||||
const handleJoin = async (event) => {
|
||||
// Verhindert das Standard-Browserverhalten: Seite neu laden beim Formular-Submit.
|
||||
event.preventDefault()
|
||||
|
||||
// Client-seitige Validierung vor dem API-Call — spart einen unnötigen Netzwerk-Request.
|
||||
const validationError = validateJoinForm(username, room)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
@@ -133,12 +201,14 @@ function App() {
|
||||
setError('')
|
||||
setStatus(session ? 'reconnecting' : 'connecting')
|
||||
try {
|
||||
// encodeURIComponent kodiert Sonderzeichen im Room-Namen — z.B. Leerzeichen → %20.
|
||||
// Ohne Kodierung wäre die URL ungültig und der Request würde scheitern.
|
||||
const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username.trim(),
|
||||
userId: session?.id,
|
||||
userId: session?.id, // Bei Room-Wechsel: bestehende userId mitschicken statt neue zu erstellen.
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -150,10 +220,19 @@ function App() {
|
||||
if (!data?.user) {
|
||||
throw new Error(ERROR_MESSAGES.api.noUserReturned)
|
||||
}
|
||||
|
||||
// setSession ändert session?.id → WebSocket-useEffect läuft neu → baut neue Verbindung auf.
|
||||
setSession(data.user)
|
||||
setUsername(data.user.username)
|
||||
setRoom(data.user.room)
|
||||
|
||||
// Snapshot per HTTP laden bevor der WebSocket verbunden ist — UI ist nicht leer.
|
||||
// Der WebSocket überschreibt den Snapshot später mit history:init.
|
||||
await fetchRoomSnapshots(data.user.room)
|
||||
fetchRooms()
|
||||
if (socketRef.current?.connected) {
|
||||
setStatus('connected')
|
||||
}
|
||||
} catch (joinError) {
|
||||
console.error(joinError)
|
||||
setError(joinError.message)
|
||||
@@ -161,6 +240,7 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Promise.all führt beide Requests parallel aus — Gesamtdauer = max(t1, t2) statt t1 + t2.
|
||||
const fetchRoomSnapshots = async (targetRoom) => {
|
||||
const encodedRoom = encodeURIComponent(targetRoom)
|
||||
try {
|
||||
@@ -177,20 +257,38 @@ function App() {
|
||||
setParticipants(list)
|
||||
}
|
||||
} catch (snapshotError) {
|
||||
// Nicht kritisch — WebSocket liefert history:init und participants:update nach.
|
||||
console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError)
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce-Muster: typing:start sofort senden, typing:stop erst nach 1500ms Pause.
|
||||
// clearTimeout vor jedem neuen Timeout — Timer wird bei jedem Tastendruck zurückgesetzt.
|
||||
const handleMessageInputChange = (event) => {
|
||||
setMessageInput(event.target.value)
|
||||
const socket = socketRef.current
|
||||
if (!socket?.connected) return
|
||||
socket.emit('typing:start')
|
||||
clearTimeout(typingTimerRef.current)
|
||||
typingTimerRef.current = setTimeout(() => {
|
||||
socket.emit('typing:stop')
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// Nachricht per WebSocket senden — nicht per HTTP.
|
||||
// Vorteil: Keine neue Verbindung pro Nachricht, Server broadcastet direkt an alle im Room.
|
||||
// Datenfluss: emit('message:send') → Server speichert → Server sendet 'message:new' an alle.
|
||||
const handleSendMessage = (event) => {
|
||||
event.preventDefault()
|
||||
if (!session || !messageInput.trim()) {
|
||||
return
|
||||
}
|
||||
if (!session || !messageInput.trim()) return
|
||||
const socket = socketRef.current
|
||||
if (!socket || !socket.connected) {
|
||||
setError(ERROR_MESSAGES.connection.noActiveConnection)
|
||||
return
|
||||
}
|
||||
// Tipp-Indikator sofort beenden — nicht auf die 1500ms warten.
|
||||
clearTimeout(typingTimerRef.current)
|
||||
socket.emit('typing:stop')
|
||||
try {
|
||||
socket.emit('message:send', {
|
||||
text: messageInput.trim(),
|
||||
@@ -203,8 +301,12 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// finally läuft immer — egal ob HTTP-Request erfolgreich war oder nicht.
|
||||
// setSession(null) löst den WebSocket-useEffect aus → Verbindung wird sauber getrennt.
|
||||
const handleLeave = async () => {
|
||||
if (!session) return
|
||||
clearTimeout(typingTimerRef.current)
|
||||
socketRef.current?.emit('typing:stop')
|
||||
try {
|
||||
await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, {
|
||||
method: 'POST',
|
||||
@@ -212,15 +314,19 @@ function App() {
|
||||
body: JSON.stringify({ userId: session.id }),
|
||||
})
|
||||
} catch (leaveError) {
|
||||
// HTTP-Fehler ignorieren — lokaler State wird in finally trotzdem zurückgesetzt.
|
||||
console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError)
|
||||
} finally {
|
||||
setSession(null)
|
||||
setMessages([])
|
||||
setParticipants([])
|
||||
setTypingUsers([])
|
||||
setStatus('idle')
|
||||
fetchRooms()
|
||||
}
|
||||
}
|
||||
|
||||
// useMemo: Kontextsensitiver Hilfstext — wird nur neu berechnet wenn session, status oder Sprache wechselt.
|
||||
const connectionHint = useMemo(() => {
|
||||
if (!session) return t('hint.noSession')
|
||||
switch (status) {
|
||||
@@ -239,6 +345,7 @@ function App() {
|
||||
<h1>{t('app.headline')}</h1>
|
||||
<p>{connectionHint}</p>
|
||||
</div>
|
||||
{/* CSS-Klasse wird dynamisch gesetzt: status-connected, status-error etc. */}
|
||||
<div className={`status-badge status-${status}`}>
|
||||
<span className="status-dot" aria-hidden="true"></span>
|
||||
{connectionLabel}
|
||||
@@ -249,6 +356,7 @@ function App() {
|
||||
<form className="session-form" onSubmit={handleJoin}>
|
||||
<div className="field-group">
|
||||
<label htmlFor="username">{t('form.username')}</label>
|
||||
{/* Controlled Input: value kommt aus State, onChange aktualisiert State. */}
|
||||
<input
|
||||
id="username"
|
||||
value={username}
|
||||
@@ -263,12 +371,15 @@ function App() {
|
||||
value={room}
|
||||
placeholder={t('form.roomPlaceholder')}
|
||||
onChange={(event) => setRoom(event.target.value.toLowerCase())}
|
||||
// toLowerCase() verhindert doppelte Rooms durch unterschiedliche Schreibweise ("General" vs "general").
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="submit">
|
||||
{/* Button-Text wechselt: "Verbinden" ohne Session, "Aktualisieren" wenn bereits eingeloggt. */}
|
||||
{session ? t('form.update') : t('form.connect')}
|
||||
</button>
|
||||
{/* Leave-Button nur anzeigen wenn eine Session aktiv ist (Conditional Rendering). */}
|
||||
{session ? (
|
||||
<button type="button" className="ghost" onClick={handleLeave}>
|
||||
{t('form.leave')}
|
||||
@@ -277,6 +388,25 @@ function App() {
|
||||
</div>
|
||||
</form>
|
||||
{error ? <p className="form-error">{t(error)}</p> : null}
|
||||
{rooms.length > 0 && (
|
||||
<div className="rooms-list">
|
||||
<p className="rooms-list__label">{t('rooms.title')}</p>
|
||||
<div className="rooms-list__chips">
|
||||
{rooms.map((r) => (
|
||||
<button
|
||||
key={r.name}
|
||||
// key ist Pflicht bei React-Listen — React nutzt es beim Re-Render um Elemente zu identifizieren.
|
||||
type="button"
|
||||
className={`room-chip ${session?.room === r.name ? 'room-chip--active' : ''}`}
|
||||
onClick={() => setRoom(r.name)}
|
||||
>
|
||||
#{r.name}
|
||||
<span className="room-chip__count">{r.participantCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<main className="chat-grid">
|
||||
@@ -284,6 +414,7 @@ function App() {
|
||||
<header className="chat-panel__header">
|
||||
<div>
|
||||
<h2>{t('chat.title')}</h2>
|
||||
{/* ?? zeigt session?.room wenn vorhanden, sonst den Wert aus dem Input-Feld. */}
|
||||
<p>{t('chat.liveFeed', { room: session?.room ?? room })}</p>
|
||||
</div>
|
||||
<span className="muted">{t('chat.messageCount', { count: messages.length })}</span>
|
||||
@@ -296,6 +427,7 @@ function App() {
|
||||
<article
|
||||
key={message.id}
|
||||
className={`message ${message.userId === session?.id ? 'me' : ''}`}
|
||||
// CSS-Klasse 'me': Eigene Nachrichten werden rechts ausgerichtet (Chat-Bubble-Stil).
|
||||
>
|
||||
<header>
|
||||
<strong>{message.username}</strong>
|
||||
@@ -305,23 +437,31 @@ function App() {
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
{/* Leeres div als Scroll-Anker — scrollIntoView() hier = ans Ende scrollen. */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{/* Tipp-Indikator nur anzeigen wenn typingLabel nicht leer ist. */}
|
||||
{typingLabel && (
|
||||
<p className="typing-indicator">{typingLabel}</p>
|
||||
)}
|
||||
<form className="composer" onSubmit={handleSendMessage}>
|
||||
<input
|
||||
value={messageInput}
|
||||
onChange={(event) => setMessageInput(event.target.value)}
|
||||
onChange={handleMessageInputChange}
|
||||
placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')}
|
||||
disabled={!session || !isConnected}
|
||||
// Deaktiviert wenn kein Session oder WebSocket nicht verbunden — kein Senden ohne Verbindung.
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!session || !messageInput.trim() || !isConnected}
|
||||
// Aktiv nur wenn: Session vorhanden + Text eingegeben + WebSocket verbunden.
|
||||
>
|
||||
{t('chat.send')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside className="participants-panel">
|
||||
<header>
|
||||
<h2>{t('participants.title')}</h2>
|
||||
@@ -333,6 +473,7 @@ function App() {
|
||||
) : (
|
||||
participants.map((participant) => (
|
||||
<li key={participant.id}>
|
||||
{/* Presence-Punkt: CSS-Klasse 'online' oder 'away' je nach isOnline-Status. */}
|
||||
<span
|
||||
className={`presence ${participant.isOnline ? 'online' : 'away'}`}
|
||||
aria-label={participant.isOnline ? t('participants.online') : 'offline'}
|
||||
@@ -355,6 +496,10 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// Ausserhalb der Komponente — wird nicht bei jedem Render neu erstellt.
|
||||
|
||||
// Intl.DateTimeFormat: Eingebaute Browser-API, kein extra Paket nötig.
|
||||
// 'de-DE' formatiert im 24-Stunden-Format (13:45).
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return ''
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
@@ -363,6 +508,9 @@ function formatTime(timestamp) {
|
||||
}).format(new Date(timestamp))
|
||||
}
|
||||
|
||||
// Intl.RelativeTimeFormat: Gibt "vor 5 Minuten", "gestern" etc. aus.
|
||||
// numeric: 'auto' → "gestern" statt "vor 1 Tag".
|
||||
// Berechnung: Minuten → Stunden → Tage (von klein nach gross).
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return ''
|
||||
const formatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' })
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"placeholderDisabled": "Verbinde dich, um zu schreiben",
|
||||
"send": "Senden"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Aktive Räume"
|
||||
},
|
||||
"participants": {
|
||||
"title": "Aktive Teilnehmer",
|
||||
"empty": "Niemand ist aktiv",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: "Testprotokoll – Blackbox Test"
|
||||
author: "Chat App"
|
||||
date: "19.03.2026"
|
||||
geometry: margin=2cm
|
||||
fontsize: 11pt
|
||||
---
|
||||
|
||||
## Projektinformationen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Projekt** | Chat App |
|
||||
| **Datum** | 19.03.2026 |
|
||||
| **Tester** | _______________ |
|
||||
| **Version** | 1.0 |
|
||||
|
||||
---
|
||||
|
||||
## Testumgebung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Browser** | Chrome / Firefox |
|
||||
| **URL** | http://localhost |
|
||||
| **Methode** | Manueller Blackbox Test |
|
||||
|
||||
---
|
||||
|
||||
## Beschreibung der Systemteile
|
||||
|
||||
| Komponente | Technologie | Aufgabe |
|
||||
|---|---|---|
|
||||
| **Frontend** | React, Vite, Socket.IO Client, Nginx | Benutzeroberfläche für den Chat. Stellt Verbindung zum Backend über WebSocket her und zeigt Nachrichten, Teilnehmer und Verbindungsstatus an. |
|
||||
| **Backend** | Node.js, Express, Socket.IO | REST-API und WebSocket-Server. Verwaltet Benutzer-Sessions, Räume und leitet Nachrichten in Echtzeit weiter. |
|
||||
| **Datenbank** | MongoDB | Persistente Speicherung von Nachrichten und Benutzerdaten. Alternativ steht ein JSON-Fallback-Store zur Verfügung. |
|
||||
| **Infrastruktur** | Docker, Docker Compose, Nginx | Containerisierung aller Dienste. Nginx fungiert als Reverse Proxy für das Frontend. |
|
||||
|
||||
---
|
||||
|
||||
## Vorschläge zur Ausfallsicherheit
|
||||
|
||||
**Frontend**
|
||||
|
||||
- Automatischer Reconnect bei WebSocket-Verbindungsabbruch ist bereits implementiert (5 Versuche)
|
||||
- Statische Dateien über CDN ausliefern für höhere Verfügbarkeit
|
||||
|
||||
**Backend**
|
||||
|
||||
- Mehrere Instanzen hinter einem Load Balancer betreiben (z. B. Nginx upstream)
|
||||
- Bei mehreren Instanzen Socket.IO mit Redis Adapter koppeln, damit alle Instanzen dieselben Räume/Events teilen
|
||||
- Health-Check-Endpunkt `/health` ist bereits vorhanden und kann von einem Orchestrator (z. B. Kubernetes) genutzt werden
|
||||
|
||||
**Datenbank**
|
||||
|
||||
- MongoDB Replica Set einrichten: primärer Node schreibt, sekundäre Nodes lesen → bei Ausfall des primären Nodes übernimmt automatisch ein sekundärer
|
||||
- Regelmäßige automatische Backups des Volumes einrichten
|
||||
- JSON-Fallback-Store dient als Notlösung bei MongoDB-Ausfall (bereits implementiert)
|
||||
|
||||
**Infrastruktur**
|
||||
|
||||
- `restart: unless-stopped` ist in allen Docker-Diensten bereits konfiguriert → automatischer Neustart bei Absturz
|
||||
- Für Produktion: Docker Swarm oder Kubernetes für automatisches Failover und Skalierung
|
||||
|
||||
---
|
||||
|
||||
## Testfälle
|
||||
|
||||
| Nr. | Beschreibung | Eingabe | Erwartetes Ergebnis | Tatsächliches Ergebnis | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| T01 | Beitreten mit gültigem Benutzernamen und Raum | Username: `Max`, Room: `general` | Benutzer tritt dem Raum bei, Chatverlauf wird angezeigt | | [ ] |
|
||||
| T02 | Beitreten ohne Benutzernamen | Username: *(leer)*, Room: `general` | Fehlermeldung wird angezeigt, kein Beitritt | | [ ] |
|
||||
| T03 | Beitreten ohne Raumnamen | Username: `Max`, Room: *(leer)* | Fehlermeldung wird angezeigt, kein Beitritt | | [ ] |
|
||||
| T04 | Nachricht senden | Nachricht: `Hallo!` → Senden | Nachricht erscheint im Chat | | [ ] |
|
||||
| T05 | Leere Nachricht senden | Nachricht: *(leer)* → Senden | Nachricht wird nicht gesendet | | [ ] |
|
||||
| T06 | Zwei Benutzer im selben Raum | Tab 1: `Max` / `general`, Tab 2: `Anna` / `general` | Beide sehen Nachrichten des jeweils anderen in Echtzeit | | [ ] |
|
||||
| T07 | Benutzer in verschiedenen Räumen | Tab 1: `Max` / `general`, Tab 2: `Anna` / `privat` | Nachrichten sind voneinander getrennt | | [ ] |
|
||||
| T08 | Teilnehmerliste aktualisiert sich | Tab 2 schließen | Tab 1 zeigt Benutzer als offline/entfernt | | [ ] |
|
||||
| T09 | Chatverlauf nach Neuverbindung | Seite neu laden, wieder einloggen | Vorherige Nachrichten werden geladen | | [ ] |
|
||||
| T10 | Sonderzeichen in Nachricht | Nachricht: `<script>alert(1)</script>` | Text wird als plain text angezeigt, kein Script ausgeführt | | [ ] |
|
||||
| T11 | Langer Text | Nachricht mit 500 Zeichen | Nachricht vollständig angezeigt, kein Layout-Fehler | | [ ] |
|
||||
| T12 | Backend nicht erreichbar | Backend stoppen, App öffnen | Fehlermeldung oder Reconnect-Versuch wird angezeigt | | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Legende
|
||||
|
||||
| Symbol | Bedeutung |
|
||||
|---|---|
|
||||
| [OK] | Bestanden |
|
||||
| [X] | Fehlgeschlagen |
|
||||
| [ ] | Nicht getestet |
|
||||
|
||||
---
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
| Gesamt | Bestanden | Fehlgeschlagen | Nicht getestet |
|
||||
|---|---|---|---|
|
||||
| 12 | | | |
|
||||
|
||||
---
|
||||
|
||||
## Bemerkungen
|
||||
|
||||
\vspace{1cm}
|
||||
|
||||
_______________________________________________
|
||||
|
||||
\vspace{1cm}
|
||||
|
||||
_______________________________________________
|
||||
Reference in New Issue
Block a user