using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using TeamUp.Modules.Skills.Domain;
using TeamUp.Modules.Skills.Indexing;
using Xunit;
namespace TeamUp.IntegrationTests;
///
/// The skill marketplace, end to end: the publish gate (only golden-tested may list), own-org
/// exclusion, another org's published skill listed and installed as a private copy, the duplicate
/// conflict, and ManageSkills authorization. One flow per class — bootstrap is single-use.
///
public sealed class SkillMarketplaceTests(PostgresFixture postgres) : IClassFixture
{
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
private sealed record AuthResponse(string Token, Guid MemberId);
private sealed record InviteResponse(Guid InvitationId, string Token);
private sealed record ActionDto(string Name, string Risk, string? Description);
private sealed record GoldenTestDto(string Input, string Expected);
private sealed record SkillSummary(
Guid Id, string SkillKey, string Name, string Version, string? Summary, List Roles,
string Visibility, string MinTier, string Status, string Origin, Guid? OrganizationId,
int GoldenTestCount, List Actions);
private sealed record SkillDetail(
SkillSummary Skill, string? Inputs, string? Outputs, List Tools,
List Context, List GoldenTests, string Body);
private sealed record MarketplaceEntry(SkillSummary Skill, bool AlreadyInLibrary);
[Fact]
public async Task Publishes_lists_installs_with_gate_own_exclusion_and_authorization()
{
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
using var anon = factory.CreateClient();
var orgA = await PostOk(anon, "/api/identity/bootstrap", new
{
organizationName = "OrgA",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
using var client = Authed(factory, orgA.Token);
// Gate: a Draft (no golden test) cannot be listed on the marketplace.
await PostOk(client, "/api/skills/authored", Authored(orgA.OrganizationId, "draft-skill", goldenTested: false));
var cantPublish = await client.PostAsJsonAsync("/api/skills/draft-skill/publish",
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
Assert.Equal(HttpStatusCode.BadRequest, cantPublish.StatusCode);
// A published skill lists fine; visibility flips to Public, but it never shows in its own org's marketplace.
await PostOk(client, "/api/skills/authored", Authored(orgA.OrganizationId, "prod-skill", goldenTested: true));
var published = await PostOk(client, "/api/skills/prod-skill/publish",
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
Assert.Equal("Public", published.Skill.Visibility);
var ownMarket = await client.GetFromJsonAsync>(
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
Assert.DoesNotContain(ownMarket!, e => e.Skill.SkillKey == "prod-skill");
// You can't install your own skill.
var prod = await client.GetFromJsonAsync>(
$"/api/skills/prod-skill?organizationId={orgA.OrganizationId}");
var installOwn = await client.PostAsJsonAsync("/api/skills/install",
new { organizationId = orgA.OrganizationId, sourceSkillId = prod!.Single().Skill.Id });
Assert.Equal(HttpStatusCode.BadRequest, installOwn.StatusCode);
// Another org's published skill (no second-org onboarding yet — seed it via the indexer).
var orgB = Guid.NewGuid();
var sourceId = await SeedPublishedSkillAsync(factory, orgB, "research-brief", "Research Brief");
// Org A sees org B's skill on the marketplace, not yet in its library.
var market = await client.GetFromJsonAsync>(
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
var entry = Assert.Single(market!, e => e.Skill.SkillKey == "research-brief");
Assert.False(entry.AlreadyInLibrary);
Assert.Equal(sourceId, entry.Skill.Id);
// Installing lands a private, Installed copy in org A's namespace.
var installed = await PostOk(client, "/api/skills/install",
new { organizationId = orgA.OrganizationId, sourceSkillId = sourceId });
Assert.Equal("Installed", installed.Skill.Origin);
Assert.Equal(orgA.OrganizationId, installed.Skill.OrganizationId);
Assert.Equal("PrivateToOrg", installed.Skill.Visibility);
Assert.Equal("research-brief", installed.Skill.SkillKey);
// Now it's in the library (flagged), and re-installing the same version is a conflict.
var market2 = await client.GetFromJsonAsync>(
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
Assert.True(Assert.Single(market2!, e => e.Skill.SkillKey == "research-brief").AlreadyInLibrary);
var dup = await client.PostAsJsonAsync("/api/skills/install",
new { organizationId = orgA.OrganizationId, sourceSkillId = sourceId });
Assert.Equal(HttpStatusCode.Conflict, dup.StatusCode);
// A newer version of an already-installed key stays installable — the flag is per (key, version).
var sourceV2 = await SeedPublishedSkillAsync(factory, orgB, "research-brief", "Research Brief", "2.0.0");
var market3 = await client.GetFromJsonAsync>(
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
Assert.False(Assert.Single(market3!, e => e.Skill.Id == sourceV2).AlreadyInLibrary);
Assert.True(Assert.Single(market3!, e => e.Skill.SkillKey == "research-brief" && e.Skill.Version == "1.0.0").AlreadyInLibrary);
// Invariant: re-authoring a listed version without golden tests cannot leave it Public.
await PostOk(client, "/api/skills/authored", Authored(orgA.OrganizationId, "regate-skill", goldenTested: true));
await PostOk(client, "/api/skills/regate-skill/publish",
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
var degated = await PostOk(client, "/api/skills/authored",
Authored(orgA.OrganizationId, "regate-skill", goldenTested: false, visibility: "public"));
Assert.Equal("Draft", degated.Skill.Status);
Assert.Equal("PrivateToOrg", degated.Skill.Visibility);
// A plain Member cannot publish/unpublish (ManageSkills).
var invite = await PostOk(client, "/api/identity/invitations", new
{
email = "member@alia.test",
scopeType = "Organization",
scopeId = orgA.OrganizationId,
role = "Member",
organizationId = orgA.OrganizationId,
});
var member = await PostOk(anon, "/api/identity/invitations/accept",
new { token = invite.Token, displayName = "Member", password = "Passw0rd!" });
using var memberClient = Authed(factory, member.Token);
var forbidden = await memberClient.PostAsJsonAsync("/api/skills/prod-skill/unpublish",
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
}
private static object Authored(Guid organizationId, string key, bool goldenTested, string visibility = "private") => new
{
organizationId,
skillKey = key,
name = key,
version = "1.0.0",
summary = "x",
roles = new[] { "engineer" },
inputs = (string?)null,
outputs = (string?)null,
actions = Array.Empty(),
tools = Array.Empty(),
context = Array.Empty(),
visibility,
minTier = "free",
body = "Do the thing.",
goldenTests = goldenTested
? new[] { new GoldenTestDto("in", "out") }
: Array.Empty(),
};
// No public second-org onboarding yet, so seed the "other org's" published skill directly through
// the same indexer the authoring endpoint uses (InternalsVisibleTo grants the test access).
private static async Task SeedPublishedSkillAsync(
TeamUpWebFactory factory, Guid orgId, string key, string name, string version = "1.0.0")
{
using var scope = factory.Services.CreateScope();
var indexer = scope.ServiceProvider.GetRequiredService();
var manifest = new SkillManifest
{
Id = key,
Name = name,
Version = version,
Summary = "A shared, published skill.",
Roles = ["analyst"],
Visibility = "public",
GoldenTests = [new GoldenExample { Input = "a topic", Expected = "a brief" }],
};
var skill = await indexer.IndexAsync(manifest, "Write a research brief.", SkillOwnership.Authored(orgId, Guid.NewGuid()));
return skill.Id;
}
private static HttpClient Authed(TeamUpWebFactory factory, string token)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static async Task PostOk(HttpClient client, string url, object body)
{
var response = await client.PostAsJsonAsync(url, body);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var value = await response.Content.ReadFromJsonAsync();
Assert.NotNull(value);
return value!;
}
}