using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Shared.Models.Deletion;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
///
/// Pure-logic tests for the SoftwareKeysJson codec used by the inventory contributions side-row
/// (Plan §17). Encoding determinism + delta computation are the load-bearing properties — the
/// cascade decrement step reads back the side-row's keys and is byte-identical to what the
/// increment path wrote.
///
public class SoftwareKeysJsonCodecTests
{
[Fact]
public void Encode_uses_raw_json_below_compression_threshold()
{
var keys = new List
{
new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
new DeletionDecrementKey { Vendor = "Fabrikam", Name = "Gadget", Version = "2.0" },
};
var encoded = SoftwareKeysJsonCodec.Encode(keys);
Assert.False(encoded.IsCompressed);
Assert.Equal(2, encoded.KeyCount);
Assert.Contains("Contoso", encoded.Encoded);
Assert.Contains("Fabrikam", encoded.Encoded);
}
[Fact]
public void Encode_uses_gzip_base64_above_compression_threshold()
{
// Generate >30 KB of raw JSON: 1000 distinct keys with ~30-char vendor each.
var keys = new List();
for (var i = 0; i < 1000; i++)
{
keys.Add(new DeletionDecrementKey
{
Vendor = $"VendorWithALongerNameForBulk-{i:D6}",
Name = $"Product-{i:D6}",
Version = $"1.{i}.0",
});
}
var encoded = SoftwareKeysJsonCodec.Encode(keys);
Assert.True(encoded.IsCompressed);
Assert.Equal(1000, encoded.KeyCount);
// Compressed payload should be much smaller than raw (heavy redundancy).
var rawSize = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(keys, DeletionManifestJson.SerializerOptions).Length;
Assert.True(encoded.Encoded.Length * 3 / 4 < rawSize / 3,
$"Compression should shrink payload by >3x. Got base64 {encoded.Encoded.Length} chars vs raw {rawSize} bytes.");
}
[Fact]
public void Encode_decode_round_trips_keys_in_deterministic_order()
{
var keys = new List
{
new DeletionDecrementKey { Vendor = "Tailspin", Name = "Service", Version = "3.0" },
new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
new DeletionDecrementKey { Vendor = "Fabrikam", Name = "Gadget", Version = "2.0" },
};
var encoded = SoftwareKeysJsonCodec.Encode(keys);
var decoded = SoftwareKeysJsonCodec.Decode(encoded.Encoded, encoded.IsCompressed);
Assert.Equal(3, decoded.Count);
// Builder sorts by (Vendor, Name, Version) so the side-row content is byte-stable
// across builds — critical for manifest-determinism (Plan §18.6).
Assert.Equal("Contoso", decoded[0].Vendor);
Assert.Equal("Fabrikam", decoded[1].Vendor);
Assert.Equal("Tailspin", decoded[2].Vendor);
}
[Fact]
public void Encode_with_compression_round_trips_through_gzip_base64()
{
// Force the compressed path with a key set that just barely exceeds the threshold.
var keys = new List();
for (var i = 0; i < 500; i++)
{
keys.Add(new DeletionDecrementKey
{
Vendor = "VendorWithALongerNameForBulk-" + i.ToString("D6"),
Name = "Product-" + i.ToString("D6"),
Version = "1." + i + ".0",
});
}
var encoded = SoftwareKeysJsonCodec.Encode(keys);
var decoded = SoftwareKeysJsonCodec.Decode(encoded.Encoded, encoded.IsCompressed);
Assert.True(encoded.IsCompressed);
Assert.Equal(500, decoded.Count);
Assert.Equal(keys[0].Vendor, decoded.First(k => k.Vendor == keys[0].Vendor).Vendor);
}
[Fact]
public void Decode_empty_string_returns_empty_list()
{
Assert.Empty(SoftwareKeysJsonCodec.Decode("", false));
Assert.Empty(SoftwareKeysJsonCodec.Decode("", true));
}
[Fact]
public void Decode_invalid_base64_throws_invalid_data()
{
var ex = Assert.Throws(
() => SoftwareKeysJsonCodec.Decode("!!! not base64 !!!", isCompressed: true));
Assert.Contains("Base64", ex.Message);
}
[Fact]
public void Decode_malformed_json_throws_invalid_data()
{
Assert.Throws(
() => SoftwareKeysJsonCodec.Decode("{not-an-array}", isCompressed: false));
}
[Fact]
public void ComputeDelta_returns_all_added_when_oldKeys_is_empty()
{
var newKeys = new[]
{
new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
new DeletionDecrementKey { Vendor = "Fabrikam", Name = "Gadget", Version = "2.0" },
};
var (added, removed) = SoftwareKeysJsonCodec.ComputeDelta(System.Array.Empty(), newKeys);
Assert.Equal(2, added.Count);
Assert.Empty(removed);
}
[Fact]
public void ComputeDelta_returns_one_added_when_newKeys_adds_a_phase2_app()
{
var oldKeys = new[]
{
new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
};
var newKeys = new[]
{
new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
new DeletionDecrementKey { Vendor = "Fabrikam", Name = "NewApp", Version = "2.0" },
};
var (added, removed) = SoftwareKeysJsonCodec.ComputeDelta(oldKeys, newKeys);
Assert.Single(added);
Assert.Contains(SoftwareKeysJsonCodec.CompositeKey("Fabrikam", "NewApp", "2.0"), added);
Assert.Empty(removed);
}
[Fact]
public void ComputeDelta_returns_one_removed_when_uninstall_happens_between_phases()
{
var oldKeys = new[]
{
new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
new DeletionDecrementKey { Vendor = "Fabrikam", Name = "OldApp", Version = "0.9" },
};
var newKeys = new[]
{
new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
};
var (added, removed) = SoftwareKeysJsonCodec.ComputeDelta(oldKeys, newKeys);
Assert.Empty(added);
Assert.Single(removed);
Assert.Contains(SoftwareKeysJsonCodec.CompositeKey("Fabrikam", "OldApp", "0.9"), removed);
}
[Fact]
public void ComputeDelta_is_empty_when_keys_are_identical()
{
var keys = new[]
{
new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
new DeletionDecrementKey { Vendor = "Fabrikam", Name = "Gadget", Version = "2.0" },
};
var (added, removed) = SoftwareKeysJsonCodec.ComputeDelta(keys, keys);
Assert.Empty(added);
Assert.Empty(removed);
}
[Fact]
public void ComputeDelta_is_case_insensitive()
{
// Upstream normalization is best-effort; "Contoso" and "contoso" must collapse so a case-
// flip between two correlation events on the same session doesn't trigger spurious deltas.
var oldKeys = new[]
{
new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" },
};
var newKeys = new[]
{
new DeletionDecrementKey { Vendor = "contoso", Name = "widget", Version = "1.0" },
};
var (added, removed) = SoftwareKeysJsonCodec.ComputeDelta(oldKeys, newKeys);
Assert.Empty(added);
Assert.Empty(removed);
}
[Fact]
public void CompositeKey_builds_three_part_separated_value()
{
var ck = SoftwareKeysJsonCodec.CompositeKey("Contoso", "Widget", "1.0");
Assert.NotEqual("ContosoWidget1.0", ck); // separator IS present
// Different orderings of the same string components produce different composites.
var ck2 = SoftwareKeysJsonCodec.CompositeKey("Widget", "Contoso", "1.0");
Assert.NotEqual(ck, ck2);
}
}