Skip to content

Commit 4cdb769

Browse files
committed
Return 409 Conflict for duplicate skill version uploads (#41)
Previously, uploading a skill version that already existed returned 400 Bad Request, making it hard for clients to distinguish between validation errors and benign duplicate uploads without parsing the response body. - Add IsDuplicateVersion flag and DuplicateVersion() factory to SkillUploadResult - Return 409 Conflict with error code 'duplicate_version' for duplicates - Add integration tests for 409 duplicate and multi-version success
1 parent a33478b commit 4cdb769

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

src/SkillServer/Endpoints.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// -----------------------------------------------------------------------
1+
// -----------------------------------------------------------------------
22
// <copyright file="Endpoints.cs" company="Petabridge, LLC">
33
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
44
// </copyright>
@@ -232,6 +232,15 @@ private static async Task<IResult> UploadSkill(
232232

233233
if (!result.Success)
234234
{
235+
if (result.IsDuplicateVersion)
236+
{
237+
return Results.Conflict(new ErrorResponse
238+
{
239+
Error = "duplicate_version",
240+
Message = result.Error ?? "Version already exists."
241+
});
242+
}
243+
235244
return Results.BadRequest(new ErrorResponse
236245
{
237246
Error = "upload_failed",

src/SkillServer/Services/SkillUploadService.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// -----------------------------------------------------------------------
1+
// -----------------------------------------------------------------------
22
// <copyright file="SkillUploadService.cs" company="Petabridge, LLC">
33
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
44
// </copyright>
@@ -91,7 +91,7 @@ public async Task<SkillUploadResult> UploadSkillMdAsync(
9191
var existingVersion = await _repository.GetVersionAsync(skillId, version.Value, ct);
9292
if (existingVersion is not null)
9393
{
94-
return SkillUploadResult.Failed($"Version {version.Value} already exists for skill {name.Value}.");
94+
return SkillUploadResult.DuplicateVersion($"Version {version.Value} already exists for skill {name.Value}.");
9595
}
9696
}
9797

@@ -181,12 +181,16 @@ public sealed record SkillUploadResult
181181
public SkillVersionString? Version { get; init; }
182182
public Sha256Digest? Digest { get; init; }
183183
public string? Error { get; init; }
184+
public bool IsDuplicateVersion { get; init; }
184185

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

188189
public static SkillUploadResult Failed(string error) =>
189190
new() { Success = false, Error = error };
191+
192+
public static SkillUploadResult DuplicateVersion(string error) =>
193+
new() { Success = false, Error = error, IsDuplicateVersion = true };
190194
}
191195

192196
/// <summary>

tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// -----------------------------------------------------------------------
1+
// -----------------------------------------------------------------------
22
// <copyright file="SkillServerIntegrationTests.cs" company="Petabridge, LLC">
33
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
44
// </copyright>
@@ -481,4 +481,94 @@ public async Task ApiKeyManagement_WithoutAuth_Returns401()
481481
var response = await _fixture.HttpClient.GetAsync("/api-keys", ct);
482482
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
483483
}
484+
485+
[Fact]
486+
public async Task UploadSkill_DuplicateVersion_Returns409()
487+
{
488+
var ct = TestContext.Current.CancellationToken;
489+
var skillName = $"dup-test-{Guid.NewGuid():N}"[..20];
490+
491+
var skillContent = $"""
492+
---
493+
name: {skillName}
494+
description: Testing duplicate version
495+
---
496+
497+
# Duplicate Test
498+
""";
499+
500+
// Upload the first version
501+
using var content1 = new MultipartFormDataContent();
502+
content1.Add(new StringContent(skillName), "name");
503+
content1.Add(new StringContent("1.0.0"), "version");
504+
505+
var fileContent1 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
506+
fileContent1.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
507+
content1.Add(fileContent1, "file", "SKILL.md");
508+
509+
var uploadResponse1 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content1, ct);
510+
Assert.Equal(HttpStatusCode.Created, uploadResponse1.StatusCode);
511+
512+
// Upload the same version again - should return 409 Conflict
513+
using var content2 = new MultipartFormDataContent();
514+
content2.Add(new StringContent(skillName), "name");
515+
content2.Add(new StringContent("1.0.0"), "version");
516+
517+
var fileContent2 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
518+
fileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
519+
content2.Add(fileContent2, "file", "SKILL.md");
520+
521+
var uploadResponse2 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content2, ct);
522+
Assert.Equal(HttpStatusCode.Conflict, uploadResponse2.StatusCode);
523+
524+
// Verify the response body contains the duplicate_version error
525+
var errorBody = await uploadResponse2.Content.ReadFromJsonAsync<SkillServer.Models.ErrorResponse>(ct);
526+
Assert.NotNull(errorBody);
527+
Assert.Equal("duplicate_version", errorBody.Error);
528+
Assert.Contains(errorBody.Message!, "already exists");
529+
}
530+
531+
[Fact]
532+
public async Task UploadSkill_DifferentVersions_AreAllowed()
533+
{
534+
var ct = TestContext.Current.CancellationToken;
535+
var skillName = $"ver-test-{Guid.NewGuid():N}"[..20];
536+
537+
var skillContent = $"""
538+
---
539+
name: {skillName}
540+
description: Testing different versions
541+
---
542+
543+
# Version Test
544+
""";
545+
546+
// Upload v1.0.0
547+
using var content1 = new MultipartFormDataContent();
548+
content1.Add(new StringContent(skillName), "name");
549+
content1.Add(new StringContent("1.0.0"), "version");
550+
551+
var fileContent1 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
552+
fileContent1.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
553+
content1.Add(fileContent1, "file", "SKILL.md");
554+
555+
var uploadResponse1 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content1, ct);
556+
Assert.Equal(HttpStatusCode.Created, uploadResponse1.StatusCode);
557+
558+
// Upload v2.0.0 - should succeed
559+
using var content2 = new MultipartFormDataContent();
560+
content2.Add(new StringContent(skillName), "name");
561+
content2.Add(new StringContent("2.0.0"), "version");
562+
563+
var fileContent2 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
564+
fileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
565+
content2.Add(fileContent2, "file", "SKILL.md");
566+
567+
var uploadResponse2 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content2, ct);
568+
Assert.Equal(HttpStatusCode.Created, uploadResponse2.StatusCode);
569+
570+
// Verify both versions exist
571+
var versions = await _fixture.Client.GetSkillVersionsAsync(skillName, ct);
572+
Assert.Equal(2, versions.Count);
573+
}
484574
}

0 commit comments

Comments
 (0)