fix(market): 修 install 用户可见假绿——isAcquired/isInstalled/canInstall 按真实授权安装态 enrich
三路盘点发现 market install 头号用户可见假绿:install 后端真实已验(P1rMarketLicenseInstallCompletedApprovalIT 批18 绿),但 MarketAssetQueryServiceImpl 把 isAcquired/isInstalled 裸硬编 false(toCard:181-182 列表 + userActions:386-387 详情 + canInstall:383),致 studio MarketBrowse(assetType!=work && isAcquired && !isInstalled)安装按钮在真后端永不渲染,mock 伪造状态位才现身;批17 IT 把 isAcquired=false 断言为正确→假绿固化。
后端 enrich(读模型、未碰 install 写逻辑/授权语义):仿 isFavorite 范式加 isAcquired(active 授权)/isInstalled(installation)/isInstallable(类型集),与 install 写端口 MarketInstallServiceImpl 严格同源(selectActiveByOwnerAndAsset + INSTALLABLE_ASSET_TYPES={agent,knowledge_base}),避免"显示可装但装不了"新假绿;canInstall=listed && isInstallable && acquired && !installed(对齐 studio assetType!=work 门);批量加载(整页 2 次 IN vs 3N)避免放大 isFavorite 既有 N+1;mapper 加 selectActiveByOwnerAndAssetIds/existsByUserAndAsset/selectByUserAndAssetIds;批17 IT 改三态断言+订正裸写注释。
前端 MarketBrowse 补 onError(install/acquire 失败内联可见提示,对齐 AgentCreateForm/SlotBindingPanel,不再静默吞 ApiError);e2e 新增 market-install.spec(MSW-off:purchase 前置→断安装按钮真渲染→install→psql 验,连跑 2 次)+ global-setup #16 market 表复位。
活体真验(muse_slice_live,重启新 jar 10:08):purchase→isAcquired=T/canInstall=T(修前恒 false)→install→isInstalled=T/canInstall=F;独立 psql+GET 复核 asset1/4(owner1)isAcquired/isInstalled=true。批17 IT 5/5 三态绿(首跑遇 .m2 stale jar 假红、mvn install 刷新转真绿)、market 单测 9/9+11/11+7/7、UI e2e 连跑 2 次 passed。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c78a2adc4
commit
37e7a8dc27
@ -16,8 +16,10 @@ import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketGovernanceAct
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketGovernanceImpactDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketHandoffEventDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketInstallationDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAuthorizationSnapshotDO;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetVersionMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSnapshotMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSummaryMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketFavoriteMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketGovernanceActionMapper;
|
||||
@ -52,11 +54,14 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
|
||||
|
||||
private static final String FALLBACK_POPULAR = "fallback_popular";
|
||||
private static final String FALLBACK_NEWEST = "fallback_newest";
|
||||
// 可安装的资产类型与 MarketInstallServiceImpl 写端口一致;作品(work)不走安装流程。
|
||||
private static final Set<String> INSTALLABLE_ASSET_TYPES = Set.of("agent", "knowledge_base");
|
||||
|
||||
private final MuseMarketAssetMapper assetMapper;
|
||||
private final MuseMarketAssetVersionMapper assetVersionMapper;
|
||||
private final MuseMarketFavoriteMapper favoriteMapper;
|
||||
private final MuseMarketAuthorizationSummaryMapper authorizationSummaryMapper;
|
||||
private final MuseMarketAuthorizationSnapshotMapper authorizationSnapshotMapper;
|
||||
private final MuseMarketInstallationMapper installationMapper;
|
||||
private final MuseMarketHandoffEventMapper handoffEventMapper;
|
||||
private final MuseMarketGovernanceActionMapper governanceActionMapper;
|
||||
@ -71,8 +76,10 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
|
||||
PageResult<MuseMarketAssetDO> page = assetMapper.selectVisiblePage(pageParam, tenantId, loginUserId,
|
||||
defaultSort(sortBy), assetType, category == null ? null : String.valueOf(category),
|
||||
keyword, licenseType, status);
|
||||
// 列表卡片需回填当前用户的获取/安装态;一次性批量查全页授权与安装,避免逐卡片放大 N+1。
|
||||
UserAssetState userState = loadUserAssetState(tenantId, loginUserId, page.getList());
|
||||
List<MarketAssetCardRespVO> list = page.getList().stream()
|
||||
.map(asset -> toCard(asset, currentVersion(asset), loginUserId, null))
|
||||
.map(asset -> toCard(asset, currentVersion(asset), loginUserId, null, userState))
|
||||
.toList();
|
||||
log.info("[listAssets][Market 资产列表查询完成,tenantId={}, loginUserId={}, total={}, sortBy={}]",
|
||||
tenantId, loginUserId, page.getTotal(), defaultSort(sortBy));
|
||||
@ -103,8 +110,10 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
|
||||
List<MuseMarketAssetDO> assets = assetMapper.selectRecommendations(tenantId, loginUserId, fallbackSort, limit);
|
||||
log.info("[listRecommendations][无独立推荐数据,使用 fallback 排序,tenantId={}, loginUserId={}, context={}, reason={}]",
|
||||
tenantId, loginUserId, recommendationContext, reason);
|
||||
// 推荐卡片同样批量回填获取/安装态,复用列表的批量加载逻辑。
|
||||
UserAssetState userState = loadUserAssetState(tenantId, loginUserId, assets);
|
||||
return assets.stream()
|
||||
.map(asset -> toCard(asset, currentVersion(asset), loginUserId, reason))
|
||||
.map(asset -> toCard(asset, currentVersion(asset), loginUserId, reason, userState))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@ -168,7 +177,7 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
|
||||
}
|
||||
|
||||
private MarketAssetCardRespVO toCard(MuseMarketAssetDO asset, MuseMarketAssetVersionDO version,
|
||||
Long loginUserId, String recommendationReason) {
|
||||
Long loginUserId, String recommendationReason, UserAssetState userState) {
|
||||
MarketAssetCardRespVO vo = new MarketAssetCardRespVO();
|
||||
vo.setAssetId(String.valueOf(asset.getId()));
|
||||
vo.setName(asset.getName());
|
||||
@ -178,8 +187,9 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
|
||||
vo.setSummary(summary(asset));
|
||||
vo.setLicenseSummary(asset.getLicenseType());
|
||||
vo.setListingStatus(normalizeListingStatus(asset.getListingStatus()));
|
||||
vo.setAcquired(false);
|
||||
vo.setInstalled(false);
|
||||
// 获取/安装态来自批量加载的当前用户真实事实(active 授权 / 安装记录),不能再裸写 false。
|
||||
vo.setAcquired(userState.acquired(asset.getId()));
|
||||
vo.setInstalled(userState.installed(asset.getId()));
|
||||
vo.setFavorite(isFavorite(loginUserId, asset.getId()));
|
||||
vo.setRecommendationReason(recommendationReason);
|
||||
vo.setGovernanceAlert(governanceAlert(asset));
|
||||
@ -378,13 +388,17 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
|
||||
|
||||
private MarketAssetDetailRespVO.AssetUserActions userActions(MuseMarketAssetDO asset, Long loginUserId) {
|
||||
boolean listed = isListed(asset);
|
||||
// 详情页是单资产,直接按当前用户真实事实回填获取/安装态(active 授权 / 安装记录)。
|
||||
boolean acquired = isAcquired(loginUserId, asset.getId());
|
||||
boolean installed = isInstalled(loginUserId, asset.getId());
|
||||
MarketAssetDetailRespVO.AssetUserActions actions = new MarketAssetDetailRespVO.AssetUserActions();
|
||||
actions.setCanAcquire(listed);
|
||||
actions.setCanInstall(false);
|
||||
// 安装门与写端口前置条件一致:资产 listed + 类型可安装(agent/knowledge_base) + 已获取 active 授权 + 尚未安装。
|
||||
actions.setCanInstall(listed && isInstallable(asset) && acquired && !installed);
|
||||
actions.setCanBind(false);
|
||||
actions.setCanFavorite(loginUserId != null && listed);
|
||||
actions.setAcquired(false);
|
||||
actions.setInstalled(false);
|
||||
actions.setAcquired(acquired);
|
||||
actions.setInstalled(installed);
|
||||
actions.setFavorite(isFavorite(loginUserId, asset.getId()));
|
||||
actions.setUnavailableReason(unavailableReason(asset, loginUserId));
|
||||
MarketAssetDetailRespVO.AssetActionPolicy policy = new MarketAssetDetailRespVO.AssetActionPolicy();
|
||||
@ -427,6 +441,69 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
|
||||
return loginUserId != null && favoriteMapper.existsActive(TenantContextHolder.getRequiredTenantId(), loginUserId, assetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户是否对该资产持有 active 授权(已获取)。
|
||||
*
|
||||
* <p>active 判定(owner_user_id + asset_id + status=active)与安装写端口
|
||||
* {@code MarketInstallServiceImpl} 完全一致,保证读模型与可安装前置条件不漂移。</p>
|
||||
*/
|
||||
private boolean isAcquired(Long loginUserId, Long assetId) {
|
||||
return loginUserId != null && authorizationSnapshotMapper
|
||||
.selectActiveByOwnerAndAsset(TenantContextHolder.getRequiredTenantId(), loginUserId, assetId) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户是否已安装该资产(不限版本)。
|
||||
*/
|
||||
private boolean isInstalled(Long loginUserId, Long assetId) {
|
||||
return loginUserId != null && installationMapper
|
||||
.existsByUserAndAsset(TenantContextHolder.getRequiredTenantId(), loginUserId, assetId);
|
||||
}
|
||||
|
||||
private boolean isInstallable(MuseMarketAssetDO asset) {
|
||||
return INSTALLABLE_ASSET_TYPES.contains(asset.getAssetType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载列表/推荐页中当前用户的获取(active 授权)与安装态。
|
||||
*
|
||||
* <p>列表卡片在循环内回填这两个布尔位,逐条单查会放大 N+1;此处对整页 assetId 各发一次
|
||||
* IN 查询得到两个 Set,再交给 {@code toCard} 命中。未登录或空列表直接返回空状态。</p>
|
||||
*/
|
||||
private UserAssetState loadUserAssetState(Long tenantId, Long loginUserId, List<MuseMarketAssetDO> assets) {
|
||||
if (loginUserId == null || assets.isEmpty()) {
|
||||
return UserAssetState.empty();
|
||||
}
|
||||
List<Long> assetIds = assets.stream().map(MuseMarketAssetDO::getId).toList();
|
||||
Set<Long> acquiredAssetIds = authorizationSnapshotMapper
|
||||
.selectActiveByOwnerAndAssetIds(tenantId, loginUserId, assetIds).stream()
|
||||
.map(MuseMarketAuthorizationSnapshotDO::getAssetId)
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> installedAssetIds = installationMapper
|
||||
.selectByUserAndAssetIds(tenantId, loginUserId, assetIds).stream()
|
||||
.map(MuseMarketInstallationDO::getAssetId)
|
||||
.collect(Collectors.toSet());
|
||||
return new UserAssetState(acquiredAssetIds, installedAssetIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 整页当前用户的获取/安装资产 id 集合,供卡片回填命中,避免逐卡片单查。
|
||||
*/
|
||||
private record UserAssetState(Set<Long> acquiredAssetIds, Set<Long> installedAssetIds) {
|
||||
|
||||
private static UserAssetState empty() {
|
||||
return new UserAssetState(Set.of(), Set.of());
|
||||
}
|
||||
|
||||
private boolean acquired(Long assetId) {
|
||||
return acquiredAssetIds.contains(assetId);
|
||||
}
|
||||
|
||||
private boolean installed(Long assetId) {
|
||||
return installedAssetIds.contains(assetId);
|
||||
}
|
||||
}
|
||||
|
||||
private String versionName(MuseMarketAssetVersionDO version) {
|
||||
return version == null || !StringUtils.hasText(version.getVersion()) ? "unknown" : version.getVersion();
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAuthorization
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Market 授权快照 Mapper。
|
||||
*/
|
||||
@ -27,6 +29,21 @@ public interface MuseMarketAuthorizationSnapshotMapper extends BaseMapperX<MuseM
|
||||
.last("ORDER BY id DESC LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询当前用户对一批资产中持有 active 授权的那些资产。
|
||||
*
|
||||
* <p>用于列表卡片回填 isAcquired,避免每张卡片单独查授权放大 N+1。active 判定与
|
||||
* {@link #selectActiveByOwnerAndAsset} / 安装服务一致(owner_user_id + asset_id + status=active)。</p>
|
||||
*/
|
||||
default List<MuseMarketAuthorizationSnapshotDO> selectActiveByOwnerAndAssetIds(Long tenantId, Long ownerUserId,
|
||||
List<Long> assetIds) {
|
||||
return selectList(new LambdaQueryWrapperX<MuseMarketAuthorizationSnapshotDO>()
|
||||
.eq(MuseMarketAuthorizationSnapshotDO::getTenantId, tenantId)
|
||||
.eq(MuseMarketAuthorizationSnapshotDO::getOwnerUserId, ownerUserId)
|
||||
.in(MuseMarketAuthorizationSnapshotDO::getAssetId, assetIds)
|
||||
.eq(MuseMarketAuthorizationSnapshotDO::getStatus, "active"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权快照按业务授权键幂等写入。
|
||||
*
|
||||
|
||||
@ -30,6 +30,29 @@ public interface MuseMarketInstallationMapper extends BaseMapperX<MuseMarketInst
|
||||
.orderByDesc(MuseMarketInstallationDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户对某资产是否存在安装事实(不限版本),用于详情页回填 isInstalled。
|
||||
*/
|
||||
default boolean existsByUserAndAsset(Long tenantId, Long userId, Long assetId) {
|
||||
return selectOne(new LambdaQueryWrapperX<MuseMarketInstallationDO>()
|
||||
.eq(MuseMarketInstallationDO::getTenantId, tenantId)
|
||||
.eq(MuseMarketInstallationDO::getUserId, userId)
|
||||
.eq(MuseMarketInstallationDO::getAssetId, assetId)
|
||||
.last("LIMIT 1")) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询当前用户在一批资产中已安装的那些资产。
|
||||
*
|
||||
* <p>用于列表卡片回填 isInstalled,避免逐张卡片单独查安装事实放大 N+1。</p>
|
||||
*/
|
||||
default List<MuseMarketInstallationDO> selectByUserAndAssetIds(Long tenantId, Long userId, List<Long> assetIds) {
|
||||
return selectList(new LambdaQueryWrapperX<MuseMarketInstallationDO>()
|
||||
.eq(MuseMarketInstallationDO::getTenantId, tenantId)
|
||||
.eq(MuseMarketInstallationDO::getUserId, userId)
|
||||
.in(MuseMarketInstallationDO::getAssetId, assetIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一用户同一资产版本只保留一条安装事实;重复 command 通过 command envelope 回放。
|
||||
*/
|
||||
|
||||
@ -9,8 +9,11 @@ import cn.iocoder.muse.module.market.controller.app.muse.vo.MarketAssetDetailRes
|
||||
import cn.iocoder.muse.module.market.controller.app.muse.vo.MarketCategoriesRespVO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAssetDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAssetVersionDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAuthorizationSnapshotDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketInstallationDO;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetVersionMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSnapshotMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSummaryMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketFavoriteMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketGovernanceActionMapper;
|
||||
@ -31,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
@ -49,6 +53,8 @@ class MarketAssetQueryServiceTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private MuseMarketAuthorizationSummaryMapper authorizationSummaryMapper;
|
||||
@Mock
|
||||
private MuseMarketAuthorizationSnapshotMapper authorizationSnapshotMapper;
|
||||
@Mock
|
||||
private MuseMarketInstallationMapper installationMapper;
|
||||
@Mock
|
||||
private MuseMarketHandoffEventMapper handoffEventMapper;
|
||||
@ -150,12 +156,83 @@ class MarketAssetQueryServiceTest extends BaseMockitoUnitTest {
|
||||
when(assetMapper.selectRecommendations(100L, 9001L, "newest", 2))
|
||||
.thenReturn(List.of(asset(1001L, "listed", 2001L, "10")));
|
||||
when(assetVersionMapper.selectById(3001L)).thenReturn(version());
|
||||
// 登录用户的推荐列表会批量回填获取/安装态;无授权/安装事实时返回空,卡片应为未获取/未安装。
|
||||
when(authorizationSnapshotMapper.selectActiveByOwnerAndAssetIds(eq(100L), eq(9001L), any()))
|
||||
.thenReturn(List.of());
|
||||
when(installationMapper.selectByUserAndAssetIds(eq(100L), eq(9001L), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
List<MarketAssetCardRespVO> result = queryService.listRecommendations(9001L, "detail_related",
|
||||
1001L, null, 2);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("fallback_newest", result.getFirst().getRecommendationReason());
|
||||
assertEquals(Boolean.FALSE, result.getFirst().getAcquired());
|
||||
assertEquals(Boolean.FALSE, result.getFirst().getInstalled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_backfillAcquiredAndInstalledFromBatchStateForLoginUser() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
// 同一页两条 listed agent:1001 已获取+已安装,1002 无任何事实(两态对照,反裸写 false)。
|
||||
PageResult<MuseMarketAssetDO> page = new PageResult<>(
|
||||
List.of(asset(1001L, "listed", 2001L, "10"), asset(1002L, "listed", 2002L, "10")), 2L);
|
||||
when(assetMapper.selectVisiblePage(any(), eq(100L), eq(9001L), eq("popular"), any(),
|
||||
any(), any(), any(), any())).thenReturn(page);
|
||||
when(assetVersionMapper.selectById(3001L)).thenReturn(version());
|
||||
// 批量授权只返回 1001;批量安装只返回 1001 → 1001 两态 true,1002 两态 false。
|
||||
when(authorizationSnapshotMapper.selectActiveByOwnerAndAssetIds(eq(100L), eq(9001L), any()))
|
||||
.thenReturn(List.of(authorizationSnapshot(1001L, 9001L)));
|
||||
when(installationMapper.selectByUserAndAssetIds(eq(100L), eq(9001L), any()))
|
||||
.thenReturn(List.of(installation(1001L, 9001L)));
|
||||
|
||||
PageResult<MarketAssetCardRespVO> result = queryService.listAssets(9001L, 1, 20, "popular",
|
||||
null, null, null, null, null);
|
||||
|
||||
MarketAssetCardRespVO acquiredCard = result.getList().stream()
|
||||
.filter(card -> "1001".equals(card.getAssetId())).findFirst().orElseThrow();
|
||||
MarketAssetCardRespVO freshCard = result.getList().stream()
|
||||
.filter(card -> "1002".equals(card.getAssetId())).findFirst().orElseThrow();
|
||||
assertEquals(Boolean.TRUE, acquiredCard.getAcquired());
|
||||
assertEquals(Boolean.TRUE, acquiredCard.getInstalled());
|
||||
assertEquals(Boolean.FALSE, freshCard.getAcquired());
|
||||
assertEquals(Boolean.FALSE, freshCard.getInstalled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_openInstallGateOnlyWhenAcquiredAndNotInstalled() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
// 已获取但尚未安装的 listed agent → canInstall=true(studio 安装按钮渲染条件)。
|
||||
when(assetMapper.selectVisibleById(100L, 1001L, 9001L)).thenReturn(asset(1001L, "listed", 2001L, "10"));
|
||||
when(assetVersionMapper.selectById(3001L)).thenReturn(version());
|
||||
when(authorizationSnapshotMapper.selectActiveByOwnerAndAsset(100L, 9001L, 1001L))
|
||||
.thenReturn(authorizationSnapshot(1001L, 9001L));
|
||||
when(installationMapper.existsByUserAndAsset(100L, 9001L, 1001L)).thenReturn(false);
|
||||
|
||||
MarketAssetDetailRespVO.AssetUserActions actions =
|
||||
queryService.getAsset(9001L, 1001L).getUserActions();
|
||||
|
||||
assertEquals(Boolean.TRUE, actions.getAcquired());
|
||||
assertEquals(Boolean.FALSE, actions.getInstalled());
|
||||
assertEquals(Boolean.TRUE, actions.getCanInstall());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_closeInstallGateWhenAlreadyInstalled() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
// 已获取且已安装 → canInstall=false(安装门:已获取且未安装)。
|
||||
when(assetMapper.selectVisibleById(100L, 1001L, 9001L)).thenReturn(asset(1001L, "listed", 2001L, "10"));
|
||||
when(assetVersionMapper.selectById(3001L)).thenReturn(version());
|
||||
when(authorizationSnapshotMapper.selectActiveByOwnerAndAsset(100L, 9001L, 1001L))
|
||||
.thenReturn(authorizationSnapshot(1001L, 9001L));
|
||||
when(installationMapper.existsByUserAndAsset(100L, 9001L, 1001L)).thenReturn(true);
|
||||
|
||||
MarketAssetDetailRespVO.AssetUserActions actions =
|
||||
queryService.getAsset(9001L, 1001L).getUserActions();
|
||||
|
||||
assertEquals(Boolean.TRUE, actions.getAcquired());
|
||||
assertEquals(Boolean.TRUE, actions.getInstalled());
|
||||
assertEquals(Boolean.FALSE, actions.getCanInstall());
|
||||
}
|
||||
|
||||
private static MuseMarketAssetDO asset(Long id, String listingStatus, Long publisherId, String category) {
|
||||
@ -187,4 +264,22 @@ class MarketAssetQueryServiceTest extends BaseMockitoUnitTest {
|
||||
version.setTenantId(100L);
|
||||
return version;
|
||||
}
|
||||
|
||||
private static MuseMarketAuthorizationSnapshotDO authorizationSnapshot(Long assetId, Long ownerUserId) {
|
||||
MuseMarketAuthorizationSnapshotDO snapshot = new MuseMarketAuthorizationSnapshotDO();
|
||||
snapshot.setAssetId(assetId);
|
||||
snapshot.setOwnerUserId(ownerUserId);
|
||||
snapshot.setStatus("active");
|
||||
snapshot.setTenantId(100L);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static MuseMarketInstallationDO installation(Long assetId, Long userId) {
|
||||
MuseMarketInstallationDO installation = new MuseMarketInstallationDO();
|
||||
installation.setAssetId(assetId);
|
||||
installation.setUserId(userId);
|
||||
installation.setStatus("installed");
|
||||
installation.setTenantId(100L);
|
||||
return installation;
|
||||
}
|
||||
}
|
||||
|
||||
@ -206,7 +206,9 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(2)));
|
||||
|
||||
// assetType=work → 仅 listed 的 work(草稿 work 对买家隐藏),卡片字段回填且未收藏/未获取/未安装。
|
||||
// assetType=work → 仅 listed 的 work(草稿 work 对买家隐藏)。买家对该 work 无收藏/无 active 授权/无安装,
|
||||
// 故 isFavorite/isAcquired/isInstalled 均为真实 false(由 enrich 按真实事实回填,非裸写);
|
||||
// 已获取/已安装的 true 态由 should_reflectAcquiredAndInstalledStateForBuyer 两态对照验证。
|
||||
assertNoMarketMutationDuring("listMarketplaceAssets assetType 过滤 + 可见性隔离",
|
||||
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
@ -236,6 +238,60 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
|
||||
.andExpect(jsonPath("$.data[0].recommendationReason").isNotEmpty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reflectAcquiredAndInstalledStateForBuyer() throws Exception {
|
||||
// 两个可安装的 listed agent:一个买家已获取+已安装,一个买家无任何授权(两态对照,反"裸写 false"假绿)。
|
||||
long acquiredAgent = seedAsset(TENANT_ID, "已获取智能体", "agent", PUBLISHER_ID, "listed");
|
||||
long freshAgent = seedAsset(TENANT_ID, "未获取智能体", "agent", PUBLISHER_ID, "listed");
|
||||
seedActiveAuthorization(TENANT_ID, acquiredAgent, BUYER_USER_ID);
|
||||
seedInstallation(TENANT_ID, acquiredAgent, BUYER_USER_ID);
|
||||
|
||||
// 列表卡片:按 assetId 定位,已获取 agent isAcquired/isInstalled=true,未获取 agent 两者=false。
|
||||
assertNoMarketMutationDuring("listMarketplaceAssets 获取/安装态回填仍是纯读 operation",
|
||||
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.param("assetType", "agent"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(2))
|
||||
.andExpect(jsonPath("$.data.list[?(@.assetId=='" + acquiredAgent + "')].isAcquired").value(true))
|
||||
.andExpect(jsonPath("$.data.list[?(@.assetId=='" + acquiredAgent + "')].isInstalled").value(true))
|
||||
.andExpect(jsonPath("$.data.list[?(@.assetId=='" + freshAgent + "')].isAcquired").value(false))
|
||||
.andExpect(jsonPath("$.data.list[?(@.assetId=='" + freshAgent + "')].isInstalled").value(false)));
|
||||
|
||||
// 已获取+已安装 agent 详情:isAcquired/isInstalled=true;已安装 → canInstall=false(安装门:已获取且未安装)。
|
||||
assertNoMarketMutationDuring("getMarketplaceAsset 已安装详情仍是纯读 operation",
|
||||
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets/{assetId}", acquiredAgent)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userActions.isAcquired").value(true))
|
||||
.andExpect(jsonPath("$.data.userActions.isInstalled").value(true))
|
||||
.andExpect(jsonPath("$.data.userActions.canInstall").value(false)));
|
||||
|
||||
// 未获取 agent 详情:isAcquired/isInstalled=false;尚未获取 → canInstall=false。
|
||||
assertNoMarketMutationDuring("getMarketplaceAsset 未获取详情仍是纯读 operation",
|
||||
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets/{assetId}", freshAgent)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userActions.isAcquired").value(false))
|
||||
.andExpect(jsonPath("$.data.userActions.isInstalled").value(false))
|
||||
.andExpect(jsonPath("$.data.userActions.canInstall").value(false)));
|
||||
|
||||
// 已获取但尚未安装的 agent → canInstall=true(安装门真实开启,这正是 studio 安装按钮渲染条件)。
|
||||
long readyToInstallAgent = seedAsset(TENANT_ID, "可安装智能体", "agent", PUBLISHER_ID, "listed");
|
||||
seedActiveAuthorization(TENANT_ID, readyToInstallAgent, BUYER_USER_ID);
|
||||
assertNoMarketMutationDuring("getMarketplaceAsset 可安装详情仍是纯读 operation",
|
||||
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets/{assetId}", readyToInstallAgent)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userActions.isAcquired").value(true))
|
||||
.andExpect(jsonPath("$.data.userActions.isInstalled").value(false))
|
||||
.andExpect(jsonPath("$.data.userActions.canInstall").value(true)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_getGovernanceImpactForPublisherAndForbidNonOwnerProbe() throws Exception {
|
||||
long asset = seedAsset(TENANT_ID, "治理影响作品", "work", PUBLISHER_ID, "listed");
|
||||
@ -321,7 +377,9 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
|
||||
muse_market_favorite,
|
||||
muse_market_command,
|
||||
muse_market_governance_action,
|
||||
muse_market_governance_impact
|
||||
muse_market_governance_impact,
|
||||
muse_market_authorization_snapshot,
|
||||
muse_market_installation
|
||||
RESTART IDENTITY CASCADE
|
||||
""");
|
||||
} catch (SQLException exception) {
|
||||
@ -351,6 +409,53 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定用户在指定资产上 seed 一条 active 来源授权快照,模拟"已获取"事实。
|
||||
*
|
||||
* <p>列只填 NOT NULL 必填项 + status=active;owner_user_id 是 isAcquired 回填的判定键。</p>
|
||||
*/
|
||||
private void seedActiveAuthorization(Long tenantId, Long assetId, Long ownerUserId) {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO muse_market_authorization_snapshot(authorization_snapshot_id, asset_id, license_id,
|
||||
purchaser_user_id, owner_user_id, source_owner,
|
||||
source_type, source_id, source_revision, status,
|
||||
tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, 'muse-module-market', 'asset', ?, '1', 'active', ?)
|
||||
""")) {
|
||||
statement.setString(1, "auth-snap-" + assetId + "-" + ownerUserId);
|
||||
statement.setLong(2, assetId);
|
||||
statement.setString(3, "license-" + assetId + "-" + ownerUserId);
|
||||
statement.setLong(4, ownerUserId);
|
||||
statement.setLong(5, ownerUserId);
|
||||
statement.setString(6, String.valueOf(assetId));
|
||||
statement.setLong(7, tenantId);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new AssertionError("seed muse_market_authorization_snapshot 失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定用户在指定资产上 seed 一条安装事实,模拟"已安装"状态。
|
||||
*/
|
||||
private void seedInstallation(Long tenantId, Long assetId, Long userId) {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO muse_market_installation(asset_id, asset_version_id, user_id, status, tenant_id)
|
||||
VALUES (?, ?, ?, 'installed', ?)
|
||||
""")) {
|
||||
statement.setLong(1, assetId);
|
||||
// 安装事实唯一键含 asset_version_id;这里给定具体版本号即可,isInstalled 回填不限版本。
|
||||
statement.setLong(2, 1L);
|
||||
statement.setLong(3, userId);
|
||||
statement.setLong(4, tenantId);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException exception) {
|
||||
throw new AssertionError("seed muse_market_installation 失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 无突变断言(反假绿) ====================
|
||||
|
||||
private void assertNoMarketMutationDuring(String message, CheckedOperation operation) throws Exception {
|
||||
@ -365,7 +470,9 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
|
||||
tableSnapshot("muse_market_favorite", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_command", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_governance_action", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_governance_impact", "tenant_id, id")
|
||||
tableSnapshot("muse_market_governance_impact", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_authorization_snapshot", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_installation", "tenant_id, id")
|
||||
);
|
||||
}
|
||||
|
||||
@ -633,7 +740,9 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
|
||||
List<String> favorites,
|
||||
List<String> commands,
|
||||
List<String> governanceActions,
|
||||
List<String> governanceImpacts) {
|
||||
List<String> governanceImpacts,
|
||||
List<String> authorizationSnapshots,
|
||||
List<String> installations) {
|
||||
}
|
||||
|
||||
private record CompletedApprovalSettings(String jdbcUrl,
|
||||
|
||||
@ -324,7 +324,52 @@ async function globalSetup(): Promise<void> {
|
||||
VALUES (1,'writing.continuation',1,'1','active',1,'user',1)`
|
||||
);
|
||||
|
||||
console.log(`[e2e globalSetup] 已复位每轮 fixture(graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema/block/kb-status/installed-kb/handoff-bind/handoff-agent/handoff-content/agent-slot-bind);清理 e2e 累积 agent ${agentCleanup.rowCount} 条`);
|
||||
// 16) market-install(UI e2e):seed 一个固定 marker 的 listed agent 资产,并把当前用户(user1)对它的
|
||||
// 授权/安装/命令复位为「未获取、未安装」干净起点。WHY:market-install.spec 走 purchase→install 真链,
|
||||
// UI 每次用随机 commandId(crypto.randomUUID)→install 每跑落一条新 installation;不复位则二次跑资产已安装,
|
||||
// 安装按钮的渲染条件(isAcquired && !isInstalled)不再成立 → 断言必红。故每轮删净 user1 在该资产上的
|
||||
// authz/installation/command,保证 UI 起点恒为「可获取、未安装」。资产本身幂等保留(只删用户态)。
|
||||
const MARKET_INSTALL_ASSET = '活体市场资产·安装e2e';
|
||||
let installE2eAssetId: number;
|
||||
const existed = await client.query(
|
||||
"SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false",
|
||||
[MARKET_INSTALL_ASSET]
|
||||
);
|
||||
if (existed.rowCount && existed.rows[0]) {
|
||||
installE2eAssetId = existed.rows[0].id as number;
|
||||
} else {
|
||||
// 资产首建:listed agent + 一条 published 版本,并回填 current_version_id(详情/安装需可见且有版本)。
|
||||
const a = await client.query(
|
||||
`INSERT INTO muse_market_asset
|
||||
(name, description, asset_type, category, publisher_id, listing_status, license_type, status, creator, updater, tenant_id)
|
||||
VALUES ($1,'安装 e2e 专用资产','agent','general',2,'listed','standard','active','2','2',1) RETURNING id`,
|
||||
[MARKET_INSTALL_ASSET]
|
||||
);
|
||||
installE2eAssetId = a.rows[0].id as number;
|
||||
const v = await client.query(
|
||||
`INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id)
|
||||
VALUES ($1,'1.0.0','首发','published','2','2',1) RETURNING id`,
|
||||
[installE2eAssetId]
|
||||
);
|
||||
await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE id=$2', [
|
||||
v.rows[0].id,
|
||||
installE2eAssetId,
|
||||
]);
|
||||
}
|
||||
// 复位 user1 在该资产上的获取/安装/命令到干净起点(未获取、未安装),令安装按钮渲染条件每轮可复现。
|
||||
await client.query('DELETE FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1', [
|
||||
installE2eAssetId,
|
||||
]);
|
||||
await client.query(
|
||||
'DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1',
|
||||
[installE2eAssetId]
|
||||
);
|
||||
// UI 随机 commandId 的 purchase/install 命令记录按 target_id 清(target_id 存资产 id 字符串),避免幂等回放。
|
||||
await client.query("DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1", [
|
||||
String(installE2eAssetId),
|
||||
]);
|
||||
|
||||
console.log(`[e2e globalSetup] 已复位每轮 fixture(graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema/block/kb-status/installed-kb/handoff-bind/handoff-agent/handoff-content/agent-slot-bind/market-install assetId=${installE2eAssetId});清理 e2e 累积 agent ${agentCleanup.rowCount} 条`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
||||
125
muse-studio/e2e/market-install.spec.ts
Normal file
125
muse-studio/e2e/market-install.spec.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
|
||||
/**
|
||||
* 市场资产安装 UI e2e(真实后端,MSW off,反假绿)。
|
||||
*
|
||||
* 验「修复用户可见假绿」全链:后端 enrich isAcquired/canInstall(此前裸写 false,致 MarketBrowse 安装按钮永不渲染)
|
||||
* → 已获取资产在市场页真实渲染出「安装」按钮 → 点击经真实浏览器 UI → 真后端落 muse_market_installation → UI 态翻转。
|
||||
*
|
||||
* 反假绿四重锚点(任何一处假绿都会断言失败):
|
||||
* 1) DB 起点:user1 对该资产无 installation(global-setup #16 每轮复位为「未获取、未安装」)。
|
||||
* 2) 前置 purchase(page.request 真打 /purchase)后,市场卡片**真实渲染出安装按钮**——这正是修前恒缺失的按钮
|
||||
* (修前 isAcquired 裸写 false → canInstall=asset.isAcquired&&… 恒 false → 按钮永不出现)。
|
||||
* 3) 点击安装 → 真后端 install 返回 installed。
|
||||
* 4) DB 终点:muse_market_installation 新增该 user+asset 的 installed 行;UI 状态文案翻转为「已安装」。
|
||||
*
|
||||
* 前置:vite VITE_API_MOCK=false、真实 muse-server 48080、muse_slice_live;
|
||||
* global-setup #16 每轮 seed 固定 marker 的 listed agent 资产并复位 user1 在其上的授权/安装/命令。
|
||||
* PG 凭据从环境变量读取(MUSE_POSTGRES_*,见 ~/.config/muse-repo/infra.env;绝不入库):
|
||||
* 跑前 `set -a; . ~/.config/muse-repo/infra.env; set +a`。
|
||||
*/
|
||||
const TOKEN = 'test1';
|
||||
const API = 'http://localhost:48080/app-api/muse';
|
||||
const AUTH = { Authorization: 'Bearer test1', 'tenant-id': '1', 'X-API-Version': '1' };
|
||||
|
||||
/** global-setup #16 seed 的固定 marker 资产名(本 spec 与 global-setup 必须一致)。 */
|
||||
const ASSET_NAME = '活体市场资产·安装e2e';
|
||||
|
||||
function pgClient(): Client {
|
||||
return new Client({
|
||||
host: process.env.MUSE_POSTGRES_HOST,
|
||||
port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'),
|
||||
database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live',
|
||||
user: process.env.MUSE_POSTGRES_USERNAME ?? 'root',
|
||||
password: process.env.MUSE_POSTGRES_PASSWORD,
|
||||
ssl: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 读 global-setup seed 的安装 e2e 资产 id(按 marker 名定位)。 */
|
||||
async function readAssetId(): Promise<number> {
|
||||
const c = pgClient();
|
||||
await c.connect();
|
||||
try {
|
||||
const r = await c.query(
|
||||
'SELECT id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false',
|
||||
[ASSET_NAME]
|
||||
);
|
||||
expect(r.rowCount, 'global-setup #16 应已 seed 安装 e2e 资产').toBeGreaterThan(0);
|
||||
return Number(r.rows[0].id);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** 读 user1 对指定资产的 installed 安装行数(DB 核验:0=未安装,>=1=已安装)。 */
|
||||
async function countInstallation(assetId: number): Promise<number> {
|
||||
const c = pgClient();
|
||||
await c.connect();
|
||||
try {
|
||||
const r = await c.query(
|
||||
"SELECT count(*)::int AS n FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1 AND status='installed'",
|
||||
[assetId]
|
||||
);
|
||||
return Number(r.rows[0].n);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function seedToken(page: Page): Promise<void> {
|
||||
await page.addInitScript((t) => {
|
||||
window.localStorage.setItem('accessToken', t);
|
||||
window.localStorage.setItem('tenantId', '1');
|
||||
}, TOKEN);
|
||||
}
|
||||
|
||||
test('用户可经真实浏览器 UI 安装已获取的市场资产(真实后端,enrich 驱动安装按钮渲染)', async ({ page, request }) => {
|
||||
const assetId = await readAssetId();
|
||||
|
||||
// 1. DB 起点:global-setup #16 已复位为未安装(反假绿基准,确保终点新增可归因于本次 UI 安装)。
|
||||
expect(await countInstallation(assetId), 'bind 前 user1 对该资产应无 installed 安装行(global-setup 复位)').toBe(0);
|
||||
|
||||
// 2. 前置 purchase(真打后端造 active 授权)→ 使资产 isAcquired=true,这是安装按钮渲染的前置条件。
|
||||
const purchaseResp = await request.post(`${API}/marketplace/assets/${assetId}/purchase`, {
|
||||
headers: AUTH,
|
||||
data: { commandId: `e2e-acq-${Date.now()}` },
|
||||
});
|
||||
expect(purchaseResp.status(), 'purchase HTTP').toBe(200);
|
||||
expect((await purchaseResp.json()).code, 'purchase code').toBe(0);
|
||||
|
||||
// 3. 进市场页 → 筛选 agent(seed 资产是 agent 类型)。
|
||||
await seedToken(page);
|
||||
await page.goto('/market');
|
||||
await page.getByRole('button', { name: '智能体', exact: true }).click();
|
||||
|
||||
// 4. 定位该资产卡片(按资产名),断言**安装按钮真实渲染出来**——修前 enrich 裸写 false 时此按钮永不出现。
|
||||
const card = page.locator('article').filter({ hasText: ASSET_NAME });
|
||||
await expect(card, '已获取资产的卡片应可见').toBeVisible({ timeout: 15_000 });
|
||||
const installButton = card.getByRole('button', { name: '安装', exact: true });
|
||||
await expect(installButton, '已获取未安装资产应渲染「安装」按钮(enrich isAcquired=true 驱动)').toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 5. 点击安装 → 等真后端 install 返回 installed(非 mock 截获)。
|
||||
const [installResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => new RegExp(`/marketplace/assets/${assetId}/install$`).test(r.url()) && r.request().method() === 'POST',
|
||||
{ timeout: 20_000 }
|
||||
),
|
||||
installButton.click(),
|
||||
]);
|
||||
expect(installResp.status(), 'install HTTP').toBe(200);
|
||||
const installBody = await installResp.json();
|
||||
expect(installBody.code, `install 返回: ${JSON.stringify(installBody)}`).toBe(0);
|
||||
expect(installBody.data?.status, 'install status').toBe('installed');
|
||||
|
||||
// 6a. UI 态翻转:安装成功后卡片状态文案变「已安装」(useInstallMarketAsset onSuccess 失效列表 → 重查 enrich isInstalled=true)。
|
||||
await expect(card.getByText('已安装', { exact: true }), '安装后卡片状态应翻转为「已安装」').toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 6b. DB 终点核验:user1 对该资产新增 installed 行(证 UI 操作真落库,非 mock 假绿)。
|
||||
expect(await countInstallation(assetId), '安装后 user1 应有 installed 安装行').toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
@ -42,6 +42,14 @@ export default function MarketBrowse() {
|
||||
installAsset.mutate(asset.assetId);
|
||||
};
|
||||
|
||||
// 获取/安装失败时给用户可见提示,不再静默吞 ApiError(error.message 即后端 msg)。
|
||||
// 对齐 studio 既有内联错误范式(AgentCreateForm / SlotBindingPanel:rose-50 内联提示)。
|
||||
const actionError = installAsset.isError
|
||||
? `安装失败:${installAsset.error.message}`
|
||||
: acquireAsset.isError
|
||||
? `获取授权失败:${acquireAsset.error.message}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50/60 px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
@ -81,6 +89,16 @@ export default function MarketBrowse() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 获取/安装失败的可见提示(此前 mutate 后静默吞错,用户无任何反馈)。 */}
|
||||
{actionError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{categories?.categories && (
|
||||
<div className="mt-6 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{categories.categories.map((category) => (
|
||||
|
||||
@ -83,6 +83,22 @@ describe('useMarket API Hooks 单元测试', () => {
|
||||
expect(installHook.current.data?.canBindTarget).toBe(true);
|
||||
});
|
||||
|
||||
it('安装失败时 hook 暴露 isError + 后端错误消息(供 UI 可见提示,反静默吞错)', async () => {
|
||||
// 后端拒安装(如未获取授权 → MARKET_LICENSE_NOT_EXISTS):CommonResult.code 非 0,
|
||||
// client 抛 ApiError(message=后端 msg)。MarketBrowse 据此渲染「安装失败:{message}」。
|
||||
server.use(
|
||||
http.post('/app-api/muse/marketplace/assets/:assetId/install', () =>
|
||||
HttpResponse.json({ code: 1_041_000_001, msg: 'Market 授权不存在', data: null })
|
||||
)
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useInstallMarketAsset(), { wrapper });
|
||||
result.current.mutate('20000000-0000-4000-8000-000000000002');
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(result.current.error?.message).toBe('Market 授权不存在');
|
||||
});
|
||||
|
||||
it('读资产详情(含许可)并向正确端点收藏', async () => {
|
||||
const requestedUrls: string[] = [];
|
||||
server.use(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user