diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts
index 7c05270..770e713 100644
--- a/client/src/app/app.routes.ts
+++ b/client/src/app/app.routes.ts
@@ -38,6 +38,11 @@ export const routes: Routes = [
canActivate: [authGuard],
loadComponent: () => import('./pages/pipeline/pipeline').then((m) => m.PipelineComponent),
},
+ {
+ path: 'projects/:projectId/widget',
+ canActivate: [authGuard],
+ loadComponent: () => import('./pages/widget-settings/widget-settings').then((m) => m.WidgetSettingsComponent),
+ },
{
path: 'projects/:projectId/widget',
canActivate: [authGuard],
diff --git a/client/src/app/pages/dashboard/dashboard.html b/client/src/app/pages/dashboard/dashboard.html
index 8795388..8df275f 100644
--- a/client/src/app/pages/dashboard/dashboard.html
+++ b/client/src/app/pages/dashboard/dashboard.html
@@ -34,6 +34,14 @@
{{ p.name }}
📄 {{ p.documentCount }} docs
{{ timeAgo(p.updatedAt ?? p.createdAt) }}
+
}
+
+
+
+
+
+ @if (loading()) {
+
+ } @else {
+
+
+
+
+
+
+
+
Widget Status
+
Enable or disable the chat widget
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Security
+
+
+
+
Use * for all origins, or comma-separated URLs
+
+
+
+
+
+
+
+
+
+ @if (saveMessage()) {
+
+ {{ saveMessage() }}
+
+ }
+
+
+
+
+
+
+
+
Live Preview
+
+
+
+
+
+
+
+
{{ config().title }}
+
{{ config().subtitle }}
+
+
+
+
+
+ Hi! How can I help you today?
+
+
+ What is PipeRAG?
+
+
+ PipeRAG is a no-code RAG pipeline builder...
+
+
+
+
+
+
+ {{ config().placeholderText }}
+
+
Send
+
+
+
+
+
+
+
+
+
+
Embed Code
+
+
+
+
{{ embedCode() }}
+
+ Add this code before the closing </body> tag on your website.
+
+
+
+
+
+ }
+
+
+ `,
+})
+export class WidgetSettingsComponent implements OnInit {
+ private http = inject(HttpClient);
+ private route = inject(ActivatedRoute);
+
+ projectId = signal('');
+ loading = signal(true);
+ saving = signal(false);
+ saveMessage = signal('');
+ saveError = signal(false);
+ copied = signal(false);
+
+ config = signal({
+ id: '',
+ projectId: '',
+ primaryColor: '#6366f1',
+ backgroundColor: '#1e1e2e',
+ textColor: '#ffffff',
+ position: 'bottom-right',
+ avatarUrl: null,
+ title: 'Chat with us',
+ subtitle: 'Ask anything about our docs',
+ placeholderText: 'Type a message...',
+ allowedOrigins: '*',
+ isActive: true,
+ createdAt: '',
+ updatedAt: null,
+ });
+
+ embedCode = computed(() => {
+ const pid = this.projectId();
+ return `
+
+`;
+ });
+
+ ngOnInit() {
+ const id = this.route.snapshot.paramMap.get('projectId') ?? '';
+ this.projectId.set(id);
+ this.loadConfig();
+ }
+
+ loadConfig() {
+ this.loading.set(true);
+ this.http.get(`/api/projects/${this.projectId()}/widget`).subscribe({
+ next: (data) => {
+ this.config.set(data);
+ this.loading.set(false);
+ },
+ error: () => {
+ // No config yet, use defaults
+ this.loading.set(false);
+ },
+ });
+ }
+
+ toggleActive() {
+ const c = this.config();
+ this.config.set({ ...c, isActive: !c.isActive });
+ }
+
+ save() {
+ this.saving.set(true);
+ this.saveMessage.set('');
+ const c = this.config();
+ this.http.put(`/api/projects/${this.projectId()}/widget`, {
+ primaryColor: c.primaryColor,
+ backgroundColor: c.backgroundColor,
+ textColor: c.textColor,
+ position: c.position,
+ avatarUrl: c.avatarUrl,
+ title: c.title,
+ subtitle: c.subtitle,
+ placeholderText: c.placeholderText,
+ allowedOrigins: c.allowedOrigins,
+ isActive: c.isActive,
+ }).subscribe({
+ next: (data) => {
+ this.config.set(data);
+ this.saving.set(false);
+ this.saveMessage.set('Widget settings saved!');
+ this.saveError.set(false);
+ },
+ error: () => {
+ this.saving.set(false);
+ this.saveMessage.set('Failed to save settings.');
+ this.saveError.set(true);
+ },
+ });
+ }
+
+ deleteConfig() {
+ if (!confirm('Delete widget configuration?')) return;
+ this.saving.set(true);
+ this.http.delete(`/api/projects/${this.projectId()}/widget`).subscribe({
+ next: () => {
+ this.saving.set(false);
+ this.saveMessage.set('Widget configuration deleted.');
+ this.saveError.set(false);
+ this.config.set({
+ id: '', projectId: this.projectId(), primaryColor: '#6366f1', backgroundColor: '#1e1e2e',
+ textColor: '#ffffff', position: 'bottom-right', avatarUrl: null, title: 'Chat with us',
+ subtitle: 'Ask anything about our docs', placeholderText: 'Type a message...',
+ allowedOrigins: '*', isActive: true, createdAt: '', updatedAt: null,
+ });
+ },
+ error: () => { this.saving.set(false); this.saveMessage.set('Failed to delete.'); this.saveError.set(true); },
+ });
+ }
+
+ copyEmbed() {
+ navigator.clipboard.writeText(this.embedCode());
+ this.copied.set(true);
+ setTimeout(() => this.copied.set(false), 2000);
+ }
+}
diff --git a/src/PipeRAG.Api/Controllers/PublicWidgetController.cs b/src/PipeRAG.Api/Controllers/PublicWidgetController.cs
new file mode 100644
index 0000000..0644747
--- /dev/null
+++ b/src/PipeRAG.Api/Controllers/PublicWidgetController.cs
@@ -0,0 +1,299 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using PipeRAG.Core.DTOs;
+using PipeRAG.Core.Entities;
+using PipeRAG.Infrastructure.Data;
+
+namespace PipeRAG.Api.Controllers;
+
+///
+/// Public endpoints for the embeddable chat widget (no auth required, uses project API key).
+///
+[ApiController]
+[Route("api/widget")]
+public class PublicWidgetController : ControllerBase
+{
+ private readonly PipeRagDbContext _db;
+
+ public PublicWidgetController(PipeRagDbContext db) => _db = db;
+
+ ///
+ /// Get widget configuration by project ID and API key.
+ /// Used by the embedded widget to load its theme/settings.
+ ///
+ [HttpGet("{projectId:guid}/config")]
+ public async Task GetConfig(Guid projectId, [FromQuery] string apiKey, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(apiKey))
+ return BadRequest(new { error = "API key is required." });
+
+ // Validate the API key belongs to the project owner
+ var project = await _db.Projects.FindAsync([projectId], ct);
+ if (project is null)
+ return NotFound(new { error = "Project not found." });
+
+ var keyPrefix = apiKey.Length >= 8 ? apiKey.Substring(0, 8) : apiKey;
+ var validKey = await _db.ApiKeys
+ .AnyAsync(k => k.KeyPrefix == keyPrefix && k.UserId == project.OwnerId && k.IsActive, ct);
+ if (!validKey)
+ return Unauthorized(new { error = "Invalid API key." });
+
+ var config = await _db.WidgetConfigs.FirstOrDefaultAsync(w => w.ProjectId == projectId, ct);
+ if (config is null || !config.IsActive)
+ return NotFound(new { error = "Widget not configured or disabled." });
+
+ // Check origin if AllowedOrigins is not wildcard
+ var origin = Request.Headers.Origin.ToString();
+ if (config.AllowedOrigins != "*" && !string.IsNullOrEmpty(origin))
+ {
+ var allowed = config.AllowedOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (!allowed.Any(a => a.Equals(origin, StringComparison.OrdinalIgnoreCase)))
+ return StatusCode(403, new { error = "Origin not allowed." });
+ }
+
+ return Ok(new WidgetPublicConfigResponse(
+ config.ProjectId,
+ config.PrimaryColor,
+ config.BackgroundColor,
+ config.TextColor,
+ config.Position,
+ config.AvatarUrl,
+ config.Title,
+ config.Subtitle,
+ config.PlaceholderText));
+ }
+
+ ///
+ /// Public chat endpoint for the widget. Sends a message and returns a response.
+ ///
+ [HttpPost("{projectId:guid}/chat")]
+ public async Task Chat(Guid projectId, [FromQuery] string apiKey, [FromBody] WidgetChatRequest request, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(apiKey))
+ return BadRequest(new { error = "API key is required." });
+
+ if (string.IsNullOrWhiteSpace(request.Message))
+ return BadRequest(new { error = "Message is required." });
+
+ var project = await _db.Projects.FindAsync([projectId], ct);
+ if (project is null)
+ return NotFound(new { error = "Project not found." });
+
+ var chatKeyPrefix = apiKey.Length >= 8 ? apiKey.Substring(0, 8) : apiKey;
+ var validKey = await _db.ApiKeys
+ .AnyAsync(k => k.KeyPrefix == chatKeyPrefix && k.UserId == project.OwnerId && k.IsActive, ct);
+ if (!validKey)
+ return Unauthorized(new { error = "Invalid API key." });
+
+ var config = await _db.WidgetConfigs.FirstOrDefaultAsync(w => w.ProjectId == projectId, ct);
+ if (config is null || !config.IsActive)
+ return NotFound(new { error = "Widget not configured or disabled." });
+
+ // For now, return a placeholder. In production this would call QueryEngineService.
+ // The widget chat uses the same RAG pipeline as the main chat.
+ return Ok(new WidgetChatResponse(
+ "I'm the PipeRAG assistant. The chat widget is connected successfully! Full RAG responses will be available once the pipeline is configured.",
+ DateTime.UtcNow));
+ }
+
+ ///
+ /// Serves the embeddable widget loader script.
+ ///
+ [HttpGet("embed.js")]
+ [Produces("application/javascript")]
+ public IActionResult GetEmbedScript()
+ {
+ var baseUrl = $"{Request.Scheme}://{Request.Host}";
+ var script = GenerateEmbedScript(baseUrl);
+ return Content(script, "application/javascript");
+ }
+
+ private static string GenerateEmbedScript(string baseUrl) => $$"""
+ (function() {
+ 'use strict';
+ if (window.__piperag_widget_loaded) return;
+ window.__piperag_widget_loaded = true;
+
+ var config = window.PipeRAGWidget || {};
+ var projectId = config.projectId;
+ var apiKey = config.apiKey;
+ if (!projectId || !apiKey) {
+ console.error('PipeRAG Widget: projectId and apiKey are required.');
+ return;
+ }
+
+ var iframe = document.createElement('iframe');
+ iframe.id = 'piperag-widget-frame';
+ iframe.src = '{{baseUrl}}/api/widget/' + projectId + '/frame?apiKey=' + encodeURIComponent(apiKey);
+ iframe.style.cssText = 'position:fixed;bottom:20px;right:20px;width:0;height:0;border:none;z-index:999999;opacity:0;transition:all 0.3s ease;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,0.15);';
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
+ iframe.setAttribute('allow', 'clipboard-write');
+ document.body.appendChild(iframe);
+
+ var btn = document.createElement('div');
+ btn.id = 'piperag-widget-btn';
+ btn.innerHTML = '';
+ btn.style.cssText = 'position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;background:#6366f1;color:white;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:1000000;box-shadow:0 4px 12px rgba(0,0,0,0.15);transition:transform 0.2s ease;';
+ document.body.appendChild(btn);
+
+ var open = false;
+ btn.addEventListener('click', function() {
+ open = !open;
+ if (open) {
+ iframe.style.width = (config.width || 400) + 'px';
+ iframe.style.height = (config.height || 600) + 'px';
+ iframe.style.opacity = '1';
+ btn.style.transform = 'scale(0)';
+ } else {
+ iframe.style.width = '0';
+ iframe.style.height = '0';
+ iframe.style.opacity = '0';
+ btn.style.transform = 'scale(1)';
+ }
+ });
+
+ window.addEventListener('message', function(e) {
+ if (e.data && e.data.type === 'piperag-close') {
+ open = false;
+ iframe.style.width = '0';
+ iframe.style.height = '0';
+ iframe.style.opacity = '0';
+ btn.style.transform = 'scale(1)';
+ }
+ if (e.data && e.data.type === 'piperag-loaded' && e.data.config) {
+ btn.style.background = e.data.config.primaryColor || '#6366f1';
+ if (e.data.config.position === 'bottom-left') {
+ btn.style.right = 'auto';
+ btn.style.left = '20px';
+ iframe.style.right = 'auto';
+ iframe.style.left = '20px';
+ }
+ }
+ });
+
+ // Fetch config to apply theme to button
+ fetch('{{baseUrl}}/api/widget/' + projectId + '/config?apiKey=' + encodeURIComponent(apiKey))
+ .then(function(r) { return r.json(); })
+ .then(function(cfg) {
+ if (cfg.primaryColor) btn.style.background = cfg.primaryColor;
+ if (cfg.position === 'bottom-left') {
+ btn.style.right = 'auto';
+ btn.style.left = '20px';
+ iframe.style.right = 'auto';
+ iframe.style.left = '20px';
+ }
+ })
+ .catch(function() {});
+ })();
+ """;
+
+ ///
+ /// Serves the widget iframe HTML page.
+ ///
+ [HttpGet("{projectId:guid}/frame")]
+ public async Task GetFrame(Guid projectId, [FromQuery] string apiKey, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(apiKey))
+ return BadRequest("Missing API key");
+
+ var project = await _db.Projects.FindAsync([projectId], ct);
+ if (project is null)
+ return NotFound("Project not found");
+
+ var config = await _db.WidgetConfigs.FirstOrDefaultAsync(w => w.ProjectId == projectId, ct);
+ var primaryColor = config?.PrimaryColor ?? "#6366f1";
+ var bgColor = config?.BackgroundColor ?? "#1e1e2e";
+ var textColor = config?.TextColor ?? "#ffffff";
+ var title = config?.Title ?? "Chat with us";
+ var subtitle = config?.Subtitle ?? "Ask anything";
+ var placeholder = config?.PlaceholderText ?? "Type a message...";
+ var avatarUrl = config?.AvatarUrl ?? "";
+ var baseUrl = $"{Request.Scheme}://{Request.Host}";
+
+ var avatarHtml = string.IsNullOrEmpty(avatarUrl)
+ ? ""
+ : $"
";
+ var position = config?.Position ?? "bottom-right";
+
+ var html = @"
+
+
+
+
+
+
+
+
+Hi! How can I help you today?
+
+
+
+
+
+"
+ .Replace("__PRIMARY__", primaryColor)
+ .Replace("__BG__", bgColor)
+ .Replace("__TEXT__", textColor)
+ .Replace("__TITLE__", title)
+ .Replace("__SUBTITLE__", subtitle)
+ .Replace("__PLACEHOLDER__", placeholder)
+ .Replace("__AVATAR__", avatarHtml)
+ .Replace("__PROJECTID__", projectId.ToString())
+ .Replace("__APIKEY__", apiKey)
+ .Replace("__BASEURL__", baseUrl)
+ .Replace("__POSITION__", position);
+
+ return Content(html, "text/html");
+ }
+}
+
+public record WidgetPublicConfigResponse(
+ Guid ProjectId,
+ string PrimaryColor,
+ string BackgroundColor,
+ string TextColor,
+ string Position,
+ string? AvatarUrl,
+ string Title,
+ string Subtitle,
+ string PlaceholderText);
+
+public record WidgetChatRequest(string Message);
+public record WidgetChatResponse(string Response, DateTime Timestamp);
diff --git a/src/PipeRAG.Api/Validators/WidgetValidators.cs b/src/PipeRAG.Api/Validators/WidgetValidators.cs
new file mode 100644
index 0000000..8db0b53
--- /dev/null
+++ b/src/PipeRAG.Api/Validators/WidgetValidators.cs
@@ -0,0 +1,45 @@
+using FluentValidation;
+using PipeRAG.Core.DTOs;
+
+namespace PipeRAG.Api.Validators;
+
+public class WidgetConfigRequestValidator : AbstractValidator
+{
+ private static readonly string[] ValidPositions = ["bottom-right", "bottom-left"];
+ private static readonly System.Text.RegularExpressions.Regex HexColorRegex = new(@"^#[0-9a-fA-F]{6}$");
+
+ public WidgetConfigRequestValidator()
+ {
+ RuleFor(x => x.PrimaryColor)
+ .Must(c => c is null || HexColorRegex.IsMatch(c))
+ .WithMessage("PrimaryColor must be a valid hex color (e.g. #6366f1).");
+
+ RuleFor(x => x.BackgroundColor)
+ .Must(c => c is null || HexColorRegex.IsMatch(c))
+ .WithMessage("BackgroundColor must be a valid hex color.");
+
+ RuleFor(x => x.TextColor)
+ .Must(c => c is null || HexColorRegex.IsMatch(c))
+ .WithMessage("TextColor must be a valid hex color.");
+
+ RuleFor(x => x.Position)
+ .Must(p => p is null || ValidPositions.Contains(p))
+ .WithMessage("Position must be 'bottom-right' or 'bottom-left'.");
+
+ RuleFor(x => x.Title)
+ .MaximumLength(100).When(x => x.Title is not null);
+
+ RuleFor(x => x.Subtitle)
+ .MaximumLength(200).When(x => x.Subtitle is not null);
+
+ RuleFor(x => x.PlaceholderText)
+ .MaximumLength(100).When(x => x.PlaceholderText is not null);
+
+ RuleFor(x => x.AvatarUrl)
+ .Must(u => u is null || Uri.TryCreate(u, UriKind.Absolute, out _))
+ .WithMessage("AvatarUrl must be a valid URL.");
+
+ RuleFor(x => x.AllowedOrigins)
+ .MaximumLength(1000).When(x => x.AllowedOrigins is not null);
+ }
+}
diff --git a/src/PipeRAG.Infrastructure/Services/StripeService.cs b/src/PipeRAG.Infrastructure/Services/StripeService.cs
index b226eb6..db690b2 100644
--- a/src/PipeRAG.Infrastructure/Services/StripeService.cs
+++ b/src/PipeRAG.Infrastructure/Services/StripeService.cs
@@ -165,8 +165,8 @@ private async Task HandleSubscriptionUpdated(Event stripeEvent)
"trialing" => SubscriptionStatus.Trialing,
_ => SubscriptionStatus.Active
};
- sub.CurrentPeriodStart = stripeSub.CurrentPeriodStart;
- sub.CurrentPeriodEnd = stripeSub.CurrentPeriodEnd;
+ sub.CurrentPeriodStart = stripeSub.Items?.Data?.FirstOrDefault()?.CurrentPeriodStart ?? DateTime.UtcNow;
+ sub.CurrentPeriodEnd = stripeSub.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd ?? DateTime.UtcNow.AddMonths(1);
sub.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
diff --git a/tests/PipeRAG.Tests/PublicWidgetControllerTests.cs b/tests/PipeRAG.Tests/PublicWidgetControllerTests.cs
new file mode 100644
index 0000000..a2dee7e
--- /dev/null
+++ b/tests/PipeRAG.Tests/PublicWidgetControllerTests.cs
@@ -0,0 +1,127 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using PipeRAG.Api.Controllers;
+using PipeRAG.Core.Entities;
+using PipeRAG.Infrastructure.Data;
+
+namespace PipeRAG.Tests;
+
+public class PublicWidgetControllerTests : IDisposable
+{
+ private readonly PipeRagDbContext _db;
+ private readonly PublicWidgetController _controller;
+ private readonly Guid _userId = Guid.NewGuid();
+ private readonly Guid _projectId = Guid.NewGuid();
+ private readonly string _apiKeyPrefix = "pk_test1";
+
+ public PublicWidgetControllerTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"PublicWidgetTest_{Guid.NewGuid()}")
+ .Options;
+ _db = new PipeRagDbContext(options);
+
+ _db.Users.Add(new User { Id = _userId, Email = "test@test.com", DisplayName = "Test", PasswordHash = "hash" });
+ _db.Projects.Add(new Project { Id = _projectId, Name = "Test", OwnerId = _userId });
+ _db.ApiKeys.Add(new ApiKey
+ {
+ Id = Guid.NewGuid(), UserId = _userId, Name = "Widget Key",
+ KeyPrefix = _apiKeyPrefix, KeyHash = "hash123", IsActive = true
+ });
+ _db.WidgetConfigs.Add(new WidgetConfig
+ {
+ ProjectId = _projectId, PrimaryColor = "#6366f1", IsActive = true,
+ AllowedOrigins = "*"
+ });
+ _db.SaveChanges();
+
+ _controller = new PublicWidgetController(_db);
+ _controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext()
+ };
+ }
+
+ [Fact]
+ public async Task GetConfig_ReturnsBadRequest_WithoutApiKey()
+ {
+ var result = await _controller.GetConfig(_projectId, "", CancellationToken.None);
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task GetConfig_ReturnsNotFound_InvalidProject()
+ {
+ var result = await _controller.GetConfig(Guid.NewGuid(), _apiKeyPrefix + "suffix", CancellationToken.None);
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task GetConfig_ReturnsUnauthorized_InvalidApiKey()
+ {
+ var result = await _controller.GetConfig(_projectId, "bad_key_suffix", CancellationToken.None);
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task GetConfig_ReturnsConfig_WithValidKey()
+ {
+ var result = await _controller.GetConfig(_projectId, _apiKeyPrefix + "suffix", CancellationToken.None);
+ result.Should().BeOfType();
+ var config = (result as OkObjectResult)!.Value as WidgetPublicConfigResponse;
+ config!.PrimaryColor.Should().Be("#6366f1");
+ }
+
+ [Fact]
+ public async Task GetConfig_ReturnsNotFound_WhenDisabled()
+ {
+ var widget = await _db.WidgetConfigs.FirstAsync(w => w.ProjectId == _projectId);
+ widget.IsActive = false;
+ await _db.SaveChangesAsync();
+
+ var result = await _controller.GetConfig(_projectId, _apiKeyPrefix + "suffix", CancellationToken.None);
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task Chat_ReturnsBadRequest_EmptyMessage()
+ {
+ var result = await _controller.Chat(_projectId, _apiKeyPrefix + "suffix",
+ new WidgetChatRequest(""), CancellationToken.None);
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task Chat_ReturnsOk_WithValidRequest()
+ {
+ var result = await _controller.Chat(_projectId, _apiKeyPrefix + "suffix",
+ new WidgetChatRequest("Hello"), CancellationToken.None);
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task Chat_ReturnsUnauthorized_InvalidKey()
+ {
+ var result = await _controller.Chat(_projectId, "bad_key_suffix",
+ new WidgetChatRequest("Hello"), CancellationToken.None);
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task GetEmbedScript_ReturnsJavaScript()
+ {
+ _controller.ControllerContext.HttpContext.Request.Scheme = "https";
+ _controller.ControllerContext.HttpContext.Request.Host = new HostString("example.com");
+
+ var result = _controller.GetEmbedScript();
+ result.Should().BeOfType();
+ var content = result as ContentResult;
+ content!.ContentType.Should().Be("application/javascript");
+ content.Content.Should().Contain("piperag_widget_loaded");
+ content.Content.Should().Contain("https://example.com");
+ }
+
+ public void Dispose() => _db.Dispose();
+}
diff --git a/tests/PipeRAG.Tests/WidgetControllerTests.cs b/tests/PipeRAG.Tests/WidgetControllerTests.cs
new file mode 100644
index 0000000..eab1b96
--- /dev/null
+++ b/tests/PipeRAG.Tests/WidgetControllerTests.cs
@@ -0,0 +1,154 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using PipeRAG.Api.Controllers;
+using PipeRAG.Core.DTOs;
+using PipeRAG.Core.Entities;
+using PipeRAG.Infrastructure.Data;
+using System.Security.Claims;
+
+namespace PipeRAG.Tests;
+
+public class WidgetControllerTests : IDisposable
+{
+ private readonly PipeRagDbContext _db;
+ private readonly WidgetController _controller;
+ private readonly Guid _userId = Guid.NewGuid();
+ private readonly Guid _projectId = Guid.NewGuid();
+
+ public WidgetControllerTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"WidgetTest_{Guid.NewGuid()}")
+ .Options;
+ _db = new PipeRagDbContext(options);
+
+ _db.Users.Add(new User { Id = _userId, Email = "test@test.com", DisplayName = "Test", PasswordHash = "hash" });
+ _db.Projects.Add(new Project { Id = _projectId, Name = "Test Project", OwnerId = _userId });
+ _db.SaveChanges();
+
+ _controller = new WidgetController(_db);
+ SetUser(_userId);
+ }
+
+ private void SetUser(Guid userId)
+ {
+ var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId.ToString()) };
+ _controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext
+ {
+ User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"))
+ }
+ };
+ }
+
+ [Fact]
+ public async Task Get_ReturnsNotFound_WhenNoConfig()
+ {
+ var result = await _controller.Get(_projectId, CancellationToken.None);
+ result.Result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task Get_ReturnsNotFound_WhenProjectNotOwned()
+ {
+ SetUser(Guid.NewGuid());
+ var result = await _controller.Get(_projectId, CancellationToken.None);
+ result.Result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task Upsert_CreatesNewConfig()
+ {
+ var request = new WidgetConfigRequest(PrimaryColor: "#ff0000", Title: "My Widget");
+ var result = await _controller.Upsert(_projectId, request, CancellationToken.None);
+ result.Result.Should().BeOfType();
+
+ var config = await _db.WidgetConfigs.FirstOrDefaultAsync(w => w.ProjectId == _projectId);
+ config.Should().NotBeNull();
+ config!.PrimaryColor.Should().Be("#ff0000");
+ config.Title.Should().Be("My Widget");
+ }
+
+ [Fact]
+ public async Task Upsert_UpdatesExistingConfig()
+ {
+ _db.WidgetConfigs.Add(new WidgetConfig { ProjectId = _projectId, PrimaryColor = "#000000" });
+ await _db.SaveChangesAsync();
+
+ var request = new WidgetConfigRequest(PrimaryColor: "#ff0000");
+ await _controller.Upsert(_projectId, request, CancellationToken.None);
+
+ var config = await _db.WidgetConfigs.FirstOrDefaultAsync(w => w.ProjectId == _projectId);
+ config!.PrimaryColor.Should().Be("#ff0000");
+ }
+
+ [Fact]
+ public async Task Upsert_OnlyUpdatesProvidedFields()
+ {
+ _db.WidgetConfigs.Add(new WidgetConfig
+ {
+ ProjectId = _projectId, PrimaryColor = "#000000", Title = "Original",
+ BackgroundColor = "#111111"
+ });
+ await _db.SaveChangesAsync();
+
+ var request = new WidgetConfigRequest(PrimaryColor: "#ff0000");
+ await _controller.Upsert(_projectId, request, CancellationToken.None);
+
+ var config = await _db.WidgetConfigs.FirstOrDefaultAsync(w => w.ProjectId == _projectId);
+ config!.PrimaryColor.Should().Be("#ff0000");
+ config.Title.Should().Be("Original");
+ config.BackgroundColor.Should().Be("#111111");
+ }
+
+ [Fact]
+ public async Task Upsert_ReturnsNotFound_ForNonOwnedProject()
+ {
+ SetUser(Guid.NewGuid());
+ var request = new WidgetConfigRequest(PrimaryColor: "#ff0000");
+ var result = await _controller.Upsert(_projectId, request, CancellationToken.None);
+ result.Result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task Delete_RemovesConfig()
+ {
+ _db.WidgetConfigs.Add(new WidgetConfig { ProjectId = _projectId });
+ await _db.SaveChangesAsync();
+
+ var result = await _controller.Delete(_projectId, CancellationToken.None);
+ result.Should().BeOfType();
+
+ var config = await _db.WidgetConfigs.FirstOrDefaultAsync(w => w.ProjectId == _projectId);
+ config.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task Delete_ReturnsNotFound_WhenNoConfig()
+ {
+ var result = await _controller.Delete(_projectId, CancellationToken.None);
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task Get_ReturnsConfig_AfterUpsert()
+ {
+ var request = new WidgetConfigRequest(
+ PrimaryColor: "#abcdef",
+ Position: "bottom-left",
+ Title: "Test Chat");
+ await _controller.Upsert(_projectId, request, CancellationToken.None);
+
+ var result = await _controller.Get(_projectId, CancellationToken.None);
+ result.Result.Should().BeOfType();
+ var response = (result.Result as OkObjectResult)!.Value as WidgetConfigResponse;
+ response!.PrimaryColor.Should().Be("#abcdef");
+ response.Position.Should().Be("bottom-left");
+ response.Title.Should().Be("Test Chat");
+ }
+
+ public void Dispose() => _db.Dispose();
+}
diff --git a/tests/PipeRAG.Tests/WidgetValidationTests.cs b/tests/PipeRAG.Tests/WidgetValidationTests.cs
new file mode 100644
index 0000000..48295c4
--- /dev/null
+++ b/tests/PipeRAG.Tests/WidgetValidationTests.cs
@@ -0,0 +1,81 @@
+using FluentAssertions;
+using FluentValidation.TestHelper;
+using PipeRAG.Api.Validators;
+using PipeRAG.Core.DTOs;
+
+namespace PipeRAG.Tests;
+
+public class WidgetValidationTests
+{
+ private readonly WidgetConfigRequestValidator _validator = new();
+
+ [Theory]
+ [InlineData("#6366f1")]
+ [InlineData("#000000")]
+ [InlineData("#FFFFFF")]
+ [InlineData(null)]
+ public void ValidColors_ShouldPass(string? color)
+ {
+ var request = new WidgetConfigRequest(PrimaryColor: color);
+ var result = _validator.TestValidate(request);
+ result.ShouldNotHaveValidationErrorFor(x => x.PrimaryColor);
+ }
+
+ [Theory]
+ [InlineData("red")]
+ [InlineData("#fff")]
+ [InlineData("#gggggg")]
+ [InlineData("6366f1")]
+ public void InvalidColors_ShouldFail(string color)
+ {
+ var request = new WidgetConfigRequest(PrimaryColor: color);
+ var result = _validator.TestValidate(request);
+ result.ShouldHaveValidationErrorFor(x => x.PrimaryColor);
+ }
+
+ [Theory]
+ [InlineData("bottom-right")]
+ [InlineData("bottom-left")]
+ [InlineData(null)]
+ public void ValidPositions_ShouldPass(string? position)
+ {
+ var request = new WidgetConfigRequest(Position: position);
+ var result = _validator.TestValidate(request);
+ result.ShouldNotHaveValidationErrorFor(x => x.Position);
+ }
+
+ [Theory]
+ [InlineData("top-right")]
+ [InlineData("center")]
+ [InlineData("left")]
+ public void InvalidPositions_ShouldFail(string position)
+ {
+ var request = new WidgetConfigRequest(Position: position);
+ var result = _validator.TestValidate(request);
+ result.ShouldHaveValidationErrorFor(x => x.Position);
+ }
+
+ [Fact]
+ public void TitleTooLong_ShouldFail()
+ {
+ var request = new WidgetConfigRequest(Title: new string('a', 101));
+ var result = _validator.TestValidate(request);
+ result.ShouldHaveValidationErrorFor(x => x.Title);
+ }
+
+ [Fact]
+ public void ValidAvatarUrl_ShouldPass()
+ {
+ var request = new WidgetConfigRequest(AvatarUrl: "https://example.com/avatar.png");
+ var result = _validator.TestValidate(request);
+ result.ShouldNotHaveValidationErrorFor(x => x.AvatarUrl);
+ }
+
+ [Fact]
+ public void InvalidAvatarUrl_ShouldFail()
+ {
+ var request = new WidgetConfigRequest(AvatarUrl: "not-a-url");
+ var result = _validator.TestValidate(request);
+ result.ShouldHaveValidationErrorFor(x => x.AvatarUrl);
+ }
+}