using System.Net;
using System.Text;
using System.Xml;
using XFEEDToolBox.Core.News;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class GalnetNewsTests
{
private static string Feed(string id = "one", string date = "Sat, 22 Oct 3312 00:00:00 GMT", string title = "Galnet headline") => $"""
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"><channel><title>Elite Dangerous Galnet News</title><item>
<guid>{id}</guid><title><![CDATA[{title}]]></title>
<link>https://www.elitedangerous.com/news/galnet/example</link><pubDate>{date}</pubDate>
<description><![CDATA[First & second.<br/>Next paragraph.<script>hidden()</script>]]></description>
</item></channel></rss>
""";
private sealed class Handler : HttpMessageHandler
{
public string Xml = Feed();
public HttpStatusCode Status = HttpStatusCode.OK;
public bool Offline, WaitForCancellation;
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
{
Check.Equal(GalnetRss.FeedUrl, request.RequestUri!.AbsoluteUri);
Entered.TrySetResult();
if (WaitForCancellation) await Task.Delay(Timeout.Infinite, token);
if (Offline) throw new HttpRequestException("offline");
return new(Status) { Content = new StringContent(Xml, Encoding.UTF8, "application/rss+xml") };
}
}
[Test] public void ParsesRealGameYearAndReadablePlainText()
{
var article = GalnetRss.Parse(Feed(title: "Anaconda Mk II & field trials")).Single();
Check.Equal("Anaconda Mk II & field trials", article.Title);
Check.Equal("First & second.\nNext paragraph.", article.Body);
Check.Equal(3312, article.PublishedAt!.Value.Year);
Check.Equal("22 OCT 3312", article.GameDate);
Check.That(article.SourceUri?.Host == "www.elitedangerous.com");
}
[Test] public void RejectsExternalEntitiesAndNonOfficialLinks()
{
Check.Throws<XmlException>(() => GalnetRss.Parse("<!DOCTYPE rss [<!ENTITY x SYSTEM 'file:///C:/Windows/win.ini'>]><rss><channel><item>&x;</item></channel></rss>"));
var article = GalnetRss.Parse(Feed().Replace("https://www.elitedangerous.com/news/galnet/example", "https://invalid.example/news")).Single();
Check.Equal("", article.Link);
Check.That(article.SourceUri is null);
Check.That(GalnetRss.OfficialUri("https://user@www.elitedangerous.com/news") is null);
}
[Test] public async Task PersistsNewsAcrossRestartAndRetainsItWhenOffline()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
using var handler = new Handler(); using var http = new HttpClient(handler);
var first = await new GalnetNewsService(path, http).RefreshAsync();
Check.That(first.Updated && File.Exists(path));
handler.Offline = true;
var restarted = new GalnetNewsService(path, http);
var saved = await restarted.LoadCachedAsync();
Check.Equal("one", saved!.Articles.Single().Id);
var failed = await restarted.RefreshAsync();
Check.That(ReferenceEquals(saved, failed.Snapshot) && !failed.Updated);
Check.Equal(first.Snapshot!.RetrievedAt, failed.Snapshot!.RetrievedAt);
}
[Test] public async Task EmptyMalformedAndOlderResponsesCannotEraseTheSavedEdition()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
using var handler = new Handler(); using var http = new HttpClient(handler);
var service = new GalnetNewsService(path, http);
var original = (await service.RefreshAsync()).Snapshot;
var saved = await File.ReadAllTextAsync(path);
foreach (var response in new[] { "<rss><channel/></rss>", "<html>not news</html>", "bad xml", Feed("old", "21 Oct 3312 00:00:00 GMT"), new string('x', GalnetRss.MaximumBytes + 1) })
{
handler.Xml = response;
var result = await service.RefreshAsync();
Check.That(ReferenceEquals(original, result.Snapshot) && !result.Updated);
Check.Equal(saved, await File.ReadAllTextAsync(path));
}
}
[Test] public async Task OnlyNewOrChangedContentReplacesTheEdition()
{
using var folder = new TemporaryDirectory();
using var handler = new Handler(); using var http = new HttpClient(handler);
var service = new GalnetNewsService(folder.File("news.json"), http);
var first = await service.RefreshAsync();
var same = await service.RefreshAsync();
Check.That(!same.Updated && ReferenceEquals(first.Snapshot, same.Snapshot));
handler.Xml = Feed("two", "23 Oct 3312 00:00:00 GMT");
var next = await service.RefreshAsync();
Check.That(next.Updated);
Check.Equal("two", next.Snapshot!.Articles.Single().Id);
Check.Equal("two", (await new GalnetNewsService(folder.File("news.json"), http).LoadCachedAsync())!.Articles.Single().Id);
}
[Test] public async Task CancellingRefreshLeavesTheSavedNewsIntact()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
using var handler = new Handler(); using var http = new HttpClient(handler);
var service = new GalnetNewsService(path, http);
var original = (await service.RefreshAsync()).Snapshot;
var saved = await File.ReadAllTextAsync(path);
handler.WaitForCancellation = true;
using var stop = new CancellationTokenSource();
var pending = service.RefreshAsync(stop.Token);
stop.Cancel();
try { await pending; throw new InvalidOperationException("Expected cancellation"); }
catch (OperationCanceledException) { }
Check.That(ReferenceEquals(original, service.Current));
Check.Equal(saved, await File.ReadAllTextAsync(path));
}
[Test] public async Task InvalidCacheCanBeReplacedByValidNews()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
await File.WriteAllTextAsync(path, """{"articles":[null]}""");
using var handler = new Handler(); using var http = new HttpClient(handler);
var result = await new GalnetNewsService(path, http).RefreshAsync();
Check.That(result.Updated && result.Snapshot!.Articles.Count == 1);
}
}
[TestFixture, Explicit("Reads Frontier's public Galnet feed; opt in manually.")]
public class GalnetNewsLiveTests
{
[Test] public async Task ReadsOfficialFeedAndRestoresTheDownloadedEdition()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
var result = await new GalnetNewsService(path).RefreshAsync();
Check.That(result.Snapshot is { Articles.Count: > 0 }, result.Message);
var snapshot = result.Snapshot!;
var restored = await new GalnetNewsService(path).LoadCachedAsync();
Check.That(restored is not null && restored.Articles.SequenceEqual(snapshot.Articles));
var report = System.IO.Path.GetFullPath("artifacts/local-news-0416/live-summary.json");
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(report)!);
await File.WriteAllTextAsync(report, System.Text.Json.JsonSerializer.Serialize(new
{
source = GalnetRss.FeedUrl, count = snapshot.Articles.Count,
latestTitle = snapshot.Articles[0].Title, gameDate = snapshot.Articles[0].GameDate,
snapshot.RetrievedAt, restoredAfterRestart = true
}, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
}
}
using System.Net;
using System.Text;
using System.Xml;
using XFEEDToolBox.Core.News;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class GalnetNewsTests
{
private static string Feed(string id = "one", string date = "Sat, 22 Oct 3312 00:00:00 GMT", string title = "Galnet headline") => $"""
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"><channel><title>Elite Dangerous Galnet News</title><item>
<guid>{id}</guid><title><![CDATA[{title}]]></title>
<link>https://www.elitedangerous.com/news/galnet/example</link><pubDate>{date}</pubDate>
<description><![CDATA[First & second.<br/>Next paragraph.<script>hidden()</script>]]></description>
</item></channel></rss>
""";
private sealed class Handler : HttpMessageHandler
{
public string Xml = Feed();
public HttpStatusCode Status = HttpStatusCode.OK;
public bool Offline, WaitForCancellation;
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
{
Check.Equal(GalnetRss.FeedUrl, request.RequestUri!.AbsoluteUri);
Entered.TrySetResult();
if (WaitForCancellation) await Task.Delay(Timeout.Infinite, token);
if (Offline) throw new HttpRequestException("offline");
return new(Status) { Content = new StringContent(Xml, Encoding.UTF8, "application/rss+xml") };
}
}
[Test] public void ParsesRealGameYearAndReadablePlainText()
{
var article = GalnetRss.Parse(Feed(title: "Anaconda Mk II & field trials")).Single();
Check.Equal("Anaconda Mk II & field trials", article.Title);
Check.Equal("First & second.\nNext paragraph.", article.Body);
Check.Equal(3312, article.PublishedAt!.Value.Year);
Check.Equal("22 OCT 3312", article.GameDate);
Check.That(article.SourceUri?.Host == "www.elitedangerous.com");
}
[Test] public void RejectsExternalEntitiesAndNonOfficialLinks()
{
Check.Throws<XmlException>(() => GalnetRss.Parse("<!DOCTYPE rss [<!ENTITY x SYSTEM 'file:///C:/Windows/win.ini'>]><rss><channel><item>&x;</item></channel></rss>"));
var article = GalnetRss.Parse(Feed().Replace("https://www.elitedangerous.com/news/galnet/example", "https://invalid.example/news")).Single();
Check.Equal("", article.Link);
Check.That(article.SourceUri is null);
Check.That(GalnetRss.OfficialUri("https://user@www.elitedangerous.com/news") is null);
}
[Test] public async Task PersistsNewsAcrossRestartAndRetainsItWhenOffline()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
using var handler = new Handler(); using var http = new HttpClient(handler);
var first = await new GalnetNewsService(path, http).RefreshAsync();
Check.That(first.Updated && File.Exists(path));
handler.Offline = true;
var restarted = new GalnetNewsService(path, http);
var saved = await restarted.LoadCachedAsync();
Check.Equal("one", saved!.Articles.Single().Id);
var failed = await restarted.RefreshAsync();
Check.That(ReferenceEquals(saved, failed.Snapshot) && !failed.Updated);
Check.Equal(first.Snapshot!.RetrievedAt, failed.Snapshot!.RetrievedAt);
}
[Test] public async Task EmptyMalformedAndOlderResponsesCannotEraseTheSavedEdition()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
using var handler = new Handler(); using var http = new HttpClient(handler);
var service = new GalnetNewsService(path, http);
var original = (await service.RefreshAsync()).Snapshot;
var saved = await File.ReadAllTextAsync(path);
foreach (var response in new[] { "<rss><channel/></rss>", "<html>not news</html>", "bad xml", Feed("old", "21 Oct 3312 00:00:00 GMT"), new string('x', GalnetRss.MaximumBytes + 1) })
{
handler.Xml = response;
var result = await service.RefreshAsync();
Check.That(ReferenceEquals(original, result.Snapshot) && !result.Updated);
Check.Equal(saved, await File.ReadAllTextAsync(path));
}
}
[Test] public async Task OnlyNewOrChangedContentReplacesTheEdition()
{
using var folder = new TemporaryDirectory();
using var handler = new Handler(); using var http = new HttpClient(handler);
var service = new GalnetNewsService(folder.File("news.json"), http);
var first = await service.RefreshAsync();
var same = await service.RefreshAsync();
Check.That(!same.Updated && ReferenceEquals(first.Snapshot, same.Snapshot));
handler.Xml = Feed("two", "23 Oct 3312 00:00:00 GMT");
var next = await service.RefreshAsync();
Check.That(next.Updated);
Check.Equal("two", next.Snapshot!.Articles.Single().Id);
Check.Equal("two", (await new GalnetNewsService(folder.File("news.json"), http).LoadCachedAsync())!.Articles.Single().Id);
}
[Test] public async Task CancellingRefreshLeavesTheSavedNewsIntact()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
using var handler = new Handler(); using var http = new HttpClient(handler);
var service = new GalnetNewsService(path, http);
var original = (await service.RefreshAsync()).Snapshot;
var saved = await File.ReadAllTextAsync(path);
handler.WaitForCancellation = true;
using var stop = new CancellationTokenSource();
var pending = service.RefreshAsync(stop.Token);
stop.Cancel();
try { await pending; throw new InvalidOperationException("Expected cancellation"); }
catch (OperationCanceledException) { }
Check.That(ReferenceEquals(original, service.Current));
Check.Equal(saved, await File.ReadAllTextAsync(path));
}
[Test] public async Task InvalidCacheCanBeReplacedByValidNews()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
await File.WriteAllTextAsync(path, """{"articles":[null]}""");
using var handler = new Handler(); using var http = new HttpClient(handler);
var result = await new GalnetNewsService(path, http).RefreshAsync();
Check.That(result.Updated && result.Snapshot!.Articles.Count == 1);
}
}
[TestFixture, Explicit("Reads Frontier's public Galnet feed; opt in manually.")]
public class GalnetNewsLiveTests
{
[Test] public async Task ReadsOfficialFeedAndRestoresTheDownloadedEdition()
{
using var folder = new TemporaryDirectory();
var path = folder.File("news.json");
var result = await new GalnetNewsService(path).RefreshAsync();
Check.That(result.Snapshot is { Articles.Count: > 0 }, result.Message);
var snapshot = result.Snapshot!;
var restored = await new GalnetNewsService(path).LoadCachedAsync();
Check.That(restored is not null && restored.Articles.SequenceEqual(snapshot.Articles));
var report = System.IO.Path.GetFullPath("artifacts/local-news-0416/live-summary.json");
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(report)!);
await File.WriteAllTextAsync(report, System.Text.Json.JsonSerializer.Serialize(new
{
source = GalnetRss.FeedUrl, count = snapshot.Articles.Count,
latestTitle = snapshot.Articles[0].Title, gameDate = snapshot.Articles[0].GameDate,
snapshot.RetrievedAt, restoredAfterRestart = true
}, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
}
}