Move org switcher to header, self-host Inter, add feature tags, normalize API route casing

- Replace sidebar org dropdown with a header link/menu (left of theme toggle) that
  includes a "New Organization" entry opening the create dialog
- Self-host Inter via @fontsource-variable/inter instead of relying on system fonts
- Wire up real feature<->tag assignment (was UI-only before): expose Tags on
  FeatureResponse, add assign/remove endpoints, make tag chips in the feature
  detail dialog toggle assignment, and show up to 5 tags (+ellipsis) in the
  features table next to the segment-override icon
- Normalize all API routes to singular/plural REST convention: singular resource
  name when addressing one item by id/key (e.g. /feature/{id}), plural for
  list/create endpoints (e.g. /features). Updated every controller, the admin
  frontend API clients, and affected tests to match
This commit is contained in:
2026-06-30 13:36:18 -07:00
parent 5179f5479c
commit a1de374d22
43 changed files with 564 additions and 186 deletions

View File

@@ -97,7 +97,7 @@ public class AdminApiIntegrationTests
db.Projects.Add(project);
db.SaveChanges();
var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
{
name = "dark_mode",
type = "Standard",
@@ -135,7 +135,7 @@ public class AdminApiIntegrationTests
});
db.SaveChanges();
await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
{
name = "flag_x",
type = "Standard",
@@ -220,7 +220,7 @@ public class AdminApiIntegrationTests
db.Projects.Add(project);
db.SaveChanges();
var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new
{
name = "invalid name with spaces!",
type = "Standard"
@@ -247,7 +247,7 @@ public class AdminApiIntegrationTests
db.Projects.Add(project);
db.SaveChanges();
var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/segments", new
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/segments", new
{
name = "Premium Users",
rules = new[]

View File

@@ -46,7 +46,7 @@ public class ApiKeyAuthenticationHandlerTests
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -58,7 +58,7 @@ public class ApiKeyAuthenticationHandlerTests
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "not-a-jwt");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -69,7 +69,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -95,7 +95,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -122,7 +122,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -135,7 +135,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}

View File

@@ -46,7 +46,7 @@ public class ApiKeyAuthenticationHandlerTests
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -58,7 +58,7 @@ public class ApiKeyAuthenticationHandlerTests
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "not-a-jwt");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -69,7 +69,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -95,7 +95,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -122,7 +122,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -135,7 +135,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}

View File

@@ -137,4 +137,63 @@ public class FeatureServiceTests
Assert.That(features, Has.Count.EqualTo(2));
}
[Test]
public async Task WhenAssigningATag_ThenItAppearsOnTheFeature()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Tags.Add(tag);
await _db.SaveChangesAsync();
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
Assert.That(updated.Tags.Select(t => t.Id), Does.Contain(tag.Id));
}
[Test]
public async Task WhenAssigningATagAlreadyOnTheFeature_ThenItIsNotDuplicated()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Tags.Add(tag);
await _db.SaveChangesAsync();
await _service.AssignTagAsync(feature.Id, tag.Id);
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
Assert.That(updated.Tags.Count(t => t.Id == tag.Id), Is.EqualTo(1));
}
[Test]
public async Task WhenAssigningATagFromAnotherProject_ThenDomainExceptionIsThrown()
{
_db.Projects.Add(new MicCheck.Api.Projects.Project
{
Id = 2,
Name = "Other Project",
OrganizationId = OrganizationId,
CreatedAt = DateTimeOffset.UtcNow
});
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var foreignTag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = 2 };
_db.Tags.Add(foreignTag);
await _db.SaveChangesAsync();
Assert.ThrowsAsync<DomainException>(() => _service.AssignTagAsync(feature.Id, foreignTag.Id));
}
[Test]
public async Task WhenRemovingAnAssignedTag_ThenItNoLongerAppearsOnTheFeature()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Tags.Add(tag);
await _db.SaveChangesAsync();
await _service.AssignTagAsync(feature.Id, tag.Id);
var updated = await _service.RemoveTagAsync(feature.Id, tag.Id);
Assert.That(updated.Tags.Select(t => t.Id), Does.Not.Contain(tag.Id));
}
}

View File

@@ -115,7 +115,7 @@ public class FlagsApiIntegrationTests
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
var response = await client.PostAsJsonAsync("/api/v1/identities/", new
var response = await client.PostAsJsonAsync("/api/v1/identity/", new
{
identifier = "user-123",
traits = new[] { new { trait_key = "plan", trait_value = "premium" } }