using AudibleApi;
using AudibleApi.Common;
using DataLayer;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json.Linq;
using System;
using System.IO;
using System.Net.Http;
namespace FileLiberator.Tests;
///
/// The inputs here are taken from the log attached to issue #1947: a Plus preorder Audible had no audio for,
/// and owned titles on an inactive account that Audible refused a license for.
///
[TestClass]
public class DownloadFailureClassifierTests
{
private const string LicenseRequestUri = "https://api.audible.com/1.0/content/B002V5B8OY/licenserequest";
/// The preorder failure: a license request that comes back with no content reference.
private const string NoAudioAssetJson = """
{"http_response_code":"NotFound","response":"{\"message\":\"Unable to retrieve asset details from Sable(ACRInfos), for marketplaceId:AF2M0KC94RCEA, asin:B0H956N76W, acr:null, skuLite:OR_ORIG_003592, version:LATEST, aaaClientId:urn:cdo:AudibleApiExternalRouterService:Prod:Default\"}"}
""";
private static ContentLicenseDeniedException Denied(params (string ValidationType, string RejectionReason, string Message)[] reasons)
{
var license = new ContentLicense
{
Asin = "B002V5B8OY",
StatusCode = "Denied",
LicenseDenialReasons = [.. Array.ConvertAll(reasons, r => new LicenseDenialReason
{
ValidationType = r.ValidationType,
RejectionReason = r.RejectionReason,
Message = r.Message
})]
};
return new ContentLicenseDeniedException(new Uri(LicenseRequestUri), license);
}
private static ApiErrorException ApiError(string requestUri, string json)
=> new(requestUri, JObject.Parse(json), "License response not \"OK\"");
[TestMethod]
public void An_eligibility_refusal_is_a_license_denial()
{
// Verbatim from the issue's log: an owned title on an account that is no longer active.
var ex = Denied(
("Membership", RejectionReason.RequesterEligibility, "Customer is not part of any plans"),
("Ownership", RejectionReason.RequesterEligibility, "Ownership: No Ownership information returned by DAOQS for customer [x] and for asin [B002V5B8OY]."),
("Client", RejectionReason.RequesterEligibility, "does not has access to asin[B002V5B8OY]."),
("AYCL", RejectionReason.ContentEligibility, "Asin: [B002V5B8OY] is not eligible for AYCL"));
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.LicenseDenied, diagnosis.Kind);
// The reason names which check failed, so the log and the UI can say why without another request.
StringAssert.StartsWith(diagnosis.Reason, "Ownership: ");
}
[TestMethod]
public void A_Plus_title_no_longer_in_the_catalog_is_a_license_denial()
{
var ex = Denied(
("Ownership", RejectionReason.RequesterEligibility, "No matching DAO benefit found"),
("AYCL", RejectionReason.ContentEligibility, "Asin: [B0D5JLT7YG] is not eligible for AYCL"));
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.LicenseDenied, diagnosis.Kind);
}
[TestMethod]
public void An_ownership_refusal_alone_is_a_license_denial()
{
// Audible does not always run every check. One stated eligibility refusal is still a refusal, and an
// hourly schedule must not keep asking about it.
var ex = Denied(("Ownership", RejectionReason.RequesterEligibility, "not owned"));
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.LicenseDenied, diagnosis.Kind);
}
[TestMethod]
public void GenericError_is_read_as_a_possible_service_interruption()
{
// Matches the judgement the GUI queue already makes when it offers guidance for this failure.
var ex = Denied(("AYCL", RejectionReason.GenericError, "Something went wrong"));
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.ServiceInterruption, diagnosis.Kind);
}
[TestMethod]
public void GenericError_on_any_check_is_read_as_a_possible_service_interruption()
{
var ex = Denied(
("Ownership", RejectionReason.GenericError, "Something went wrong"),
("AYCL", RejectionReason.ContentEligibility, "Asin is not eligible for AYCL"));
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.ServiceInterruption, diagnosis.Kind);
}
[TestMethod]
public void A_denial_with_no_reasons_at_all_is_not_treated_as_settled()
{
var ex = Denied();
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.ServiceInterruption, diagnosis.Kind);
Assert.AreEqual(ex.Message, diagnosis.Reason);
}
[TestMethod]
public void A_license_request_with_no_content_reference_means_there_is_no_audio_yet()
{
var ex = ApiError(LicenseRequestUri, NoAudioAssetJson);
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.AssetUnavailable, diagnosis.Kind);
StringAssert.Contains(diagnosis.Reason, "preorder");
}
[TestMethod]
public void An_acr_null_error_from_another_endpoint_is_not_classified()
{
// Only the license request tells us whether audio exists to download.
var ex = ApiError("https://api.audible.com/1.0/content/B0H956N76W/metadata", NoAudioAssetJson);
Assert.IsFalse(DownloadFailureClassifier.TryClassify(ex, out _));
}
[TestMethod]
public void An_ordinary_api_error_is_not_classified()
{
var ex = ApiError(LicenseRequestUri, """{"message":"Internal server error"}""");
Assert.IsFalse(DownloadFailureClassifier.TryClassify(ex, out _));
}
[TestMethod]
public void Failures_that_are_nothing_to_do_with_Audible_keep_being_retried_every_run()
{
// Nothing here suggests the next attempt fails the same way, so these must not be waited on.
Assert.IsFalse(DownloadFailureClassifier.TryClassify(new IOException("There is not enough space on the disk."), out _));
Assert.IsFalse(DownloadFailureClassifier.TryClassify(new HttpRequestException("Connection reset"), out _));
Assert.IsFalse(DownloadFailureClassifier.TryClassify(new OperationCanceledException(), out _));
Assert.IsFalse(DownloadFailureClassifier.TryClassify(new InvalidDataException("Widevine license response is null."), out _));
}
[TestMethod]
public void A_wrapped_denial_is_still_recognised()
{
// The Widevine path rethrows through whichever step gave up on it.
var inner = Denied(("Ownership", RejectionReason.RequesterEligibility, "not owned"));
var ex = new InvalidOperationException("Failed to request a Widevine license.", inner);
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.LicenseDenied, diagnosis.Kind);
}
[TestMethod]
public void Classifying_null_is_harmless()
=> Assert.IsNull(DownloadFailureClassifier.Classify(null));
}