• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Aldaviva / InoreaderCs / 25933685254

15 May 2026 06:07PM UTC coverage: 99.345% (-0.7%) from 100.0%
25933685254

push

github

Aldaviva
When determining if a label is either a tag or a folder and the result isn't cached, automatically refresh from the server immediately once, instead of always relying on the last periodically cached value. Avoid possible race between label cache being updated and read at the same time. Updated from xUnit 2 to 3.

212 of 250 branches covered (84.8%)

15 of 20 new or added lines in 4 files covered. (75.0%)

758 of 763 relevant lines covered (99.34%)

14.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.95
/InoreaderCs/Requests/ClientRequests.cs
1
using InoreaderCs.Entities;
2
using InoreaderCs.RateLimit;
3
using System.Net;
4
using System.Text;
5
using Unfucked.HTTP.Exceptions;
6

7
namespace InoreaderCs.Requests;
8

9
internal sealed partial class ClientRequests(InoreaderClient client):
55✔
10
    IInoreaderClient.IArticleMethods,
11
    IInoreaderClient.IFolderMethods,
12
    IInoreaderClient.INewsfeedMethods,
13
    IInoreaderClient.ISubscriptionMethods,
14
    IInoreaderClient.ITagMethods,
15
    IInoreaderClient.IUserMethods {
16

17
    private static readonly Encoding MessageEncoding = new UTF8Encoding(false, true);
1✔
18

19
    private IWebTarget ApiBase => client.ApiBase;
78✔
20

21
    internal event EventHandler<IEnumerable<StreamState>>? TagAndFolderStatesListed;
22

23
    /// <exception cref="InoreaderException"></exception>
24
    private async Task<DetailedArticles> ListArticlesDetailed(StreamId stream, int maxArticles, DateTimeOffset? minTime, StreamId? subtract, StreamId? intersect, PaginationToken? pagination,
25
                                                              bool sortAscending, bool showFolders, bool showAnnotations, CancellationToken cancellationToken) {
26
        try {
27
            Task<DetailedArticles> articlesTask = ApiBase
13!
28
                .Path("stream/contents/{streamId}")
13✔
29
                .ResolveTemplate("streamId", stream)
13✔
30
                .QueryParam("n", maxArticles.Clip(1, 200))
13✔
31
                .QueryParam("ot", minTime?.ToUnixTimeMicroseconds())
13✔
32
                .QueryParam("r", sortAscending ? "o" : null)
13✔
33
                .QueryParam("xt", subtract)
13✔
34
                .QueryParam("it", intersect)
13✔
35
                .QueryParam("c", pagination)
13✔
36
                .QueryParam("includeAllDirectStreamIds", showFolders)
13✔
37
                .QueryParam("annotations", Convert.ToInt32(showAnnotations)) // docs are wrong, "true" is ignored
13✔
38
                .Get<DetailedArticles>(cancellationToken);
13✔
39

40
            Task<LabelNameCache.Labels> labelNamesTask     = client.LabelNameCache.GetLabelNames(ct: cancellationToken);
13✔
41
            bool                        reloadedLabelNames = false;
13✔
42

43
            DetailedArticles      articles = await articlesTask.ConfigureAwait(false);
13✔
44
            LabelNameCache.Labels labels   = await labelNamesTask.ConfigureAwait(false);
12✔
45

46
            foreach (Article article in articles.Articles) {
48✔
47
                try {
48
                    article.SetCategories(labels);
12✔
49
                } catch (InoreaderException.UnknownLabel) when (!reloadedLabelNames) {
12✔
50
                    // If the cache of label names and types is outdated and missing this label, this method gets exactly one free do-over to re-request the label names, uncached.
NEW
51
                    labels             = await client.LabelNameCache.GetLabelNames(true, cancellationToken).ConfigureAwait(false);
×
NEW
52
                    reloadedLabelNames = true;
×
NEW
53
                    article.SetCategories(labels);
×
54
                }
55
            }
12✔
56

57
            return articles;
12✔
58
        } catch (HttpException e) {
59
            throw TransformError(e, $"Failed to list articles in stream {stream}");
1✔
60
        }
61
    }
12✔
62

63
    /// <exception cref="InoreaderException"></exception>
64
    private async Task<BriefArticles> ListArticlesBrief(StreamId stream, int maxArticles, DateTimeOffset? minTime, StreamId? subtract, StreamId? intersect, PaginationToken? pagination,
65
                                                        bool sortAscending, bool showFolders, CancellationToken cancellationToken) {
66
        try {
67
            return await ApiBase
15!
68
                .Path("stream/items/ids")
15✔
69
                .QueryParam("n", maxArticles.Clip(1, 1000))
15✔
70
                .QueryParam("ot", minTime?.ToUnixTimeMicroseconds())
15✔
71
                .QueryParam("r", sortAscending ? "o" : null)
15✔
72
                .QueryParam("xt", subtract)
15✔
73
                .QueryParam("it", intersect)
15✔
74
                .QueryParam("c", pagination)
15✔
75
                .QueryParam("s", stream)
15✔
76
                .QueryParam("includeAllDirectStreamIds", showFolders)
15✔
77
                .Get<BriefArticles>(cancellationToken)
15✔
78
                .ConfigureAwait(false);
15✔
79
        } catch (HttpException e) {
80
            throw TransformError(e, $"Failed to list article IDs in feed {stream}");
3✔
81
        }
82
    }
12✔
83

84
    private static async Task<TList> ListAllArticles<TList, TItem>(Func<int, PaginationToken?, CancellationToken, Task<TList>> fetchPage, int? maxArticles, CancellationToken cancellationToken)
85
        where TItem: BaseArticle where TList: PaginatedListResponse<TItem> {
86
        IEnumerable<TItem> allArticles     = [];
8✔
87
        int                articleCount    = 0;
8✔
88
        PaginationToken?   paginationToken = null;
8✔
89
        TList?             firstPage       = null;
8✔
90

91
        do {
92
            int   remainingArticles = (maxArticles ?? int.MaxValue) - articleCount;
16✔
93
            TList page              = await fetchPage(remainingArticles, paginationToken, cancellationToken).ConfigureAwait(false);
16✔
94
            firstPage       ??= page;
16✔
95
            allArticles     =   allArticles.Concat(page.Articles);
16✔
96
            articleCount    +=  page.Articles.Count;
16✔
97
            paginationToken =   page.PaginationToken;
16✔
98
        } while ((maxArticles is null || articleCount < maxArticles) && paginationToken is not null);
16!
99

100
        /*
101
         * Inoreader's API server sometimes ignores the pagination parameter and returns the same page multiple times instead of the next page.
102
         * These duplicate pages of articles cause FeedAssistant to mark them as read when they should be left unread.
103
         * To fix this, ignore duplicate downloaded articles by their primary key, which is by definition unique.
104
         */
105
        return firstPage with { Articles = allArticles.Distinct(ArticlePrimaryKeyComparer<TItem>.Instance).ToList() };
8✔
106
    }
8✔
107

108
    /// <returns>Length of <paramref name="articleIds"/></returns>
109
    /// <exception cref="InoreaderException"></exception>
110
    private async Task<int> MarkArticles(StreamId label, bool removeLabel, CancellationToken cancellationToken, params IEnumerable<string> articleIds) {
111
        try {
112
            List<KeyValuePair<string, string>> articleIdFormParams = articleIds.Select(static id => new KeyValuePair<string, string>("i", id)).ToList();
16✔
113

114
            if (articleIdFormParams.Count != 0) {
8✔
115
                await using IAsyncDisposable response = (await ApiBase
7✔
116
                    .Path("edit-tag")
7✔
117
                    .Post<Stream>(new FormUrlEncodedContent(articleIdFormParams.Prepend(new KeyValuePair<string, string>(removeLabel ? "r" : "a", label.Id))), cancellationToken)
7✔
118
                    .ConfigureAwait(false)).AsAsyncDisposable();
7✔
119
            }
120
            return articleIdFormParams.Count;
7✔
121
        } catch (HttpException e) {
122
            throw TransformError(e, $"Failed to {(removeLabel ? "untag" : "tag")} articles with tag {label}");
1!
123
        }
124
    }
7✔
125

126
    /// <exception cref="InoreaderException"></exception>
127
    internal async Task<IEnumerable<StreamState>> ListTagAndFolderStates(CancellationToken cancellationToken) {
128
        try {
129
            IList<StreamState> tagAndFolderStates = (await ApiBase
13✔
130
                    .Path("tag/list")
13✔
131
                    .QueryParam("types", 1)
13✔
132
                    .QueryParam("counts", 1)
13✔
133
                    .Get<TagList>(cancellationToken)
13✔
134
                    .ConfigureAwait(false))
13✔
135
                .Tags
13✔
136
                .ToList();
13✔
137

138
            TagAndFolderStatesListed?.Invoke(this, tagAndFolderStates);
12✔
139
            return tagAndFolderStates;
12✔
140
        } catch (HttpException e) {
141
            throw TransformError(e, "Failed to list tag and folder states");
1✔
142
        }
143
    }
12✔
144

145
    /// <exception cref="InoreaderException"></exception>
146
    private async Task MarkAllArticlesAsRead(StreamId stream, DateTimeOffset maxSeenArticleTime, CancellationToken cancellationToken) {
147
        try {
148
            await using IAsyncDisposable response = (await ApiBase
5✔
149
                .Path("mark-all-as-read")
5✔
150
                .Post<Stream>(new FormUrlEncodedContent(new Dictionary<string, string> {
5✔
151
                    ["s"]  = stream.Id,
5✔
152
                    ["ts"] = maxSeenArticleTime.ToUnixTimeMicroseconds().ToString()
5✔
153
                }), cancellationToken)
5✔
154
                .ConfigureAwait(false)).AsAsyncDisposable();
5✔
155
        } catch (HttpException e) {
4✔
156
            throw TransformError(e, $"Failed to mark all articles as read in {stream}");
1✔
157
        }
158
    }
4✔
159

160
    /// <exception cref="InoreaderException"></exception>
161
    private async Task RenameFolderOrTag(StreamId stream, string newName, CancellationToken cancellationToken) {
162
        try {
163
            await using IAsyncDisposable response = (await ApiBase
3✔
164
                .Path("rename-tag")
3✔
165
                .Post<Stream>(new FormUrlEncodedContent(new Dictionary<string, string> {
3✔
166
                    ["s"]    = stream.Id,
3✔
167
                    ["dest"] = newName
3✔
168
                }), cancellationToken)
3✔
169
                .ConfigureAwait(false)).AsAsyncDisposable();
3✔
170
        } catch (HttpException e) {
2✔
171
            throw TransformError(e, $"Failed to rename folder or tag {stream} to {newName}");
1✔
172
        }
173
    }
2✔
174

175
    /// <exception cref="InoreaderException"></exception>
176
    private async Task DeleteFolderOrTag(StreamId stream, CancellationToken cancellationToken = default) {
177
        try {
178
            await using IAsyncDisposable response = (await ApiBase
4✔
179
                .Path("disable-tag")
4✔
180
                .Post<Stream>(new FormUrlEncodedContent(new Dictionary<string, string> {
4✔
181
                    ["s"] = stream.Id
4✔
182
                }), cancellationToken)
4✔
183
                .ConfigureAwait(false)).AsAsyncDisposable();
4✔
184
            // If the stream did not already exist, the response body is "Error=Tag not found!" instead of "OK", but I don't care because either way it successfully doesn't exist now.
185
        } catch (HttpException e) {
3✔
186
            throw TransformError(e, $"Failed to delete folder or tag {stream}");
1✔
187
        }
188
    }
3✔
189

190
    /// <exception cref="InoreaderException"></exception>
191
    private async Task ModifySubscription(StreamId stream, SubscriptionEditAction action, string? newTitle, string? newFolder, string? removeFromFolder, CancellationToken cancellationToken) {
192
        try {
193
            await using IAsyncDisposable response = (await ApiBase
6✔
194
                .Path("subscription/edit")
6✔
195
                .Post<Stream>(new FormUrlEncodedContent(new Dictionary<string, string?> {
6✔
196
                    ["ac"] = action.ToString().ToLowerInvariant(),
6✔
197
                    ["s"]  = stream.Id,
6✔
198
                    ["t"]  = newTitle,
6✔
199
                    ["a"]  = newFolder != null ? StreamId.ForFolder(newFolder).Id : null,
6✔
200
                    ["r"]  = removeFromFolder != null ? StreamId.ForFolder(removeFromFolder).Id : null
6✔
201
                }.Compact()), cancellationToken)
6✔
202
                .ConfigureAwait(false)).AsAsyncDisposable();
6✔
203
        } catch (HttpException e) {
5✔
204
            throw TransformError(e, $"Failed to modify feed {stream}");
1✔
205
        }
206
    }
5✔
207

208
    private enum SubscriptionEditAction {
209

210
        Subscribe, // Incorrectly documented as "follow"
211
        Edit,
212
        Unsubscribe // Incorrectly documented as "unfollow", found in Android app in com.innologica.inoreader.httpreq.MessageToServer.SendEditSubscriptionToServer(List<NameValuePair>,String)
213

214
    }
215

216
    /// <exception cref="InoreaderException"></exception>
217
    private async Task<UnreadCountResponses> GetUnreadCounts(CancellationToken cancellationToken = default) {
218
        try {
219
            return await ApiBase.Path("unread-count")
5✔
220
                .Get<UnreadCountResponses>(cancellationToken)
5✔
221
                .ConfigureAwait(false);
5✔
222
        } catch (HttpException e) {
223
            throw TransformError(e, "Failed to get unread counts");
1✔
224
        }
225
    }
4✔
226

227
    /// <exception cref="InoreaderException"></exception>
228
    private async Task<LabelUnreadCounts> GetLabelUnreadCounts(bool tagsInsteadOfFolders, CancellationToken cancellationToken) {
229
        Task<UnreadCountResponses>  unreadCountsTask = GetUnreadCounts(cancellationToken);
3✔
230
        Task<LabelNameCache.Labels> labelNamesTask   = client.LabelNameCache.GetLabelNames(ct: cancellationToken);
3✔
231

232
        UnreadCountResponses unreadCounts = await unreadCountsTask.ConfigureAwait(false);
3✔
233
        ISet<string>         folderNames  = (await labelNamesTask.ConfigureAwait(false)).Folders;
2✔
234

235
        // ReSharper disable once RedundantEnumerableCastCall - it's changing nullability, so it's not redundant, and it prevents warnings
236
        return new LabelUnreadCounts(unreadCounts.UnreadCounts
2✔
237
                .Select(static response => (response, labelNameOrNull: response.Id.LabelName))
10✔
238
                .Where(static responseWithLabel => responseWithLabel.labelNameOrNull is not null)
10✔
239
                .Cast<(UnreadCountResponse response, string labelName)>()
2✔
240
                .Where(responseWithLabel => tagsInsteadOfFolders != folderNames.Contains(responseWithLabel.labelName))
4✔
241
                .ToDictionary(static responseWithLabel => responseWithLabel.labelName,
2✔
242
                    static responseWithLabel => new StreamUnreadState(responseWithLabel.response.Count, responseWithLabel.response.NewestArticleTime)),
2✔
243
            unreadCounts.Max);
2✔
244
    }
2✔
245

246
    private static InoreaderException TransformError(HttpException cause, string message) => cause switch {
14✔
247
        ForbiddenException or NotAuthorizedException              => new InoreaderException.Unauthorized("Inoreader auth failure", cause),
1✔
248
        ClientErrorException { StatusCode: (HttpStatusCode) 429 } => new InoreaderException.RateLimited((RateLimitStatistics) cause.RequestProperties![RateLimitReader.RequestPropertyKey]!, cause),
1✔
249
        _ => new InoreaderException(message + (cause is WebApplicationException { ResponseBody: {} body } ? ": " + MessageEncoding.GetString(body.Span
12!
250
#if !(NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_1_OR_GREATER)
12✔
251
                .ToArray()
12✔
252
#endif
12✔
253
        ).Trim().TrimStart(1, "Error=") : null), cause)
12✔
254
    };
14✔
255

256
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc