Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/SkillServer/Endpoints.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="Endpoints.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -232,6 +232,15 @@ private static async Task<IResult> UploadSkill(

if (!result.Success)
{
if (result.IsDuplicateVersion)
{
return Results.Conflict(new ErrorResponse
{
Error = "duplicate_version",
Message = result.Error ?? "Version already exists."
});
}

return Results.BadRequest(new ErrorResponse
{
Error = "upload_failed",
Expand Down
8 changes: 6 additions & 2 deletions src/SkillServer/Services/SkillUploadService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="SkillUploadService.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -91,7 +91,7 @@ public async Task<SkillUploadResult> UploadSkillMdAsync(
var existingVersion = await _repository.GetVersionAsync(skillId, version.Value, ct);
if (existingVersion is not null)
{
return SkillUploadResult.Failed($"Version {version.Value} already exists for skill {name.Value}.");
return SkillUploadResult.DuplicateVersion($"Version {version.Value} already exists for skill {name.Value}.");
}
}

Expand Down Expand Up @@ -181,12 +181,16 @@ public sealed record SkillUploadResult
public SkillVersionString? Version { get; init; }
public Sha256Digest? Digest { get; init; }
public string? Error { get; init; }
public bool IsDuplicateVersion { get; init; }

public static SkillUploadResult Succeeded(SkillName name, SkillVersionString version, Sha256Digest digest) =>
new() { Success = true, Name = name, Version = version, Digest = digest };

public static SkillUploadResult Failed(string error) =>
new() { Success = false, Error = error };

public static SkillUploadResult DuplicateVersion(string error) =>
new() { Success = false, Error = error, IsDuplicateVersion = true };
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="SkillServerIntegrationTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -481,4 +481,94 @@ public async Task ApiKeyManagement_WithoutAuth_Returns401()
var response = await _fixture.HttpClient.GetAsync("/api-keys", ct);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task UploadSkill_DuplicateVersion_Returns409()
{
var ct = TestContext.Current.CancellationToken;
var skillName = $"dup-test-{Guid.NewGuid():N}"[..20];

var skillContent = $"""
---
name: {skillName}
description: Testing duplicate version
---

# Duplicate Test
""";

// Upload the first version
using var content1 = new MultipartFormDataContent();
content1.Add(new StringContent(skillName), "name");
content1.Add(new StringContent("1.0.0"), "version");

var fileContent1 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
fileContent1.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
content1.Add(fileContent1, "file", "SKILL.md");

var uploadResponse1 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content1, ct);
Assert.Equal(HttpStatusCode.Created, uploadResponse1.StatusCode);

// Upload the same version again - should return 409 Conflict
using var content2 = new MultipartFormDataContent();
content2.Add(new StringContent(skillName), "name");
content2.Add(new StringContent("1.0.0"), "version");

var fileContent2 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
fileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
content2.Add(fileContent2, "file", "SKILL.md");

var uploadResponse2 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content2, ct);
Assert.Equal(HttpStatusCode.Conflict, uploadResponse2.StatusCode);

// Verify the response body contains the duplicate_version error
var errorBody = await uploadResponse2.Content.ReadFromJsonAsync<SkillServer.Models.ErrorResponse>(ct);
Assert.NotNull(errorBody);
Assert.Equal("duplicate_version", errorBody.Error);
Assert.Contains("already exists", errorBody.Message!);
}

[Fact]
public async Task UploadSkill_DifferentVersions_AreAllowed()
{
var ct = TestContext.Current.CancellationToken;
var skillName = $"ver-test-{Guid.NewGuid():N}"[..20];

var skillContent = $"""
---
name: {skillName}
description: Testing different versions
---

# Version Test
""";

// Upload v1.0.0
using var content1 = new MultipartFormDataContent();
content1.Add(new StringContent(skillName), "name");
content1.Add(new StringContent("1.0.0"), "version");

var fileContent1 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
fileContent1.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
content1.Add(fileContent1, "file", "SKILL.md");

var uploadResponse1 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content1, ct);
Assert.Equal(HttpStatusCode.Created, uploadResponse1.StatusCode);

// Upload v2.0.0 - should succeed
using var content2 = new MultipartFormDataContent();
content2.Add(new StringContent(skillName), "name");
content2.Add(new StringContent("2.0.0"), "version");

var fileContent2 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
fileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
content2.Add(fileContent2, "file", "SKILL.md");

var uploadResponse2 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content2, ct);
Assert.Equal(HttpStatusCode.Created, uploadResponse2.StatusCode);

// Verify both versions exist
var versions = await _fixture.Client.GetSkillVersionsAsync(skillName, ct);
Assert.Equal(2, versions.Count);
}
}
Loading