Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,42 @@ public async Task RegisterAsync_SameTokenTwice_UpsertsWithoutDuplicate()
Assert.That(stored.Platform, Is.EqualTo("ios"));
}

[Test]
public async Task RegisterAsync_NewToken_PersistsAppBuildNumber()
{
var result = await _service.RegisterAsync(42, "build-token", "android", 31221);

Assert.That(result.Success, Is.True);

var stored = await TimePlanningPnDbContext!.DeviceTokens.SingleAsync();
Assert.That(stored.AppBuildNumber, Is.EqualTo(31221));
}

[Test]
public async Task RegisterAsync_ReRegister_UpdatesAppBuildNumber()
{
await _service.RegisterAsync(42, "build-token", "android", 31221);

var result = await _service.RegisterAsync(42, "build-token", "android", 40000);

Assert.That(result.Success, Is.True);
Assert.That(await TimePlanningPnDbContext!.DeviceTokens.CountAsync(), Is.EqualTo(1));

var stored = await TimePlanningPnDbContext.DeviceTokens.SingleAsync();
Assert.That(stored.AppBuildNumber, Is.EqualTo(40000),
"a re-register must refresh the stored app build number");
}

[Test]
public async Task RegisterAsync_DefaultBuildNumber_PersistsZero()
{
await _service.RegisterAsync(42, "legacy-token", "android");

var stored = await TimePlanningPnDbContext!.DeviceTokens.SingleAsync();
Assert.That(stored.AppBuildNumber, Is.EqualTo(0),
"an old client that omits the build number is stored as 0");
}

[Test]
public async Task UnregisterAsync_ExistingToken_IsRemoved()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FirebaseAdmin.Messaging;
using Microsoft.Extensions.Logging;
using Microting.TimePlanningBase.Infrastructure.Data.Entities;
using NSubstitute;
using NUnit.Framework;
using TimePlanning.Pn.Services.PushNotificationService;
Expand Down Expand Up @@ -29,10 +33,87 @@ public void Constructor_WithoutFirebaseConfig_DoesNotThrow()
[Test]
public async Task SendToSiteAsync_WhenFirebaseNotConfigured_IsNoOp()
{
var service = new PushNotificationService(
TimePlanningPnDbContext!,
Substitute.For<ILogger<PushNotificationService>>());
var service = CreateService();

await service.SendToSiteAsync(1, "Title", "Body");
}

[Test]
public void BuildMessage_DataOnly_OmitsNotificationAndSetsContentAvailable()
{
var msg = PushNotificationService.BuildMessage(
"tok",
"",
"",
new Dictionary<string, string> { { "type", "settings_changed" } });

Assert.That(msg.Notification, Is.Null,
"a data-only push must not attach a visible Notification block");
Assert.That(msg.Apns, Is.Not.Null);
Assert.That(msg.Apns.Aps.ContentAvailable, Is.True,
"iOS needs content-available to wake the app for a silent data push");
Assert.That(msg.Data["type"], Is.EqualTo("settings_changed"));
}

[Test]
public void BuildMessage_WithTitleOrBody_SetsNotificationBlock()
{
var msg = PushNotificationService.BuildMessage("tok", "Hello", "World", null);

Assert.That(msg.Notification, Is.Not.Null);
Assert.That(msg.Notification.Title, Is.EqualTo("Hello"));
Assert.That(msg.Notification.Body, Is.EqualTo("World"));
}

// Firebase is not configured in tests, so SendToSiteAsync short-circuits
// before it queries. The token-selection query it delegates to is exercised
// directly via the internal ResolveTargetTokensAsync seam.

[Test]
public async Task ResolveTargetTokens_WithMinBuild_ExcludesOlderBuildsAndIncludesAtOrAbove()
{
await SeedToken("old-0", sdkSiteId: 7, buildNumber: 0);
await SeedToken("old-below", sdkSiteId: 7, buildNumber: 31220);
await SeedToken("exact", sdkSiteId: 7, buildNumber: 31221);
await SeedToken("newer", sdkSiteId: 7, buildNumber: 40000);
await SeedToken("other-site", sdkSiteId: 8, buildNumber: 40000);

var service = CreateService();

var tokens = await service.ResolveTargetTokensAsync(7, minBuild: 31221);
var picked = tokens.Select(t => t.Token).ToList();

Assert.That(picked, Is.EquivalentTo(new[] { "exact", "newer" }),
"only same-site tokens reporting build >= minBuild must be targeted");
}

[Test]
public async Task ResolveTargetTokens_DefaultMinBuildZero_IncludesEveryDevice()
{
await SeedToken("legacy-0", sdkSiteId: 7, buildNumber: 0);
await SeedToken("modern", sdkSiteId: 7, buildNumber: 40000);

var service = CreateService();

var tokens = await service.ResolveTargetTokensAsync(7, minBuild: 0);

Assert.That(tokens.Select(t => t.Token),
Is.EquivalentTo(new[] { "legacy-0", "modern" }),
"minBuild 0 must keep existing callers unaffected (all devices included)");
}

private PushNotificationService CreateService() =>
new(TimePlanningPnDbContext!, Substitute.For<ILogger<PushNotificationService>>());

private async Task SeedToken(string token, int sdkSiteId, int buildNumber)
{
var deviceToken = new DeviceToken
{
SdkSiteId = sdkSiteId,
Token = token,
Platform = "android",
AppBuildNumber = buildNumber
};
await deviceToken.Create(TimePlanningPnDbContext!);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ public async Task SetUp()
_userService,
_localizationService,
null,
_coreService);
_coreService,
Substitute.For<TimePlanning.Pn.Services.PushNotificationService.IPushNotificationService>());
}

// --- GetAvailableSites tests ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ public async Task SetUp()
_userService,
_localizationService,
null,
_coreService);
_coreService,
Substitute.For<TimePlanning.Pn.Services.PushNotificationService.IPushNotificationService>());
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -27,6 +28,7 @@ public class SettingsServiceTests : TestBaseSetup
private ITimePlanningLocalizationService _localizationService;
private IEFormCoreService _coreService;
private IPluginDbOptions<TimePlanningBaseSettings> _options;
private TimePlanning.Pn.Services.PushNotificationService.IPushNotificationService _pushNotificationService;

[SetUp]
public async Task SetUp()
Expand All @@ -51,14 +53,18 @@ public async Task SetUp()
SnapshotEnabled = "0"
});

_pushNotificationService =
Substitute.For<TimePlanning.Pn.Services.PushNotificationService.IPushNotificationService>();

_settingsService = new TimeSettingService(
_options,
TimePlanningPnDbContext,
Substitute.For<ILogger<TimeSettingService>>(),
_userService,
_localizationService,
null,
_coreService);
_coreService,
_pushNotificationService);
}

[Test]
Expand Down Expand Up @@ -194,6 +200,41 @@ public async Task UpdateAssignedSite_UpdatesGpsEnabled_Successfully()
Assert.That(updatedSite.SnapshotEnabled, Is.False);
}

[Test]
public async Task UpdateAssignedSite_StillSucceeds_WhenPushThrows()
{
// The settings_changed push is fire-and-forget: a push failure must
// never fail the settings update.
_pushNotificationService
.SendToSiteAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<Dictionary<string, string>?>())
.Returns(_ => Task.FromException(new Exception("boom")));

var assignedSite = new AssignedSiteEntity
{
SiteId = 42,
UseGoogleSheetAsDefault = true,
CreatedByUserId = 1,
UpdatedByUserId = 1
};
await assignedSite.Create(TimePlanningPnDbContext);

var updateModel = new AssignedSiteModel
{
Id = assignedSite.Id,
SiteId = 42,
UseGoogleSheetAsDefault = true
};

var result = await _settingsService.UpdateAssignedSite(updateModel);

Assert.That(result.Success, Is.True,
"a push failure must not fail the settings update");
}

[Test]
public async Task UpdateAssignedSite_UpdatesGlobalGpsEnabled_Successfully()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ option csharp_namespace = "TimePlanning.Pn.Grpc";
message RegisterDeviceTokenRequest {
string token = 1;
string platform = 2;
// Ignored since 2026-07: the server resolves the caller's site id from
// the JWT. Field retained for wire compatibility with shipped clients.
int32 sdk_site_id = 3;
// App build number reported by the client at registration. 0 = old/unknown
// client that predates this field. Used to version-gate silent pushes.
//
// Field 3 previously carried sdk_site_id, which the server ignored since
// 2026-07 (the caller's site id is resolved from the JWT). The client
// repurposed this int32 slot for build_number, so the wire stays identical.
int32 build_number = 3;
}

message UnregisterDeviceTokenRequest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public DeviceTokenService(
_coreService = coreService;
}

public async Task<OperationResult> RegisterForCallerAsync(string token, string platform)
public async Task<OperationResult> RegisterForCallerAsync(string token, string platform, int buildNumber = 0)
{
var sdkSiteId = await ResolveCallerSdkSiteIdAsync();
if (sdkSiteId == 0)
Expand All @@ -46,7 +46,7 @@ public async Task<OperationResult> RegisterForCallerAsync(string token, string p
false, "Could not resolve an active site for the calling user");
}

return await RegisterAsync(sdkSiteId, token, platform);
return await RegisterAsync(sdkSiteId, token, platform, buildNumber);
}

/// <summary>
Expand Down Expand Up @@ -80,7 +80,7 @@ private async Task<int> ResolveCallerSdkSiteIdAsync()
return worker.ResolveActiveSdkSiteId() ?? 0;
}

public async Task<OperationResult> RegisterAsync(int sdkSiteId, string token, string platform)
public async Task<OperationResult> RegisterAsync(int sdkSiteId, string token, string platform, int buildNumber = 0)
{
try
{
Expand All @@ -91,6 +91,7 @@ public async Task<OperationResult> RegisterAsync(int sdkSiteId, string token, st
{
existing.SdkSiteId = sdkSiteId;
existing.Platform = platform;
existing.AppBuildNumber = buildNumber;
await existing.Update(_dbContext);
}
else
Expand All @@ -100,6 +101,7 @@ public async Task<OperationResult> RegisterAsync(int sdkSiteId, string token, st
SdkSiteId = sdkSiteId,
Token = token,
Platform = platform,
AppBuildNumber = buildNumber,
};
await deviceToken.Create(_dbContext);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ public interface IDeviceTokenService
/// Registers a device token for the authenticated caller. The SDK site id
/// is resolved server-side from the JWT (client-sent ids are ignored).
/// Fails without storing anything when no active site resolves.
/// <paramref name="buildNumber"/> is the client's app build number
/// (0 = old/unknown), stored for push version-gating.
/// </summary>
Task<OperationResult> RegisterForCallerAsync(string token, string platform);
Task<OperationResult> RegisterForCallerAsync(string token, string platform, int buildNumber = 0);

Task<OperationResult> RegisterAsync(int sdkSiteId, string token, string platform);
Task<OperationResult> RegisterAsync(int sdkSiteId, string token, string platform, int buildNumber = 0);
Task<OperationResult> UnregisterAsync(string token);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ public override async Task<OperationResponse> RegisterDeviceToken(
{
try
{
// request.SdkSiteId is deliberately ignored: the site id is
// resolved server-side from the JWT (see DeviceTokenService).
// The site id is resolved server-side from the JWT (see
// DeviceTokenService); the client no longer sends one. The client's
// reported app build number is persisted for push version-gating.
var result = await _deviceTokenService.RegisterForCallerAsync(
request.Token, request.Platform);
request.Token, request.Platform, request.BuildNumber);

return new OperationResponse
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@ namespace TimePlanning.Pn.Services.PushNotificationService;

public interface IPushNotificationService
{
Task SendToSiteAsync(int targetSdkSiteId, string title, string body, Dictionary<string, string>? data = null);
/// <summary>
/// Sends a push to every registered device of <paramref name="targetSdkSiteId"/>.
/// <paramref name="minBuild"/> gates delivery to devices whose reported
/// AppBuildNumber is &gt;= the value; the default of 0 includes every device
/// (including old installs that report 0), so existing callers are unaffected.
/// </summary>
Task SendToSiteAsync(int targetSdkSiteId, string title, string body, Dictionary<string, string>? data = null, int minBuild = 0);
}
Loading
Loading