fix(identity): bootstrap canonical provider mappings on clean start

prakhar82 committed Sep 12, 2026 at 19:47 UTC 38ecb0481ccaca2ec722d0d5c104186cefb37254
18 files changed +517 -12
.gitignore
+1
@@ -87,3 +87,4 @@ smtp.password
87 # Local runtime/debug artifacts
88 kafka-deployment.txt
89 services/running-broker-jar-inspect/
90 +/artifacts/
ai/research-engine/app/main.py
+15
@@ -895,6 +895,21 @@ async def structured_market_snapshot(instrument: dict = Body(...)):
895 raise HTTPException(status_code=422, detail=str(exc)) from exc
896
897
898 +@app.post("/internal/v1/research/instruments/resolve-provider")
899 +async def resolve_provider_identity(instrument: dict = Body(...), x_aip_service_identity: str | None = Header(default=None)):
900 + """Identity-only reconciliation. Never collects or persists research data."""
901 + if x_aip_service_identity != "portfolio-service":
902 + raise HTTPException(status_code=403, detail="INTERNAL_SERVICE_REQUIRED")
903 + if (instrument.get("structuredNseCandidateSource") != "VERIFIED_NSE"
904 + or not instrument.get("isin") or instrument.get("assetType") != "EQUITY"):
905 + raise HTTPException(status_code=422, detail="VERIFIED_NSE_IDENTITY_REQUIRED")
906 + try:
907 + resolution = await portfolio_orchestrator.structured_provider.resolve_instrument(instrument)
908 + return resolution.model_dump(mode="json")
909 + except StructuredProviderError as exc:
910 + raise HTTPException(status_code=503 if "UNAVAILABLE" in str(exc) else 422, detail=str(exc)) from exc
911 +
912 +
913 def _require_profile(instrument_id: UUID) -> None:
914 try:
915 repository.profile(instrument_id)
ai/research-engine/app/structured_market.py
+1 -1
@@ -170,7 +170,7 @@ class YahooFinanceProvider:
170 if reason is not None:
171 logger.info("yahoo_mapping_resolution globalInstrumentId=%s candidateSource=VERIFIED_NSE candidate=%s outcome=REJECTED reason=%s",
172 instrument.get("instrumentId"), candidate_symbol, reason)
173 - raise StructuredProviderError("COMPANY_NOT_RESOLVED")
173 + raise StructuredProviderError("COMPANY_NOT_RESOLVED:" + reason)
174 logger.info("yahoo_mapping_resolution globalInstrumentId=%s candidateSource=VERIFIED_NSE candidate=%s outcome=VALIDATED reason=NONE",
175 instrument.get("instrumentId"), candidate_symbol)
176 return StructuredInstrumentResolution(
ai/research-engine/tests/test_identity_bootstrap.py new
+74
@@ -0,0 +1,74 @@
1 +from uuid import uuid4
2 +from unittest.mock import AsyncMock
3 +import httpx
4 +import pytest
5 +from fastapi.testclient import TestClient
6 +from app import main
7 +from app.models import StructuredInstrumentResolution
8 +from app.structured_market import StructuredProviderError
9 +from test_structured_market import provider, response, instrument
10 +
11 +@pytest.mark.asyncio
12 +@pytest.mark.parametrize('symbol,isin,name', [('ALPHA','INE111A01010','Alpha Components Limited'),('BETA','INE222A01010','Beta Engineering Limited'),('GAMMA','INE333A01010','Gamma Services Limited')])
13 +async def test_trusted_official_identity_resolves_generically_without_research_acquisition(symbol,isin,name):
14 + calls=[]
15 + def handler(request):
16 + calls.append(request.url.path)
17 + assert request.url.params['newsCount']=='0'
18 + return response({'quotes':[{'symbol':symbol+'.NS','exchange':'NSI','quoteType':'EQUITY','longname':name,'isin':isin}]})
19 + resolved=await provider(handler).resolve_instrument(instrument(canonicalName=name,isin=isin,structuredNseCandidateTicker=symbol+'.NS',structuredNseCandidateSource='VERIFIED_NSE'))
20 + assert resolved.provider_ticker==symbol+'.NS'
21 + assert resolved.status=='VERIFIED_NSE_CANDIDATE'
22 + assert calls==['/v1/finance/search']
23 +
24 +@pytest.mark.asyncio
25 +@pytest.mark.parametrize('case',['mismatched_isin','ambiguous','unavailable'])
26 +async def test_mapping_validation_fails_closed(case):
27 + quote={'symbol':'ALPHA.NS','exchange':'NSI','quoteType':'EQUITY','longname':'Alpha Components Limited','isin':'INE111A01010'}
28 + if case=='mismatched_isin':quote['isin']='INE222A01010'
29 + def handler(request):return response({'quotes':[quote,quote] if case=='ambiguous' else [quote]},503 if case=='unavailable' else 200)
30 + with pytest.raises(StructuredProviderError,match='UNAVAILABLE' if case=='unavailable' else 'COMPANY_NOT_RESOLVED'):
31 + await provider(handler).resolve_instrument(instrument(canonicalName='Alpha Components Limited',isin='INE111A01010',structuredNseCandidateTicker='ALPHA.NS',structuredNseCandidateSource='VERIFIED_NSE'))
32 +
33 +def test_internal_identity_route_never_collects_snapshot(monkeypatch):
34 + from datetime import datetime, timezone
35 + resolver=AsyncMock(return_value=StructuredInstrumentResolution(instrument_id=uuid4(),provider='YAHOO_FINANCE',provider_ticker='ALPHA.NS',company_name='Alpha Components Limited',confidence=.95,resolved_at=datetime.now(timezone.utc),status='VERIFIED_NSE_CANDIDATE'))
36 + collect=AsyncMock(side_effect=AssertionError('Research acquisition forbidden'))
37 + monkeypatch.setattr(main.portfolio_orchestrator.structured_provider,'resolve_instrument',resolver)
38 + monkeypatch.setattr(main.portfolio_orchestrator.structured_provider,'collect',collect)
39 + payload={'assetType':'EQUITY','isin':'INE111A01010','structuredNseCandidateSource':'VERIFIED_NSE','structuredNseCandidateTicker':'ALPHA.NS'}
40 + client=TestClient(main.app)
41 + assert client.post('/internal/v1/research/instruments/resolve-provider',json=payload).status_code==403
42 + assert client.post('/internal/v1/research/instruments/resolve-provider',json=payload,headers={'X-AIP-Service-Identity':'portfolio-service'}).status_code==200
43 + collect.assert_not_called()
44 +
45 +@pytest.mark.asyncio
46 +async def test_targeted_ensure_uses_mapping_added_after_initial_canonical_read():
47 + from app.repository import ResearchRepository
48 + from app.persistence import SqliteResearchPersistence
49 + from app.settings import Settings
50 + from app.portfolio_orchestration import PortfolioResearchOrchestrator
51 + from app.research_readiness_runtime import ResearchReadinessRuntime, RepositoryResearchReadinessAdapter
52 + from app.yahoo_mcp_acquisition import McpFirstResearchCapabilityExecutor
53 + from test_yahoo_mcp_acquisition import INSTRUMENT_ID, FakeGateway, FakeLegacy
54 + repository=ResearchRepository(settings=Settings(research_live_enabled=False,research_demo_enabled=False),persistence=SqliteResearchPersistence())
55 + orchestrator=PortfolioResearchOrchestrator(repository,repository.settings)
56 + metadata={'globalInstrumentId':str(INSTRUMENT_ID),'canonicalName':'Ready Limited','isin':'INE111A01010','assetType':'EQUITY','currency':'INR','country':'IN','primaryExchange':'NSE','primarySymbol':'READY','status':'ACTIVE','providerMappings':[{'provider':'NSE','providerSymbol':'READY','status':'VERIFIED','exchange':'NSE','currency':'INR'}]}
57 + assert orchestrator.register_global_profile_metadata(INSTRUMENT_ID,metadata)
58 + assert 'YAHOO_FINANCE' not in repository.profile(INSTRUMENT_ID).provider_instrument_ids
59 + metadata['providerMappings'].append({'provider':'YAHOO_FINANCE','providerSymbol':'READY.NS','status':'VERIFIED','exchange':'NSE','currency':'INR','resolutionSource':'YAHOO_FROM_VERIFIED_NSE'})
60 + assert orchestrator.register_global_profile_metadata(INSTRUMENT_ID,metadata)
61 + class VerifiedGateway(FakeGateway):
62 + async def acquire_requirement(self,profile,**kwargs):
63 + assert profile.instrument_id==INSTRUMENT_ID
64 + assert profile.provider_instrument_ids['YAHOO_FINANCE']=='READY.NS'
65 + return await super().acquire_requirement(profile,**kwargs)
66 + gateway=VerifiedGateway()
67 + legacy=FakeLegacy()
68 + executor=McpFirstResearchCapabilityExecutor(legacy,repository,gateway,enabled=True)
69 + runtime=ResearchReadinessRuntime(repository,RepositoryResearchReadinessAdapter(repository),executor)
70 + result=await runtime.ensure(INSTRUMENT_ID,jurisdiction='INDIA',requirement_ids=['LATEST_PRICE'])
71 + assert 'VERIFIED_YAHOO_MAPPING_REQUIRED' not in str(result.failures)
72 + assert gateway.calls
73 + assert repository.market_price_observations_for({INSTRUMENT_ID})[INSTRUMENT_ID]
74 + assert repository.financial_facts_for(INSTRUMENT_ID)==[]
platform.ps1
+1 -1
@@ -320,7 +320,7 @@ function Ensure-DevSecrets {
320
321 $defaultUser = $env:AIP_SMTP_USERNAME
322 if (-not $defaultUser) { $defaultUser = "prakhar.unique@gmail.com" }
323 - $enteredUser = Read-Host "SMTP username [$defaultUser]"
323 + $enteredUser = if ($env:AIP_SMTP_USERNAME) { $env:AIP_SMTP_USERNAME } else { Read-Host "SMTP username [$defaultUser]" }
324 if ([string]::IsNullOrWhiteSpace($enteredUser)) { $enteredUser = $defaultUser }
325
326 $smtpPasswordPlain = $env:AIP_SMTP_PASSWORD
services/api-gateway/src/main/java/com/aiinvestment/apigateway/PortfolioRouteController.java
+1 -1
@@ -192,7 +192,7 @@ public class PortfolioRouteController {
192 addTrustedIdentity(headers, request);
193 }
194 ResponseEntity<String> response = restClient.method(HttpMethod.valueOf(request.getMethod()))
195 - .uri(target)
195 + .uri(java.net.URI.create(target))
196 .headers(outbound -> outbound.addAll(headers))
197 .body(body)
198 .exchange((clientRequest, clientResponse) -> {
services/api-gateway/src/test/java/com/aiinvestment/apigateway/PortfolioRouteControllerTest.java
+16
@@ -194,6 +194,22 @@ class PortfolioRouteControllerTest {
194 server.start();
195 }
196
197 + @Test
198 + void researchSearchPreservesEncodedCompanyNamesWithoutDoubleEncoding() throws Exception {
199 + AtomicReference<String> query = new AtomicReference<>();
200 + startServer(exchange -> {
201 + query.set(exchange.getRequestURI().getRawQuery());
202 + writeResponse(exchange, 200, "application/json", "[]".getBytes(StandardCharsets.UTF_8));
203 + });
204 + String local = "http://127.0.0.1:" + server.getAddress().getPort();
205 + var controller = new PortfolioRouteController(local,local,local,local,local,local,local);
206 + var request = new MockHttpServletRequest("GET", "/api/v1/research/instruments/search");
207 + request.setQueryString("q=Alpha%20%26%20Beta&region=INDIA&limit=15");
208 + addTrustedIdentity(request);
209 + assertThat(controller.routeResearch(request).getStatusCode()).isEqualTo(HttpStatus.OK);
210 + assertThat(query.get()).isEqualTo(request.getQueryString());
211 + }
212 +
213 private PortfolioRouteController controllerForServer() {
214 return new PortfolioRouteController(
215 "http://127.0.0.1:" + server.getAddress().getPort(),
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/CanonicalIdentityBootstrap.java new
+98
@@ -0,0 +1,98 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import jakarta.annotation.PreDestroy;
4 +import org.slf4j.Logger;
5 +import org.slf4j.LoggerFactory;
6 +import org.springframework.beans.factory.annotation.Value;
7 +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
8 +import org.springframework.scheduling.annotation.EnableScheduling;
9 +import org.springframework.scheduling.annotation.Scheduled;
10 +import org.springframework.stereotype.Component;
11 +import java.time.Instant;
12 +import java.util.*;
13 +import java.util.concurrent.*;
14 +import java.util.stream.Collectors;
15 +
16 +/** Explicit, leased, retryable reference/identity bootstrap. No research acquisition. */
17 +@Component
18 +@EnableScheduling
19 +@ConditionalOnProperty(name="market.identity-bootstrap.enabled", havingValue="true", matchIfMissing=true)
20 +public class CanonicalIdentityBootstrap {
21 + private static final Logger log = LoggerFactory.getLogger(CanonicalIdentityBootstrap.class);
22 + private final NseOfficialSecurityMaster official;
23 + private final Nifty500ReferenceService nifty;
24 + private final InstrumentMasterService instruments;
25 + private final GlobalInstrumentReconciliationService reconciliation;
26 + private final CanonicalIdentityBootstrapStore store;
27 + private final String owner = UUID.randomUUID().toString();
28 + private final ExecutorService workers = Executors.newFixedThreadPool(4);
29 + private Instant providerRetryAfter = Instant.EPOCH;
30 + @Value("${market.identity-bootstrap.batch-size:40}") private int batchSize = 40;
31 +
32 + public CanonicalIdentityBootstrap(NseOfficialSecurityMaster official, Nifty500ReferenceService nifty,
33 + InstrumentMasterService instruments, GlobalInstrumentReconciliationService reconciliation, CanonicalIdentityBootstrapStore store) {
34 + this.official=official; this.nifty=nifty; this.instruments=instruments; this.reconciliation=reconciliation; this.store=store;
35 + }
36 +
37 + @Scheduled(initialDelayString="${market.identity-bootstrap.initial-delay-ms:15000}", fixedDelayString="${market.identity-bootstrap.delay-ms:5000}")
38 + public void tick() {
39 + Instant now = Instant.now();
40 + if (now.isBefore(providerRetryAfter) || !store.claim(owner, now)) return;
41 + try {
42 + if (store.universeDue(now)) {
43 + List<NseOfficialSecurityMaster.Listing> rows = official.listedEquities();
44 + if (rows.isEmpty() || rows.size()>10000) throw new IllegalStateException("CANONICAL_BOOTSTRAP_FAILED");
45 + var isinCounts = rows.stream().collect(Collectors.groupingBy(NseOfficialSecurityMaster.Listing::isin, Collectors.counting()));
46 + var symbolCounts = rows.stream().collect(Collectors.groupingBy(r -> r.symbol().toUpperCase(Locale.ROOT), Collectors.counting()));
47 + for (var row : rows) {
48 + if (!Set.of("EQ","BE","BZ","SM","ST","IV").contains(row.series().toUpperCase(Locale.ROOT))) continue;
49 + if (isinCounts.get(row.isin()) != 1 || symbolCounts.get(row.symbol().toUpperCase(Locale.ROOT)) != 1) {
50 + log.warn("canonical_identity_bootstrap event=CANONICAL_BOOTSTRAP_FAILED reason=AMBIGUOUS_OFFICIAL_IDENTITY symbol={}", row.symbol());
51 + continue;
52 + }
53 + try { instruments.canonicalizeOfficialNse(row.isin(), row.symbol(), row.companyName(), "NSE_OFFICIAL_ISIN_BOOTSTRAP"); }
54 + catch (RuntimeException rejected) { log.warn("canonical_identity_bootstrap event=CANONICAL_BOOTSTRAP_FAILED reason=IDENTITY_REJECTED symbol={}", row.symbol()); }
55 + }
56 + store.universeLoaded(now);
57 + }
58 + if (store.referenceDue(now)) {
59 + try { nifty.refresh(); store.referenceLoaded(now); }
60 + catch (RuntimeException unavailable) { log.warn("canonical_identity_bootstrap event=PROVIDER_TEMPORARILY_UNAVAILABLE provider=NIFTY_REFERENCE"); }
61 + }
62 + store.enqueue();
63 + List<UUID> ids = store.dueMappings(Math.max(1, Math.min(batchSize, 40)), Instant.now());
64 + List<Callable<GlobalInstrumentReconciliationService.Outcome>> tasks = ids.stream()
65 + .<Callable<GlobalInstrumentReconciliationService.Outcome>>map(id -> () -> reconcile(id)).toList();
66 + var results = workers.invokeAll(tasks, 160, TimeUnit.SECONDS);
67 + boolean allUnavailable = !results.isEmpty();
68 + for (int i=0; i<results.size(); i++) {
69 + var result = results.get(i);
70 + GlobalInstrumentReconciliationService.Outcome outcome;
71 + try { outcome = result.get(); }
72 + catch (ExecutionException | CancellationException error) { outcome = new GlobalInstrumentReconciliationService.Outcome("UNAVAILABLE", "PROVIDER_TEMPORARILY_UNAVAILABLE"); }
73 + store.completed(ids.get(i), outcome, Instant.now());
74 + allUnavailable &= "UNAVAILABLE".equals(outcome.status());
75 + }
76 + if (allUnavailable) providerRetryAfter = Instant.now().plusSeconds(60);
77 + log.info("canonical_identity_bootstrap event=BOOTSTRAP_PROGRESS counts={}", store.counts());
78 + } catch (InterruptedException interrupted) {
79 + Thread.currentThread().interrupt();
80 + } catch (RuntimeException unavailable) {
81 + providerRetryAfter = Instant.now().plusSeconds(60);
82 + log.warn("canonical_identity_bootstrap event=CANONICAL_BOOTSTRAP_FAILED reason=PROVIDER_TEMPORARILY_UNAVAILABLE exception={}", unavailable.getClass().getSimpleName());
83 + } finally { store.release(owner); }
84 + }
85 +
86 + private GlobalInstrumentReconciliationService.Outcome reconcile(UUID id) {
87 + var result = reconciliation.reconcile(id);
88 + String event = switch (result.status()) {
89 + case "VALIDATED" -> "YAHOO_MAPPING_VALIDATED";
90 + case "REJECTED" -> "NSE_MAPPING_MISSING".equals(result.reason()) ? "NSE_MAPPING_MISSING" : "YAHOO_MAPPING_REJECTED";
91 + case "SKIPPED" -> "YAHOO_MAPPING_SKIPPED";
92 + default -> "PROVIDER_TEMPORARILY_UNAVAILABLE";
93 + };
94 + log.info("canonical_identity_bootstrap event={} globalInstrumentId={} reason={}", event, id, result.reason());
95 + return result;
96 + }
97 + @PreDestroy public void stop() { workers.shutdownNow(); }
98 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/CanonicalIdentityBootstrapStore.java new
+60
@@ -0,0 +1,60 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import org.springframework.jdbc.core.JdbcTemplate;
4 +import org.springframework.stereotype.Repository;
5 +import java.sql.Timestamp;
6 +import java.time.Instant;
7 +import java.util.*;
8 +
9 +/** Durable work/lease metadata, never an alternative instrument or mapping store. */
10 +@Repository
11 +public class CanonicalIdentityBootstrapStore {
12 + private final JdbcTemplate jdbc;
13 + public CanonicalIdentityBootstrapStore(JdbcTemplate jdbc) { this.jdbc = jdbc; }
14 + public boolean claim(String owner, Instant now) {
15 + return jdbc.update("UPDATE portfolio.canonical_identity_bootstrap SET lease_owner=?, lease_until=? WHERE region='INDIA' AND lease_until<?",
16 + owner, Timestamp.from(now.plusSeconds(300)), Timestamp.from(now)) == 1;
17 + }
18 + public void release(String owner) {
19 + jdbc.update("UPDATE portfolio.canonical_identity_bootstrap SET lease_until=? WHERE region='INDIA' AND lease_owner=?", Timestamp.from(Instant.EPOCH), owner);
20 + }
21 + public boolean universeDue(Instant now) { return due("universe_loaded_at", now); }
22 + public boolean referenceDue(Instant now) { return due("reference_loaded_at", now); }
23 + private boolean due(String column, Instant now) {
24 + Timestamp value = jdbc.queryForObject("SELECT " + column + " FROM portfolio.canonical_identity_bootstrap WHERE region='INDIA'", Timestamp.class);
25 + return value == null || value.toInstant().isBefore(now.minusSeconds(86400));
26 + }
27 + public void universeLoaded(Instant now) { jdbc.update("UPDATE portfolio.canonical_identity_bootstrap SET universe_loaded_at=? WHERE region='INDIA'", Timestamp.from(now)); }
28 + public void referenceLoaded(Instant now) { jdbc.update("UPDATE portfolio.canonical_identity_bootstrap SET reference_loaded_at=? WHERE region='INDIA'", Timestamp.from(now)); }
29 + public void enqueue() {
30 + jdbc.update("""
31 + INSERT INTO portfolio.canonical_identity_mapping_jobs(instrument_id,status,next_attempt_at,updated_at)
32 + SELECT m.instrument_id,'PENDING',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP FROM portfolio.instrument_master m
33 + WHERE m.country='IN' AND m.primary_exchange='NSE' AND m.asset_type='EQUITY' AND m.status='ACTIVE'
34 + AND NOT EXISTS (SELECT 1 FROM portfolio.canonical_identity_mapping_jobs j WHERE j.instrument_id=m.instrument_id)
35 + """);
36 + }
37 + public List<UUID> dueMappings(int limit, Instant now) {
38 + return jdbc.query("""
39 + SELECT j.instrument_id FROM portfolio.canonical_identity_mapping_jobs j
40 + LEFT JOIN portfolio.nifty500_universe u ON u.instrument_id=j.instrument_id
41 + WHERE j.status NOT IN ('VALIDATED','SKIPPED') AND j.next_attempt_at<=?
42 + ORDER BY CASE WHEN u.instrument_id IS NULL THEN 1 ELSE 0 END, j.next_attempt_at, j.instrument_id LIMIT ?
43 + """, (rs,n) -> rs.getObject(1, UUID.class), Timestamp.from(now), limit);
44 + }
45 + public void completed(UUID id, GlobalInstrumentReconciliationService.Outcome outcome, Instant now) {
46 + long delay = "UNAVAILABLE".equals(outcome.status()) ? 300 : 86400;
47 + jdbc.update("UPDATE portfolio.canonical_identity_mapping_jobs SET status=?,reason=?,attempts=attempts+1,next_attempt_at=?,updated_at=? WHERE instrument_id=?",
48 + outcome.status(), outcome.reason(), Timestamp.from(now.plusSeconds(delay)), Timestamp.from(now), id);
49 + }
50 + public Map<String, Object> counts() {
51 + Map<String,Object> counts = new LinkedHashMap<>();
52 + counts.put("canonicalUniverseLoaded", jdbc.queryForObject("SELECT count(*) FROM portfolio.instrument_master WHERE country='IN' AND primary_exchange='NSE' AND asset_type='EQUITY' AND status='ACTIVE'", Long.class));
53 + counts.put("verifiedNseMappings", jdbc.queryForObject("SELECT count(DISTINCT instrument_id) FROM portfolio.instrument_provider_mappings WHERE provider='NSE' AND status='VERIFIED' AND resolution_source<>'BROKER_IMPORT_IDENTITY'", Long.class));
54 + counts.put("yahooMappingsValidated", jdbc.queryForObject("SELECT count(DISTINCT instrument_id) FROM portfolio.instrument_provider_mappings WHERE provider='YAHOO_FINANCE' AND status='VERIFIED'", Long.class));
55 + counts.put("yahooMappingsSkipped", jdbc.queryForObject("SELECT count(*) FROM portfolio.canonical_identity_mapping_jobs WHERE status='SKIPPED'", Long.class));
56 + counts.put("yahooMappingsFailed", jdbc.queryForObject("SELECT count(*) FROM portfolio.canonical_identity_mapping_jobs WHERE status IN ('REJECTED','UNAVAILABLE')", Long.class));
57 + counts.put("yahooMappingsPending", jdbc.queryForObject("SELECT count(*) FROM portfolio.canonical_identity_mapping_jobs WHERE status='PENDING'", Long.class));
58 + return counts;
59 + }
60 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/GlobalInstrumentReconciliationService.java
+12 -5
@@ -23,8 +23,7 @@ public class GlobalInstrumentReconciliationService {
23 this.structuredMarket = structuredMarket;
24 }
25
26 - @Transactional
27 - public void reconcile(UUID globalInstrumentId) {
26 + public Outcome reconcile(UUID globalInstrumentId) {
27 if (instruments.globalInstrument(globalInstrumentId).isEmpty()) throw new IllegalArgumentException("GLOBAL_INSTRUMENT_NOT_FOUND");
28 log.info("global_provider_reconciliation_start globalInstrumentId={}", globalInstrumentId);
29 var nseOutcome = nse.reconcile(globalInstrumentId);
@@ -32,22 +31,30 @@ public class GlobalInstrumentReconciliationService {
31 globalInstrumentId, nseOutcome.status(), nseOutcome.reason());
32 if (instruments.reusableMapping(globalInstrumentId, "YAHOO_FINANCE").isPresent()) {
33 log.info("global_provider_reconciliation_yahoo globalInstrumentId={} outcome=REUSED reason=VERIFIED_MAPPING_EXISTS", globalInstrumentId);
35 - return;
34 + return new Outcome("SKIPPED", "VERIFIED_MAPPING_EXISTS");
35 }
36 + if (instruments.mappings(globalInstrumentId).stream().anyMatch(mapping -> "YAHOO_FINANCE".equals(mapping.getProvider()) && "INVALID".equals(mapping.getStatus())))
37 + return new Outcome("REJECTED", "INVALID_MAPPING_REQUIRES_REVIEW");
38 boolean trustedNse = instruments.mappings(globalInstrumentId).stream().anyMatch(this::trustedNse);
39 if (!trustedNse) {
40 log.info("global_provider_reconciliation_yahoo globalInstrumentId={} outcome=REJECTED reason=NO_TRUSTED_NSE_MAPPING", globalInstrumentId);
40 - return;
41 + return new Outcome("REJECTED", "NSE_MAPPING_MISSING");
42 }
43 try {
43 - structuredMarket.fetchGlobal(globalInstrumentId);
44 + structuredMarket.resolveGlobalIdentity(globalInstrumentId);
45 log.info("global_provider_reconciliation_yahoo globalInstrumentId={} candidateSource=VERIFIED_NSE outcome=PERSISTED reason=VALIDATED", globalInstrumentId);
46 + return new Outcome("VALIDATED", "YAHOO_MAPPING_VALIDATED");
47 + } catch (StructuredMarketClient.IdentityRejectedException exception) {
48 + return new Outcome("REJECTED", exception.getMessage());
49 } catch (RuntimeException exception) {
50 log.info("global_provider_reconciliation_yahoo globalInstrumentId={} candidateSource=VERIFIED_NSE outcome=UNAVAILABLE reason={}",
51 globalInstrumentId, exception.getMessage());
52 + return new Outcome("UNAVAILABLE", "PROVIDER_TEMPORARILY_UNAVAILABLE");
53 }
54 }
55
56 + public record Outcome(String status, String reason) {}
57 +
58 private boolean trustedNse(InstrumentProviderMappingEntity mapping) {
59 return "NSE".equalsIgnoreCase(mapping.getProvider()) && "VERIFIED".equalsIgnoreCase(mapping.getStatus())
60 && mapping.getProviderSymbol() != null && !mapping.getProviderSymbol().isBlank()
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/InstrumentMasterService.java
+9 -1
@@ -89,6 +89,11 @@ public class InstrumentMasterService {
89 /** Canonicalize an exact official NSE identity; never uses a name-only match. */
90 @Transactional
91 public InstrumentMasterEntity canonicalizeOfficialNse(String isin, String symbol, String companyName) {
92 + return canonicalizeOfficialNse(isin, symbol, companyName, "OFFICIAL_NSE_NIFTY500");
93 + }
94 +
95 + @Transactional
96 + public InstrumentMasterEntity canonicalizeOfficialNse(String isin, String symbol, String companyName, String source) {
97 String normalized = InstrumentMasterEntity.normalizeIsin(isin);
98 if (normalized == null || symbol == null || symbol.isBlank()) throw new IllegalArgumentException("OFFICIAL_NSE_IDENTITY_REQUIRED");
99 lockIdentity("ISIN:" + normalized);
@@ -100,7 +105,10 @@ public class InstrumentMasterService {
105 InstrumentMasterEntity master = existing.orElseGet(() -> masters.saveAndFlush(new InstrumentMasterEntity(UUID.randomUUID(), normalized,
106 firstText(companyName, symbol), com.aiinvestment.shared.domain.AssetType.EQUITY, "INR", "IN", "NSE", symbol, "ACTIVE", Instant.now())));
107 master.applyVerifiedPrimaryListing("NSE", symbol, companyName);
103 - persistMapping(master.getInstrumentId(), "NSE", symbol, normalized, "NSE", "INR", "VERIFIED", "OFFICIAL_NSE_NIFTY500", new BigDecimal("0.99"));
108 + var verified = reusableMapping(master.getInstrumentId(), "NSE");
109 + if (verified.isPresent() && !same(verified.get().getProviderSymbol(), symbol))
110 + throw new IllegalStateException("NSE_IDENTITY_MISMATCH");
111 + if (verified.isEmpty()) persistMapping(master.getInstrumentId(), "NSE", symbol, normalized, "NSE", "INR", "VERIFIED", source, new BigDecimal("0.99"));
112 return master;
113 }
114
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/StructuredMarketClient.java
+40
@@ -76,6 +76,14 @@ public class StructuredMarketClient {
76
77 /** Resolve public structured identity directly from the global master, never a portfolio-local instrument. */
78 public Snapshot fetchGlobal(UUID globalInstrumentId) {
79 + return fetchGlobal(globalInstrumentId, false);
80 + }
81 +
82 + public Snapshot resolveGlobalIdentity(UUID globalInstrumentId) {
83 + return fetchGlobal(globalInstrumentId, true);
84 + }
85 +
86 + private Snapshot fetchGlobal(UUID globalInstrumentId, boolean identityOnly) {
87 var global = instrumentMaster.globalInstrument(globalInstrumentId)
88 .orElseThrow(() -> new IllegalArgumentException("GLOBAL_INSTRUMENT_NOT_FOUND"));
89 var master = global.master();
@@ -108,15 +116,47 @@ public class StructuredMarketClient {
116 payload.put("structuredProviderStatus", mapping.getStatus());
117 });
118 try {
119 + if (identityOnly) return resolveAndPersistIdentity(globalInstrumentId, payload, master.getCurrency(), master.getPrimaryExchange());
120 return requestAndPersist(globalInstrumentId, payload, master.getCurrency(), master.getPrimaryExchange());
121 } catch (InterruptedException exception) {
122 Thread.currentThread().interrupt();
123 throw new IllegalStateException("STRUCTURED_PROVIDER_INTERRUPTED", exception);
124 } catch (Exception exception) {
125 + if (exception instanceof IdentityRejectedException rejected) throw rejected;
126 throw new IllegalStateException("STRUCTURED_PROVIDER_UNAVAILABLE", exception);
127 }
128 }
129
130 + private Snapshot resolveAndPersistIdentity(UUID id, Map<String, Object> payload, String currency, String exchange) throws Exception {
131 + String candidate = (String) payload.get("structuredNseCandidateTicker");
132 + if (candidate == null) throw new IdentityRejectedException("NSE_MAPPING_MISSING");
133 + String identityEndpoint = endpoint.replace("/api/v1/research/structured-market/snapshot", "/internal/v1/research/instruments/resolve-provider");
134 + HttpRequest request = HttpRequest.newBuilder(URI.create(identityEndpoint)).timeout(Duration.ofSeconds(15))
135 + .header("Content-Type", "application/json").header("X-AIP-Service-Identity", "portfolio-service")
136 + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload))).build();
137 + HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
138 + if (response.statusCode() == 422) throw new IdentityRejectedException(mapper.readTree(response.body()).path("detail").asText("IDENTITY_REJECTED"));
139 + if (response.statusCode() != 200) throw new IllegalStateException("PROVIDER_TEMPORARILY_UNAVAILABLE");
140 + JsonNode raw = mapper.readTree(response.body());
141 + String ticker = text(raw, "provider_ticker", "providerTicker");
142 + String returnedId = text(raw, "instrument_id", "instrumentId");
143 + if (!id.toString().equals(returnedId) || !candidate.equalsIgnoreCase(ticker)
144 + || !"VERIFIED_NSE_CANDIDATE".equals(raw.path("status").asText()))
145 + throw new IdentityRejectedException("PROVIDER_IDENTITY_MISMATCH");
146 + // Yahoo search may omit currency. An exact validated NSE listing retains its official INR currency.
147 + Snapshot identity = new Snapshot(ticker, text(raw,"exchange","exchange"), text(raw,"currency","currency",currency),
148 + text(raw,"quote_type","quoteType"), null, null, null, Instant.now(), "YAHOO_FROM_VERIFIED_NSE", "VERIFIED");
149 + try { validateIdentity(currency, exchange, identity); }
150 + catch (IllegalArgumentException error) { throw new IdentityRejectedException(error.getMessage()); }
151 + instrumentMaster.saveResolvedMapping(id, "YAHOO_FINANCE", ticker, null, exchange, currency,
152 + "VERIFIED", "YAHOO_FROM_VERIFIED_NSE", new BigDecimal("0.95"));
153 + return identity;
154 + }
155 +
156 + public static class IdentityRejectedException extends RuntimeException {
157 + public IdentityRejectedException(String reason) { super(reason); }
158 + }
159 +
160 private Snapshot requestAndPersist(UUID masterId, Map<String, Object> payload, String expectedCurrency,
161 String expectedExchange) throws Exception {
162 HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(endpoint)).timeout(Duration.ofSeconds(15))
services/portfolio-service/src/main/resources/db/migration/V21__canonical_identity_bootstrap.sql new
+17
@@ -0,0 +1,17 @@
1 +CREATE TABLE canonical_identity_bootstrap (
2 + region VARCHAR(16) PRIMARY KEY,
3 + lease_owner VARCHAR(64),
4 + lease_until TIMESTAMP WITH TIME ZONE NOT NULL,
5 + universe_loaded_at TIMESTAMP WITH TIME ZONE,
6 + reference_loaded_at TIMESTAMP WITH TIME ZONE
7 +);
8 +INSERT INTO canonical_identity_bootstrap(region, lease_until) VALUES ('INDIA', TIMESTAMP '1970-01-01 00:00:00');
9 +CREATE TABLE canonical_identity_mapping_jobs (
10 + instrument_id UUID PRIMARY KEY REFERENCES instrument_master(instrument_id),
11 + status VARCHAR(32) NOT NULL,
12 + reason VARCHAR(200),
13 + attempts INTEGER NOT NULL DEFAULT 0,
14 + next_attempt_at TIMESTAMP WITH TIME ZONE NOT NULL,
15 + updated_at TIMESTAMP WITH TIME ZONE NOT NULL
16 +);
17 +CREATE INDEX idx_identity_mapping_due ON canonical_identity_mapping_jobs(status, next_attempt_at);
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/CanonicalIdentityBootstrapFailureTest.java new
+40
@@ -0,0 +1,40 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import org.junit.jupiter.api.Test;
4 +import java.util.List;
5 +import static org.mockito.Mockito.*;
6 +
7 +class CanonicalIdentityBootstrapFailureTest {
8 + @Test void temporaryOfficialOutageDoesNotMarkUniverseLoadedOrCreateMappings() {
9 + var official=mock(NseOfficialSecurityMaster.class);
10 + var nifty=mock(Nifty500ReferenceService.class);
11 + var instruments=mock(InstrumentMasterService.class);
12 + var reconciliation=mock(GlobalInstrumentReconciliationService.class);
13 + var store=mock(CanonicalIdentityBootstrapStore.class);
14 + when(store.claim(any(),any())).thenReturn(true);
15 + when(store.universeDue(any())).thenReturn(true);
16 + when(official.listedEquities()).thenReturn(List.of());
17 + var bootstrap=new CanonicalIdentityBootstrap(official,nifty,instruments,reconciliation,store);
18 + try {bootstrap.tick();bootstrap.tick();} finally {bootstrap.stop();}
19 + verify(official,times(1)).listedEquities(); // bounded retry cooldown
20 + verify(store,never()).universeLoaded(any());
21 + verifyNoInteractions(instruments,reconciliation,nifty);
22 + verify(store).release(any());
23 + }
24 +
25 + @Test void ambiguousOfficialRowsNeverReachCanonicalizationOrYahoo() {
26 + var official=mock(NseOfficialSecurityMaster.class);
27 + var nifty=mock(Nifty500ReferenceService.class);
28 + var instruments=mock(InstrumentMasterService.class);
29 + var reconciliation=mock(GlobalInstrumentReconciliationService.class);
30 + var store=mock(CanonicalIdentityBootstrapStore.class);
31 + when(store.claim(any(),any())).thenReturn(true);
32 + when(store.universeDue(any())).thenReturn(true);
33 + when(official.listedEquities()).thenReturn(List.of(
34 + new NseOfficialSecurityMaster.Listing("ALPHA","INE111A01010","Alpha Limited","EQ"),
35 + new NseOfficialSecurityMaster.Listing("BETA","INE111A01010","Beta Limited","EQ")));
36 + var bootstrap=new CanonicalIdentityBootstrap(official,nifty,instruments,reconciliation,store);
37 + try {bootstrap.tick();} finally {bootstrap.stop();}
38 + verifyNoInteractions(instruments,reconciliation);
39 + }
40 +}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/CanonicalIdentityBootstrapTest.java new
+76
@@ -0,0 +1,76 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import com.aiinvestment.portfolio.infrastructure.persistence.*;
4 +import org.junit.jupiter.api.Test;
5 +import org.springframework.beans.factory.annotation.Autowired;
6 +import org.springframework.boot.test.context.SpringBootTest;
7 +import org.springframework.boot.test.mock.mockito.MockBean;
8 +import org.springframework.test.context.ActiveProfiles;
9 +import java.math.BigDecimal;
10 +import java.time.Instant;
11 +import java.util.*;
12 +import static org.assertj.core.api.Assertions.*;
13 +import static org.mockito.Mockito.*;
14 +
15 +@SpringBootTest
16 +@ActiveProfiles("test")
17 +class CanonicalIdentityBootstrapTest {
18 + @Autowired InstrumentMasterService instruments;
19 + @Autowired InstrumentMasterRepository masters;
20 + @Autowired InstrumentProviderMappingRepository mappings;
21 + @Autowired PortfolioPositionRepository positions;
22 + @Autowired CanonicalIdentityBootstrapStore store;
23 + @Autowired GlobalInstrumentReconciliationService reconciliation;
24 + @MockBean NseOfficialSecurityMaster official;
25 + @MockBean Nifty500ReferenceService nifty;
26 + @MockBean StructuredMarketClient structured;
27 +
28 + @Test void emptyDatabaseBootstrapPersistsOnlyIdentitiesAndIsRestartSafe() {
29 + assertThat(masters.count()).isZero();
30 + var rows = List.of(new NseOfficialSecurityMaster.Listing("ALPHA", "INE111A01010", "Alpha Components Limited", "EQ"),
31 + new NseOfficialSecurityMaster.Listing("BETA", "INE222A01010", "Beta Engineering Limited", "EQ"),
32 + new NseOfficialSecurityMaster.Listing("GAMMA", "INE333A01010", "Gamma Services Limited", "EQ"));
33 + when(official.listedEquities()).thenReturn(rows);
34 + when(structured.resolveGlobalIdentity(any())).thenAnswer(call -> {
35 + UUID id = call.getArgument(0);
36 + var master = instruments.globalInstrument(id).orElseThrow().master();
37 + instruments.saveResolvedMapping(id,"YAHOO_FINANCE",master.getPrimarySymbol()+".NS",null,"NSE","INR","VERIFIED","YAHOO_FROM_VERIFIED_NSE",new BigDecimal("0.95"));
38 + return null;
39 + });
40 + CanonicalIdentityBootstrap bootstrap = new CanonicalIdentityBootstrap(official,nifty,instruments,reconciliation,store);
41 + try { bootstrap.tick(); } finally { bootstrap.stop(); }
42 + assertThat(masters.count()).isEqualTo(3);
43 + assertThat(mappings.count()).isEqualTo(6);
44 + assertThat(positions.count()).isZero();
45 + Map<UUID,Instant> verified = new HashMap<>();
46 + mappings.findAll().forEach(m -> verified.put(m.getMappingId(),m.getVerifiedAt()));
47 + CanonicalIdentityBootstrap restarted = new CanonicalIdentityBootstrap(official,nifty,instruments,reconciliation,store);
48 + try { restarted.tick(); } finally { restarted.stop(); }
49 + assertThat(mappings.count()).isEqualTo(6);
50 + mappings.findAll().forEach(m -> assertThat(m.getVerifiedAt()).isEqualTo(verified.get(m.getMappingId())));
51 + verify(official,times(1)).listedEquities();
52 + verify(structured,times(3)).resolveGlobalIdentity(any());
53 + verify(structured,never()).fetchGlobal(any());
54 + verify(structured,never()).fetch(any());
55 + assertThat(store.dueMappings(40,Instant.now())).isEmpty();
56 + for (var master : masters.findAll()) {
57 + instruments.canonicalizeOfficialNse(master.getIsin(),master.getPrimarySymbol(),master.getCanonicalName());
58 + }
59 + mappings.findAll().forEach(m -> assertThat(m.getVerifiedAt()).isEqualTo(verified.get(m.getMappingId())));
60 +
61 + var failed = instruments.canonicalizeOfficialNse("INE444A01010","DELTA","Delta Manufacturing Limited");
62 + doThrow(new IllegalStateException("PROVIDER_TEMPORARILY_UNAVAILABLE")).when(structured).resolveGlobalIdentity(failed.getInstrumentId());
63 + var unavailable = reconciliation.reconcile(failed.getInstrumentId());
64 + assertThat(unavailable.status()).isEqualTo("UNAVAILABLE");
65 + assertThat(instruments.reusableMapping(failed.getInstrumentId(),"YAHOO_FINANCE")).isEmpty();
66 + assertThat(instruments.reusableMapping(failed.getInstrumentId(),"NSE")).isPresent();
67 + instruments.recordMappingFailure(failed.getInstrumentId(),"YAHOO_FINANCE","WRONG.NS",null,"NSE","INR","TEST_REJECTION",BigDecimal.ZERO,"ISIN_MISMATCH");
68 + clearInvocations(structured);
69 + assertThat(reconciliation.reconcile(failed.getInstrumentId()).status()).isEqualTo("REJECTED");
70 + assertThat(instruments.mappings(failed.getInstrumentId())).filteredOn(m -> m.getProvider().equals("YAHOO_FINANCE"))
71 + .allMatch(m -> m.getStatus().equals("INVALID"));
72 + verifyNoInteractions(structured);
73 + assertThatThrownBy(() -> instruments.canonicalizeOfficialNse("INE555A01010","DELTA","Different Company"))
74 + .isInstanceOf(IllegalStateException.class).hasMessageContaining("NSE_IDENTITY_MISMATCH");
75 + }
76 +}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/GlobalInstrumentReconciliationServiceTest.java
+3 -3
@@ -40,7 +40,7 @@ class GlobalInstrumentReconciliationServiceTest {
40 service.reconcile(id);
41
42 verify(nse).reconcile(id);
43 - verify(structured).fetchGlobal(id);
43 + verify(structured).resolveGlobalIdentity(id);
44 }
45
46 @Test
@@ -66,11 +66,11 @@ class GlobalInstrumentReconciliationServiceTest {
66
67 @Test
68 void structuredProviderFailureIsContainedForFutureRetry() {
69 - doThrow(new IllegalStateException("STRUCTURED_PROVIDER_UNAVAILABLE")).when(structured).fetchGlobal(id);
69 + doThrow(new IllegalStateException("STRUCTURED_PROVIDER_UNAVAILABLE")).when(structured).resolveGlobalIdentity(id);
70
71 service.reconcile(id);
72
73 - verify(structured).fetchGlobal(id);
73 + verify(structured).resolveGlobalIdentity(id);
74 verify(instruments, never()).saveResolvedMapping(any(), any(), any(), any(), any(), any(), any(), any(), any());
75 }
76
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/ProviderIdentityClientTest.java new
+51
@@ -0,0 +1,51 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import com.aiinvestment.portfolio.infrastructure.persistence.*;
4 +import com.aiinvestment.shared.domain.AssetType;
5 +import com.fasterxml.jackson.databind.ObjectMapper;
6 +import com.sun.net.httpserver.HttpServer;
7 +import org.junit.jupiter.params.ParameterizedTest;
8 +import org.junit.jupiter.params.provider.ValueSource;
9 +import java.net.InetSocketAddress;
10 +import java.nio.charset.StandardCharsets;
11 +import java.time.Instant;
12 +import java.math.BigDecimal;
13 +import java.util.*;
14 +import static org.assertj.core.api.Assertions.*;
15 +import static org.mockito.Mockito.*;
16 +
17 +class ProviderIdentityClientTest {
18 + @ParameterizedTest @ValueSource(ints={200,422,503})
19 + void identityOnlyWireContractNeverFetchesResearchAndPersistsOnlyValidatedResponses(int status) throws Exception {
20 + UUID id=UUID.randomUUID();
21 + var instruments=mock(InstrumentMasterService.class);
22 + var nse=mock(NseMappingReconciliationService.class);
23 + var master=new InstrumentMasterEntity(id,"INE111A01010","Alpha Components Limited",AssetType.EQUITY,"INR","IN","NSE","ALPHA","ACTIVE",Instant.now());
24 + var mapping=new InstrumentProviderMappingEntity(UUID.randomUUID(),id,"NSE","ALPHA","INE111A01010","NSE","INR","VERIFIED","NSE_OFFICIAL_ISIN_BOOTSTRAP",new BigDecimal(".99"),Instant.now());
25 + when(instruments.globalInstrument(id)).thenReturn(Optional.of(new InstrumentMasterService.GlobalInstrument(master,List.of(mapping))));
26 + List<String> paths=new ArrayList<>();
27 + HttpServer server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);
28 + server.createContext("/", exchange -> {
29 + paths.add(exchange.getRequestURI().getPath());
30 + assertThat(exchange.getRequestHeaders().getFirst("X-AIP-Service-Identity")).isEqualTo("portfolio-service");
31 + String body=new String(exchange.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);
32 + assertThat(body).contains("VERIFIED_NSE","ALPHA.NS","INE111A01010");
33 + String response=status==200?"{\"instrument_id\":\""+id+"\",\"provider_ticker\":\"ALPHA.NS\",\"exchange\":\"NSI\",\"quote_type\":\"EQUITY\",\"status\":\"VERIFIED_NSE_CANDIDATE\"}":"{\"detail\":\"ISIN_MISMATCH\"}";
34 + byte[] bytes=response.getBytes(StandardCharsets.UTF_8);exchange.sendResponseHeaders(status,bytes.length);
35 + exchange.getResponseBody().write(bytes);exchange.close();
36 + });
37 + server.start();
38 + try {
39 + var client=new StructuredMarketClient(new ObjectMapper(),instruments,nse,"http://127.0.0.1:"+server.getAddress().getPort());
40 + if(status==200) {
41 + assertThat(client.resolveGlobalIdentity(id).providerTicker()).isEqualTo("ALPHA.NS");
42 + verify(instruments).saveResolvedMapping(eq(id),eq("YAHOO_FINANCE"),eq("ALPHA.NS"),isNull(),eq("NSE"),eq("INR"),eq("VERIFIED"),eq("YAHOO_FROM_VERIFIED_NSE"),eq(new BigDecimal(".95")));
43 + } else {
44 + assertThatThrownBy(()->client.resolveGlobalIdentity(id)).isInstanceOf(RuntimeException.class);
45 + verify(instruments,never()).saveResolvedMapping(any(),any(),any(),any(),any(),any(),any(),any(),any());
46 + }
47 + assertThat(paths).containsExactly("/internal/v1/research/instruments/resolve-provider");
48 + verifyNoInteractions(nse);
49 + } finally {server.stop(0);}
50 + }
51 +}
services/portfolio-service/src/test/resources/application-test.yml
+2
@@ -16,4 +16,6 @@ springdoc:
16 swagger-ui:
17 enabled: false
18 market:
19 + identity-bootstrap:
20 + enabled: false
21 demo-mode: true