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
10 changes: 9 additions & 1 deletion InterlinedList.Contract.Tests/EndpointInventoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ public sealed class EndpointInventoryTests
private static readonly Regex PathLiteral = new("\"\\$?(api/[^\"]*)\"", RegexOptions.Compiled);
private static readonly Regex HttpVerb = new(@"HttpMethod\.(Get|Post|Put|Patch|Delete)", RegexOptions.Compiled);
private static readonly Regex Interpolation = new(@"\{Uri\.EscapeDataString\(([A-Za-z0-9_]+)\)\}", RegexOptions.Compiled);
private static readonly Regex GetHelper = new(@"\bGet[A-Za-z]*Async\s*[<(]", RegexOptions.Compiled);
/// <summary>
/// A read helper, by name. <c>Get…Async</c> covers the shared plumbing
/// (<c>GetJsonAsync</c>, <c>GetElementAsync</c>, <c>GetStringAsync</c>,
/// <c>GetUserArrayAsync</c>) and most domain helpers; <c>Read…Async</c>
/// covers the ones that read more naturally that way, like
/// <c>ReadTagsAsync</c>. Both unambiguously denote a GET.
/// </summary>
private static readonly Regex GetHelper =
new(@"\b(Get|Read)[A-Za-z]*Async\s*[<(]", RegexOptions.Compiled);

/// <summary>
/// A helper whose NAME carries its verb — <c>Get…Async</c>, <c>Put…Async</c>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,18 @@
AppLog.cs -> System.Diagnostics, Windows Event Log
CredentialStore.cs -> System.Security.Cryptography, DPAPI
SessionService.cs -> CommunityToolkit.Mvvm
AppServices.cs -> transitively needs SessionService
AiAvailabilityService.cs -> CommunityToolkit.Mvvm
AppServices.cs -> transitively needs the two above
ProfileLocationProvider.cs-> transitively needs SessionService
GitHubListIndex.cs -> uses AppServices and AppLog
If you add a file needing WPF, DPAPI, the Event Log or the MVVM toolkit
— or one that merely references such a file — exclude it here too. And
prefer not to: portability is what lets this suite run on
ubuntu-latest, and it doubles as a standing check that the client
stayed portable. -->
<Compile Include="..\InterlinedList\Models\*.cs" LinkBase="AppSource\Models" />
<Compile Include="..\InterlinedList\Services\*.cs" LinkBase="AppSource\Services"
Exclude="..\InterlinedList\Services\AppLog.cs;..\InterlinedList\Services\CredentialStore.cs;..\InterlinedList\Services\SessionService.cs;..\InterlinedList\Services\AppServices.cs;..\InterlinedList\Services\ProfileLocationProvider.cs" />
Exclude="..\InterlinedList\Services\AppLog.cs;..\InterlinedList\Services\CredentialStore.cs;..\InterlinedList\Services\SessionService.cs;..\InterlinedList\Services\AiAvailabilityService.cs;..\InterlinedList\Services\AppServices.cs;..\InterlinedList\Services\ProfileLocationProvider.cs;..\InterlinedList\Services\GitHubListIndex.cs" />
</ItemGroup>

<!-- The endpoint-inventory test reads the client sources as *text* to rediscover
Expand Down
53 changes: 53 additions & 0 deletions InterlinedList.Contract.Tests/ReadContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,59 @@ internal static class ReadProbes
return $"{page.Organizations.Count} organizations (pagination={(page.Pagination is null ? "absent, as the model allows" : "present")})";
},

// ── Added 2026-09-24 (second batch) ─────────────────────────────────

["GET api/folders"] = async e =>
{
var folders = await e.Client.GetListFoldersAsync();
// Nesting is expressed only through parentId — the payload is flat.
Assert.All(folders, f => Assert.False(string.IsNullOrWhiteSpace(f.Id)));
return $"{folders.Count} list folder(s), {folders.Count(f => f.IsRoot)} at root";
},

["GET api/tags/trending"] = async e =>
{
var tags = await e.Client.GetTrendingTagsAsync();
Assert.All(tags, t => Assert.False(string.IsNullOrWhiteSpace(t.Tag)));
return $"{tags.Count} trending tag(s)";
},

["GET api/tags/autocomplete"] = async e =>
{
// An empty q is a 400, so the client short-circuits it; use a
// single letter, which matches broadly without assuming content.
var tags = await e.Client.AutocompleteTagsAsync("a");
return $"{tags.Count} completion(s) for 'a'";
},

["GET api/link-metadata"] = async e =>
{
// A stable, long-lived URL — this asserts the unfurler answers,
// not that any particular site is up.
var link = await e.Client.GetLinkMetadataAsync("https://example.com/");
return link is null
? "no metadata returned for example.com"
: $"platform={link.Platform ?? "(none)"}, title={(link.Metadata?.Title is { Length: > 0 } t ? t : "(none)")}";
},

["GET api/documents/{documentId}"] = async e =>
{
var id = Require(e.Ids.DocumentId, "GET api/documents/{documentId}", "the account has no document to address");
var doc = await e.Client.GetDocumentAsync(id);
// The tree omits `content`; this is the only bulk source of a body,
// so the editor breaks if it stops carrying one.
Assert.False(string.IsNullOrWhiteSpace(doc.Id));
return $"document {doc.Title}, content {(doc.Content is null ? "absent" : $"{doc.Content.Length} chars")}";
},

["GET api/messages/{messageId}/metadata"] = async e =>
{
var id = Require(e.Ids.MessageId, "GET api/messages/{messageId}/metadata", "the feed returned no message to address");
var links = await e.Client.GetMessageLinkMetadataAsync(id);
// A message with no links legitimately returns an empty set.
return $"{links.Count} stored link preview(s)";
},

// ── Added 2026-09-24 with the parity merge pass ─────────────────────
// The inventory test demanded these: classifying an endpoint 'read'
// in the manifest without a probe here is a failure, deliberately.
Expand Down
25 changes: 25 additions & 0 deletions InterlinedList.Contract.Tests/endpoints.manifest.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,28 @@ POST api/auth/reset-password skip A successful reset would change the shared acc
POST api/auth/verify-email skip Consumes a single-use emailed token; no token is obtainable without a mailbox.
POST api/auth/verify-email-change skip Consumes a single-use emailed token, and would change the account's address.
POST api/auth/undo-email-change skip Would reverse a real pending email change.

# ── Added 2026-09-24 (second batch): blog, list folders, invites, tags,
# link metadata, weather/location, and the GitHub-backed list refresh.
# Same story as the batch above — the inventory test flagged every one.
GET api/folders read
GET api/tags/trending read
GET api/tags/autocomplete read
GET api/link-metadata read
GET api/documents/{documentId} read
GET api/messages/{messageId}/metadata read
GET api/weather skip Requires latitude/longitude and is US-only; the probe would assert against a real user's stored profile location.
GET api/location skip Same coordinate dependency as weather.
GET api/lists/{listId}/invites skip Owner-only and the suite owns no list; probing the account's single real list risks confusion with a user's own data.
GET api/documents/{documentId}/invites skip Owner-only; same reasoning as the list invites.
GET api/blog/subscribe/confirm skip Consumes a single-use token from a confirmation email.
POST api/folders skip Creates a real list folder (subscriber-gated).
PUT api/folders/{id} skip Renames or moves a real folder — and the server does NOT reject a cycle, so a careless move corrupts the tree.
DELETE api/folders/{id} skip Soft-deletes a folder AND its subfolders.
POST api/lists/{listId}/invites skip Sends a real invite email to a real address.
DELETE api/lists/{listId}/invites/{token} skip Revokes a real invite; nothing safe to revoke without first creating one.
POST api/documents/{documentId}/invites skip Sends a real invite email.
DELETE api/documents/{documentId}/invites/{token} skip Revokes a real invite.
POST api/lists/{listId}/refresh skip Re-syncs a GitHub-backed list from its repository; 400 on a local list, and the account owns no GitHub-backed list to refresh.
POST api/blog/subscribe skip Sends a real double-opt-in email to whatever address is passed.
POST api/blog/unsubscribe skip Needs a token from an email footer, and would unsubscribe a real address.
Loading