diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs
new file mode 100644
index 0000000..88e64c3
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs
@@ -0,0 +1,153 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
+
+///
+/// Galaxy backend that uses the live ZB repository for —
+/// real gobject hierarchy + attributes flow through to the Proxy without needing the MXAccess
+/// COM client. Runtime data-plane calls (Read/Write/Subscribe/Alarm/History) still surface
+/// as "MXAccess code lift pending" until the COM client port lands. This is the highest-value
+/// intermediate state because Discover is what powers the OPC UA address-space build, so
+/// downstream Proxy + parity tests can exercise the complete tree shape today.
+///
+public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxyBackend
+{
+ private long _nextSessionId;
+ private long _nextSubscriptionId;
+
+ public Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
+ {
+ var id = Interlocked.Increment(ref _nextSessionId);
+ return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
+ }
+
+ public Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) => Task.CompletedTask;
+
+ public async Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct)
+ {
+ try
+ {
+ var hierarchy = await repository.GetHierarchyAsync(ct).ConfigureAwait(false);
+ var attributes = await repository.GetAttributesAsync(ct).ConfigureAwait(false);
+
+ // Group attributes by their owning gobject for the IPC payload.
+ var attrsByGobject = attributes
+ .GroupBy(a => a.GobjectId)
+ .ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray());
+
+ var parentByChild = hierarchy
+ .ToDictionary(o => o.GobjectId, o => o.ParentGobjectId);
+ var nameByGobject = hierarchy
+ .ToDictionary(o => o.GobjectId, o => o.TagName);
+
+ var objects = hierarchy.Select(o => new GalaxyObjectInfo
+ {
+ ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName,
+ TagName = o.TagName,
+ ParentContainedName = parentByChild.TryGetValue(o.GobjectId, out var p)
+ && p != 0
+ && nameByGobject.TryGetValue(p, out var pName)
+ ? pName
+ : null,
+ TemplateCategory = MapCategory(o.CategoryId),
+ Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : System.Array.Empty(),
+ }).ToArray();
+
+ return new DiscoverHierarchyResponse { Success = true, Objects = objects };
+ }
+ catch (Exception ex) when (ex is System.Data.SqlClient.SqlException
+ or InvalidOperationException
+ or TimeoutException)
+ {
+ return new DiscoverHierarchyResponse
+ {
+ Success = false,
+ Error = $"Galaxy ZB repository error: {ex.Message}",
+ Objects = System.Array.Empty(),
+ };
+ }
+ }
+
+ public Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct)
+ => Task.FromResult(new ReadValuesResponse
+ {
+ Success = false,
+ Error = "MXAccess code lift pending (Phase 2 Task B.1) — DB-backed backend covers Discover only",
+ Values = System.Array.Empty(),
+ });
+
+ public Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct)
+ {
+ var results = new WriteValueResult[req.Writes.Length];
+ for (var i = 0; i < req.Writes.Length; i++)
+ {
+ results[i] = new WriteValueResult
+ {
+ TagReference = req.Writes[i].TagReference,
+ StatusCode = 0x80020000u,
+ Error = "MXAccess code lift pending (Phase 2 Task B.1)",
+ };
+ }
+ return Task.FromResult(new WriteValuesResponse { Results = results });
+ }
+
+ public Task SubscribeAsync(SubscribeRequest req, CancellationToken ct)
+ {
+ var sid = Interlocked.Increment(ref _nextSubscriptionId);
+ return Task.FromResult(new SubscribeResponse
+ {
+ Success = true,
+ SubscriptionId = sid,
+ ActualIntervalMs = req.RequestedIntervalMs,
+ });
+ }
+
+ public Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
+ public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
+ public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
+
+ public Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
+ => Task.FromResult(new HistoryReadResponse
+ {
+ Success = false,
+ Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
+ Tags = System.Array.Empty(),
+ });
+
+ public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct)
+ => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
+
+ private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new()
+ {
+ AttributeName = row.AttributeName,
+ MxDataType = row.MxDataType,
+ IsArray = row.IsArray,
+ ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
+ SecurityClassification = row.SecurityClassification,
+ IsHistorized = row.IsHistorized,
+ };
+
+ ///
+ /// Galaxy template_definition.category_id → human-readable name.
+ /// Mirrors v1 Host's AlarmObjectFilter mapping.
+ ///
+ private static string MapCategory(int categoryId) => categoryId switch
+ {
+ 1 => "$WinPlatform",
+ 3 => "$AppEngine",
+ 4 => "$Area",
+ 10 => "$UserDefined",
+ 11 => "$ApplicationObject",
+ 13 => "$Area",
+ 17 => "$DeviceIntegration",
+ 24 => "$ViewEngine",
+ 26 => "$ViewApp",
+ _ => $"category-{categoryId}",
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs
new file mode 100644
index 0000000..8f0ede4
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs
@@ -0,0 +1,35 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
+
+///
+/// One row from the v1 HierarchySql. Galaxy gobject deployed instance with its
+/// hierarchy parent + template-chain context.
+///
+public sealed class GalaxyHierarchyRow
+{
+ public int GobjectId { get; init; }
+ public string TagName { get; init; } = string.Empty;
+ public string ContainedName { get; init; } = string.Empty;
+ public string BrowseName { get; init; } = string.Empty;
+ public int ParentGobjectId { get; init; }
+ public bool IsArea { get; init; }
+ public int CategoryId { get; init; }
+ public int HostedByGobjectId { get; init; }
+ public System.Collections.Generic.IReadOnlyList TemplateChain { get; init; } = System.Array.Empty();
+}
+
+/// One row from the v1 AttributesSql.
+public sealed class GalaxyAttributeRow
+{
+ public int GobjectId { get; init; }
+ public string TagName { get; init; } = string.Empty;
+ public string AttributeName { get; init; } = string.Empty;
+ public string FullTagReference { get; init; } = string.Empty;
+ public int MxDataType { get; init; }
+ public string? DataTypeName { get; init; }
+ public bool IsArray { get; init; }
+ public int? ArrayDimension { get; init; }
+ public int MxAttributeCategory { get; init; }
+ public int SecurityClassification { get; init; }
+ public bool IsHistorized { get; init; }
+ public bool IsAlarm { get; init; }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs
new file mode 100644
index 0000000..2d511be
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs
@@ -0,0 +1,224 @@
+using System;
+using System.Collections.Generic;
+using System.Data.SqlClient;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
+
+///
+/// SQL access to the Galaxy ZB repository — port of v1 GalaxyRepositoryService.
+/// The two SQL bodies (Hierarchy + Attributes) are byte-for-byte identical to v1 so the
+/// queries surface the same row set at parity time. Extended-attributes and scope-filter
+/// queries from v1 are intentionally not ported yet — they're refinements that aren't on
+/// the Phase 2 critical path.
+///
+public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
+{
+ public async Task TestConnectionAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ using var conn = new SqlConnection(options.ConnectionString);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+ using var cmd = new SqlCommand("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
+ var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
+ return result is int i && i == 1;
+ }
+ catch (SqlException) { return false; }
+ catch (InvalidOperationException) { return false; }
+ }
+
+ public async Task GetLastDeployTimeAsync(CancellationToken ct = default)
+ {
+ using var conn = new SqlConnection(options.ConnectionString);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+ using var cmd = new SqlCommand("SELECT time_of_last_deploy FROM galaxy", conn)
+ { CommandTimeout = options.CommandTimeoutSeconds };
+ var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
+ return result is DateTime dt ? dt : null;
+ }
+
+ public async Task> GetHierarchyAsync(CancellationToken ct = default)
+ {
+ var rows = new List();
+
+ using var conn = new SqlConnection(options.ConnectionString);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+
+ using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
+ using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ var templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
+ var templateChain = templateChainRaw.Length == 0
+ ? Array.Empty()
+ : templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(s => s.Trim())
+ .Where(s => s.Length > 0)
+ .ToArray();
+
+ rows.Add(new GalaxyHierarchyRow
+ {
+ GobjectId = Convert.ToInt32(reader.GetValue(0)),
+ TagName = reader.GetString(1),
+ ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
+ BrowseName = reader.GetString(3),
+ ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
+ IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
+ CategoryId = Convert.ToInt32(reader.GetValue(6)),
+ HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
+ TemplateChain = templateChain,
+ });
+ }
+ return rows;
+ }
+
+ public async Task> GetAttributesAsync(CancellationToken ct = default)
+ {
+ var rows = new List();
+
+ using var conn = new SqlConnection(options.ConnectionString);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+
+ using var cmd = new SqlCommand(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
+ using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ rows.Add(new GalaxyAttributeRow
+ {
+ GobjectId = Convert.ToInt32(reader.GetValue(0)),
+ TagName = reader.GetString(1),
+ AttributeName = reader.GetString(2),
+ FullTagReference = reader.GetString(3),
+ MxDataType = Convert.ToInt32(reader.GetValue(4)),
+ DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5),
+ IsArray = Convert.ToInt32(reader.GetValue(6)) == 1,
+ ArrayDimension = reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)),
+ MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)),
+ SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
+ IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
+ IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1,
+ });
+ }
+ return rows;
+ }
+
+ private const string HierarchySql = @"
+;WITH template_chain AS (
+ SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
+ t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
+ FROM gobject g
+ INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
+ WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
+ UNION ALL
+ SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
+ FROM template_chain tc
+ INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
+ WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
+)
+SELECT DISTINCT
+ g.gobject_id,
+ g.tag_name,
+ g.contained_name,
+ CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
+ THEN g.tag_name
+ ELSE g.contained_name
+ END AS browse_name,
+ CASE WHEN g.contained_by_gobject_id = 0
+ THEN g.area_gobject_id
+ ELSE g.contained_by_gobject_id
+ END AS parent_gobject_id,
+ CASE WHEN td.category_id = 13
+ THEN 1
+ ELSE 0
+ END AS is_area,
+ td.category_id AS category_id,
+ g.hosted_by_gobject_id AS hosted_by_gobject_id,
+ ISNULL(
+ STUFF((
+ SELECT '|' + tc.template_tag_name
+ FROM template_chain tc
+ WHERE tc.instance_gobject_id = g.gobject_id
+ ORDER BY tc.depth
+ FOR XML PATH('')
+ ), 1, 1, ''),
+ ''
+ ) AS template_chain
+FROM gobject g
+INNER JOIN template_definition td
+ ON g.template_definition_id = td.template_definition_id
+WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
+ AND g.is_template = 0
+ AND g.deployed_package_id <> 0
+ORDER BY parent_gobject_id, g.tag_name";
+
+ private const string AttributesSql = @"
+;WITH deployed_package_chain AS (
+ SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
+ FROM gobject g
+ INNER JOIN package p ON p.package_id = g.deployed_package_id
+ WHERE g.is_template = 0 AND g.deployed_package_id <> 0
+ UNION ALL
+ SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
+ FROM deployed_package_chain dpc
+ INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
+ WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
+)
+SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
+ mx_data_type, data_type_name, is_array, array_dimension,
+ mx_attribute_category, security_classification, is_historized, is_alarm
+FROM (
+ SELECT
+ dpc.gobject_id,
+ g.tag_name,
+ da.attribute_name,
+ g.tag_name + '.' + da.attribute_name
+ + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
+ AS full_tag_reference,
+ da.mx_data_type,
+ dt.description AS data_type_name,
+ da.is_array,
+ CASE WHEN da.is_array = 1
+ THEN CONVERT(int, CONVERT(varbinary(2),
+ SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
+ ELSE NULL
+ END AS array_dimension,
+ da.mx_attribute_category,
+ da.security_classification,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM deployed_package_chain dpc2
+ INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
+ INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
+ WHERE dpc2.gobject_id = dpc.gobject_id
+ ) THEN 1 ELSE 0 END AS is_historized,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM deployed_package_chain dpc2
+ INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
+ INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
+ WHERE dpc2.gobject_id = dpc.gobject_id
+ ) THEN 1 ELSE 0 END AS is_alarm,
+ ROW_NUMBER() OVER (
+ PARTITION BY dpc.gobject_id, da.attribute_name
+ ORDER BY dpc.depth
+ ) AS rn
+ FROM deployed_package_chain dpc
+ INNER JOIN dynamic_attribute da
+ ON da.package_id = dpc.package_id
+ INNER JOIN gobject g
+ ON g.gobject_id = dpc.gobject_id
+ INNER JOIN template_definition td
+ ON td.template_definition_id = g.template_definition_id
+ LEFT JOIN data_type dt
+ ON dt.mx_data_type = da.mx_data_type
+ WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
+ AND da.attribute_name NOT LIKE '[_]%'
+ AND da.attribute_name NOT LIKE '%.Description'
+ AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
+) ranked
+WHERE rn = 1
+ORDER BY tag_name, attribute_name";
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs
new file mode 100644
index 0000000..b72a759
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs
@@ -0,0 +1,13 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
+
+///
+/// Connection settings for the Galaxy ZB repository database. Set from the
+/// DriverConfig JSON section Database per plan.md §"Galaxy DriverConfig".
+///
+public sealed class GalaxyRepositoryOptions
+{
+ public string ConnectionString { get; init; } =
+ "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
+
+ public int CommandTimeoutSeconds { get; init; } = 60;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj
index 565eb6c..0c713f8 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj
@@ -20,6 +20,7 @@
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs
new file mode 100644
index 0000000..fe5f741
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
+{
+ ///
+ /// Live smoke against the Galaxy ZB repository. Skipped when ZB is unreachable so
+ /// CI / dev boxes without an AVEVA install still pass. Exercises the ported
+ /// + against the same
+ /// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the
+ /// DiscoverHierarchyResponse shape.
+ ///
+ [Trait("Category", "LiveGalaxy")]
+ public sealed class GalaxyRepositoryLiveSmokeTests
+ {
+ private static GalaxyRepositoryOptions DevZbOptions() => new()
+ {
+ ConnectionString =
+ "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=2;",
+ CommandTimeoutSeconds = 10,
+ };
+
+ private static async Task ZbReachableAsync()
+ {
+ try
+ {
+ var repo = new GalaxyRepository(DevZbOptions());
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
+ return await repo.TestConnectionAsync(cts.Token);
+ }
+ catch { return false; }
+ }
+
+ [Fact]
+ public async Task TestConnection_returns_true_against_live_ZB()
+ {
+ if (!await ZbReachableAsync()) return;
+
+ var repo = new GalaxyRepository(DevZbOptions());
+ (await repo.TestConnectionAsync()).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task GetHierarchy_returns_at_least_one_deployed_gobject()
+ {
+ if (!await ZbReachableAsync()) return;
+
+ var repo = new GalaxyRepository(DevZbOptions());
+ var rows = await repo.GetHierarchyAsync();
+
+ rows.Count.ShouldBeGreaterThan(0,
+ "the dev Galaxy has at least the WinPlatform + AppEngine deployed");
+ rows.ShouldAllBe(r => !string.IsNullOrEmpty(r.TagName));
+ }
+
+ [Fact]
+ public async Task GetAttributes_returns_attributes_for_deployed_objects()
+ {
+ if (!await ZbReachableAsync()) return;
+
+ var repo = new GalaxyRepository(DevZbOptions());
+ var attrs = await repo.GetAttributesAsync();
+
+ attrs.Count.ShouldBeGreaterThan(0);
+ attrs.ShouldAllBe(a => !string.IsNullOrEmpty(a.FullTagReference) && a.FullTagReference.Contains("."));
+ }
+
+ [Fact]
+ public async Task GetLastDeployTime_returns_a_value()
+ {
+ if (!await ZbReachableAsync()) return;
+
+ var repo = new GalaxyRepository(DevZbOptions());
+ var ts = await repo.GetLastDeployTimeAsync();
+ ts.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task DbBackedBackend_DiscoverAsync_returns_objects_with_attributes_and_categories()
+ {
+ if (!await ZbReachableAsync()) return;
+
+ var backend = new DbBackedGalaxyBackend(new GalaxyRepository(DevZbOptions()));
+ var resp = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = 1 }, CancellationToken.None);
+
+ resp.Success.ShouldBeTrue(resp.Error);
+ resp.Objects.Length.ShouldBeGreaterThan(0);
+
+ var firstWithAttrs = System.Linq.Enumerable.FirstOrDefault(resp.Objects, o => o.Attributes.Length > 0);
+ firstWithAttrs.ShouldNotBeNull("at least one gobject in the dev Galaxy carries dynamic attributes");
+ firstWithAttrs!.TemplateCategory.ShouldNotBeNullOrEmpty();
+ }
+ }
+}