mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: enforce canonical entity action contracts
This commit is contained in:
@@ -78,32 +78,37 @@ class EntityLink(BaseModel):
|
||||
action_names = [action.action for action in self.actions]
|
||||
if len(action_names) != len(set(action_names)):
|
||||
raise ValueError("entity actions must be unique by action name")
|
||||
expected_links = {
|
||||
action.action: str(action.href)
|
||||
for action in self.actions
|
||||
if action.available and action.href is not None
|
||||
}
|
||||
if self.links != expected_links:
|
||||
raise ValueError("links must exactly match available action hrefs")
|
||||
if expected_links:
|
||||
# Validate the executable contract at the public schema boundary,
|
||||
# not only in the preferred builder. Direct API producers must not
|
||||
# make pending routes or invalid entity context clickable.
|
||||
expected_links: Dict[str, str] = {}
|
||||
if self.actions:
|
||||
# Validate the complete route contract at the public schema
|
||||
# boundary, not only in the preferred builder. Direct producers
|
||||
# must neither enable pending routes nor disable actions whose
|
||||
# entity context is already sufficient.
|
||||
from src.services.entity_link_service import build_entity_action
|
||||
|
||||
for action in self.actions:
|
||||
if not action.available:
|
||||
continue
|
||||
expected_action = build_entity_action(
|
||||
self.entity_type,
|
||||
normalized_id,
|
||||
action.action,
|
||||
)
|
||||
if action.available != expected_action["available"]:
|
||||
raise ValueError(
|
||||
"entity action availability must match the supported route contract"
|
||||
)
|
||||
if action.href != expected_action["href"]:
|
||||
raise ValueError(
|
||||
"entity action href must match the supported route contract"
|
||||
)
|
||||
if (
|
||||
not expected_action["available"]
|
||||
or action.href != expected_action["href"]
|
||||
not action.available
|
||||
and action.disabled_reason != expected_action["disabled_reason"]
|
||||
):
|
||||
raise ValueError(
|
||||
"available entity action must match the supported route contract"
|
||||
"unavailable entity action reason must match the supported route contract"
|
||||
)
|
||||
if expected_action["available"] and expected_action["href"] is not None:
|
||||
expected_links[action.action] = str(expected_action["href"])
|
||||
if self.links != expected_links:
|
||||
raise ValueError("links must exactly match available action hrefs")
|
||||
return self
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('entityLink helpers', () => {
|
||||
|
||||
it('deduplicates requested actions in first-seen order', () => {
|
||||
const link = buildEntityLink('report', '123', {
|
||||
actions: ['view', 'view', 'track_outcome', 'view'],
|
||||
actions: ['view', ' view ' as EntityActionType, 'track_outcome', 'view'],
|
||||
});
|
||||
|
||||
expect(link.actions.map((item) => item.action)).toEqual(['view', 'track_outcome']);
|
||||
@@ -187,5 +187,8 @@ describe('entityLink helpers', () => {
|
||||
'1',
|
||||
'launch' as EntityActionType,
|
||||
)).toThrow('unsupported entity action');
|
||||
expect(() => buildEntityLink('report', null as unknown as string)).toThrow('entityId must be a string');
|
||||
expect(() => buildEntityLink('report', undefined as unknown as string)).toThrow('entityId must be a string');
|
||||
expect(() => makeEntityRef('report', null as unknown as string)).toThrow('entityId must be a string');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,11 +75,7 @@ const normalizeActionType = (value: unknown): EntityActionType => {
|
||||
|
||||
export const makeEntityRef = (entityType: EntityType | string, entityId: string): string => {
|
||||
const normalizedType = normalizeEntityType(entityType);
|
||||
const rawId = String(entityId).trim();
|
||||
if (!rawId) throw new Error('entityId is required');
|
||||
const normalizedId = normalizedType === 'stock'
|
||||
? normalizeEntityId('stock', rawId)
|
||||
: rawId;
|
||||
const normalizedId = normalizeEntityId(normalizedType, entityId);
|
||||
return `${normalizedType}:${normalizedId}`;
|
||||
};
|
||||
|
||||
@@ -104,8 +100,9 @@ export const buildEntityLink = (
|
||||
): EntityLink => {
|
||||
const normalizedType = normalizeEntityType(entityType);
|
||||
const normalizedId = normalizeEntityId(normalizedType, entityId);
|
||||
const actions = [...new Set(options.actions ?? DEFAULT_ACTIONS[normalizedType])]
|
||||
.map(normalizeActionType);
|
||||
const actions = [...new Set(
|
||||
(options.actions ?? DEFAULT_ACTIONS[normalizedType]).map(normalizeActionType),
|
||||
)];
|
||||
const actionItems = actions.map((action) => buildEntityAction(normalizedType, normalizedId, action));
|
||||
const links = actionItems.reduce<Partial<Record<EntityActionType, string>>>((result, item) => {
|
||||
if (item.href && item.available) result[item.action] = item.href;
|
||||
@@ -195,7 +192,9 @@ const splitMarketEntityId = (entityId: string): [string, string] => {
|
||||
};
|
||||
|
||||
const normalizeEntityId = (entityType: EntityType, entityId: string): string => {
|
||||
const normalizedId = String(entityId).trim();
|
||||
if (typeof entityId !== 'string') throw new Error('entityId must be a string');
|
||||
const normalizedId = entityId.trim();
|
||||
if (!normalizedId) throw new Error('entityId is required');
|
||||
if (entityType !== 'stock') return normalizedId;
|
||||
return normalizeStockEntityId(normalizedId.normalize('NFKC'));
|
||||
};
|
||||
|
||||
@@ -115,6 +115,21 @@ def test_entity_link_schema_rejects_builder_availability_bypasses() -> None:
|
||||
},
|
||||
)
|
||||
|
||||
forced_disabled_action = EntityAction(
|
||||
action="track_outcome",
|
||||
available=False,
|
||||
href="/decision-signals?sourceReportId=123",
|
||||
disabled_reason="manual_disable",
|
||||
)
|
||||
with pytest.raises(ValueError, match="availability must match"):
|
||||
EntityLink(
|
||||
entity_type="report",
|
||||
entity_id="123",
|
||||
ref="report:123",
|
||||
actions=[forced_disabled_action],
|
||||
links={},
|
||||
)
|
||||
|
||||
|
||||
def test_entity_link_builder_deduplicates_actions_in_first_seen_order() -> None:
|
||||
payload = build_entity_link(
|
||||
|
||||
Reference in New Issue
Block a user