feat: Add initial implementation of CI/CD workflows, documentation, and testing framework

This commit is contained in:
HueByte
2026-02-19 05:35:05 +01:00
parent 3188d18da2
commit 56ff6197a2
21 changed files with 888 additions and 2 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"docfx": {
"version": "2.78.3",
"commands": [
"docfx"
]
}
}
}
+177
View File
@@ -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 '(?<=<Version>)[^<]+' 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
+65
View File
@@ -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"
+5
View File
@@ -429,3 +429,8 @@ CLAUDE.md
*.db-* *.db-*
src/EchoHub.Server/uploads/* src/EchoHub.Server/uploads/*
.claude/* .claude/*
# DocFx generated output
docs/_site/
docs/api/
!docs/api/.gitkeep
+14
View File
@@ -0,0 +1,14 @@
{
"config": {
"MD013": false,
"MD033": false,
"MD041": false
},
"ignores": [
".dev/**",
"docs/api/**",
"**/node_modules/**",
"**/bin/**",
"**/obj/**"
]
}
+8 -1
View File
@@ -76,7 +76,7 @@ graph TD
### Client ### Client
- **Runs in your terminal** — no browser, no Electron, no 500MB of bundled Chromium - **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. - **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc.
- **Colored nicknames** — pick your hex color, express yourself - **Colored nicknames** — pick your hex color, express yourself
- **File/image sharing** — local files or URLs - **File/image sharing** — local files or URLs
@@ -151,6 +151,13 @@ dotnet build src/EchoHub.slnx
| `light` | Black on white — for the brave | | `light` | Black on white — for the brave |
| `hacker` | Green on black — *I'm in* | | `hacker` | Green on black — *I'm in* |
| `solarized` | Cyan/yellow on dark gray — for the refined | | `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 ## Configuration
+41
View File
@@ -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
+31
View File
@@ -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
```
+4
View File
@@ -0,0 +1,4 @@
- name: Getting Started
href: getting-started.md
- name: Architecture
href: architecture.md
+2
View File
@@ -0,0 +1,2 @@
- name: v0.1.0
href: v0.1.0.md
+13
View File
@@ -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
+55
View File
@@ -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"
}
}
+14
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
- name: Articles
href: articles/
- name: Changelog
href: changelog/
- name: API Reference
href: api/
homepage: api/index.md
+5
View File
@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
</PropertyGroup>
</Project>
+239 -1
View File
@@ -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() private static readonly Theme TransparentTheme = new()
{ {
Name = "Transparent", Name = "Transparent",
@@ -221,7 +452,14 @@ public static class ThemeManager
ClassicTheme, ClassicTheme,
LightTheme, LightTheme,
HackerTheme, HackerTheme,
SolarizedTheme SolarizedTheme,
DraculaTheme,
MonokaiTheme,
NordTheme,
GruvboxTheme,
OceanTheme,
HighContrastTheme,
RosePineTheme
]; ];
public static List<Theme> GetAvailableThemes() public static List<Theme> GetAvailableThemes()
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
</ItemGroup>
</Project>
@@ -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);
}
}
+68
View File
@@ -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);
}
}
@@ -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);
}
}
+1
View File
@@ -2,4 +2,5 @@
<Project Path="EchoHub.Client/EchoHub.Client.csproj" /> <Project Path="EchoHub.Client/EchoHub.Client.csproj" />
<Project Path="EchoHub.Core/EchoHub.Core.csproj" /> <Project Path="EchoHub.Core/EchoHub.Core.csproj" />
<Project Path="EchoHub.Server/EchoHub.Server.csproj" /> <Project Path="EchoHub.Server/EchoHub.Server.csproj" />
<Project Path="EchoHub.Tests/EchoHub.Tests.csproj" />
</Solution> </Solution>