diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts index ca27c92..eba56ef 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -2,6 +2,7 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import type { OfflinePrincipalId } from './offline-identity'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineNetworkService } from './offline-network.service'; +import { DEFAULT_OFFLINE_CONNECTION_VERIFICATION_TIMEOUT_MS } from './offline-network.service'; import { OfflineMutationPersistenceService } from './offline-mutation-persistence.service'; import { OFFLINE_REPOSITORY } from './offline-repository'; import { OfflineSessionService } from './offline-session.service'; @@ -55,6 +56,7 @@ export class OfflineCoordinatorService { readonly isStorageReady = computed(() => this.#storageState().status === 'ready'); readonly networkState = this.#network.state; + readonly checkingConnection = this.#network.checkingConnection; readonly syncState = this.#sync.syncState; readonly pendingCommands = this.#sync.pendingCommands; readonly pendingCount = this.#sync.pendingCount; @@ -62,6 +64,11 @@ export class OfflineCoordinatorService { /** Device-local control for accepting new durable Outbox mutations. */ readonly mutationPersistence = this.#mutationPersistence; + /** Immediately verifies remote API reachability without consulting the local replica. */ + verifyConnection(url: string, timeoutMs = DEFAULT_OFFLINE_CONNECTION_VERIFICATION_TIMEOUT_MS): Promise { + return this.#network.verifyConnection(url, timeoutMs); + } + /** Opens local storage and restores its persisted session boundary without waiting for network discovery. */ initializeLocal(): Promise { this.#localInitialization ??= this.#initializeLocal(); diff --git a/projects/kit/offline/src/lib/offline-network-verification.spec.ts b/projects/kit/offline/src/lib/offline-network-verification.spec.ts new file mode 100644 index 0000000..1ce83eb --- /dev/null +++ b/projects/kit/offline/src/lib/offline-network-verification.spec.ts @@ -0,0 +1,101 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OfflineNetworkService } from './offline-network.service'; +import { OFFLINE_BYPASS, OFFLINE_IGNORE_TRANSPORT_FAILURE } from './offline-request-policy'; + +describe('OfflineNetworkService connection verification', () => { + let http: HttpTestingController; + let service: OfflineNetworkService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [OfflineNetworkService, provideHttpClient(), provideHttpClientTesting()], + }); + http = TestBed.inject(HttpTestingController); + service = TestBed.inject(OfflineNetworkService); + }); + + afterEach(() => { + http.verify(); + vi.useRealTimers(); + }); + + it('bypasses local fallback and records a successful API check', async () => { + const markApiSuccess = vi.spyOn(service, 'markApiSuccess'); + const result = service.verifyConnection('/status'); + expect(service.checkingConnection()).toBe(true); + const request = http.expectOne('/status'); + expect(request.request.context.get(OFFLINE_BYPASS)).toBe(true); + expect(request.request.context.get(OFFLINE_IGNORE_TRANSPORT_FAILURE)).toBe(true); + request.flush({}); + + await expect(result).resolves.toBe(true); + expect(markApiSuccess).toHaveBeenCalledOnce(); + expect(service.checkingConnection()).toBe(false); + }); + + it('shares the service-wide in-flight check and permits another check after it settles', async () => { + const first = service.verifyConnection('/status'); + const second = service.verifyConnection('/another-status'); + expect(second).toBe(first); + http.expectOne('/status').flush({}); + http.expectNone('/another-status'); + await Promise.all([first, second]); + + const third = service.verifyConnection('/status'); + http.expectOne('/status').flush({}); + await expect(third).resolves.toBe(true); + }); + + it('does not classify an HTTP error as a disconnected transport', async () => { + const markApiFailure = vi.spyOn(service, 'markApiFailure'); + const result = service.verifyConnection('/status'); + http.expectOne('/status').flush({}, { status: 500, statusText: 'Server Error' }); + + await expect(result).resolves.toBe(false); + expect(markApiFailure).not.toHaveBeenCalled(); + expect(service.checkingConnection()).toBe(false); + }); + + it('records a status-zero failure when no newer API observation exists and permits retry', async () => { + const markApiFailure = vi.spyOn(service, 'markApiFailure'); + const first = service.verifyConnection('/status'); + http.expectOne('/status').error(new ProgressEvent('error')); + await expect(first).resolves.toBe(false); + expect(markApiFailure).toHaveBeenCalledOnce(); + expect(service.state()).toBe('offline'); + + const second = service.verifyConnection('/status'); + http.expectOne('/status').flush({}); + await expect(second).resolves.toBe(true); + }); + + it('does not let an older probe failure overwrite a newer successful API observation', async () => { + const markApiFailure = vi.spyOn(service, 'markApiFailure'); + const verification = service.verifyConnection('/status'); + service.markApiSuccess(); + http.expectOne('/status').error(new ProgressEvent('error')); + + await expect(verification).resolves.toBe(false); + expect(markApiFailure).not.toHaveBeenCalled(); + expect(service.state()).toBe('unverified'); + }); + + it('does not overwrite reachability when a stalled check times out and permits retry', async () => { + vi.useFakeTimers(); + const markApiFailure = vi.spyOn(service, 'markApiFailure'); + const first = service.verifyConnection('/status', 10); + http.expectOne('/status'); + await vi.advanceTimersByTimeAsync(10); + + await expect(first).resolves.toBe(false); + expect(markApiFailure).not.toHaveBeenCalled(); + expect(service.checkingConnection()).toBe(false); + + const second = service.verifyConnection('/status', 10); + http.expectOne('/status').flush({}); + await expect(second).resolves.toBe(true); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-network.service.spec.ts b/projects/kit/offline/src/lib/offline-network.service.spec.ts index db8b216..7570044 100644 --- a/projects/kit/offline/src/lib/offline-network.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-network.service.spec.ts @@ -1,4 +1,6 @@ import type { PluginListenerHandle } from '@capacitor/core'; +import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { OfflineNetworkService } from './offline-network.service'; @@ -16,16 +18,12 @@ class TestOfflineNetworkService extends OfflineNetworkService { return this.getNetworkStatusMock(); } - protected override async addAppStateListener( - listener: (state: { isActive: boolean }) => void, - ): Promise { + protected override async addAppStateListener(listener: (state: { isActive: boolean }) => void): Promise { this.appListener = listener; return { remove: vi.fn(async () => undefined) }; } - protected override async addNetworkStatusListener( - listener: (state: { connected: boolean }) => void, - ): Promise { + protected override async addNetworkStatusListener(listener: (state: { connected: boolean }) => void): Promise { this.networkListener = listener; return { remove: vi.fn(async () => undefined) }; } @@ -35,7 +33,8 @@ describe('OfflineNetworkService', () => { let service: TestOfflineNetworkService; beforeEach(() => { - service = new TestOfflineNetworkService(); + TestBed.configureTestingModule({ providers: [{ provide: HttpClient, useValue: {} }] }); + service = TestBed.runInInjectionContext(() => new TestOfflineNetworkService()); service.getAppStateMock.mockResolvedValue({ isActive: true }); service.getNetworkStatusMock.mockResolvedValue({ connected: true }); }); @@ -53,9 +52,7 @@ describe('OfflineNetworkService', () => { it('listener登録後のappStateChangeを遅延した初期stateで上書きしない', async () => { let resolveInitialState!: (state: { isActive: boolean }) => void; - service.getAppStateMock.mockImplementation( - () => new Promise((resolve) => (resolveInitialState = resolve)), - ); + service.getAppStateMock.mockImplementation(() => new Promise((resolve) => (resolveInitialState = resolve))); const initialization = service.initialize(); await vi.waitFor(() => { expect(service.appListener).not.toBeNull(); diff --git a/projects/kit/offline/src/lib/offline-network.service.ts b/projects/kit/offline/src/lib/offline-network.service.ts index 9dea399..de4be7c 100644 --- a/projects/kit/offline/src/lib/offline-network.service.ts +++ b/projects/kit/offline/src/lib/offline-network.service.ts @@ -1,7 +1,12 @@ -import { computed, Injectable, signal } from '@angular/core'; +import { HttpClient, HttpContext } from '@angular/common/http'; +import { computed, inject, Injectable, signal } from '@angular/core'; import { App } from '@capacitor/app'; import type { PluginListenerHandle } from '@capacitor/core'; import { Network } from '@capacitor/network'; +import { catchError, firstValueFrom, map, of, timeout } from 'rxjs'; +import { OFFLINE_BYPASS, OFFLINE_IGNORE_TRANSPORT_FAILURE } from './offline-request-policy'; + +export const DEFAULT_OFFLINE_CONNECTION_VERIFICATION_TIMEOUT_MS = 8_000; export type OfflineNetworkState = 'online' | 'offline' | 'unverified'; @@ -17,9 +22,14 @@ export class OfflineNetworkService { readonly #apiReachable = signal(null); readonly #appActive = signal(true); readonly #lifecycleRevision = signal(0); + #apiReachabilityRevision = 0; #networkRevision = 0; readonly #listeners: PluginListenerHandle[] = []; #initialized = false; + readonly #checkingConnection = signal(false); + #connectionVerification: Promise | null = null; + + readonly #http = inject(HttpClient); readonly state = computed(() => { if (this.#osConnected() === false || this.#apiReachable() === false) return 'offline'; @@ -31,6 +41,8 @@ export class OfflineNetworkService { readonly appActive = this.#appActive.asReadonly(); /** Changes on every foreground/background transition, even when connectivity is unchanged. */ readonly lifecycleRevision = this.#lifecycleRevision.asReadonly(); + /** Whether an explicit remote API reachability check is running. */ + readonly checkingConnection = this.#checkingConnection.asReadonly(); async initialize(): Promise { if (this.#initialized) return; @@ -56,13 +68,51 @@ export class OfflineNetworkService { } markApiSuccess(): void { + this.#apiReachabilityRevision += 1; this.#apiReachable.set(true); } markApiFailure(): void { + this.#apiReachabilityRevision += 1; this.#apiReachable.set(false); } + /** + * Runs one remote-only reachability check for this service instance and updates the observed API state. + * While it is running, every caller shares that check; products should therefore use one stable health endpoint. + */ + verifyConnection(url: string, timeoutMs = DEFAULT_OFFLINE_CONNECTION_VERIFICATION_TIMEOUT_MS): Promise { + if (this.#connectionVerification) return this.#connectionVerification; + + const startingApiReachabilityRevision = this.#apiReachabilityRevision; + this.#checkingConnection.set(true); + const verification = firstValueFrom( + this.#http + .get(url, { + context: new HttpContext().set(OFFLINE_BYPASS, true).set(OFFLINE_IGNORE_TRANSPORT_FAILURE, true), + observe: 'response', + }) + .pipe( + timeout({ first: timeoutMs }), + map(() => { + this.markApiSuccess(); + return true; + }), + catchError((error: unknown) => { + if (isOfflineFallbackError(error) && this.#apiReachabilityRevision === startingApiReachabilityRevision) { + this.markApiFailure(); + } + return of(false); + }), + ), + ).finally(() => { + this.#checkingConnection.set(false); + this.#connectionVerification = null; + }); + this.#connectionVerification = verification; + return verification; + } + /** Factory seam for Capacitor app-state discovery and deterministic tests. */ protected getAppState(): Promise<{ isActive: boolean }> { return App.getState(); diff --git a/projects/kit/offline/src/lib/offline-request-policy.ts b/projects/kit/offline/src/lib/offline-request-policy.ts index 3b96c7a..2f8c7f1 100644 --- a/projects/kit/offline/src/lib/offline-request-policy.ts +++ b/projects/kit/offline/src/lib/offline-request-policy.ts @@ -5,6 +5,8 @@ import { HttpContextToken } from '@angular/common/http'; /** outbox再送時にoffline interceptorだけを迂回する。認証・retryは維持する。 */ export const OFFLINE_BYPASS = new HttpContextToken(() => false); +/** Lets a manual reachability probe apply its transport failure with its own observation-order guard. */ +export const OFFLINE_IGNORE_TRANSPORT_FAILURE = new HttpContextToken(() => false); /** Header attached to synthetic local or optimistic responses. */ export const OFFLINE_RESPONSE_HEADER = 'X-Offline-Response'; /** Header marking a local response as a complete collection snapshot. */ diff --git a/projects/kit/offline/src/lib/offline.interceptor.spec.ts b/projects/kit/offline/src/lib/offline.interceptor.spec.ts index 571c35f..56fcef0 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.spec.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.spec.ts @@ -18,6 +18,7 @@ import { OFFLINE_REPOSITORY } from './offline-repository'; import { offlineInterceptor } from './offline.interceptor'; import { OFFLINE_BYPASS, + OFFLINE_IGNORE_TRANSPORT_FAILURE, OFFLINE_RESPONSE_HEADER, type OfflineMutationRequestPlan, OfflineMutationRequestPolicyRegistry, @@ -64,6 +65,17 @@ describe('offlineInterceptor', () => { expect(resolveMutation).not.toHaveBeenCalled(); }); + it('manual probeのtransport失敗は新しいAPI成功状態を上書きしない', async () => { + const error = new HttpErrorResponse({ status: 0, error: new Error('offline') }); + const request = new HttpRequest('GET', '/status', { + context: new HttpContext().set(OFFLINE_BYPASS, true).set(OFFLINE_IGNORE_TRANSPORT_FAILURE, true), + }); + + await expect(firstValueFrom(run(request, () => throwError(() => error)))).rejects.toBe(error); + + expect(markApiFailure).not.toHaveBeenCalled(); + }); + it('GET成功はtransport responseをそのまま返しreachabilityを更新する', async () => { resolve.mockReturnValue({ kind: 'read', readLocal: vi.fn() }); const response = new HttpResponse({ body: { userId: 1 }, status: 200 }); diff --git a/projects/kit/offline/src/lib/offline.interceptor.ts b/projects/kit/offline/src/lib/offline.interceptor.ts index c7f4d39..90a824b 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.ts @@ -28,6 +28,7 @@ import { OfflineReplicaMutationCoordinator } from './offline-replica-mutation-co import { OFFLINE_MUTATION_PERSISTENCE_ENABLED } from './offline-mutation-persistence.service'; import { OFFLINE_BYPASS, + OFFLINE_IGNORE_TRANSPORT_FAILURE, OFFLINE_RESPONSE_HEADER, OfflineMutationRequestPolicyRegistry, OfflineRequestPolicyRegistry, @@ -41,7 +42,7 @@ type MaterializedTransport = Notification> & ObservableNotifi /** Applies product read and local-first mutation policies while observing real API reachability. */ export const offlineInterceptor: HttpInterceptorFn = (request, next) => { const network = inject(OfflineNetworkService); - const transport = () => observeTransport(next(request), network); + const transport = () => observeTransport(next(request), network, !request.context.get(OFFLINE_IGNORE_TRANSPORT_FAILURE)); if (request.context.get(OFFLINE_BYPASS)) return transport(); if (request.method === 'GET') { const registry = inject(OfflineRequestPolicyRegistry); @@ -270,7 +271,11 @@ function projectReadResponse( ); } -function observeTransport(source: Observable>, network: OfflineNetworkService): Observable> { +function observeTransport( + source: Observable>, + network: OfflineNetworkService, + observeFailure: boolean, +): Observable> { return source.pipe( tap({ next: (event) => { @@ -282,7 +287,7 @@ function observeTransport(source: Observable>, network: Offli network.markApiSuccess(); }, error: (error: unknown) => { - if (isOfflineFallbackError(error)) network.markApiFailure(); + if (observeFailure && isOfflineFallbackError(error)) network.markApiFailure(); }, }), );