fix: enforce canonical entity action contracts

This commit is contained in:
ZhuLinsen
2026-08-30 21:03:53 +08:00
parent fbe18ac416
commit bbe77ca9e8
4 changed files with 47 additions and 25 deletions

View File

@@ -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

View File

@@ -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');
});
});

View File

@@ -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'));
};

View File

@@ -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(