From 56ff6197a2f3a90b352a919bc22099fe79fd4888 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 05:35:05 +0100 Subject: [PATCH] feat: Add initial implementation of CI/CD workflows, documentation, and testing framework --- .config/dotnet-tools.json | 12 + .github/workflows/ci-release.yml | 177 +++++++++++++ .github/workflows/pr-check.yml | 65 +++++ .gitignore | 5 + .markdownlint-cli2.jsonc | 14 + README.md | 9 +- docs/articles/architecture.md | 41 +++ docs/articles/getting-started.md | 31 +++ docs/articles/toc.yml | 4 + docs/changelog/toc.yml | 2 + docs/changelog/v0.1.0.md | 13 + docs/docfx.json | 55 ++++ docs/index.md | 14 + docs/toc.yml | 7 + src/Directory.Build.props | 5 + src/EchoHub.Client/Themes/ThemeManager.cs | 240 +++++++++++++++++- src/EchoHub.Tests/EchoHub.Tests.csproj | 25 ++ .../FileValidationHelperTests.cs | 56 ++++ src/EchoHub.Tests/PresenceTrackerTests.cs | 68 +++++ src/EchoHub.Tests/ValidationConstantsTests.cs | 46 ++++ src/EchoHub.slnx | 1 + 21 files changed, 888 insertions(+), 2 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .github/workflows/ci-release.yml create mode 100644 .github/workflows/pr-check.yml create mode 100644 .markdownlint-cli2.jsonc create mode 100644 docs/articles/architecture.md create mode 100644 docs/articles/getting-started.md create mode 100644 docs/articles/toc.yml create mode 100644 docs/changelog/toc.yml create mode 100644 docs/changelog/v0.1.0.md create mode 100644 docs/docfx.json create mode 100644 docs/index.md create mode 100644 docs/toc.yml create mode 100644 src/Directory.Build.props create mode 100644 src/EchoHub.Tests/EchoHub.Tests.csproj create mode 100644 src/EchoHub.Tests/FileValidationHelperTests.cs create mode 100644 src/EchoHub.Tests/PresenceTrackerTests.cs create mode 100644 src/EchoHub.Tests/ValidationConstantsTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..b220e3b --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "docfx": { + "version": "2.78.3", + "commands": [ + "docfx" + ] + } + } +} diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml new file mode 100644 index 0000000..351dc5c --- /dev/null +++ b/.github/workflows/ci-release.yml @@ -0,0 +1,177 @@ +name: CI / Release / Docs + +on: + push: + branches: [master] + +permissions: + contents: write + +jobs: + lint-markdown: + name: Markdown Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run markdownlint-cli2 + uses: DavidAnson/markdownlint-cli2-action@v19 + with: + globs: | + **/*.md + !.dev/** + !docs/api/** + !**/node_modules/** + !**/bin/** + !**/obj/** + + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + src_changed: ${{ steps.check.outputs.src_changed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Check for src/ changes + id: check + run: | + CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'src/' | wc -l) + if [ "$CHANGED" -gt 0 ]; then + echo "src_changed=true" >> "$GITHUB_OUTPUT" + else + echo "src_changed=false" >> "$GITHUB_OUTPUT" + fi + + test: + name: Build & Test + needs: [lint-markdown, detect-changes] + if: needs.detect-changes.outputs.src_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore src/EchoHub.slnx + + - name: Build + run: dotnet build src/EchoHub.slnx --no-restore --configuration Release + + - name: Test + run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal + + release: + name: Create Release + needs: [lint-markdown, detect-changes, test] + if: needs.detect-changes.outputs.src_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Read version + id: version + run: | + VERSION=$(grep -oP '(?<=)[^<]+' src/Directory.Build.props) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check if release exists + id: check_release + run: | + if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish Server win-x64 + if: steps.check_release.outputs.exists == 'false' + run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64 + + - name: Publish Server linux-x64 + if: steps.check_release.outputs.exists == 'false' + run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64 + + - name: Publish Server osx-x64 + if: steps.check_release.outputs.exists == 'false' + run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64 + + - name: Publish Client win-x64 + if: steps.check_release.outputs.exists == 'false' + run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64 + + - name: Publish Client linux-x64 + if: steps.check_release.outputs.exists == 'false' + run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64 + + - name: Publish Client osx-x64 + if: steps.check_release.outputs.exists == 'false' + run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64 + + - name: Zip artifacts + if: steps.check_release.outputs.exists == 'false' + run: | + cd publish + zip -r ../EchoHub-Server-win-x64.zip server-win-x64/ + zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/ + zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/ + zip -r ../EchoHub-Client-win-x64.zip client-win-x64/ + zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/ + zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/ + + - name: Create GitHub Release + if: steps.check_release.outputs.exists == 'false' + run: | + gh release create "${{ steps.version.outputs.tag }}" \ + --title "EchoHub ${{ steps.version.outputs.tag }}" \ + --generate-notes \ + EchoHub-Server-win-x64.zip \ + EchoHub-Server-linux-x64.zip \ + EchoHub-Server-osx-x64.zip \ + EchoHub-Client-win-x64.zip \ + EchoHub-Client-linux-x64.zip \ + EchoHub-Client-osx-x64.zip + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + docs: + name: Build & Deploy Docs + needs: lint-markdown + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore .NET tools + run: dotnet tool restore + + - name: Build solution + run: dotnet build src/EchoHub.slnx --configuration Release + + - name: Generate documentation + run: dotnet docfx docs/docfx.json + + - name: Deploy to web branch + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/_site + publish_branch: web diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..94ae104 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,65 @@ +name: PR Check + +on: + pull_request: + branches: [master] + +jobs: + lint-markdown: + name: Markdown Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run markdownlint-cli2 + uses: DavidAnson/markdownlint-cli2-action@v19 + with: + globs: | + **/*.md + !.dev/** + !docs/api/** + !**/node_modules/** + !**/bin/** + !**/obj/** + + test: + name: Build & Test + needs: lint-markdown + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for src/ changes + id: changes + run: | + git fetch origin ${{ github.base_ref }} --depth=1 + CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'src/' | wc -l) + if [ "$CHANGED" -gt 0 ]; then + echo "src_changed=true" >> "$GITHUB_OUTPUT" + else + echo "src_changed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Setup .NET 10 + if: steps.changes.outputs.src_changed == 'true' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + if: steps.changes.outputs.src_changed == 'true' + run: dotnet restore src/EchoHub.slnx + + - name: Build + if: steps.changes.outputs.src_changed == 'true' + run: dotnet build src/EchoHub.slnx --no-restore --configuration Release + + - name: Test + if: steps.changes.outputs.src_changed == 'true' + run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal + + - name: Skip notice + if: steps.changes.outputs.src_changed == 'false' + run: echo "No changes in src/ — skipping build and test" diff --git a/.gitignore b/.gitignore index fe87991..d6eb001 100644 --- a/.gitignore +++ b/.gitignore @@ -429,3 +429,8 @@ CLAUDE.md *.db-* src/EchoHub.Server/uploads/* .claude/* + +# DocFx generated output +docs/_site/ +docs/api/ +!docs/api/.gitkeep diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..5b9c36d --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,14 @@ +{ + "config": { + "MD013": false, + "MD033": false, + "MD041": false + }, + "ignores": [ + ".dev/**", + "docs/api/**", + "**/node_modules/**", + "**/bin/**", + "**/obj/**" + ] +} diff --git a/README.md b/README.md index 9a42533..1322467 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ graph TD ### Client - **Runs in your terminal** — no browser, no Electron, no 500MB of bundled Chromium -- **6 built-in themes** — including `hacker` for when you want to feel like you're in a movie +- **13 built-in themes** — including `hacker` for when you want to feel like you're in a movie - **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc. - **Colored nicknames** — pick your hex color, express yourself - **File/image sharing** — local files or URLs @@ -151,6 +151,13 @@ dotnet build src/EchoHub.slnx | `light` | Black on white — for the brave | | `hacker` | Green on black — *I'm in* | | `solarized` | Cyan/yellow on dark gray — for the refined | +| `dracula` | Purple accents on black — the classic dark theme | +| `monokai` | Yellow highlights on black — warm and familiar | +| `nord` | Cool blues — arctic vibes | +| `gruvbox` | Earthy yellows on black — retro warmth | +| `ocean` | Cyan on deep blue — underwater aesthetics | +| `highcontrast` | Bright yellow on black — maximum readability | +| `rosepine` | Muted pinks on black — cozy and soft | ## Configuration diff --git a/docs/articles/architecture.md b/docs/articles/architecture.md new file mode 100644 index 0000000..e378a27 --- /dev/null +++ b/docs/articles/architecture.md @@ -0,0 +1,41 @@ +# Architecture + +## Overview + +EchoHub follows a decentralized model where each server is fully independent. There is no central authority or account federation. Users create one account per server. + +## Components + +### EchoHub.Core + +Shared library containing: + +- **Models**: `User`, `Channel`, `Message`, `RefreshToken` +- **DTOs**: Record types for API requests/responses +- **Contracts**: `IEchoHubClient` -- the strongly-typed SignalR client interface +- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants` + +### EchoHub.Server + +ASP.NET Core web application: + +- **Controllers**: REST API endpoints for auth, channels, users, files, server info +- **Hubs**: SignalR `ChatHub` for real-time messaging +- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens) +- **Data**: EF Core with SQLite +- **Services**: Presence tracking, file storage, image-to-ASCII conversion + +### EchoHub.Client + +Terminal.Gui v2 TUI application: + +- **UI**: Main window, dialogs, chat renderer with ANSI color support +- **Services**: API client with automatic token refresh, SignalR connection wrapper +- **Themes**: 6 built-in color themes +- **Config**: Client configuration management + +## Communication + +- REST API for authentication, profile management, channel CRUD, file uploads +- SignalR WebSocket for real-time messaging and presence updates +- JWT tokens passed via query string for SignalR authentication diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md new file mode 100644 index 0000000..6bd4748 --- /dev/null +++ b/docs/articles/getting-started.md @@ -0,0 +1,31 @@ +# Getting Started + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) + +## Run the Server + +```bash +dotnet run --project src/EchoHub.Server +``` + +On first run, the server automatically: + +1. Creates `appsettings.json` from the example config +2. Generates a secure JWT secret +3. Creates the SQLite database with a `#general` channel + +## Run the Client + +```bash +dotnet run --project src/EchoHub.Client +``` + +Connect to a server, register an account, and start chatting. + +## Build from Source + +```bash +dotnet build src/EchoHub.slnx +``` diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml new file mode 100644 index 0000000..cd731cf --- /dev/null +++ b/docs/articles/toc.yml @@ -0,0 +1,4 @@ +- name: Getting Started + href: getting-started.md +- name: Architecture + href: architecture.md diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml new file mode 100644 index 0000000..621bc25 --- /dev/null +++ b/docs/changelog/toc.yml @@ -0,0 +1,2 @@ +- name: v0.1.0 + href: v0.1.0.md diff --git a/docs/changelog/v0.1.0.md b/docs/changelog/v0.1.0.md new file mode 100644 index 0000000..927807f --- /dev/null +++ b/docs/changelog/v0.1.0.md @@ -0,0 +1,13 @@ +# v0.1.0 - Initial Release + +## Features + +- Real-time messaging via SignalR +- JWT authentication with access and refresh tokens +- Channel management (create, set topic, delete) +- File and image uploads with magic-byte validation +- Image-to-ASCII conversion for terminal display +- Presence tracking (online, away, DND, invisible) +- 6 built-in TUI themes +- Rate limiting on auth, upload, and general endpoints +- Self-contained binary releases for Windows, Linux, and macOS diff --git a/docs/docfx.json b/docs/docfx.json new file mode 100644 index 0000000..679f709 --- /dev/null +++ b/docs/docfx.json @@ -0,0 +1,55 @@ +{ + "metadata": [ + { + "src": [ + { + "src": "../src", + "files": [ + "EchoHub.Core/EchoHub.Core.csproj", + "EchoHub.Server/EchoHub.Server.csproj", + "EchoHub.Client/EchoHub.Client.csproj" + ] + } + ], + "dest": "api", + "properties": { + "TargetFramework": "net10.0" + } + } + ], + "build": { + "content": [ + { + "files": [ + "**/*.{md,yml}" + ], + "exclude": [ + "_site/**" + ] + } + ], + "resource": [ + { + "files": [ + "images/**" + ] + } + ], + "output": "_site", + "template": [ + "default", + "modern" + ], + "globalMetadata": { + "_appName": "EchoHub", + "_appTitle": "EchoHub Documentation", + "_enableSearch": true, + "_disableContribution": false, + "_gitContribute": { + "repo": "https://github.com/HueByte/EchoHub", + "branch": "master" + } + }, + "markdownEngineName": "markdig" + } +} diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9d25bd4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,14 @@ +--- +_layout: landing +--- + +# EchoHub Documentation + +Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-like chat application built with .NET 10 and SignalR. + +## Quick Links + +- [Getting Started](articles/getting-started.md) - Set up and run EchoHub +- [Architecture](articles/architecture.md) - Understand the system design +- [API Reference](api/index.md) - Generated C# API documentation +- [Changelog](changelog/v0.1.0.md) - Release history diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..de1e9fc --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,7 @@ +- name: Articles + href: articles/ +- name: Changelog + href: changelog/ +- name: API Reference + href: api/ + homepage: api/index.md diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..0362619 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,5 @@ + + + 0.1.0 + + diff --git a/src/EchoHub.Client/Themes/ThemeManager.cs b/src/EchoHub.Client/Themes/ThemeManager.cs index 6d5f0c3..056e6f3 100644 --- a/src/EchoHub.Client/Themes/ThemeManager.cs +++ b/src/EchoHub.Client/Themes/ThemeManager.cs @@ -181,6 +181,237 @@ public static class ThemeManager } }; + private static readonly Theme DraculaTheme = new() + { + Name = "Dracula", + Base = new ThemeColors + { + Foreground = "White", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "Magenta" + }, + Menu = new ThemeColors + { + Foreground = "BrightMagenta", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "BrightMagenta" + }, + Dialog = new ThemeColors + { + Foreground = "White", + Background = "DarkGray", + FocusForeground = "Black", + FocusBackground = "Magenta" + }, + Status = new ThemeColors + { + Foreground = "BrightMagenta", + Background = "Black", + FocusForeground = "BrightMagenta", + FocusBackground = "Black" + } + }; + + private static readonly Theme MonokaiTheme = new() + { + Name = "Monokai", + Base = new ThemeColors + { + Foreground = "White", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "BrightYellow" + }, + Menu = new ThemeColors + { + Foreground = "BrightYellow", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "BrightYellow" + }, + Dialog = new ThemeColors + { + Foreground = "White", + Background = "DarkGray", + FocusForeground = "Black", + FocusBackground = "BrightYellow" + }, + Status = new ThemeColors + { + Foreground = "BrightYellow", + Background = "Black", + FocusForeground = "BrightYellow", + FocusBackground = "Black" + } + }; + + private static readonly Theme NordTheme = new() + { + Name = "Nord", + Base = new ThemeColors + { + Foreground = "White", + Background = "DarkBlue", + FocusForeground = "BrightCyan", + FocusBackground = "Blue" + }, + Menu = new ThemeColors + { + Foreground = "White", + Background = "Blue", + FocusForeground = "BrightCyan", + FocusBackground = "DarkBlue" + }, + Dialog = new ThemeColors + { + Foreground = "White", + Background = "Blue", + FocusForeground = "BrightCyan", + FocusBackground = "DarkBlue" + }, + Status = new ThemeColors + { + Foreground = "BrightCyan", + Background = "DarkBlue", + FocusForeground = "BrightCyan", + FocusBackground = "DarkBlue" + } + }; + + private static readonly Theme GruvboxTheme = new() + { + Name = "Gruvbox", + Base = new ThemeColors + { + Foreground = "BrightYellow", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "DarkYellow" + }, + Menu = new ThemeColors + { + Foreground = "BrightYellow", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "DarkYellow" + }, + Dialog = new ThemeColors + { + Foreground = "BrightYellow", + Background = "DarkGray", + FocusForeground = "Black", + FocusBackground = "DarkYellow" + }, + Status = new ThemeColors + { + Foreground = "DarkYellow", + Background = "Black", + FocusForeground = "DarkYellow", + FocusBackground = "Black" + } + }; + + private static readonly Theme OceanTheme = new() + { + Name = "Ocean", + Base = new ThemeColors + { + Foreground = "BrightCyan", + Background = "DarkBlue", + FocusForeground = "White", + FocusBackground = "DarkCyan" + }, + Menu = new ThemeColors + { + Foreground = "White", + Background = "DarkCyan", + FocusForeground = "BrightCyan", + FocusBackground = "DarkBlue" + }, + Dialog = new ThemeColors + { + Foreground = "White", + Background = "DarkCyan", + FocusForeground = "BrightCyan", + FocusBackground = "DarkBlue" + }, + Status = new ThemeColors + { + Foreground = "BrightCyan", + Background = "DarkCyan", + FocusForeground = "BrightCyan", + FocusBackground = "DarkCyan" + } + }; + + private static readonly Theme HighContrastTheme = new() + { + Name = "HighContrast", + Base = new ThemeColors + { + Foreground = "BrightYellow", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "BrightYellow" + }, + Menu = new ThemeColors + { + Foreground = "BrightYellow", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "BrightYellow" + }, + Dialog = new ThemeColors + { + Foreground = "BrightYellow", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "BrightYellow" + }, + Status = new ThemeColors + { + Foreground = "BrightYellow", + Background = "Black", + FocusForeground = "BrightYellow", + FocusBackground = "Black" + } + }; + + private static readonly Theme RosePineTheme = new() + { + Name = "RosePine", + Base = new ThemeColors + { + Foreground = "White", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "DarkMagenta" + }, + Menu = new ThemeColors + { + Foreground = "Magenta", + Background = "Black", + FocusForeground = "White", + FocusBackground = "DarkMagenta" + }, + Dialog = new ThemeColors + { + Foreground = "White", + Background = "DarkGray", + FocusForeground = "White", + FocusBackground = "DarkMagenta" + }, + Status = new ThemeColors + { + Foreground = "Magenta", + Background = "Black", + FocusForeground = "Magenta", + FocusBackground = "Black" + } + }; + private static readonly Theme TransparentTheme = new() { Name = "Transparent", @@ -221,7 +452,14 @@ public static class ThemeManager ClassicTheme, LightTheme, HackerTheme, - SolarizedTheme + SolarizedTheme, + DraculaTheme, + MonokaiTheme, + NordTheme, + GruvboxTheme, + OceanTheme, + HighContrastTheme, + RosePineTheme ]; public static List GetAvailableThemes() diff --git a/src/EchoHub.Tests/EchoHub.Tests.csproj b/src/EchoHub.Tests/EchoHub.Tests.csproj new file mode 100644 index 0000000..1d82ba0 --- /dev/null +++ b/src/EchoHub.Tests/EchoHub.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/src/EchoHub.Tests/FileValidationHelperTests.cs b/src/EchoHub.Tests/FileValidationHelperTests.cs new file mode 100644 index 0000000..645ac13 --- /dev/null +++ b/src/EchoHub.Tests/FileValidationHelperTests.cs @@ -0,0 +1,56 @@ +using EchoHub.Server.Services; +using Xunit; + +namespace EchoHub.Tests; + +public class FileValidationHelperTests +{ + [Fact] + public void IsValidImage_JpegMagicBytes_ReturnsTrue() + { + byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]; + using var stream = new MemoryStream(jpeg); + Assert.True(FileValidationHelper.IsValidImage(stream)); + } + + [Fact] + public void IsValidImage_PngMagicBytes_ReturnsTrue() + { + byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + using var stream = new MemoryStream(png); + Assert.True(FileValidationHelper.IsValidImage(stream)); + } + + [Fact] + public void IsValidImage_GifMagicBytes_ReturnsTrue() + { + byte[] gif = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; + using var stream = new MemoryStream(gif); + Assert.True(FileValidationHelper.IsValidImage(stream)); + } + + [Fact] + public void IsValidImage_WebpMagicBytes_ReturnsTrue() + { + byte[] webp = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50]; + using var stream = new MemoryStream(webp); + Assert.True(FileValidationHelper.IsValidImage(stream)); + } + + [Fact] + public void IsValidImage_RandomBytes_ReturnsFalse() + { + byte[] random = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05]; + using var stream = new MemoryStream(random); + Assert.False(FileValidationHelper.IsValidImage(stream)); + } + + [Fact] + public void IsValidImage_ResetsStreamPosition() + { + byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]; + using var stream = new MemoryStream(jpeg); + FileValidationHelper.IsValidImage(stream); + Assert.Equal(0, stream.Position); + } +} diff --git a/src/EchoHub.Tests/PresenceTrackerTests.cs b/src/EchoHub.Tests/PresenceTrackerTests.cs new file mode 100644 index 0000000..c3663be --- /dev/null +++ b/src/EchoHub.Tests/PresenceTrackerTests.cs @@ -0,0 +1,68 @@ +using EchoHub.Server.Services; +using Xunit; + +namespace EchoHub.Tests; + +public class PresenceTrackerTests +{ + [Fact] + public void UserConnected_IsOnline_ReturnsTrue() + { + var tracker = new PresenceTracker(); + tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); + Assert.True(tracker.IsOnline("alice")); + } + + [Fact] + public void UserDisconnected_LastConnection_IsOnlineReturnsFalse() + { + var tracker = new PresenceTracker(); + tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); + tracker.UserDisconnected("conn1"); + Assert.False(tracker.IsOnline("alice")); + } + + [Fact] + public void MultipleConnections_DisconnectOne_StillOnline() + { + var tracker = new PresenceTracker(); + var userId = Guid.NewGuid(); + tracker.UserConnected("conn1", userId, "alice"); + tracker.UserConnected("conn2", userId, "alice"); + tracker.UserDisconnected("conn1"); + Assert.True(tracker.IsOnline("alice")); + } + + [Fact] + public void JoinChannel_GetOnlineUsersInChannel_ReturnsUser() + { + var tracker = new PresenceTracker(); + tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); + tracker.JoinChannel("alice", "general"); + var users = tracker.GetOnlineUsersInChannel("general"); + Assert.Contains("alice", users); + } + + [Fact] + public void LeaveChannel_UserNoLongerInChannel() + { + var tracker = new PresenceTracker(); + tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); + tracker.JoinChannel("alice", "general"); + tracker.LeaveChannel("alice", "general"); + var users = tracker.GetOnlineUsersInChannel("general"); + Assert.DoesNotContain("alice", users); + } + + [Fact] + public void GetChannelsForUser_ReturnsJoinedChannels() + { + var tracker = new PresenceTracker(); + tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); + tracker.JoinChannel("alice", "general"); + tracker.JoinChannel("alice", "random"); + var channels = tracker.GetChannelsForUser("alice"); + Assert.Contains("general", channels); + Assert.Contains("random", channels); + } +} diff --git a/src/EchoHub.Tests/ValidationConstantsTests.cs b/src/EchoHub.Tests/ValidationConstantsTests.cs new file mode 100644 index 0000000..8261e56 --- /dev/null +++ b/src/EchoHub.Tests/ValidationConstantsTests.cs @@ -0,0 +1,46 @@ +using EchoHub.Core.Constants; +using Xunit; + +namespace EchoHub.Tests; + +public class ValidationConstantsTests +{ + [Theory] + [InlineData("alice", true)] + [InlineData("Bob_123", true)] + [InlineData("user-name", true)] + [InlineData("abc", true)] + [InlineData("ab", false)] + [InlineData("", false)] + [InlineData("has space", false)] + [InlineData("has@symbol", false)] + public void UsernameRegex_ValidatesCorrectly(string input, bool expected) + { + var result = ValidationConstants.UsernameRegex().IsMatch(input); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("general", true)] + [InlineData("my-channel_01", true)] + [InlineData("ab", true)] + [InlineData("a", false)] + [InlineData("has space", false)] + public void ChannelNameRegex_ValidatesCorrectly(string input, bool expected) + { + var result = ValidationConstants.ChannelNameRegex().IsMatch(input); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("#FF0000", true)] + [InlineData("#aabbcc", true)] + [InlineData("FF0000", false)] + [InlineData("#FFF", false)] + [InlineData("#GGGGGG", false)] + public void HexColorRegex_ValidatesCorrectly(string input, bool expected) + { + var result = ValidationConstants.HexColorRegex().IsMatch(input); + Assert.Equal(expected, result); + } +} diff --git a/src/EchoHub.slnx b/src/EchoHub.slnx index 55ce494..4a160e3 100644 --- a/src/EchoHub.slnx +++ b/src/EchoHub.slnx @@ -2,4 +2,5 @@ +