Merge pull request #1951 from rmcrackan/cursor/heal-corrupt-lucene-search-index-3c5b

Heal a search index Lucene cannot open, and say which account needs a login
This commit is contained in:
rmcrackan authored and GitHub committed 2026-08-16 14:44:17 -04:00
commit a122a6917b
24 files changed
+1065 -97

No files matched your search

+2 -2
View File
@@ -341,10 +341,10 @@ public static class LibationScaffolding
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (_, libraryBooks)
=> SearchEngineCommands.FullReIndex(libraryBooks);
=> SearchEngineCommands.OnLibrarySizeChanged(libraryBooks);
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
=> SearchEngineCommands.UpdateBooks(books);
=> SearchEngineCommands.OnBookUserDefinedItemCommitted(books);
}
public static VersionCheckResult GetLatestRelease()
@@ -44,9 +44,41 @@ public static class SearchEngineCommands
public static event EventHandler? SearchEngineUpdated;
/// <summary>
/// Occurs when the index could not be updated even after automatic repair, so it needs the user's help.
/// </summary>
public static event EventHandler<Exception>? UpdateFailed;
#region Update
private static bool isUpdating;
/// <summary>Updates the index after books were added to or removed from the library.</summary>
public static void OnLibrarySizeChanged(List<LibraryBook> libraryBooks)
=> tryUpdate(() => FullReIndex(libraryBooks));
/// <summary>Updates the index after book details, tags or statuses were committed.</summary>
public static void OnBookUserDefinedItemCommitted(IEnumerable<LibraryBook> books)
=> tryUpdate(() => UpdateBooks(books));
/// <summary>
/// The database change that triggers an update is committed before the update runs, and this index is derived
/// from that database, so a failure here is reported instead of propagated. Letting it escape reported a
/// successful scan as "Error importing library", and, since this is the first subscriber to those events,
/// stopped the handlers that refresh the grid and the backup counts from running at all.
/// </summary>
private static void tryUpdate(Action update)
{
try
{
update();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to update the search index. Library changes are saved; search and filter results may be stale until the next update succeeds.");
UpdateFailed?.Invoke(null, ex);
}
}
public static void UpdateBooks(IEnumerable<LibraryBook> books)
{
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
@@ -0,0 +1,37 @@
using Dinah.Core;
namespace AudibleUtilities;
/// <summary>Describes whether an account has usable stored Audible tokens.</summary>
public static class AccountCredentialStatus
{
/// <summary>
/// True when identity tokens are absent or carry no refresh token, meaning the account was never fully logged
/// in or its credentials were cleared, rather than merely holding an expired access token.
/// </summary>
public static bool LooksLikeMissingCredentials(Account? account)
{
if (account?.IdentityTokens is not { } tokens)
return true;
if (tokens.IsValid)
return false;
// without a refresh token there is nothing to renew from, so this needs a fresh login rather than a retry
return string.IsNullOrWhiteSpace(tokens.RefreshToken?.Value);
}
/// <summary>
/// Account label for dialogs shown to the person who owns the account. Not for logs: those get attached to
/// public issue reports, which is what <see cref="Account.MaskedLogEntry"/> is for.
/// </summary>
public static string FormatAccountLabel(Account? account)
{
if (account is null)
return "an Audible account";
return string.IsNullOrWhiteSpace(account.AccountName) || account.AccountName.EqualsInsensitive(account.AccountId)
? $"'{account.AccountId}'"
: $"'{account.AccountName}' ({account.AccountId})";
}
}
+11 -3
View File
@@ -59,19 +59,27 @@ public class ApiExtended
{
if (!allowInteractiveLogin || LoginChoiceFactory is null)
{
// an account that was never logged in needs a first login, not a renewal, and saying so saves the
// user hunting for an expired session that never existed
var missingCredentials = AccountCredentialStatus.LooksLikeMissingCredentials(account);
Serilog.Log.Logger.Error(ex,
"Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
missingCredentials
? "Stored credentials are missing or incomplete and interactive login is not available. {@DebugInfo}"
: "Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
new
{
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = locale.Name,
AllowInteractiveLogin = allowInteractiveLogin,
LoginChoiceFactorySet = LoginChoiceFactory is not null
LoginChoiceFactorySet = LoginChoiceFactory is not null,
LooksLikeMissingCredentials = missingCredentials
});
throw new AuthenticationRequiredException(
account,
message: $"Stored credentials for '{account.AccountId}' could not be used"
message: $"Stored credentials for '{account.AccountId}' "
+ (missingCredentials ? "are missing or incomplete" : "could not be used")
+ (LoginChoiceFactory is null
? " and interactive login is not available in this context (CLI/Docker)."
: " and interactive login was not allowed.")
@@ -24,4 +24,28 @@ public static class AuthenticationExceptionHelper
return false;
}
/// <summary>
/// Finds the <see cref="AuthenticationRequiredException"/> in <paramref name="ex"/> or its inner chain, which is
/// the one that knows which account needs a login.
/// </summary>
public static AuthenticationRequiredException? FindAuthenticationRequired(Exception ex)
{
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is AuthenticationRequiredException auth)
return auth;
}
if (ex is AggregateException aggregate)
{
foreach (var inner in aggregate.InnerExceptions)
{
if (FindAuthenticationRequired(inner) is { } found)
return found;
}
}
return null;
}
}
@@ -4,6 +4,7 @@ using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
using ReactiveUI;
using System;
@@ -56,22 +57,42 @@ partial class MainVM
public async Task EditQuickFiltersAsync() => await new LibationAvalonia.Dialogs.EditQuickFilters().ShowDialog(MainWindow);
public async Task PerformFilter(QuickFilters.NamedFilter? namedFilter)
{
SelectedNamedFilter = namedFilter;
var tryFilter = namedFilter?.Filter;
var failure = await applyFilterAsync(namedFilter);
if (failure is null)
return;
Serilog.Log.Logger.Error(failure, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter);
if (SearchIndexRecovery.IsIndexUnavailable(failure))
await MessageBox.Show(SearchIndexRecovery.ManualRecoveryInstructions, SearchIndexRecovery.Caption, MessageBoxButtons.OK, MessageBoxIcon.Warning);
else
await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{failure.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
// Restore the last filter that worked, then give up on filtering entirely. Recursing into PerformFilter
// here never terminated when the search index rather than the query was at fault, because that fails for
// every filter including the one being restored. An empty filter never reaches the search engine.
if (lastGoodSearch.Length > 0 && await applyFilterAsync(lastGoodFilter) is null)
return;
await applyFilterAsync(new(string.Empty, null));
}
/// <summary>Applies a filter, returning the exception that stopped it, or null when it worked.</summary>
private async Task<Exception?> applyFilterAsync(QuickFilters.NamedFilter? namedFilter)
{
SelectedNamedFilter = namedFilter;
try
{
await ProductsDisplay.Filter(tryFilter);
await ProductsDisplay.Filter(namedFilter?.Filter);
lastGoodSearch = namedFilter?.Filter ?? "";
return null;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter);
await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
// re-apply last good filter
namedFilter = (namedFilter ?? new(string.Empty, null)) with { Filter = lastGoodSearch };
await PerformFilter(namedFilter);
return ex;
}
}
@@ -34,13 +34,12 @@ partial class MainVM
Configuration.Instance.PropertyChanged += startAutoScan;
}
private async Task notifyAutoScanAuthRequiredAsync()
private async Task notifyAutoScanAuthRequiredAsync(AuthenticationRequiredException ex)
{
await MessageBox.Show(
MainWindow,
"Libation could not refresh your Audible library because your login session expired.\n\n"
+ "Background auto-scan has been paused. Use Import > Scan Library to log in again to resume periodic scans.",
"Auto-scan paused - login required",
AutoScanAuthPrompt.FormatBody(ex),
AutoScanAuthPrompt.Caption,
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
@@ -0,0 +1,33 @@
using ApplicationServices;
using LibationUiBase;
using LibationUiBase.Forms;
using System;
namespace LibationAvalonia.ViewModels;
partial class MainVM
{
private void Configure_SearchIndex()
=> SearchEngineCommands.UpdateFailed += searchIndexUpdateFailed;
private async void searchIndexUpdateFailed(object? sender, Exception ex)
{
try
{
if (!SearchIndexRecovery.ShouldNotify())
return;
await MessageBox.Show(
MainWindow,
SearchIndexRecovery.ManualRecoveryInstructions,
SearchIndexRecovery.Caption,
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
catch (Exception dialogEx)
{
// nothing above this is allowed to fail: the library change that got us here already succeeded
Serilog.Log.Logger.Error(dialogEx, "Could not show the search index recovery instructions");
}
}
}
@@ -34,6 +34,7 @@ public partial class MainVM : ViewModelBase
Configure_Liberate();
Configure_ProcessQueue();
Configure_ScanAuto();
Configure_SearchIndex();
Configure_Settings();
Configure_VisibleBooks();
}
+80 -62
View File
@@ -80,58 +80,105 @@ public class SearchEngine
/// <summary>create new. ie: full re-index</summary>
public void CreateNewIndex(IEnumerable<LibraryBook> library, bool overwrite = true)
{
const int maxRetries = 5;
const int baseDelayMs = 400;
var libraryList = library.ToList();
// Corruption (e.g. checksum mismatch in segments) is not fixed by waiting; clear and rebuild immediately.
var corruptRebuildAttemptsRemaining = 2;
// Exponential backoff retry: 400 ms, 800 ms, 1600 ms, etc
// Total wait time before giving up: 12.4 sec
for (var attempt = 0; attempt < maxRetries; attempt++)
// location of index/create the index
using var index = getIndex();
// analyzer for tokenizing text. same analyzer should be used for indexing and searching
using var analyzer = new StandardAnalyzer(Version);
using var ixWriter = openWriterForRebuild(index, analyzer, overwrite);
foreach (var libraryBook in libraryList)
{
var doc = createBookIndexDocument(libraryBook);
ixWriter.AddDocument(doc);
}
}
/// <summary>
/// Opens the writer for a full re-index, waiting out lock conflicts and repairing an index Lucene cannot open.
/// </summary>
private IndexWriter openWriterForRebuild(Lucene.Net.Store.Directory index, StandardAnalyzer analyzer, bool overwrite)
{
// Exponential backoff for lock conflicts: 400 ms, 800 ms, 1600 ms, 3200 ms. 6.4 sec total before giving up.
const int maxAttempts = 5;
const int baseDelayMs = 400;
var repairsRemaining = 1;
for (var attempt = 1; ; attempt++)
{
try
{
createNewIndexCore(libraryList, overwrite);
return;
var createNewIndex = overwrite || !IndexReader.IndexExists(index);
return new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED);
}
catch (CorruptIndexException ex) when (corruptRebuildAttemptsRemaining-- > 0)
catch (Exception ex) when (isTransientLockConflict(ex) && attempt < maxAttempts)
{
Serilog.Log.Logger.Warning(ex, "Lucene search index corrupt at {Path}. Clearing for rebuild.", SearchEngineDirectory);
deleteAllSearchIndexFiles(SearchEngineDirectory);
attempt--;
}
catch (IOException ex) when (attempt < maxRetries - 1 && ex is not CorruptIndexException)
{
var delayMs = baseDelayMs * (1 << attempt);
// write.lock can be held by another process (e.g. second Libation instance, antivirus) or a prior writer that did not release. Retry after delay.
Serilog.Log.Logger.Warning(ex, "Search index lock conflict (attempt {Attempt}/{Max}), retrying in {Delay}ms", attempt + 1, maxRetries, delayMs);
var delayMs = baseDelayMs * (1 << (attempt - 1));
Serilog.Log.Logger.Warning(ex, "Search index lock conflict (attempt {Attempt}/{Max}), retrying in {Delay}ms", attempt, maxAttempts, delayMs);
Thread.Sleep(delayMs);
}
catch (UnauthorizedAccessException ex) when (attempt < maxRetries - 1)
catch (Exception ex) when (!isTransientLockConflict(ex) && repairsRemaining-- > 0)
{
var delayMs = baseDelayMs * (1 << attempt);
// Windows may report "file in use" as UnauthorizedAccessException
Serilog.Log.Logger.Warning(ex, "Search index lock conflict (attempt {Attempt}/{Max}), retrying in {Delay}ms", attempt + 1, maxRetries, delayMs);
Thread.Sleep(delayMs);
// Passing overwrite/create to IndexWriter does not repair an unreadable index: its IndexFileDeleter
// reads every segments_* file in the directory and only tolerates missing ones, so a single unreadable
// segments file -- even a stale one from an older commit -- keeps the whole directory unusable until it
// is deleted. This is a full re-index, so nothing on disk is worth preserving.
// warning, not error: the index is a cache of the database, so rebuilding it resolves this
Serilog.Log.Logger.Warning(ex, "Search index at {Path} could not be opened. Deleting it and rebuilding from the library.", SearchEngineDirectory);
if (deleteAllSearchIndexFiles(SearchEngineDirectory) is { Count: > 0 } undeletable)
throw new IOException($"The search index at '{SearchEngineDirectory}' is damaged and could not be deleted automatically. Close Libation, delete that folder, then restart. Undeletable file(s): {string.Join(", ", undeletable)}", ex);
// the repair, not the failed open, gets a fresh allowance of lock retries
attempt = 0;
}
}
}
/// <summary>
/// Lucene 3 parses <c>segments_*</c> filenames in the index directory. Cloud sync (e.g. OneDrive) can leave debris
/// or conflict copies whose names break that parser, throwing <see cref="ArgumentException"/> with this message shape.
/// Actual error is likely to be something like: Invalid or unsupported character in number, hence this string check.
/// <see cref="CorruptIndexException"/> (e.g. checksum mismatch in segments) is also recoverable by deleting the index and rebuilding.
/// True when the index could not be opened because something else is holding it -- a second Libation instance,
/// antivirus, a backup agent -- which is worth waiting out rather than repairing.
/// <see cref="LockObtainFailedException"/> derives from <see cref="IOException"/>, so a bare
/// <c>IOException</c> check cannot tell a lock conflict apart from a damaged index. Nor can the type alone:
/// Windows raises a sharing violation on the lock file before Lucene turns it into a
/// <see cref="LockObtainFailedException"/>, so a plain <see cref="IOException"/> naming the lock file counts
/// too. Matching the file name rather than the wording keeps that working on non-English Windows.
/// </summary>
private static bool isTransientLockConflict(Exception ex)
=> ex is LockObtainFailedException
|| (ex is IOException && ex.Message.Contains(IndexWriter.WRITE_LOCK_NAME, StringComparison.OrdinalIgnoreCase))
// Windows may report "file in use" as UnauthorizedAccessException
|| ex is UnauthorizedAccessException;
/// <summary>
/// True when Lucene cannot read the index and the only cure is deleting it and rebuilding from the database.
/// <list type="bullet">
/// <item><see cref="CorruptIndexException"/>: eg. checksum mismatch in the segments file.</item>
/// <item>A truncated or zero-length <c>segments_*</c> file, which Lucene 3 reports as a plain
/// <see cref="IOException"/> from <c>BufferedIndexInput.Refill</c> rather than as a
/// <see cref="CorruptIndexException"/>. Matched by message because the type is shared with
/// <see cref="LockObtainFailedException"/>, which must keep retrying instead.</item>
/// <item>Lucene 3 parses <c>segments_*</c> filenames in the index directory. Cloud sync (eg. OneDrive) can leave
/// debris or conflict copies whose names break that parser, throwing <see cref="ArgumentException"/> with a
/// message like "Invalid or unsupported character in number", hence this string check.</item>
/// </list>
/// </summary>
public static bool IsRecoverableCorruptIndexException(Exception ex)
=> ex is CorruptIndexException
|| (ex is IOException && !isTransientLockConflict(ex) && ex.Message.Contains("read past EOF", StringComparison.OrdinalIgnoreCase))
|| (ex is ArgumentException aex && aex.Message.Contains("character in number", StringComparison.OrdinalIgnoreCase));
private static void deleteAllSearchIndexFiles(string searchEngineDirectory)
/// <summary>
/// Best-effort delete of everything under the index directory. Returns the <c>segments_*</c> files that survived:
/// Lucene rebuilds happily in an empty directory, and ignores leftover segment data, but any segments file it
/// cannot read keeps the directory permanently unusable.
/// </summary>
private static List<string> deleteAllSearchIndexFiles(string searchEngineDirectory)
{
if (!System.IO.Directory.Exists(searchEngineDirectory))
return;
return [];
foreach (var file in System.IO.Directory.GetFiles(searchEngineDirectory, "*", SearchOption.AllDirectories))
FileUtility.TrySaferDelete(file);
@@ -147,39 +194,10 @@ public class SearchEngine
Serilog.Log.Logger.Warning(ex, "Could not remove search index subdirectory {Dir}", dir);
}
}
}
private void createNewIndexCore(List<LibraryBook> library, bool overwrite)
{
bool indexExists;
using (var indexProbe = getIndex())
{
try
{
indexExists = IndexReader.IndexExists(indexProbe);
}
catch (Exception ex) when (IsRecoverableCorruptIndexException(ex))
{
Serilog.Log.Logger.Warning(ex, "Lucene search index at {Path} is unreadable or corrupt (often cloud-sync debris or a partial write). Clearing it for rebuild.", SearchEngineDirectory);
indexExists = false;
}
}
if (!indexExists)
deleteAllSearchIndexFiles(SearchEngineDirectory);
// location of index/create the index
using var index = getIndex();
var createNewIndex = overwrite || !indexExists;
// analyzer for tokenizing text. same analyzer should be used for indexing and searching
using var analyzer = new StandardAnalyzer(Version);
using var ixWriter = new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED);
foreach (var libraryBook in library)
{
var doc = createBookIndexDocument(libraryBook);
ixWriter.AddDocument(doc);
}
return System.IO.Directory.Exists(searchEngineDirectory)
? [.. System.IO.Directory.GetFiles(searchEngineDirectory, "segments*", SearchOption.AllDirectories)]
: [];
}
public SearchEngine(string? directory = null)
@@ -0,0 +1,23 @@
using AudibleUtilities;
using System;
namespace LibationUiBase;
/// <summary>Shared copy for the dialog shown when auto-scan pauses itself waiting for a login.</summary>
public static class AutoScanAuthPrompt
{
public const string Caption = "Auto-scan paused - login required";
public static string FormatBody(AuthenticationRequiredException ex)
{
ArgumentNullException.ThrowIfNull(ex);
var account = AccountCredentialStatus.FormatAccountLabel(ex.Account);
var cause = AccountCredentialStatus.LooksLikeMissingCredentials(ex.Account)
? "that account has never been logged in, or its stored credentials are missing"
: "the stored login for that account expired";
return $"Libation could not refresh the Audible library for {account} because {cause}.\n\n"
+ "Background auto-scan has been paused. Use Import > Scan Library to log in for that account and resume periodic scans.";
}
}
+13 -6
View File
@@ -16,7 +16,7 @@ public sealed class AutoScanRunner
private readonly Func<bool> isAutoScanEnabled;
private readonly Action pauseTimer;
private readonly Action resumeTimer;
private readonly Func<Task>? notifyAuthRequired;
private readonly Func<AuthenticationRequiredException, Task>? notifyAuthRequired;
private bool pausedForAuthentication;
@@ -24,7 +24,7 @@ public sealed class AutoScanRunner
Func<bool> isAutoScanEnabled,
Action pauseTimer,
Action resumeTimer,
Func<Task>? notifyAuthRequired = null)
Func<AuthenticationRequiredException, Task>? notifyAuthRequired = null)
{
this.isAutoScanEnabled = isAutoScanEnabled;
this.pauseTimer = pauseTimer;
@@ -72,7 +72,11 @@ public sealed class AutoScanRunner
}
catch (Exception ex) when (AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
{
await pauseForAuthenticationAsync(ex);
// LoginFailedException and the "ADP token is null" case do not name an account, so fall back to one
// that at least carries the original failure
await pauseForAuthenticationAsync(
AuthenticationExceptionHelper.FindAuthenticationRequired(ex)
?? new AuthenticationRequiredException(account: null, message: ex.Message, innerException: ex));
}
catch (Exception ex)
{
@@ -80,7 +84,7 @@ public sealed class AutoScanRunner
}
}
private async Task pauseForAuthenticationAsync(Exception ex)
private async Task pauseForAuthenticationAsync(AuthenticationRequiredException ex)
{
if (pausedForAuthentication)
return;
@@ -88,9 +92,12 @@ public sealed class AutoScanRunner
pausedForAuthentication = true;
pauseTimer();
Log.Warning(ex, "Auto-scan paused: Audible login is required. Log in with Import > Scan Library to resume background scans.");
// masked, not the label the dialog uses: log files get attached to public issue reports
Log.Warning(ex,
"Auto-scan paused: Audible login is required for {Account}. Log in with Import > Scan Library to resume background scans.",
ex.Account?.MaskedLogEntry ?? "[unknown account]");
if (notifyAuthRequired is not null)
await notifyAuthRequired();
await notifyAuthRequired(ex);
}
}
@@ -0,0 +1,43 @@
using LibationSearchEngine;
using System;
using System.IO;
using System.Threading;
namespace LibationUiBase;
/// <summary>
/// Shared copy for telling the user that Libation's search index needs to be deleted by hand.
/// Reached only after the automatic delete-and-rebuild has already failed.
/// </summary>
public static class SearchIndexRecovery
{
public const string Caption = "Search index needs attention";
public const string ManualRecoveryInstructions
= "Libation could not use its search index, and could not repair it automatically. "
+ "Your library itself is fine; only searching and filtering are affected.\n\n"
+ "To fix it by hand:\n"
+ "1. In Settings, click 'Open log folder'\n"
+ "2. Close Libation\n"
+ "3. Delete the SearchEngine folder you find there\n"
+ "4. Start Libation again";
/// <summary>
/// True when a search failed because the index could not be reached, rather than because the query was
/// malformed. A damaged index, a held write.lock and a permission problem all arrive as IO-family exceptions,
/// plus the cloud-sync debris that Lucene reports as an <see cref="ArgumentException"/>; a query Lucene cannot
/// parse arrives as none of those. Getting this backwards would send the user hunting for a typo in a query
/// that was fine.
/// </summary>
public static bool IsIndexUnavailable(Exception ex)
=> ex is IOException or UnauthorizedAccessException
|| SearchEngine.IsRecoverableCorruptIndexException(ex);
private static int notified;
/// <summary>
/// True the first time the index fails in this session, false afterwards. A damaged index fails on every
/// library change, and these steps only need following once.
/// </summary>
public static bool ShouldNotify() => Interlocked.Exchange(ref notified, 1) == 0;
}
+26 -5
View File
@@ -1,4 +1,5 @@
using LibationWinForms.Dialogs;
using LibationUiBase;
using LibationWinForms.Dialogs;
using System;
using System.Windows.Forms;
@@ -29,6 +30,28 @@ public partial class Form1
private string? lastGoodFilter = null;
private void performFilter(string? filterString)
{
if (applyFilter(filterString) is not Exception failure)
return;
Serilog.Log.Logger.Error(failure, "Error performing filtering. {@DebugInfo}", new { filterString, lastGoodFilter });
if (SearchIndexRecovery.IsIndexUnavailable(failure))
MessageBox.Show(this, SearchIndexRecovery.ManualRecoveryInstructions, SearchIndexRecovery.Caption, MessageBoxButtons.OK, MessageBoxIcon.Warning);
else
MessageBox.Show(this, $"Bad filter string:\r\n\r\n{failure.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
// Restore the last filter that worked, then give up on filtering entirely. Recursing into performFilter
// here never terminated when the search index rather than the query was at fault, because that fails for
// every filter including the one being restored. An empty filter never reaches the search engine.
if (!string.IsNullOrEmpty(lastGoodFilter) && applyFilter(lastGoodFilter) is null)
return;
applyFilter(string.Empty);
}
/// <summary>Applies a filter, returning the exception that stopped it, or null when it worked.</summary>
private Exception? applyFilter(string? filterString)
{
this.filterSearchTb.Text = filterString;
@@ -36,13 +59,11 @@ public partial class Form1
{
productsDisplay.Filter(filterString);
lastGoodFilter = filterString;
return null;
}
catch (Exception ex)
{
MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
// re-apply last good filter
performFilter(lastGoodFilter);
return ex;
}
}
+6 -7
View File
@@ -39,26 +39,25 @@ public partial class Form1
Configuration.Instance.PropertyChanged += Configuration_PropertyChanged;
}
private void notifyAutoScanAuthRequired()
private void notifyAutoScanAuthRequired(AuthenticationRequiredException ex)
{
MessageBox.Show(
this,
"Libation could not refresh your Audible library because your login session expired.\n\n"
+ "Background auto-scan has been paused. Use Import > Scan Library to log in again to resume periodic scans.",
"Auto-scan paused - login required",
AutoScanAuthPrompt.FormatBody(ex),
AutoScanAuthPrompt.Caption,
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
private Task notifyAutoScanAuthRequiredAsync()
private Task notifyAutoScanAuthRequiredAsync(AuthenticationRequiredException ex)
{
if (InvokeRequired)
{
Invoke(notifyAutoScanAuthRequired);
Invoke(() => notifyAutoScanAuthRequired(ex));
return Task.CompletedTask;
}
notifyAutoScanAuthRequired();
notifyAutoScanAuthRequired(ex);
return Task.CompletedTask;
}
@@ -0,0 +1,42 @@
using ApplicationServices;
using LibationUiBase;
using System;
using System.Windows.Forms;
namespace LibationWinForms;
public partial class Form1
{
private void Configure_SearchIndex()
=> SearchEngineCommands.UpdateFailed += searchIndexUpdateFailed;
private void searchIndexUpdateFailed(object? sender, Exception ex)
{
try
{
if (!SearchIndexRecovery.ShouldNotify())
return;
if (InvokeRequired)
{
BeginInvoke(showSearchIndexRecoveryInstructions);
return;
}
showSearchIndexRecoveryInstructions();
}
catch (Exception dialogEx)
{
// nothing above this is allowed to fail: the library change that got us here already succeeded
Serilog.Log.Logger.Error(dialogEx, "Could not show the search index recovery instructions");
}
}
private void showSearchIndexRecoveryInstructions()
=> MessageBox.Show(
this,
SearchIndexRecovery.ManualRecoveryInstructions,
SearchIndexRecovery.Caption,
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
+1
View File
@@ -37,6 +37,7 @@ public partial class Form1 : Form
// eg: if one of these init'd productsGrid, then another can't reliably subscribe to it
Configure_BackupCounts();
Configure_ScanAuto();
Configure_SearchIndex();
Configure_ScanNotification();
Configure_VisibleBooks();
Configure_QuickFilters();
@@ -0,0 +1,95 @@
using ApplicationServices;
using AssertionHelper;
using DataLayer;
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.IO;
namespace SearchIndexUpdateGuardTests;
/// <summary>
/// A library change is committed to the database before the search index is updated, so a failure to update that
/// index must be reported rather than propagated. Letting it escape reported a successful scan as
/// "Error importing library" and starved the event's remaining subscribers.
/// </summary>
[TestClass]
[DoNotParallelize]
public class SearchIndexUpdateGuardTests
{
private string tempLibationFiles = string.Empty;
private readonly List<Exception> reportedFailures = [];
[TestInitialize]
public void Initialize()
{
tempLibationFiles = Path.Combine(Path.GetTempPath(), $"libation-search-index-guard-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempLibationFiles);
// A fresh Configuration resolves LibationFiles from this variable, so the index lands in the temp dir.
Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, tempLibationFiles);
Configuration.CreateMockInstance();
SearchEngineCommands.UpdateFailed += recordFailure;
}
[TestCleanup]
public void Cleanup()
{
SearchEngineCommands.UpdateFailed -= recordFailure;
Configuration.RestoreSingletonInstance();
Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, null);
try
{
Directory.Delete(tempLibationFiles, recursive: true);
}
catch (IOException)
{
// a leftover temp directory is not worth failing a test over
}
}
private void recordFailure(object? sender, Exception ex) => reportedFailures.Add(ex);
/// <summary>Occupying the SearchEngine path with a file leaves the engine nowhere to build its index.</summary>
private void blockTheIndexDirectory()
=> File.WriteAllText(Path.Combine(tempLibationFiles, "SearchEngine"), "not a directory");
private static List<LibraryBook> library()
{
var contributor = Contributor.GetEmpty();
var book = new Book(new AudibleProductId("B0TEST0001"), "Hound of the Baskervilles", null, null, 1, ContentType.Product, [contributor], [contributor], "us");
return [new LibraryBook(book, new DateTime(2026, 8, 15), "account")];
}
[TestMethod]
public void a_library_size_change_survives_an_unusable_search_index()
{
blockTheIndexDirectory();
SearchEngineCommands.OnLibrarySizeChanged(library());
reportedFailures.Should().HaveCount(1);
}
[TestMethod]
public void a_book_detail_change_survives_an_unusable_search_index()
{
blockTheIndexDirectory();
SearchEngineCommands.OnBookUserDefinedItemCommitted(library());
reportedFailures.Should().HaveCount(1);
}
[TestMethod]
public void a_successful_update_reports_no_failure()
{
SearchEngineCommands.OnLibrarySizeChanged(library());
reportedFailures.Should().HaveCount(0);
Directory.Exists(Path.Combine(tempLibationFiles, "SearchEngine")).Should().BeTrue();
}
}
@@ -0,0 +1,74 @@
using AssertionHelper;
using AudibleApi;
using AudibleApi.Authorization;
using AudibleApi.Cryptography;
using AudibleUtilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
namespace AudibleUtilities.Tests;
[TestClass]
public class AccountCredentialStatusTests
{
private static Account registeredAccount()
{
var identity = new Identity(Localization.Get("us"));
identity.Update(
new PrivateKey(RSA.Create(2048).ExportRSAPrivateKeyPem()),
new AdpToken("{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}"),
new AccessToken("Atna|_CHAR_ACCESS_", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
new RefreshToken("Atnr|_CHAR_REFRESH_"),
new List<KeyValuePair<string, string?>> { new("session-id", "cookie-value") },
deviceSerialNumber: "device-serial",
deviceType: "device-type",
amazonAccountId: "amzn-account",
deviceName: "device-name",
storeAuthenticationCookie: "store-auth-cookie");
return new Account("user@example.com") { AccountName = "Jade", IdentityTokens = identity };
}
[TestMethod]
public void no_account_looks_like_missing_credentials()
=> AccountCredentialStatus.LooksLikeMissingCredentials(null).Should().BeTrue();
[TestMethod]
public void an_account_that_was_never_logged_in_looks_like_missing_credentials()
=> AccountCredentialStatus.LooksLikeMissingCredentials(new Account("user@example.com")).Should().BeTrue();
/// <summary>A bare Identity has a locale but no tokens to renew from, so it needs a first login.</summary>
[TestMethod]
public void tokens_without_a_refresh_token_look_like_missing_credentials()
{
var account = new Account("user@example.com") { IdentityTokens = new Identity(Localization.Get("us")) };
AccountCredentialStatus.LooksLikeMissingCredentials(account).Should().BeTrue();
}
[TestMethod]
public void a_registered_account_does_not_look_like_missing_credentials()
=> AccountCredentialStatus.LooksLikeMissingCredentials(registeredAccount()).Should().BeFalse();
[TestMethod]
public void the_label_pairs_a_friendly_name_with_the_id()
=> AccountCredentialStatus.FormatAccountLabel(new Account("user@example.com") { AccountName = "Jade" })
.Should().Be("'Jade' (user@example.com)");
[TestMethod]
public void the_label_falls_back_to_the_id_alone()
=> AccountCredentialStatus.FormatAccountLabel(new Account("user@example.com"))
.Should().Be("'user@example.com'");
/// <summary>Accounts default their name to their id, and "'x' (x)" reads like a mistake.</summary>
[TestMethod]
public void the_label_does_not_repeat_an_id_used_as_the_name()
=> AccountCredentialStatus.FormatAccountLabel(new Account("user@example.com") { AccountName = "USER@example.com" })
.Should().Be("'user@example.com'");
[TestMethod]
public void the_label_stays_readable_without_an_account()
=> AccountCredentialStatus.FormatAccountLabel(null).Should().Be("an Audible account");
}
@@ -0,0 +1,46 @@
using AssertionHelper;
using AudibleUtilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
namespace AudibleUtilities.Tests;
/// <summary>
/// The scan wraps failures on the way up, so the exception that knows which account needs a login has to be dug
/// back out before the auto-scan dialog can name it.
/// </summary>
[TestClass]
public class FindAuthenticationRequiredTests
{
[TestMethod]
public void the_exception_itself_is_returned()
{
var auth = new AuthenticationRequiredException(new Account("user@example.com"));
AuthenticationExceptionHelper.FindAuthenticationRequired(auth).Should().BeSameAs(auth);
}
[TestMethod]
public void a_wrapped_exception_is_found()
{
var auth = new AuthenticationRequiredException(new Account("user@example.com"), "need login");
var wrapped = new Exception("Error importing library", new Exception("inner", auth));
AuthenticationExceptionHelper.FindAuthenticationRequired(wrapped).Should().BeSameAs(auth);
}
/// <summary>Scanning several accounts at once surfaces failures as an AggregateException.</summary>
[TestMethod]
public void an_aggregated_exception_is_found()
{
var auth = new AuthenticationRequiredException(new Account("user@example.com"), "need login");
var aggregate = new AggregateException(new InvalidOperationException("unrelated"), auth);
AuthenticationExceptionHelper.FindAuthenticationRequired(aggregate).Should().BeSameAs(auth);
}
[TestMethod]
public void an_unrelated_exception_yields_nothing()
=> AuthenticationExceptionHelper.FindAuthenticationRequired(new InvalidOperationException("unrelated"))
.Should().BeNull();
}
@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AssertionHelper;
using DataLayer;
using LibationSearchEngine;
using Lucene.Net.Index;
using Lucene.Net.Store;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Directory = System.IO.Directory;
namespace SearchEngineTests;
/// <summary>
/// A damaged search index used to block library imports forever: Lucene 3 reports an unreadable segments file as a
/// plain <see cref="IOException"/>, which Libation mistook for a write.lock conflict and retried instead of repairing,
/// and passing create/overwrite to <see cref="IndexWriter"/> does not repair it either.
/// </summary>
[TestClass]
public class CorruptIndexRecoveryTests
{
private string indexDirectory = null!;
[TestInitialize]
public void Initialize()
{
indexDirectory = Path.Combine(Path.GetTempPath(), "LibationSearchEngineTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(indexDirectory);
}
[TestCleanup]
public void Cleanup()
{
try
{
if (Directory.Exists(indexDirectory))
Directory.Delete(indexDirectory, recursive: true);
}
catch (IOException)
{
// Windows refuses to delete a file Lucene still holds open, and a leftover temp directory is not
// worth failing a test over
}
}
private static LibraryBook book(string asin, string title)
{
var contributor = Contributor.GetEmpty();
var b = new Book(new AudibleProductId(asin), title, null, null, 1, ContentType.Product, [contributor], [contributor], "us");
return new LibraryBook(b, new DateTime(2026, 8, 15), "account");
}
private static readonly List<LibraryBook> library
= [book("B0TEST0001", "Hound of the Baskervilles"), book("B0TEST0002", "Sign of the Four")];
private SearchEngine reIndex()
{
var engine = new SearchEngine(indexDirectory);
engine.CreateNewIndex(library);
return engine;
}
private void assertIndexIsUsable()
{
var engine = new SearchEngine(indexDirectory);
engine.Search("Baskervilles").Docs.Select(d => d.ProductId).Should().BeEquivalentTo(["B0TEST0001"]);
engine.Search(SearchEngine.ALL_QUERY).Docs.Should().HaveCount(2);
}
private string currentSegmentsFile()
=> Directory.GetFiles(indexDirectory, "segments_*").OrderBy(f => f.Length).ThenBy(f => f).Last();
/// <summary>Reproduces the state Lucene leaves behind when a commit is interrupted, eg. by a crash or power loss.</summary>
private void truncateCurrentSegmentsFile(int keepBytes)
{
var file = currentSegmentsFile();
File.WriteAllBytes(file, File.ReadAllBytes(file).Take(keepBytes).ToArray());
}
private static Exception exceptionFromOpeningWriter(string directory)
{
using var index = FSDirectory.Open(directory);
using var analyzer = new Lucene.Net.Analysis.Standard.StandardAnalyzer(SearchEngine.Version);
return Assert.ThrowsExactly<IOException>(() => new IndexWriter(index, analyzer, true, IndexWriter.MaxFieldLength.UNLIMITED).Dispose());
}
[TestMethod]
public void full_reindex_builds_a_searchable_index()
{
reIndex();
assertIndexIsUsable();
}
[TestMethod]
public void full_reindex_repairs_a_zero_length_segments_file()
{
reIndex();
truncateCurrentSegmentsFile(0);
reIndex();
assertIndexIsUsable();
}
[TestMethod]
public void full_reindex_repairs_a_partially_written_segments_file()
{
reIndex();
truncateCurrentSegmentsFile(File.ReadAllBytes(currentSegmentsFile()).Length / 2);
reIndex();
assertIndexIsUsable();
}
/// <summary>
/// The nastiest variant: the current commit is intact, so the index looks fine, but IndexWriter's IndexFileDeleter
/// reads every segments_* file it finds and one unreadable leftover poisons the whole directory.
/// </summary>
[TestMethod]
public void full_reindex_repairs_a_stale_unreadable_segments_file_beside_a_valid_commit()
{
reIndex();
File.WriteAllBytes(Path.Combine(indexDirectory, "segments_9"), []);
reIndex();
assertIndexIsUsable();
}
[TestMethod]
public void full_reindex_repairs_a_checksum_mismatch()
{
reIndex();
var file = currentSegmentsFile();
var bytes = File.ReadAllBytes(file);
bytes[^1] ^= 0xFF;
File.WriteAllBytes(file, bytes);
reIndex();
assertIndexIsUsable();
}
/// <summary>Cloud sync (eg. OneDrive) leaves conflict copies whose names Lucene 3's segments parser rejects.</summary>
[TestMethod]
public void full_reindex_repairs_cloud_sync_debris()
{
reIndex();
File.WriteAllText(Path.Combine(indexDirectory, "segments_2 (1)"), "conflict copy");
reIndex();
assertIndexIsUsable();
}
/// <summary>
/// A garbled segments.gen sends Lucene looking for an absurd generation, and Lucene 3's base-36 filename
/// formatter overruns its buffer on the way. Damage does not always announce itself as an IOException.
/// </summary>
[TestMethod]
public void full_reindex_repairs_a_segments_gen_pointing_at_a_bogus_generation()
{
reIndex();
var gen = Path.Combine(indexDirectory, "segments.gen");
// format(int) then the generation as a long, written twice; corrupt the high bytes of both copies
var bytes = File.ReadAllBytes(gen);
bytes[7] = 0x63;
bytes[15] = 0x63;
File.WriteAllBytes(gen, bytes);
reIndex();
assertIndexIsUsable();
}
[TestMethod]
public void search_repairs_a_zero_length_segments_file()
{
reIndex();
truncateCurrentSegmentsFile(0);
// the query path recovers via SearchEngineCommands, which keys off IsRecoverableCorruptIndexException
var ex = Assert.ThrowsExactly<IOException>(() => new SearchEngine(indexDirectory).Search("Baskervilles"));
SearchEngine.IsRecoverableCorruptIndexException(ex).Should().BeTrue();
}
[TestMethod]
public void an_unreadable_segments_file_is_classified_as_recoverable_corruption()
{
reIndex();
truncateCurrentSegmentsFile(0);
var ex = exceptionFromOpeningWriter(indexDirectory);
(ex is CorruptIndexException).Should().BeFalse();
ex.Message.Should().Be("read past EOF");
SearchEngine.IsRecoverableCorruptIndexException(ex).Should().BeTrue();
}
/// <summary>
/// A held write.lock is transient and must keep being retried. It derives from <see cref="IOException"/>, so
/// widening the corruption check to cover IOException must not swallow it and delete a healthy index.
/// </summary>
[TestMethod]
public void a_write_lock_conflict_is_not_classified_as_corruption()
{
SearchEngine.IsRecoverableCorruptIndexException(new LockObtainFailedException("Lock obtain timed out")).Should().BeFalse();
SearchEngine.IsRecoverableCorruptIndexException(new UnauthorizedAccessException()).Should().BeFalse();
// Windows raises the sharing violation on the lock file before Lucene can turn it into a
// LockObtainFailedException, so this arrives as a plain IOException. Mistaking it for corruption would
// delete the index the other holder is using.
SearchEngine.IsRecoverableCorruptIndexException(
new IOException(@"The process cannot access the file 'C:\Users\me\Libation\SearchEngine\write.lock' because it is being used by another process."))
.Should().BeFalse();
}
/// <summary>
/// The risk in repairing anything that is not a lock conflict is over-reach, so a held write.lock has to keep
/// being retried and must leave the index alone. The lock is held for the whole retry budget rather than
/// released part way through, which would race with Lucene 3's own lock bookkeeping.
/// </summary>
[TestMethod]
public void a_write_lock_conflict_is_retried_and_leaves_the_index_intact()
{
reIndex();
var indexedBefore = indexFiles();
using var index = FSDirectory.Open(indexDirectory);
var writeLock = index.MakeLock(IndexWriter.WRITE_LOCK_NAME);
writeLock.Obtain().Should().BeTrue();
try
{
// Linux surfaces this as Lucene's LockObtainFailedException, Windows as a sharing violation on the
// lock file. Either way it must not be read as corruption, and the index must survive.
var ex = Assert.Throws<IOException>(() => reIndex());
SearchEngine.IsRecoverableCorruptIndexException(ex).Should().BeFalse();
indexFiles().Should().BeEquivalentTo(indexedBefore);
}
finally
{
// a competing NativeFSLock.Obtain can delete the lock file it failed to take, leaving Release nothing
// to delete. Lucene 3's lock bookkeeping is not what this test is about.
try { writeLock.Release(); }
catch (Exception ex) when (ex is LockReleaseFailedException or IOException) { }
}
}
private List<string> indexFiles()
=> [.. Directory.GetFiles(indexDirectory)
.Select(Path.GetFileName)
.OfType<string>()
.Where(f => f != IndexWriter.WRITE_LOCK_NAME)
.OrderBy(f => f)];
}
@@ -0,0 +1,71 @@
using AssertionHelper;
using LibationSearchEngine;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.IO;
using DataLayer;
using Directory = System.IO.Directory;
namespace SearchEngineTests;
/// <summary>
/// The filter box has to tell a query the user mistyped apart from an index Libation cannot read: one deserves
/// "bad filter string", the other deserves the recovery steps, and only the latter is worth rebuilding over.
/// </summary>
[TestClass]
public class QueryFailureShapeTests
{
private string indexDirectory = null!;
[TestInitialize]
public void Initialize()
{
indexDirectory = Path.Combine(Path.GetTempPath(), "LibationSearchEngineTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(indexDirectory);
var contributor = Contributor.GetEmpty();
var book = new Book(new AudibleProductId("B0TEST0001"), "Hound of the Baskervilles", null, null, 1, ContentType.Product, [contributor], [contributor], "us");
new SearchEngine(indexDirectory).CreateNewIndex(new List<LibraryBook> { new(book, new DateTime(2026, 8, 15), "account") });
}
[TestCleanup]
public void Cleanup()
{
try
{
if (Directory.Exists(indexDirectory))
Directory.Delete(indexDirectory, recursive: true);
}
catch (IOException) { }
}
[TestMethod]
[DataRow("title:[unclosed")]
[DataRow("*")]
[DataRow("AND OR")]
[DataRow("(((")]
[DataRow(@"title:""unbalanced")]
public void a_malformed_query_does_not_look_like_an_unreachable_index(string searchString)
{
var engine = new SearchEngine(indexDirectory);
Exception? thrown = null;
try
{
engine.Search(searchString);
}
catch (Exception ex)
{
thrown = ex;
}
// some of these parse fine and simply match nothing, which is also acceptable
if (thrown is null)
return;
// what must never happen is a parse failure being read as index trouble and triggering a rebuild
(thrown is IOException).Should().BeFalse();
SearchEngine.IsRecoverableCorruptIndexException(thrown).Should().BeFalse();
}
}
@@ -0,0 +1,71 @@
using AudibleApi;
using AudibleApi.Authorization;
using AudibleApi.Cryptography;
using AudibleUtilities;
using LibationUiBase;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
namespace LibationUiBase.Tests;
[TestClass]
public class AutoScanAuthPromptTests
{
private static Account registeredAccount()
{
var identity = new Identity(Localization.Get("us"));
identity.Update(
new PrivateKey(RSA.Create(2048).ExportRSAPrivateKeyPem()),
new AdpToken("{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}"),
new AccessToken("Atna|_CHAR_ACCESS_", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
new RefreshToken("Atnr|_CHAR_REFRESH_"),
new List<KeyValuePair<string, string?>> { new("session-id", "cookie-value") },
deviceSerialNumber: "device-serial",
deviceType: "device-type",
amazonAccountId: "amzn-account",
deviceName: "device-name",
storeAuthenticationCookie: "store-auth-cookie");
return new Account("jade@example.com") { AccountName = "Jade", IdentityTokens = identity };
}
/// <summary>
/// The reporter's log paused auto-scan on a second account that had never been logged in, while the dialog
/// blamed an expired session and named no account at all.
/// </summary>
[TestMethod]
public void an_account_that_was_never_logged_in_is_named_and_explained()
{
var account = new Account("jade@example.com") { AccountName = "Jade" };
var body = AutoScanAuthPrompt.FormatBody(new AuthenticationRequiredException(account, "missing"));
StringAssert.Contains(body, "'Jade' (jade@example.com)");
StringAssert.Contains(body, "never been logged in");
StringAssert.Contains(body, "Import > Scan Library");
}
[TestMethod]
public void an_account_with_stored_tokens_is_told_its_login_expired()
{
var body = AutoScanAuthPrompt.FormatBody(new AuthenticationRequiredException(registeredAccount(), "expired"));
StringAssert.Contains(body, "'Jade' (jade@example.com)");
StringAssert.Contains(body, "expired");
}
[TestMethod]
public void an_unattributed_failure_still_reads_sensibly()
{
var body = AutoScanAuthPrompt.FormatBody(new AuthenticationRequiredException(account: null, "auth failed"));
StringAssert.Contains(body, "an Audible account");
StringAssert.Contains(body, "Import > Scan Library");
}
[TestMethod]
public void a_missing_exception_is_rejected()
=> Assert.ThrowsExactly<ArgumentNullException>(() => AutoScanAuthPrompt.FormatBody(null!));
}
@@ -0,0 +1,49 @@
using LibationUiBase;
using Lucene.Net.Index;
using Lucene.Net.QueryParsers;
using Lucene.Net.Store;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.IO;
namespace LibationUiBase.Tests;
[TestClass]
public class SearchIndexRecoveryTests
{
[TestMethod]
public void instructions_name_the_folder_and_how_to_find_it()
{
StringAssert.Contains(SearchIndexRecovery.ManualRecoveryInstructions, "SearchEngine");
StringAssert.Contains(SearchIndexRecovery.ManualRecoveryInstructions, "Open log folder");
// the whole point of the guard is that the library survived
StringAssert.Contains(SearchIndexRecovery.ManualRecoveryInstructions, "library itself is fine");
}
/// <summary>
/// Every way of failing to reach the index. Reporting one of these as a bad filter string sends the user
/// looking for a typo in a query that was fine.
/// </summary>
[TestMethod]
public void failures_to_reach_the_index_are_told_apart_from_a_bad_query()
{
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new IOException("read past EOF")));
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new CorruptIndexException("checksum mismatch in segments file")));
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new LockObtainFailedException("Lock obtain timed out")));
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new UnauthorizedAccessException()));
// cloud-sync debris, which Lucene reports while parsing segments file names
Assert.IsTrue(SearchIndexRecovery.IsIndexUnavailable(new ArgumentException("Invalid or unsupported character in number: )")));
Assert.IsFalse(SearchIndexRecovery.IsIndexUnavailable(new ParseException("Cannot parse '[unclosed'")));
Assert.IsFalse(SearchIndexRecovery.IsIndexUnavailable(new ArgumentException("some other argument problem")));
}
/// <summary>A damaged index fails on every library change, and these steps only need following once.</summary>
[TestMethod]
public void the_user_is_told_once_per_session()
{
Assert.IsTrue(SearchIndexRecovery.ShouldNotify());
Assert.IsFalse(SearchIndexRecovery.ShouldNotify());
Assert.IsFalse(SearchIndexRecovery.ShouldNotify());
}
}