2026-10-15 16:42:02 +02:00
using System.Text.Json ;
using AIStudio.Tools.DataBackup ;
using SettingsVersion = AIStudio . Settings . Version ;
namespace AIStudio.Tests.Tools.DataBackup ;
/// <summary>
/// Checks what the first start after a restore, a crashed backup, or a crashed preparation removes,
/// and what it must keep.
/// </summary>
/// <remarks>
/// While the runtime has not finished a restore, its journal and the data it replaced are the only way
/// back. Removing them would lose the data of the user for good.
/// </remarks>
[TestFixture]
public sealed class DataRestoreFollowUpTests
{
private string testDirectory = string . Empty ;
private string DataDirectory => Path . Combine ( this . testDirectory , "data" );
private string ConfigDirectory => Path . Combine ( this . testDirectory , "config" );
[SetUp]
public void CreateFolders () => this . testDirectory = Path . Combine ( Path . GetTempPath (), $"ai-studio-restore-follow-up-{Guid.NewGuid():N}" );
[TearDown]
public void RemoveFolders ()
{
if ( Directory . Exists ( this . testDirectory ))
Directory . Delete ( this . testDirectory , true );
}
[Test]
public void WhatABackupOrAPreparationLeftHalfwayIsRemoved ()
{
WriteFile ( Path . Combine ( this . DataDirectory , ".backup-staging" , "0123456789abcdef" , "store" ), "copy" );
WriteFile ( Path . Combine ( this . DataDirectory , ".restore-pending" , "workspaces" , "chat" , "thread.json" ), "{}" );
WriteFile ( Path . Combine ( this . ConfigDirectory , ".restore-pending" , "settings.json" ), "{}" );
DataRestoreFollowUp . RemoveLeftovers ( this . DataDirectory , this . ConfigDirectory );
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-15 16:42:02 +02:00
{
Assert . That ( Directory . Exists ( Path . Combine ( this . DataDirectory , ".backup-staging" )), Is . False );
Assert . That ( Directory . Exists ( Path . Combine ( this . DataDirectory , ".restore-pending" )), Is . False );
Assert . That ( Directory . Exists ( Path . Combine ( this . ConfigDirectory , ".restore-pending" )), Is . False );
2026-11-01 20:32:16 +01:00
}
2026-10-15 16:42:02 +02:00
}
[Test]
public void ARestoreWhichWaitsForTheRestartIsKept ()
{
WriteFile ( Path . Combine ( this . DataDirectory , ".restore-pending" , "workspaces" , "chat" , "thread.json" ), "{}" );
WriteFile ( Path . Combine ( this . DataDirectory , ".restore-pending" , DataRestoreFormat . READY_FILE_NAME ), "now" );
DataRestoreFollowUp . RemoveLeftovers ( this . DataDirectory , this . ConfigDirectory );
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-15 16:42:02 +02:00
{
Assert . That ( DataRestoreFollowUp . IsRestorePending ( this . DataDirectory ), Is . True );
Assert . That ( DataRestoreFollowUp . IsRestoreUnfinished ( this . DataDirectory ), Is . False );
Assert . That ( File . Exists ( Path . Combine ( this . DataDirectory , ".restore-pending" , "workspaces" , "chat" , "thread.json" )), Is . True );
2026-11-01 20:32:16 +01:00
}
2026-10-15 16:42:02 +02:00
}
[Test]
public void ARestoreWhichTheRuntimeHasNotFinishedKeepsEverything ()
{
WriteFile ( Path . Combine ( this . DataDirectory , ".restore-pending" , DataRestoreFormat . READY_FILE_NAME ), "now" );
WriteFile ( Path . Combine ( this . DataDirectory , ".restore-pending" , DataRestoreFormat . JOURNAL_FILE_NAME ), """{ " moves ": [], " started ": 0 }""" );
WriteFile ( Path . Combine ( this . DataDirectory , ".restore-previous" , "workspaces" , "chat" , "thread.json" ), "{}" );
var target = new DataRestoreTarget ( this . DataDirectory , this . ConfigDirectory , "26.10.1" , SettingsVersion . V6 , []);
DataRestoreFollowUp . RemoveLeftovers ( this . DataDirectory , this . ConfigDirectory );
var removedReplacedData = DataRestoreFollowUp . RemoveReplacedData ( this . DataDirectory , this . ConfigDirectory );
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-15 16:42:02 +02:00
{
Assert . That ( DataRestoreFollowUp . IsRestoreUnfinished ( this . DataDirectory ), Is . True );
Assert . That ( DataRestoreFollowUp . IsRestorePending ( this . DataDirectory ), Is . False , "The runtime moves it back rather than applying it." );
Assert . That ( removedReplacedData , Is . False );
Assert . That ( File . Exists ( Path . Combine ( this . DataDirectory , ".restore-previous" , "workspaces" , "chat" , "thread.json" )), Is . True , "These are the data of the user." );
Assert . That (() => DataRestorePreparer . Discard ( target ), Throws . InvalidOperationException );
Assert . That ( File . Exists ( Path . Combine ( this . DataDirectory , ".restore-pending" , DataRestoreFormat . JOURNAL_FILE_NAME )), Is . True );
2026-11-01 20:32:16 +01:00
}
2026-10-15 16:42:02 +02:00
}
[Test]
public void TheReplacedDataAreRemovedOnceNoRestoreWaits ()
{
WriteFile ( Path . Combine ( this . DataDirectory , ".restore-previous" , "workspaces" , "chat" , "thread.json" ), "{}" );
WriteFile ( Path . Combine ( this . ConfigDirectory , ".restore-previous" , "settings.json" ), "{}" );
var removedReplacedData = DataRestoreFollowUp . RemoveReplacedData ( this . DataDirectory , this . ConfigDirectory );
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-15 16:42:02 +02:00
{
Assert . That ( removedReplacedData , Is . True );
Assert . That ( Directory . Exists ( Path . Combine ( this . DataDirectory , ".restore-previous" )), Is . False );
Assert . That ( Directory . Exists ( Path . Combine ( this . ConfigDirectory , ".restore-previous" )), Is . False );
2026-11-01 20:32:16 +01:00
}
2026-10-15 16:42:02 +02:00
}
[Test]
public async Task TheReportOfTheRuntimeIsRead ()
{
WriteFile ( Path . Combine ( this . DataDirectory , DataRestoreFormat . RESULT_FILE_NAME ), """
{
"outcome" : "ROLLED_BACK" ,
"detail" : "Could not move '.restore-pending/plugins' to 'plugins': access denied" ,
"prepared_at_utc" : "2026-10-15T08:00:00.0000000+00:00" ,
"backup_created_at_utc" : "2026-10-01T17:30:00.0000000+00:00" ,
"backup_app_version" : "26.10.1" ,
"finished_at_utc" : "2026-10-15T08:01:00.123456+00:00"
}
""");
var report = await DataRestoreFollowUp . ReadReportAsync ( this . DataDirectory , CancellationToken . None );
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-15 16:42:02 +02:00
{
Assert . That ( report , Is . Not . Null );
Assert . That ( report !. Outcome , Is . EqualTo ( DataRestoreOutcome . ROLLED_BACK ));
Assert . That ( report . BackupAppVersion , Is . EqualTo ( "26.10.1" ));
Assert . That ( report . Detail , Does . Contain ( "access denied" ));
2026-11-01 20:32:16 +01:00
}
2026-10-15 16:42:02 +02:00
}
[Test]
public async Task WithoutARestoreThereIsNoReport ()
{
Directory . CreateDirectory ( this . DataDirectory );
var report = await DataRestoreFollowUp . ReadReportAsync ( this . DataDirectory , CancellationToken . None );
Assert . That ( report , Is . Null );
}
[Test]
public void AReportWithAnUnknownOutcomeCannotBeRead ()
{
WriteFile ( Path . Combine ( this . DataDirectory , DataRestoreFormat . RESULT_FILE_NAME ), """{ " outcome ": " SOMETHING_NEW ", " detail ": "", " prepared_at_utc ": "", " backup_created_at_utc ": "", " backup_app_version ": "", " finished_at_utc ": "" }""" );
Assert . That ( async () => await DataRestoreFollowUp . ReadReportAsync ( this . DataDirectory , CancellationToken . None ), Throws . InstanceOf < JsonException >());
}
private static void WriteFile ( string path , string content )
{
Directory . CreateDirectory ( Path . GetDirectoryName ( path )!);
File . WriteAllText ( path , content );
}
}