diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index c2fb20c..1e6b93f 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -20,6 +20,14 @@ jobs: with: dotnet-version: 5.0.x + - name: Build & test + run: dotnet test --configuration Release --logger GitHubActions + + - name: Upload coverage + uses: codecov/codecov-action@v1.0.5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + - name: Build & publish (CLI) run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release @@ -36,4 +44,4 @@ jobs: uses: actions/upload-artifact@v1 with: name: DiscordChatExporter - path: DiscordChatExporter.Gui/bin/Publish/ + path: DiscordChatExporter.Gui/bin/Publish/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index f874e7e..1923141 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,7 @@ bld/ [Oo]bj/ # Coverage -*.opencover.xml \ No newline at end of file +*.opencover.xml + +# Secrets +*.secret \ No newline at end of file diff --git a/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj b/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj new file mode 100644 index 0000000..668ea7b --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj @@ -0,0 +1,31 @@ + + + + false + true + true + opencover + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DiscordChatExporter.Cli.Tests/Fixtures/TempOutputFixture.cs b/DiscordChatExporter.Cli.Tests/Fixtures/TempOutputFixture.cs new file mode 100644 index 0000000..1e9a2b7 --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/Fixtures/TempOutputFixture.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; + +namespace DiscordChatExporter.Cli.Tests.Fixtures +{ + public class TempOutputFixture : IDisposable + { + public string DirPath => Path.Combine( + Path.GetDirectoryName(typeof(TempOutputFixture).Assembly.Location) ?? Directory.GetCurrentDirectory(), + "Temp" + ); + + public TempOutputFixture() => Directory.CreateDirectory(DirPath); + + public string GetTempFilePath(string fileName) => Path.Combine(DirPath, fileName); + + public string GetTempFilePath() => GetTempFilePath(Guid.NewGuid().ToString()); + + public void Dispose() + { + if (Directory.Exists(DirPath)) + Directory.Delete(DirPath, true); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Cli.Tests/Infra/Secrets.cs b/DiscordChatExporter.Cli.Tests/Infra/Secrets.cs new file mode 100644 index 0000000..dc6a647 --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/Infra/Secrets.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; + +namespace DiscordChatExporter.Cli.Tests.Infra +{ + public static class Secrets + { + private static readonly Lazy DiscordTokenLazy = new(() => + { + var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN"); + if (!string.IsNullOrWhiteSpace(fromEnvironment)) + return fromEnvironment; + + var secretFilePath = Path.Combine( + Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(), + "DiscordToken.secret" + ); + + if (File.Exists(secretFilePath)) + return File.ReadAllText(secretFilePath); + + throw new InvalidOperationException("Discord token not provided for tests."); + }); + + private static readonly Lazy IsDiscordTokenBotLazy = new(() => + { + // Default to true + var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN_BOT"); + if (string.IsNullOrWhiteSpace(fromEnvironment)) + return true; + + return string.Equals(fromEnvironment, "true", StringComparison.OrdinalIgnoreCase); + }); + + public static string DiscordToken => DiscordTokenLazy.Value; + + public static bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Cli.Tests/MentionSpecs.cs b/DiscordChatExporter.Cli.Tests/MentionSpecs.cs new file mode 100644 index 0000000..6b0a049 --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/MentionSpecs.cs @@ -0,0 +1,304 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AngleSharp.Dom; +using CliFx.Infrastructure; +using DiscordChatExporter.Cli.Commands; +using DiscordChatExporter.Cli.Tests.Fixtures; +using DiscordChatExporter.Cli.Tests.Infra; +using DiscordChatExporter.Cli.Tests.TestData; +using DiscordChatExporter.Cli.Tests.Utils; +using DiscordChatExporter.Core.Discord; +using DiscordChatExporter.Core.Exporting; +using FluentAssertions; +using JsonExtensions; +using Xunit; +using Xunit.Abstractions; + +namespace DiscordChatExporter.Cli.Tests +{ + public class MentionSpecs : IClassFixture + { + private readonly ITestOutputHelper _testOutput; + private readonly TempOutputFixture _tempOutput; + + public MentionSpecs(ITestOutputHelper testOutput, TempOutputFixture tempOutput) + { + _testOutput = testOutput; + _tempOutput = tempOutput; + } + + [Fact] + public async Task User_mention_is_rendered_correctly_in_JSON() + { + // Arrange + var outputFilePath = Path.ChangeExtension(_tempOutput.GetTempFilePath(), "json"); + + // Act + await new ExportChannelsCommand + { + TokenValue = Secrets.DiscordToken, + IsBotToken = Secrets.IsDiscordTokenBot, + ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)}, + ExportFormat = ExportFormat.Json, + OutputPath = outputFilePath + }.ExecuteAsync(new FakeConsole()); + + var jsonData = await File.ReadAllTextAsync(outputFilePath); + _testOutput.WriteLine(jsonData); + + var json = Json.Parse(jsonData); + + var messageJson = json + .GetProperty("messages") + .EnumerateArray() + .Single(j => string.Equals( + j.GetProperty("id").GetString(), + "866458840245076028", + StringComparison.OrdinalIgnoreCase + )); + + var content = messageJson + .GetProperty("content") + .GetString(); + + var mentionedUserIds = messageJson + .GetProperty("mentions") + .EnumerateArray() + .Select(j => j.GetProperty("id").GetString()) + .ToArray(); + + // Assert + content.Should().Be("User mention: @Tyrrrz"); + mentionedUserIds.Should().Contain("128178626683338752"); + } + + [Fact] + public async Task User_mention_is_rendered_correctly_in_HTML() + { + // Arrange + var outputFilePath = Path.ChangeExtension(_tempOutput.GetTempFilePath(), "html"); + + // Act + await new ExportChannelsCommand + { + TokenValue = Secrets.DiscordToken, + IsBotToken = Secrets.IsDiscordTokenBot, + ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)}, + ExportFormat = ExportFormat.HtmlDark, + OutputPath = outputFilePath + }.ExecuteAsync(new FakeConsole()); + + var htmlData = await File.ReadAllTextAsync(outputFilePath); + _testOutput.WriteLine(htmlData); + + var html = Html.Parse(htmlData); + + var messageHtml = html.GetElementById("message-866458840245076028"); + + // Assert + messageHtml.Should().NotBeNull(); + messageHtml?.Text().Trim().Should().Be("User mention: @Tyrrrz"); + messageHtml?.InnerHtml.Should().Contain("Tyrrrz#5447"); + } + + [Fact] + public async Task Text_channel_mention_is_rendered_correctly_in_JSON() + { + // Arrange + var outputFilePath = Path.ChangeExtension(_tempOutput.GetTempFilePath(), "json"); + + // Act + await new ExportChannelsCommand + { + TokenValue = Secrets.DiscordToken, + IsBotToken = Secrets.IsDiscordTokenBot, + ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)}, + ExportFormat = ExportFormat.Json, + OutputPath = outputFilePath + }.ExecuteAsync(new FakeConsole()); + + var jsonData = await File.ReadAllTextAsync(outputFilePath); + _testOutput.WriteLine(jsonData); + + var json = Json.Parse(jsonData); + + var messageJson = json + .GetProperty("messages") + .EnumerateArray() + .Single(j => string.Equals( + j.GetProperty("id").GetString(), + "866459040480624680", + StringComparison.OrdinalIgnoreCase + )); + + var content = messageJson + .GetProperty("content") + .GetString(); + + // Assert + content.Should().Be("Text channel mention: #mention-tests"); + } + + [Fact] + public async Task Text_channel_mention_is_rendered_correctly_in_HTML() + { + // Arrange + var outputFilePath = Path.ChangeExtension(_tempOutput.GetTempFilePath(), "html"); + + // Act + await new ExportChannelsCommand + { + TokenValue = Secrets.DiscordToken, + IsBotToken = Secrets.IsDiscordTokenBot, + ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)}, + ExportFormat = ExportFormat.HtmlDark, + OutputPath = outputFilePath + }.ExecuteAsync(new FakeConsole()); + + var htmlData = await File.ReadAllTextAsync(outputFilePath); + _testOutput.WriteLine(htmlData); + + var html = Html.Parse(htmlData); + + var messageHtml = html.GetElementById("message-866459040480624680"); + + // Assert + messageHtml.Should().NotBeNull(); + messageHtml?.Text().Trim().Should().Be("Text channel mention: #mention-tests"); + } + + [Fact] + public async Task Voice_channel_mention_is_rendered_correctly_in_JSON() + { + // Arrange + var outputFilePath = Path.ChangeExtension(_tempOutput.GetTempFilePath(), "json"); + + // Act + await new ExportChannelsCommand + { + TokenValue = Secrets.DiscordToken, + IsBotToken = Secrets.IsDiscordTokenBot, + ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)}, + ExportFormat = ExportFormat.Json, + OutputPath = outputFilePath + }.ExecuteAsync(new FakeConsole()); + + var jsonData = await File.ReadAllTextAsync(outputFilePath); + _testOutput.WriteLine(jsonData); + + var json = Json.Parse(jsonData); + + var messageJson = json + .GetProperty("messages") + .EnumerateArray() + .Single(j => string.Equals( + j.GetProperty("id").GetString(), + "866459175462633503", + StringComparison.OrdinalIgnoreCase + )); + + var content = messageJson + .GetProperty("content") + .GetString(); + + // Assert + content.Should().Be("Voice channel mention: #chaos-vc [voice]"); + } + + [Fact] + public async Task Voice_channel_mention_is_rendered_correctly_in_HTML() + { + // Arrange + var outputFilePath = Path.ChangeExtension(_tempOutput.GetTempFilePath(), "html"); + + // Act + await new ExportChannelsCommand + { + TokenValue = Secrets.DiscordToken, + IsBotToken = Secrets.IsDiscordTokenBot, + ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)}, + ExportFormat = ExportFormat.HtmlDark, + OutputPath = outputFilePath + }.ExecuteAsync(new FakeConsole()); + + var htmlData = await File.ReadAllTextAsync(outputFilePath); + _testOutput.WriteLine(htmlData); + + var html = Html.Parse(htmlData); + + var messageHtml = html.GetElementById("message-866459175462633503"); + + // Assert + messageHtml.Should().NotBeNull(); + messageHtml?.Text().Trim().Should().Be("Voice channel mention: 🔊chaos-vc"); + } + + [Fact] + public async Task Role_mention_is_rendered_correctly_in_JSON() + { + // Arrange + var outputFilePath = Path.ChangeExtension(_tempOutput.GetTempFilePath(), "json"); + + // Act + await new ExportChannelsCommand + { + TokenValue = Secrets.DiscordToken, + IsBotToken = Secrets.IsDiscordTokenBot, + ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)}, + ExportFormat = ExportFormat.Json, + OutputPath = outputFilePath + }.ExecuteAsync(new FakeConsole()); + + var jsonData = await File.ReadAllTextAsync(outputFilePath); + _testOutput.WriteLine(jsonData); + + var json = Json.Parse(jsonData); + + var messageJson = json + .GetProperty("messages") + .EnumerateArray() + .Single(j => string.Equals( + j.GetProperty("id").GetString(), + "866459254693429258", + StringComparison.OrdinalIgnoreCase + )); + + var content = messageJson + .GetProperty("content") + .GetString(); + + // Assert + content.Should().Be("Role mention: @Role 1"); + } + + [Fact] + public async Task Role_mention_is_rendered_correctly_in_HTML() + { + // Arrange + var outputFilePath = Path.ChangeExtension(_tempOutput.GetTempFilePath(), "html"); + + // Act + await new ExportChannelsCommand + { + TokenValue = Secrets.DiscordToken, + IsBotToken = Secrets.IsDiscordTokenBot, + ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)}, + ExportFormat = ExportFormat.HtmlDark, + OutputPath = outputFilePath + }.ExecuteAsync(new FakeConsole()); + + var htmlData = await File.ReadAllTextAsync(outputFilePath); + _testOutput.WriteLine(htmlData); + + var html = Html.Parse(htmlData); + + var messageHtml = html.GetElementById("message-866459254693429258"); + + // Assert + messageHtml.Should().NotBeNull(); + messageHtml?.Text().Trim().Should().Be("Role mention: @Role 1"); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Cli.Tests/TestData/ChannelIds.cs b/DiscordChatExporter.Cli.Tests/TestData/ChannelIds.cs new file mode 100644 index 0000000..c5f7ef4 --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/TestData/ChannelIds.cs @@ -0,0 +1,9 @@ +namespace DiscordChatExporter.Cli.Tests.TestData +{ + public static class ChannelIds + { + public static string MentionTestCases { get; } = "866458801389174794"; + + public static string ReplyTestCases { get; } = "866459871934677052"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Cli.Tests/Utils/Html.cs b/DiscordChatExporter.Cli.Tests/Utils/Html.cs new file mode 100644 index 0000000..4f9ca13 --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/Utils/Html.cs @@ -0,0 +1,12 @@ +using AngleSharp.Html.Dom; +using AngleSharp.Html.Parser; + +namespace DiscordChatExporter.Cli.Tests.Utils +{ + internal static class Html + { + private static readonly IHtmlParser Parser = new HtmlParser(); + + public static IHtmlDocument Parse(string source) => Parser.ParseDocument(source); + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Cli.Tests/xunit.runner.json b/DiscordChatExporter.Cli.Tests/xunit.runner.json new file mode 100644 index 0000000..186540e --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "methodDisplayOptions": "all", + "methodDisplay": "method" +} \ No newline at end of file diff --git a/DiscordChatExporter.sln b/DiscordChatExporter.sln index 34f9b7b..027c60c 100644 --- a/DiscordChatExporter.sln +++ b/DiscordChatExporter.sln @@ -18,6 +18,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Core", "DiscordChatExporter.Core\DiscordChatExporter.Core.csproj", "{E19980B9-2B84-4257-A517-540FF1E3FCDD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Cli.Tests", "DiscordChatExporter.Cli.Tests\DiscordChatExporter.Cli.Tests.csproj", "{C5064B8B-692E-4515-BA55-A9BE392EE540}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,6 +38,10 @@ Global {E19980B9-2B84-4257-A517-540FF1E3FCDD}.Debug|Any CPU.Build.0 = Debug|Any CPU {E19980B9-2B84-4257-A517-540FF1E3FCDD}.Release|Any CPU.ActiveCfg = Release|Any CPU {E19980B9-2B84-4257-A517-540FF1E3FCDD}.Release|Any CPU.Build.0 = Release|Any CPU + {C5064B8B-692E-4515-BA55-A9BE392EE540}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5064B8B-692E-4515-BA55-A9BE392EE540}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5064B8B-692E-4515-BA55-A9BE392EE540}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5064B8B-692E-4515-BA55-A9BE392EE540}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Readme.md b/Readme.md index 5adeeee..85ca8c2 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,7 @@ # DiscordChatExporter [![Build](https://github.com/Tyrrrz/DiscordChatExporter/workflows/CI/badge.svg?branch=master)](https://github.com/Tyrrrz/DiscordChatExporter/actions) +[![Coverage](https://codecov.io/gh/Tyrrrz/DiscordChatExporter/branch/master/graph/badge.svg)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter) [![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![Donate](https://img.shields.io/badge/donate-$$$-purple.svg)](https://tyrrrz.me/donate)