Merge branch 'B2B'
This commit is contained in:
@@ -9,10 +9,12 @@ import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
|
||||
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
||||
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
|
||||
import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519VerificationService, AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
|
||||
import { provideMarketplacesPayment } from '@marketplaces/payment';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { MediaRepository } from './core/media/media-repository';
|
||||
import { MockMediaRepository } from './core/media/mock-media-repository.service';
|
||||
import { ApiConfigService } from './core/config/api-config.service';
|
||||
import { TenantResolverService } from './core/config/tenant-resolver.service';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
@@ -43,6 +45,30 @@ export const appConfig: ApplicationConfig = {
|
||||
// Real fix belongs in vitanovaPackages: publish with ng-packagr.
|
||||
{ provide: Ed25519VerificationService, useFactory: () => new NoopEd25519VerificationService() },
|
||||
{ provide: MediaRepository, useClass: MockMediaRepository },
|
||||
// apiUrl: environment.qrApiUrl ('https://qr.vitanova.network/api') is the
|
||||
// same "central payment service" the legacy /qr and
|
||||
// /card/{partnerId}/{orderId} endpoints already used (api.service.ts) -
|
||||
// one service shared across every tenant, unlike the per-tenant
|
||||
// AUTH_API_URL above. Stripped the trailing /api here: the package's own
|
||||
// default paymentsPath is '/api/v1/payments', so passing qrApiUrl
|
||||
// unchanged would double it to .../api/api/v1/payments. Confirmed by
|
||||
// reading the package's baseUrl() directly (apiUrl + paymentsPath,
|
||||
// simple concatenation, no de-dup) - not yet confirmed against a live
|
||||
// backend, since qrApiUrl's own /api suffix was never meant for this
|
||||
// package. Revisit once a real payment request has actually been made.
|
||||
//
|
||||
// marketplaceDomain: a plain closure, not TenantResolverService.
|
||||
// provideMarketplacesPayment runs outside the injector (it returns
|
||||
// EnvironmentProviders, called before DI exists), so inject(DOCUMENT)
|
||||
// isn't available here. The package evaluates this function lazily
|
||||
// inside PaymentMarketplaceContext, which IS a real injection context -
|
||||
// this closure just can't be one itself. Mirrors
|
||||
// TenantResolverService.getHostname() intentionally; if that method's
|
||||
// logic changes, this needs to change with it.
|
||||
provideMarketplacesPayment({
|
||||
apiUrl: environment.qrApiUrl.replace(/\/api\/?$/, ''),
|
||||
marketplaceDomain: () => window.location.hostname.toLowerCase(),
|
||||
}),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-d
|
||||
import { DialogComponent } from '../../shared/ui/dialog/dialog.component';
|
||||
import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe';
|
||||
import { CurrencyRatesService } from '../../services/currency-rates.service';
|
||||
import { MARKETPLACES_PAYMENT_GATEWAY, PaymentAttempt, PaymentMethod as PackagePaymentMethod } from '@marketplaces/payment';
|
||||
|
||||
type PaymentMethod = 'qr' | 'card';
|
||||
|
||||
@@ -82,6 +83,18 @@ export class CartComponent implements OnDestroy {
|
||||
|
||||
private currencyRates = inject(CurrencyRatesService);
|
||||
private readonly analytics = inject(AnalyticsService);
|
||||
/**
|
||||
* Payment creation and status polling now go through @marketplaces/payment
|
||||
* (POST/GET {qrApiUrl}/api/v1/payments) instead of api.service.ts's
|
||||
* createPaymentIntent/checkCartPaymentStatus - that endpoint pair is now
|
||||
* superseded, see the comment on createPaymentIntent() below. Only the I/O
|
||||
* layer changed; the surrounding popup state machine (paymentStatus,
|
||||
* checkoutInFlight, the bank-iframe UX, timeout/success handling) is
|
||||
* untouched and stays hand-rolled - <mp-payment>'s own UI is a different,
|
||||
* simpler paradigm (window.open for redirects, no iframe) that would be a
|
||||
* separate, much larger change to adopt wholesale.
|
||||
*/
|
||||
private readonly paymentGateway = inject(MARKETPLACES_PAYMENT_GATEWAY);
|
||||
|
||||
constructor(
|
||||
private cartService: CartService,
|
||||
@@ -317,39 +330,26 @@ export class CartComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Superseded api.service.ts's createPaymentIntent (POST
|
||||
* /api/v2/storefront/payments/intents, our own inferred contract) with
|
||||
* @marketplaces/payment's real, published one. That method, createPayment
|
||||
* (legacy /qr), createCartPayment (legacy /cart), checkCartPaymentStatus,
|
||||
* checkCartCardPaymentStatus, checkPaymentStatus, and the
|
||||
* QrCreateResponse-based resolvePaymentQrId/resolvePaymentQrUrl/
|
||||
* resolvePaymentLink/resolveBankPaymentUrl helpers - all now deleted from
|
||||
* ApiService, confirmed dead first (zero remaining callers) before removal.
|
||||
*/
|
||||
private createPaymentIntent(
|
||||
session: import('../../services/api.service').CheckoutSessionResponse,
|
||||
paymentMethod: PaymentMethod,
|
||||
merchantReference: string,
|
||||
): void {
|
||||
this.apiService.createPaymentIntent({
|
||||
this.paymentGateway.create(paymentMethod as PackagePaymentMethod, {
|
||||
checkoutSessionId: session.checkoutSessionId,
|
||||
paymentMethod,
|
||||
merchantReference,
|
||||
metadata: { merchantReference },
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
const qrId = this.apiService.resolvePaymentQrId(response);
|
||||
const qrUrl = this.apiService.resolvePaymentQrUrl(response);
|
||||
const paymentLink = this.apiService.resolvePaymentLink(response);
|
||||
const bankUrl = this.apiService.resolveBankPaymentUrl(response);
|
||||
|
||||
if (!qrId || (paymentMethod === 'qr' && !qrUrl) || (paymentMethod === 'card' && !bankUrl)) {
|
||||
console.error('Payment intent response missing payment fields:', response);
|
||||
this.setPaymentError();
|
||||
return;
|
||||
}
|
||||
|
||||
this.paymentId.set(qrId);
|
||||
this.qrCodeUrl.set(qrUrl);
|
||||
this.paymentUrl.set(paymentLink);
|
||||
this.bankPaymentUrl.set(bankUrl);
|
||||
|
||||
this.paymentStatus.set('waiting');
|
||||
this.startPolling(response.qrTTL);
|
||||
if (paymentMethod === 'card') {
|
||||
this.openBankPaymentPopup();
|
||||
}
|
||||
},
|
||||
next: (attempt) => this.handlePaymentAttempt(attempt, paymentMethod),
|
||||
error: (err) => {
|
||||
console.error('Error creating payment intent:', err);
|
||||
this.setPaymentError();
|
||||
@@ -357,33 +357,59 @@ export class CartComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
startPolling(qrTTL?: number): void {
|
||||
private handlePaymentAttempt(attempt: PaymentAttempt, paymentMethod: PaymentMethod): void {
|
||||
if (!attempt.paymentId || (attempt.status !== 'created' && attempt.status !== 'pending' && !attempt.action)) {
|
||||
console.error('Payment attempt missing required fields:', attempt);
|
||||
this.setPaymentError();
|
||||
return;
|
||||
}
|
||||
|
||||
this.paymentId.set(attempt.paymentId);
|
||||
|
||||
if (attempt.action?.type === 'qr') {
|
||||
// Same external QR-image rendering used everywhere else in this
|
||||
// component (previously via ApiService.resolvePaymentQrUrl) - kept
|
||||
// rather than switching to the package's own client-side qrcode
|
||||
// generation, to avoid adding a second QR-rendering path for one call site.
|
||||
this.qrCodeUrl.set(`https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(attempt.action.url)}`);
|
||||
this.paymentUrl.set(attempt.action.url);
|
||||
} else if (attempt.action?.type === 'redirect') {
|
||||
this.bankPaymentUrl.set(attempt.action.url);
|
||||
}
|
||||
|
||||
this.paymentStatus.set('waiting');
|
||||
// The package's PaymentAttempt carries no TTL/expiry field, unlike the
|
||||
// legacy provider's qrTTL - polling duration falls back to
|
||||
// PAYMENT_MIN_POLL_SECONDS alone. Revisit if the real backend adds one.
|
||||
this.startPolling();
|
||||
if (paymentMethod === 'card' && attempt.action?.type === 'redirect') {
|
||||
this.openBankPaymentPopup();
|
||||
}
|
||||
}
|
||||
|
||||
startPolling(): void {
|
||||
this.stopPolling();
|
||||
if (!this.paymentId()) {
|
||||
this.setPaymentError();
|
||||
return;
|
||||
}
|
||||
|
||||
const pollSeconds = Math.max(PAYMENT_MIN_POLL_SECONDS, (qrTTL ?? 0) * 60);
|
||||
const pollSeconds = PAYMENT_MIN_POLL_SECONDS;
|
||||
this.maxChecks = Math.ceil(pollSeconds / (PAYMENT_POLL_INTERVAL_MS / 1000));
|
||||
|
||||
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
|
||||
.pipe(
|
||||
take(this.maxChecks), // qrTTL minutes from create response, minimum 1 minute
|
||||
exhaustMap(() => {
|
||||
const statusRequest = this.selectedPaymentMethod() === 'card'
|
||||
? this.apiService.checkCartCardPaymentStatus(this.paymentId())
|
||||
: this.apiService.checkCartPaymentStatus(this.paymentId());
|
||||
|
||||
return statusRequest.pipe(
|
||||
take(this.maxChecks),
|
||||
exhaustMap(() =>
|
||||
this.paymentGateway.status(this.paymentId(), this.selectedPaymentMethod() as PackagePaymentMethod).pipe(
|
||||
timeout(8000),
|
||||
catchError((err) => {
|
||||
console.error('Error checking payment status:', err);
|
||||
this.setPaymentError();
|
||||
return EMPTY;
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
@@ -391,10 +417,14 @@ export class CartComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentStatus = response.status?.toUpperCase() || '';
|
||||
const paymentCode = response.code?.toUpperCase() || '';
|
||||
// Package's PaymentStatus is a fixed union
|
||||
// ('created'|'pending'|'authorized'|'paid'|'failed'|'cancelled'|'expired'),
|
||||
// not a free-form string+code pair like the legacy provider - no
|
||||
// .toUpperCase() normalization needed, and no 'REJECTED'/'APPROVED'
|
||||
// equivalents exist (those were legacy-provider-specific spellings).
|
||||
const paymentStatus = response.status;
|
||||
|
||||
if (paymentStatus === 'FAILED' || paymentStatus === 'EXPIRED' || paymentStatus === 'CANCELLED' || paymentStatus === 'REJECTED') {
|
||||
if (paymentStatus === 'failed' || paymentStatus === 'expired' || paymentStatus === 'cancelled') {
|
||||
this.paymentStatus.set('timeout');
|
||||
this.closeBankPaymentPopup();
|
||||
this.stopPolling();
|
||||
@@ -405,8 +435,9 @@ export class CartComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if payment is successful
|
||||
if (paymentStatus === 'COMPLETED' || paymentStatus === 'APPROVED' || paymentStatus === 'PAID' || paymentCode === 'SUCCESS') {
|
||||
// 'authorized' counts as success too (PaymentResult's own status
|
||||
// union) - a card payment can settle as authorized before capture.
|
||||
if (paymentStatus === 'paid' || paymentStatus === 'authorized') {
|
||||
this.paymentStatus.set('success');
|
||||
this.closeBankPaymentPopup();
|
||||
this.stopPolling();
|
||||
|
||||
@@ -1,58 +1,11 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, timer } from 'rxjs';
|
||||
import { map, retry } from 'rxjs/operators';
|
||||
import { CategoryApiModel, DeliveryOption, Item, Subcategory } from '../models';
|
||||
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { ApiConfigService } from '../core/config/api-config.service';
|
||||
|
||||
export interface QrCreateRequest {
|
||||
qrtype: 'QRDynamic';
|
||||
amount: number;
|
||||
currency: 'RUB';
|
||||
partnerqrID?: string;
|
||||
qrDescription?: string;
|
||||
Userid?: string;
|
||||
Reference?: string;
|
||||
RedirectUrl?: string;
|
||||
}
|
||||
|
||||
export interface QrCreateResponse {
|
||||
qrId?: string;
|
||||
qrID?: string;
|
||||
nspkID?: string;
|
||||
nspkId?: string;
|
||||
nspkurl?: string;
|
||||
orderID?: string;
|
||||
url?: string;
|
||||
bankUrl?: string;
|
||||
status?: string;
|
||||
qrStatus?: string;
|
||||
qrExpirationDate?: string;
|
||||
qrTTL?: number;
|
||||
payload?: string;
|
||||
Payload?: string;
|
||||
qrUrl?: string;
|
||||
partnerqrID?: string | number;
|
||||
partnerID?: string | number;
|
||||
partnerId?: string | number;
|
||||
PartnerID?: string | number;
|
||||
}
|
||||
|
||||
export interface CartPaymentRequest {
|
||||
amount: number;
|
||||
currency: string;
|
||||
siteuserID: string;
|
||||
siteorderID: string;
|
||||
redirectUrl: string;
|
||||
telegramUsername: string;
|
||||
paymentMethod: 'qr' | 'card';
|
||||
qrDescription?: string;
|
||||
customerID?: string;
|
||||
items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-authoritative checkout. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2.
|
||||
* No `amount` or `price` field anywhere in this pair - the backend prices
|
||||
@@ -88,18 +41,6 @@ export interface CheckoutSessionResponse {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* References checkoutSessionId only - the amount charged is read
|
||||
* server-side from the session, never re-sent by the client (contract §5.2).
|
||||
* merchantReference is PARTNER-PROVISIONING-API-CONTRACT.md's RoutingContext
|
||||
* field: our own correlation id, echoed back on every related event.
|
||||
*/
|
||||
export interface PaymentIntentRequest {
|
||||
checkoutSessionId: string;
|
||||
paymentMethod: 'qr' | 'card';
|
||||
merchantReference: string;
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
/**
|
||||
* No `price` field: the backend must price each line item from its own
|
||||
@@ -120,28 +61,10 @@ export interface CreateOrderResponse {
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface QrDynamicStatusResponse {
|
||||
additionalInfo: string;
|
||||
paymentPurpose: string;
|
||||
amount: number;
|
||||
code: string;
|
||||
createDate: string;
|
||||
currency: string;
|
||||
order: string;
|
||||
status: string;
|
||||
qrId: string;
|
||||
transactionDate: string;
|
||||
transactionId: number;
|
||||
qrExpirationDate: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly qrBaseUrl = (environment as any).qrApiUrl as string;
|
||||
private readonly cartPaymentPartnerId = 'web-97ec-9c57-4dde-9037-3a68f7f83750';
|
||||
|
||||
private readonly retryConfig = {
|
||||
count: 2,
|
||||
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
|
||||
@@ -669,21 +592,6 @@ export class ApiService {
|
||||
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/questiion`, body);
|
||||
}
|
||||
|
||||
createPayment(payload: QrCreateRequest, headers?: { authorizationKey?: string; userIdValue?: string }): Observable<QrCreateResponse> {
|
||||
let httpHeaders = new HttpHeaders();
|
||||
if (headers?.authorizationKey) {
|
||||
httpHeaders = httpHeaders.set('authorization-key', headers.authorizationKey);
|
||||
}
|
||||
if (headers?.userIdValue) {
|
||||
httpHeaders = httpHeaders.set('userid-value', headers.userIdValue);
|
||||
}
|
||||
return this.http.post<QrCreateResponse>(`${this.qrBaseUrl}/qr`, payload, { headers: httpHeaders });
|
||||
}
|
||||
|
||||
createCartPayment(payload: CartPaymentRequest): Observable<QrCreateResponse> {
|
||||
return this.http.post<QrCreateResponse>(`${this.baseUrl}/cart`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a server-priced checkout session. Contract §5.2 - the frontend
|
||||
* sends offer ids and quantities only; the response carries the total that
|
||||
@@ -694,16 +602,6 @@ export class ApiService {
|
||||
return this.http.post<CheckoutSessionResponse>('/api/v2/storefront/checkout', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a payment intent against an existing checkout session. Same
|
||||
* response shape as createCartPayment (QrCreateResponse) - this replaces
|
||||
* how the amount is determined, not the QR/card provider integration
|
||||
* itself, which Phase 1 does not redesign.
|
||||
*/
|
||||
createPaymentIntent(payload: PaymentIntentRequest): Observable<QrCreateResponse> {
|
||||
return this.http.post<QrCreateResponse>('/api/v2/storefront/payments/intents', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the just-paid cart as a backoffice order (POST /orders). Fire-and-forget
|
||||
* from the caller's perspective - a failure here must never block the existing
|
||||
@@ -713,45 +611,6 @@ export class ApiService {
|
||||
return this.http.post<CreateOrderResponse>(`${this.baseUrl}/orders`, payload);
|
||||
}
|
||||
|
||||
checkCartPaymentStatus(qrId: string): Observable<QrDynamicStatusResponse> {
|
||||
return this.http.get<QrDynamicStatusResponse>(
|
||||
`${this.qrBaseUrl}/qr/dynamic/${this.cartPaymentPartnerId}/${encodeURIComponent(qrId)}`
|
||||
);
|
||||
}
|
||||
|
||||
checkCartCardPaymentStatus(orderId: string): Observable<QrDynamicStatusResponse> {
|
||||
return this.http.get<QrDynamicStatusResponse>(
|
||||
`${this.qrBaseUrl}/card/${this.cartPaymentPartnerId}/${encodeURIComponent(orderId)}`
|
||||
);
|
||||
}
|
||||
|
||||
checkPaymentStatus(partnerQrId: string, qrId: string): Observable<QrDynamicStatusResponse> {
|
||||
return this.http.get<QrDynamicStatusResponse>(
|
||||
`${this.qrBaseUrl}/qr/dynamic/${encodeURIComponent(partnerQrId)}/${encodeURIComponent(qrId)}`
|
||||
);
|
||||
}
|
||||
|
||||
resolvePaymentQrId(response: QrCreateResponse): string {
|
||||
return response.qrId ?? response.qrID ?? response.nspkID ?? response.nspkId ?? response.orderID ?? '';
|
||||
}
|
||||
|
||||
resolvePaymentLink(response: QrCreateResponse): string {
|
||||
return response.nspkurl ?? response.Payload ?? response.payload ?? response.qrUrl ?? '';
|
||||
}
|
||||
|
||||
resolveBankPaymentUrl(response: QrCreateResponse): string {
|
||||
return response.bankUrl ?? response.url ?? '';
|
||||
}
|
||||
|
||||
resolvePaymentQrUrl(response: QrCreateResponse): string {
|
||||
const paymentLink = this.resolvePaymentLink(response);
|
||||
if (paymentLink) {
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(paymentLink)}`;
|
||||
}
|
||||
|
||||
return response.qrUrl ?? '';
|
||||
}
|
||||
|
||||
submitPurchaseEmail(emailData: {
|
||||
email: string;
|
||||
phone?: string;
|
||||
|
||||
89
src/app/services/location.service.spec.ts
Normal file
89
src/app/services/location.service.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ApiConfigService } from '../core/config/api-config.service';
|
||||
import { LocalStorageService } from '../core/storage/local-storage.service';
|
||||
import { LocationService } from './location.service';
|
||||
|
||||
/**
|
||||
* FH-1.1. detectLocation() used to call http://ip-api.com directly. On an
|
||||
* HTTPS storefront the browser blocks mixed active content, so the request
|
||||
* never completed and region auto-detect silently did nothing in production
|
||||
* - and the attempt still exposed the visitor's IP to a third party.
|
||||
*
|
||||
* These tests pin both halves of the fix: geo resolution goes to our own
|
||||
* tenant API, and nothing in this service reaches a foreign origin.
|
||||
*/
|
||||
describe('LocationService', () => {
|
||||
const baseUrl = 'https://api.gorbushka.market';
|
||||
let service: LocationService;
|
||||
let httpTesting: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
LocationService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: ApiConfigService, useValue: { getBaseUrl: () => baseUrl } },
|
||||
{
|
||||
provide: LocalStorageService,
|
||||
useValue: { getJSON: () => null, setJSON: () => {}, removeItem: () => {} },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(LocationService);
|
||||
httpTesting = TestBed.inject(HttpTestingController);
|
||||
// The constructor loads regions; flush it so each test starts clean.
|
||||
httpTesting.expectOne(`${baseUrl}/regions`).flush([]);
|
||||
});
|
||||
|
||||
afterEach(() => httpTesting.verify());
|
||||
|
||||
it('resolves geo through the tenant API, not a third-party host', () => {
|
||||
service.detectLocation();
|
||||
|
||||
const request = httpTesting.expectOne(`${baseUrl}/geo/resolve`);
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush({ city: 'Москва', country: 'Россия', countryCode: 'RU' });
|
||||
});
|
||||
|
||||
it('issues no request to a foreign or plaintext origin', () => {
|
||||
service.detectLocation();
|
||||
|
||||
for (const request of httpTesting.match(() => true)) {
|
||||
expect(request.request.url.startsWith(baseUrl))
|
||||
.withContext(`unexpected off-origin request: ${request.request.url}`)
|
||||
.toBe(true);
|
||||
expect(request.request.url.startsWith('http://'))
|
||||
.withContext(`plaintext request: ${request.request.url}`)
|
||||
.toBe(false);
|
||||
request.flush({});
|
||||
}
|
||||
});
|
||||
|
||||
it('degrades to the manual picker when geo resolution fails', () => {
|
||||
service.detectLocation();
|
||||
httpTesting
|
||||
.expectOne(`${baseUrl}/geo/resolve`)
|
||||
.flush(null, { status: 503, statusText: 'Service Unavailable' });
|
||||
|
||||
expect(service.region()).toBeNull();
|
||||
expect(service.detecting()).toBe(false);
|
||||
expect(service.autoDetected()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not re-request geo once detection has been attempted', () => {
|
||||
service.detectLocation();
|
||||
httpTesting.expectOne(`${baseUrl}/geo/resolve`).flush({
|
||||
city: 'Ереван',
|
||||
country: 'Армения',
|
||||
countryCode: 'AM',
|
||||
});
|
||||
|
||||
service.detectLocation();
|
||||
|
||||
httpTesting.expectNone(`${baseUrl}/geo/resolve`);
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,13 @@ export class LocationService {
|
||||
if (this.detectedSignal()) return; // already tried
|
||||
this.loadingSignal.set(true);
|
||||
|
||||
// Using free ip-api.com — no key required, 45 req/min
|
||||
this.http.get<GeoIpResponse>('http://ip-api.com/json/?fields=city,country,countryCode,region,timezone,lat,lon')
|
||||
// Was a direct plaintext call to ip-api.com. Two problems, one of them
|
||||
// fatal: browsers block mixed active content, so on an HTTPS storefront
|
||||
// this request never completed and auto-detect only ever took the error
|
||||
// branch below. It also handed every visitor's IP to a third party from
|
||||
// the page itself. The client IP is the server's to read - same tenant
|
||||
// API base as /regions, same-origin, nothing leaves our infrastructure.
|
||||
this.http.get<GeoIpResponse>(`${this.apiConfig.getBaseUrl()}/geo/resolve`)
|
||||
.subscribe({
|
||||
next: (geo) => {
|
||||
this.detectedSignal.set(true);
|
||||
|
||||
Reference in New Issue
Block a user