diff --git a/.github/workflows/ci-mobile.yml b/.github/workflows/ci-mobile.yml
index c3ded279..95d39052 100644
--- a/.github/workflows/ci-mobile.yml
+++ b/.github/workflows/ci-mobile.yml
@@ -39,11 +39,37 @@ jobs:
- name: Build
run: dotnet build ${{ matrix.project }} --configuration Release --no-restore
+ dotnet-client-app-tests:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ project:
+ - apps/app-client-base-net/tests/AppClientBase.UnitTests/AppClientBase.UnitTests.csproj
+ - apps/web-client-base-net/tests/WebClientBase.SmokeTests/WebClientBase.SmokeTests.csproj
+ - apps/web-client-tpos-net/tests/WebClientTpos.SmokeTests/WebClientTpos.SmokeTests.csproj
+ - apps/web-client-eggymon-landipage-net/tests/EggymonLandingPage.SmokeTests/EggymonLandingPage.SmokeTests.csproj
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Restore tests
+ run: dotnet restore ${{ matrix.project }}
+
+ - name: Run tests
+ run: dotnet test ${{ matrix.project }} --configuration Release --no-restore
+
swift-client-app:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
+ - name: Run Swift smoke tests
+ run: swift test --package-path apps/app-client-base-swift/smoke-tests
+
- name: Build Swift iOS app
run: |
xcodebuild \
diff --git a/apps/app-client-base-net/tests/AppClientBase.UnitTests/AppClientBase.UnitTests.csproj b/apps/app-client-base-net/tests/AppClientBase.UnitTests/AppClientBase.UnitTests.csproj
new file mode 100644
index 00000000..090bf2e8
--- /dev/null
+++ b/apps/app-client-base-net/tests/AppClientBase.UnitTests/AppClientBase.UnitTests.csproj
@@ -0,0 +1,41 @@
+
+
+
+ AppClientBase.UnitTests
+ AppClientBase.UnitTests
+ net10.0
+ false
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-client-base-net/tests/AppClientBase.UnitTests/ViewModels/MainViewModelTests.cs b/apps/app-client-base-net/tests/AppClientBase.UnitTests/ViewModels/MainViewModelTests.cs
new file mode 100644
index 00000000..864c50a3
--- /dev/null
+++ b/apps/app-client-base-net/tests/AppClientBase.UnitTests/ViewModels/MainViewModelTests.cs
@@ -0,0 +1,81 @@
+using AppClientBase.Services;
+using AppClientBase.ViewModels;
+using FluentAssertions;
+using Xunit;
+
+namespace AppClientBase.UnitTests.ViewModels;
+
+///
+/// EN: Unit tests for main view model behavior.
+/// VI: Unit tests cho hành vi của main view model.
+///
+public class MainViewModelTests
+{
+ [Fact]
+ public void Constructor_ShouldSetDefaultTitleAndWelcomeMessage()
+ {
+ // Arrange
+ var navigationService = new FakeNavigationService();
+ var settingsService = new FakeSettingsService();
+
+ // Act
+ var viewModel = new MainViewModel(navigationService, settingsService);
+
+ // Assert
+ viewModel.Title.Should().Be("Home");
+ viewModel.WelcomeMessage.Should().Be("Welcome to AppClientBase!");
+ viewModel.ClickCount.Should().Be(0);
+ viewModel.ButtonText.Should().Be("Click me");
+ }
+
+ [Fact]
+ public void IncrementCounterCommand_ShouldIncreaseCountAndPersistSetting()
+ {
+ // Arrange
+ var navigationService = new FakeNavigationService();
+ var settingsService = new FakeSettingsService();
+ var viewModel = new MainViewModel(navigationService, settingsService);
+
+ // Act
+ viewModel.IncrementCounterCommand.Execute(null);
+ viewModel.IncrementCounterCommand.Execute(null);
+
+ // Assert
+ viewModel.ClickCount.Should().Be(2);
+ viewModel.ButtonText.Should().Be("Clicked 2 times");
+ settingsService.Get("ClickCount", 0).Should().Be(2);
+ }
+
+ private sealed class FakeNavigationService : INavigationService
+ {
+ public Task GoToAsync(string route, IDictionary? parameters = null) => Task.CompletedTask;
+
+ public Task GoBackAsync() => Task.CompletedTask;
+ }
+
+ private sealed class FakeSettingsService : ISettingsService
+ {
+ private readonly Dictionary _store = new(StringComparer.Ordinal);
+
+ public T Get(string key, T defaultValue)
+ {
+ if (_store.TryGetValue(key, out var value) && value is T typed)
+ {
+ return typed;
+ }
+
+ return defaultValue;
+ }
+
+ public void Set(string key, T value)
+ {
+ _store[key] = value;
+ }
+
+ public bool Contains(string key) => _store.ContainsKey(key);
+
+ public void Remove(string key) => _store.Remove(key);
+
+ public void Clear() => _store.Clear();
+ }
+}
diff --git a/apps/app-client-base-swift/smoke-tests/Package.swift b/apps/app-client-base-swift/smoke-tests/Package.swift
new file mode 100644
index 00000000..01f2264b
--- /dev/null
+++ b/apps/app-client-base-swift/smoke-tests/Package.swift
@@ -0,0 +1,25 @@
+// swift-tools-version: 5.9
+
+import PackageDescription
+
+let package = Package(
+ name: "AppClientBaseSwiftSmokeTests",
+ platforms: [
+ .macOS(.v14),
+ ],
+ products: [
+ .library(
+ name: "AppClientBaseSwiftSmoke",
+ targets: ["AppClientBaseSwiftSmoke"]
+ ),
+ ],
+ targets: [
+ .target(
+ name: "AppClientBaseSwiftSmoke"
+ ),
+ .testTarget(
+ name: "AppClientBaseSwiftSmokeTests",
+ dependencies: ["AppClientBaseSwiftSmoke"]
+ ),
+ ]
+)
diff --git a/apps/app-client-base-swift/smoke-tests/Sources/AppClientBaseSwiftSmoke/LocalizationManifest.swift b/apps/app-client-base-swift/smoke-tests/Sources/AppClientBaseSwiftSmoke/LocalizationManifest.swift
new file mode 100644
index 00000000..e1a8b2bd
--- /dev/null
+++ b/apps/app-client-base-swift/smoke-tests/Sources/AppClientBaseSwiftSmoke/LocalizationManifest.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+/// EN: Localization resource smoke-test helper.
+/// VI: Helper smoke-test cho tài nguyên localization.
+public enum LocalizationManifest {
+ /// EN: Relative path from package root to app resources.
+ /// VI: Đường dẫn tương đối từ package root đến tài nguyên ứng dụng.
+ public static let resourcesRelativePath = "../AppClientBaseSwift/AppClientBaseSwift/Resources"
+
+ /// EN: Build absolute path to Localizable.strings for a locale.
+ /// VI: Tạo đường dẫn tuyệt đối tới Localizable.strings theo locale.
+ public static func localizableFilePath(
+ for locale: String,
+ currentDirectoryPath: String = FileManager.default.currentDirectoryPath
+ ) -> String {
+ let localeFolder = "\(locale).lproj"
+ return URL(fileURLWithPath: currentDirectoryPath)
+ .appendingPathComponent(resourcesRelativePath)
+ .appendingPathComponent(localeFolder)
+ .appendingPathComponent("Localizable.strings")
+ .path
+ }
+
+ /// EN: Check if a locale file exists.
+ /// VI: Kiểm tra file locale có tồn tại hay không.
+ public static func hasLocale(_ locale: String, fileManager: FileManager = .default) -> Bool {
+ fileManager.fileExists(atPath: localizableFilePath(for: locale))
+ }
+}
diff --git a/apps/app-client-base-swift/smoke-tests/Tests/AppClientBaseSwiftSmokeTests/LocalizationManifestTests.swift b/apps/app-client-base-swift/smoke-tests/Tests/AppClientBaseSwiftSmokeTests/LocalizationManifestTests.swift
new file mode 100644
index 00000000..171f8b3c
--- /dev/null
+++ b/apps/app-client-base-swift/smoke-tests/Tests/AppClientBaseSwiftSmokeTests/LocalizationManifestTests.swift
@@ -0,0 +1,18 @@
+import XCTest
+@testable import AppClientBaseSwiftSmoke
+
+/// EN: Smoke tests for localization assets.
+/// VI: Smoke tests cho tài nguyên bản địa hóa.
+final class LocalizationManifestTests: XCTestCase {
+ func testEnglishLocalizationFileShouldExist() {
+ XCTAssertTrue(LocalizationManifest.hasLocale("en"))
+ }
+
+ func testVietnameseLocalizationFileShouldExist() {
+ XCTAssertTrue(LocalizationManifest.hasLocale("vi"))
+ }
+
+ func testUnknownLocaleShouldNotExist() {
+ XCTAssertFalse(LocalizationManifest.hasLocale("zz"))
+ }
+}
diff --git a/apps/web-client-base-net/tests/WebClientBase.SmokeTests/ApiResponseTests.cs b/apps/web-client-base-net/tests/WebClientBase.SmokeTests/ApiResponseTests.cs
new file mode 100644
index 00000000..4c7483c5
--- /dev/null
+++ b/apps/web-client-base-net/tests/WebClientBase.SmokeTests/ApiResponseTests.cs
@@ -0,0 +1,36 @@
+using FluentAssertions;
+using WebClientBase.Shared;
+using Xunit;
+
+namespace WebClientBase.SmokeTests;
+
+///
+/// EN: Smoke tests for shared API response contracts.
+/// VI: Smoke tests cho contract API response dùng chung.
+///
+public class ApiResponseTests
+{
+ [Fact]
+ public void Ok_ShouldReturnSuccessWithPayload()
+ {
+ // Act
+ var response = ApiResponse.Ok("healthy");
+
+ // Assert
+ response.Success.Should().BeTrue();
+ response.Data.Should().Be("healthy");
+ response.Error.Should().BeNull();
+ }
+
+ [Fact]
+ public void Fail_ShouldReturnErrorWithoutPayload()
+ {
+ // Act
+ var response = ApiResponse.Fail("bad-request");
+
+ // Assert
+ response.Success.Should().BeFalse();
+ response.Data.Should().BeNull();
+ response.Error.Should().Be("bad-request");
+ }
+}
diff --git a/apps/web-client-base-net/tests/WebClientBase.SmokeTests/WebClientBase.SmokeTests.csproj b/apps/web-client-base-net/tests/WebClientBase.SmokeTests/WebClientBase.SmokeTests.csproj
new file mode 100644
index 00000000..82a10674
--- /dev/null
+++ b/apps/web-client-base-net/tests/WebClientBase.SmokeTests/WebClientBase.SmokeTests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ WebClientBase.SmokeTests
+ WebClientBase.SmokeTests
+ net10.0
+ false
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/apps/web-client-eggymon-landipage-net/tests/EggymonLandingPage.SmokeTests/ApiResponseTests.cs b/apps/web-client-eggymon-landipage-net/tests/EggymonLandingPage.SmokeTests/ApiResponseTests.cs
new file mode 100644
index 00000000..9e73c2ed
--- /dev/null
+++ b/apps/web-client-eggymon-landipage-net/tests/EggymonLandingPage.SmokeTests/ApiResponseTests.cs
@@ -0,0 +1,44 @@
+using EggymonLandingPage.Shared;
+using FluentAssertions;
+using Xunit;
+
+namespace EggymonLandingPage.SmokeTests;
+
+///
+/// EN: Smoke tests for landing page shared API response model.
+/// VI: Smoke tests cho model API response dùng chung của landing page.
+///
+public class ApiResponseTests
+{
+ [Fact]
+ public void ApiResponse_ShouldStoreSuccessPayload()
+ {
+ // Act
+ var response = new ApiResponse
+ {
+ Success = true,
+ Data = "ok",
+ };
+
+ // Assert
+ response.Success.Should().BeTrue();
+ response.Data.Should().Be("ok");
+ response.Error.Should().BeNull();
+ }
+
+ [Fact]
+ public void ApiResponse_ShouldStoreErrorMessage()
+ {
+ // Act
+ var response = new ApiResponse