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)