diff --git a/Copilot-Processing.md b/Copilot-Processing.md new file mode 100644 index 00000000..03610cfe --- /dev/null +++ b/Copilot-Processing.md @@ -0,0 +1,58 @@ +# Copilot Processing + +## User Request +Implémenter les stories 3.3, 3.4 et 3.5 : Import npm Dependencies via File Upload, Import via Paste, et View Package List. + +## Action Plan + +### Phase 1: Backend Domain & Application Layer +- [x] 1.1 Create Package domain model (PackageId, Package) +- [x] 1.2 Create PackageRepository port +- [x] 1.3 Create LockfileParser port +- [x] 1.4 Create ImportPackagesFromLockfileUseCase (port + impl) +- [x] 1.5 Create ImportPackagesFromListUseCase (port + impl) +- [x] 1.6 Create ListCompanyPackagesUseCase (port + impl) +- [x] 1.7 Create DB migration for packages table + +### Phase 2: Backend Infrastructure Layer +- [x] 2.1 Create PackageEntity + JPA Repository +- [x] 2.2 Create PackageRepositoryAdapter (PackageJpaRepository + PackageMapper) +- [x] 2.3 Create LockfileParserAdapter +- [x] 2.4 Create PackageResource REST endpoints + DTOs + +### Phase 3: Frontend +- [x] 3.1 Create package API client +- [x] 3.2 Create FileDropzone component +- [x] 3.3 Create PastePackagesDialog component +- [x] 3.4 Create PackageCard component +- [x] 3.5 Create PackagesPage with search + infinite scroll +- [x] 3.6 Add route and navigation +- [x] 3.7 Update Get Started section with dynamic step states + +### Phase 4: Tests +- [x] 4.1 Backend unit tests (PackageTest, LockfileParserAdapterTest, ImportPackagesFromLockfileUseCaseImplTest, ImportPackagesFromListUseCaseImplTest, ListCompanyPackagesUseCaseImplTest) +- [x] 4.2 Validate compilation (backend + frontend) +- [x] 4.3 Update GetCompanyDashboardUseCaseImpl + tests for hasPackages + +## Summary + +Implemented stories 3.3, 3.4, and 3.5 with full backend and frontend integration. + +### Backend (27 new files) +**Domain:** Package, PackageId +**Ports:** PackageRepository, LockfileParser, ImportPackagesFromLockfileUseCase, ImportPackagesFromListUseCase, ListCompanyPackagesUseCase +**Use Cases:** ImportPackagesFromLockfileUseCaseImpl, ImportPackagesFromListUseCaseImpl, ListCompanyPackagesUseCaseImpl +**Infrastructure:** PackageEntity, PackageJpaRepository, PackageMapper, LockfileParserAdapter, PackageResource + 4 DTOs +**Migration:** V9__create_packages_table.sql + +### Frontend (5 new files) +**Features:** packages/api.ts, FileDropzone.tsx, PastePackagesDialog.tsx, PackageCard.tsx +**Pages:** PackagesPage.tsx + +### Modified Files +- GetCompanyDashboardUseCaseImpl.java (added hasPackages check) +- GetCompanyDashboardUseCaseImplTest.java (updated mocks) +- CompanyDashboardPage.tsx (dynamic Get Started steps with completion states) +- App.tsx (added /dashboard/packages route) + +### Tests: 25 new tests, all passing diff --git a/apps/api/src/main/java/com/upkeep/application/port/in/pkg/ImportPackagesFromListUseCase.java b/apps/api/src/main/java/com/upkeep/application/port/in/pkg/ImportPackagesFromListUseCase.java new file mode 100644 index 00000000..62638eb2 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/application/port/in/pkg/ImportPackagesFromListUseCase.java @@ -0,0 +1,24 @@ +package com.upkeep.application.port.in.pkg; + +import java.util.List; + +public interface ImportPackagesFromListUseCase { + + ImportListResult execute(ImportFromListCommand command); + + record ImportFromListCommand( + String companyId, + String customerId, + List packageNames + ) {} + + record ImportListResult( + int importedCount, + int skippedCount, + int invalidCount, + List importedNames, + List skippedNames, + List invalidNames + ) {} +} + diff --git a/apps/api/src/main/java/com/upkeep/application/port/in/pkg/ImportPackagesFromLockfileUseCase.java b/apps/api/src/main/java/com/upkeep/application/port/in/pkg/ImportPackagesFromLockfileUseCase.java new file mode 100644 index 00000000..786ac5e7 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/application/port/in/pkg/ImportPackagesFromLockfileUseCase.java @@ -0,0 +1,24 @@ +package com.upkeep.application.port.in.pkg; + +import java.util.List; + +public interface ImportPackagesFromLockfileUseCase { + + ImportResult execute(ImportFromLockfileCommand command); + + record ImportFromLockfileCommand( + String companyId, + String customerId, + String fileContent, + String filename + ) {} + + record ImportResult( + int importedCount, + int skippedCount, + int totalParsed, + List importedNames, + List skippedNames + ) {} +} + diff --git a/apps/api/src/main/java/com/upkeep/application/port/in/pkg/ListCompanyPackagesUseCase.java b/apps/api/src/main/java/com/upkeep/application/port/in/pkg/ListCompanyPackagesUseCase.java new file mode 100644 index 00000000..36ff7b94 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/application/port/in/pkg/ListCompanyPackagesUseCase.java @@ -0,0 +1,31 @@ +package com.upkeep.application.port.in.pkg; + +import java.util.List; + +public interface ListCompanyPackagesUseCase { + + PackageListResult execute(ListPackagesQuery query); + + record ListPackagesQuery( + String companyId, + String customerId, + String search, + int page, + int size + ) {} + + record PackageListResult( + List packages, + long totalCount, + int page, + int size + ) {} + + record PackageItem( + String id, + String name, + String registry, + String importedAt + ) {} +} + diff --git a/apps/api/src/main/java/com/upkeep/application/port/out/pkg/LockfileParser.java b/apps/api/src/main/java/com/upkeep/application/port/out/pkg/LockfileParser.java new file mode 100644 index 00000000..62ee9585 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/application/port/out/pkg/LockfileParser.java @@ -0,0 +1,11 @@ +package com.upkeep.application.port.out.pkg; + +import java.util.List; + +public interface LockfileParser { + + List parse(String content, String filename); + + boolean supports(String filename); +} + diff --git a/apps/api/src/main/java/com/upkeep/application/port/out/pkg/PackageRepository.java b/apps/api/src/main/java/com/upkeep/application/port/out/pkg/PackageRepository.java new file mode 100644 index 00000000..12f6854f --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/application/port/out/pkg/PackageRepository.java @@ -0,0 +1,27 @@ +package com.upkeep.application.port.out.pkg; + +import com.upkeep.domain.model.company.CompanyId; +import com.upkeep.domain.model.pkg.Package; + +import java.util.List; +import java.util.Set; + +public interface PackageRepository { + + void save(Package pkg); + + void saveAll(List packages); + + List findByCompanyId(CompanyId companyId, int offset, int limit); + + List findByCompanyIdAndNameContaining(CompanyId companyId, String search, int offset, int limit); + + long countByCompanyId(CompanyId companyId); + + long countByCompanyIdAndNameContaining(CompanyId companyId, String search); + + Set findExistingNamesByCompanyId(CompanyId companyId, Set names); + + boolean existsByCompanyIdAndName(CompanyId companyId, String name); +} + diff --git a/apps/api/src/main/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImpl.java b/apps/api/src/main/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImpl.java index dfb2f431..9673ff13 100644 --- a/apps/api/src/main/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImpl.java +++ b/apps/api/src/main/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImpl.java @@ -1,8 +1,10 @@ package com.upkeep.application.usecase; import com.upkeep.application.port.in.GetCompanyDashboardUseCase; +import com.upkeep.application.port.out.budget.BudgetRepository; import com.upkeep.application.port.out.company.CompanyRepository; import com.upkeep.application.port.out.membership.MembershipRepository; +import com.upkeep.application.port.out.pkg.PackageRepository; import com.upkeep.domain.exception.CompanyNotFoundException; import com.upkeep.domain.exception.MembershipNotFoundException; import com.upkeep.domain.model.company.Company; @@ -17,12 +19,18 @@ public class GetCompanyDashboardUseCaseImpl implements GetCompanyDashboardUseCas private final CompanyRepository companyRepository; private final MembershipRepository membershipRepository; + private final BudgetRepository budgetRepository; + private final PackageRepository packageRepository; @Inject public GetCompanyDashboardUseCaseImpl(CompanyRepository companyRepository, - MembershipRepository membershipRepository) { + MembershipRepository membershipRepository, + BudgetRepository budgetRepository, + PackageRepository packageRepository) { this.companyRepository = companyRepository; this.membershipRepository = membershipRepository; + this.budgetRepository = budgetRepository; + this.packageRepository = packageRepository; } @Override @@ -37,11 +45,13 @@ public CompanyDashboard execute(GetCompanyDashboardQuery query) { .orElseThrow(() -> new MembershipNotFoundException(query.customerId(), query.companyId())); long totalMembers = membershipRepository.countByCompanyId(companyId); + boolean hasBudget = budgetRepository.existsByCompanyId(companyId); + boolean hasPackages = packageRepository.countByCompanyId(companyId) > 0; DashboardStats stats = new DashboardStats( (int) totalMembers, - false, - false, + hasBudget, + hasPackages, false ); diff --git a/apps/api/src/main/java/com/upkeep/application/usecase/ImportPackagesFromListUseCaseImpl.java b/apps/api/src/main/java/com/upkeep/application/usecase/ImportPackagesFromListUseCaseImpl.java new file mode 100644 index 00000000..7f09f624 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/application/usecase/ImportPackagesFromListUseCaseImpl.java @@ -0,0 +1,87 @@ +package com.upkeep.application.usecase; + +import com.upkeep.application.port.in.pkg.ImportPackagesFromListUseCase; +import com.upkeep.application.port.out.membership.MembershipRepository; +import com.upkeep.application.port.out.pkg.PackageRepository; +import com.upkeep.domain.exception.MembershipNotFoundException; +import com.upkeep.domain.model.company.CompanyId; +import com.upkeep.domain.model.customer.CustomerId; +import com.upkeep.domain.model.pkg.Package; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@ApplicationScoped +public class ImportPackagesFromListUseCaseImpl implements ImportPackagesFromListUseCase { + + private final PackageRepository packageRepository; + private final MembershipRepository membershipRepository; + + @Inject + public ImportPackagesFromListUseCaseImpl(PackageRepository packageRepository, + MembershipRepository membershipRepository) { + this.packageRepository = packageRepository; + this.membershipRepository = membershipRepository; + } + + @Override + @Transactional + public ImportListResult execute(ImportFromListCommand command) { + CompanyId companyId = CompanyId.from(command.companyId()); + CustomerId customerId = CustomerId.from(command.customerId()); + + membershipRepository.findByCustomerIdAndCompanyId(customerId, companyId) + .orElseThrow(() -> new MembershipNotFoundException(command.customerId(), command.companyId())); + + List validNames = new ArrayList<>(); + List invalidNames = new ArrayList<>(); + + for (String rawName : command.packageNames()) { + String trimmed = rawName.trim(); + if (trimmed.isEmpty()) { + continue; + } + if (Package.isValidNpmPackageName(trimmed)) { + validNames.add(trimmed); + } else { + invalidNames.add(trimmed); + } + } + + Set existingNames = packageRepository.findExistingNamesByCompanyId(companyId, + validNames.stream().collect(Collectors.toSet())); + + List importedNames = new ArrayList<>(); + List skippedNames = new ArrayList<>(); + List toSave = new ArrayList<>(); + + for (String name : validNames) { + if (existingNames.contains(name)) { + skippedNames.add(name); + } else { + Package pkg = Package.create(companyId, name); + toSave.add(pkg); + importedNames.add(name); + } + } + + if (!toSave.isEmpty()) { + packageRepository.saveAll(toSave); + } + + return new ImportListResult( + importedNames.size(), + skippedNames.size(), + invalidNames.size(), + importedNames, + skippedNames, + invalidNames + ); + } +} + diff --git a/apps/api/src/main/java/com/upkeep/application/usecase/ImportPackagesFromLockfileUseCaseImpl.java b/apps/api/src/main/java/com/upkeep/application/usecase/ImportPackagesFromLockfileUseCaseImpl.java new file mode 100644 index 00000000..c9d21b0d --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/application/usecase/ImportPackagesFromLockfileUseCaseImpl.java @@ -0,0 +1,81 @@ +package com.upkeep.application.usecase; + +import com.upkeep.application.port.in.pkg.ImportPackagesFromLockfileUseCase; +import com.upkeep.application.port.out.membership.MembershipRepository; +import com.upkeep.application.port.out.pkg.LockfileParser; +import com.upkeep.application.port.out.pkg.PackageRepository; +import com.upkeep.domain.exception.MembershipNotFoundException; +import com.upkeep.domain.model.company.CompanyId; +import com.upkeep.domain.model.customer.CustomerId; +import com.upkeep.domain.model.pkg.Package; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@ApplicationScoped +public class ImportPackagesFromLockfileUseCaseImpl implements ImportPackagesFromLockfileUseCase { + + private final PackageRepository packageRepository; + private final MembershipRepository membershipRepository; + private final LockfileParser lockfileParser; + + @Inject + public ImportPackagesFromLockfileUseCaseImpl(PackageRepository packageRepository, + MembershipRepository membershipRepository, + LockfileParser lockfileParser) { + this.packageRepository = packageRepository; + this.membershipRepository = membershipRepository; + this.lockfileParser = lockfileParser; + } + + @Override + @Transactional + public ImportResult execute(ImportFromLockfileCommand command) { + CompanyId companyId = CompanyId.from(command.companyId()); + CustomerId customerId = CustomerId.from(command.customerId()); + + membershipRepository.findByCustomerIdAndCompanyId(customerId, companyId) + .orElseThrow(() -> new MembershipNotFoundException(command.customerId(), command.companyId())); + + if (!lockfileParser.supports(command.filename())) { + throw new IllegalArgumentException("Unsupported lockfile format: " + command.filename()); + } + + List parsedNames = lockfileParser.parse(command.fileContent(), command.filename()); + + Set existingNames = packageRepository.findExistingNamesByCompanyId(companyId, + parsedNames.stream().collect(Collectors.toSet())); + + List importedNames = new ArrayList<>(); + List skippedNames = new ArrayList<>(); + List toSave = new ArrayList<>(); + + for (String name : parsedNames) { + if (existingNames.contains(name)) { + skippedNames.add(name); + } else { + Package pkg = Package.create(companyId, name); + toSave.add(pkg); + importedNames.add(name); + } + } + + if (!toSave.isEmpty()) { + packageRepository.saveAll(toSave); + } + + return new ImportResult( + importedNames.size(), + skippedNames.size(), + parsedNames.size(), + importedNames, + skippedNames + ); + } +} + diff --git a/apps/api/src/main/java/com/upkeep/application/usecase/ListCompanyPackagesUseCaseImpl.java b/apps/api/src/main/java/com/upkeep/application/usecase/ListCompanyPackagesUseCaseImpl.java new file mode 100644 index 00000000..6bfdcb6f --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/application/usecase/ListCompanyPackagesUseCaseImpl.java @@ -0,0 +1,61 @@ +package com.upkeep.application.usecase; + +import com.upkeep.application.port.in.pkg.ListCompanyPackagesUseCase; +import com.upkeep.application.port.out.membership.MembershipRepository; +import com.upkeep.application.port.out.pkg.PackageRepository; +import com.upkeep.domain.exception.MembershipNotFoundException; +import com.upkeep.domain.model.company.CompanyId; +import com.upkeep.domain.model.customer.CustomerId; +import com.upkeep.domain.model.pkg.Package; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.List; + +@ApplicationScoped +public class ListCompanyPackagesUseCaseImpl implements ListCompanyPackagesUseCase { + + private final PackageRepository packageRepository; + private final MembershipRepository membershipRepository; + + @Inject + public ListCompanyPackagesUseCaseImpl(PackageRepository packageRepository, + MembershipRepository membershipRepository) { + this.packageRepository = packageRepository; + this.membershipRepository = membershipRepository; + } + + @Override + public PackageListResult execute(ListPackagesQuery query) { + CompanyId companyId = CompanyId.from(query.companyId()); + CustomerId customerId = CustomerId.from(query.customerId()); + + membershipRepository.findByCustomerIdAndCompanyId(customerId, companyId) + .orElseThrow(() -> new MembershipNotFoundException(query.customerId(), query.companyId())); + + int offset = query.page() * query.size(); + + List packages; + long totalCount; + + if (query.search() != null && !query.search().isBlank()) { + packages = packageRepository.findByCompanyIdAndNameContaining(companyId, query.search(), offset, query.size()); + totalCount = packageRepository.countByCompanyIdAndNameContaining(companyId, query.search()); + } else { + packages = packageRepository.findByCompanyId(companyId, offset, query.size()); + totalCount = packageRepository.countByCompanyId(companyId); + } + + List items = packages.stream() + .map(pkg -> new PackageItem( + pkg.getId().toString(), + pkg.getName(), + pkg.getRegistry(), + pkg.getImportedAt().toString() + )) + .toList(); + + return new PackageListResult(items, totalCount, query.page(), query.size()); + } +} + diff --git a/apps/api/src/main/java/com/upkeep/domain/model/pkg/Package.java b/apps/api/src/main/java/com/upkeep/domain/model/pkg/Package.java new file mode 100644 index 00000000..39f785f1 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/domain/model/pkg/Package.java @@ -0,0 +1,71 @@ +package com.upkeep.domain.model.pkg; + +import com.upkeep.domain.model.company.CompanyId; + +import java.time.Instant; +import java.util.regex.Pattern; + +public class Package { + + private static final Pattern NPM_PACKAGE_NAME = Pattern.compile( + "^(@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$" + ); + + private final PackageId id; + private final CompanyId companyId; + private final String name; + private final String registry; + private final Instant importedAt; + + private Package(PackageId id, CompanyId companyId, String name, String registry, Instant importedAt) { + this.id = id; + this.companyId = companyId; + this.name = name; + this.registry = registry; + this.importedAt = importedAt; + } + + public static Package create(CompanyId companyId, String name) { + validateName(name); + return new Package(PackageId.generate(), companyId, name, "npm", Instant.now()); + } + + public static Package reconstitute(PackageId id, CompanyId companyId, String name, + String registry, Instant importedAt) { + return new Package(id, companyId, name, registry, importedAt); + } + + public static boolean isValidNpmPackageName(String name) { + if (name == null || name.isBlank()) { + return false; + } + return NPM_PACKAGE_NAME.matcher(name.trim()).matches(); + } + + private static void validateName(String name) { + if (!isValidNpmPackageName(name)) { + throw new IllegalArgumentException("Invalid npm package name: " + name); + } + } + + public PackageId getId() { + return id; + } + + public CompanyId getCompanyId() { + return companyId; + } + + public String getName() { + return name; + } + + public String getRegistry() { + return registry; + } + + public Instant getImportedAt() { + return importedAt; + } +} + diff --git a/apps/api/src/main/java/com/upkeep/domain/model/pkg/PackageId.java b/apps/api/src/main/java/com/upkeep/domain/model/pkg/PackageId.java new file mode 100644 index 00000000..c7ef44f0 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/domain/model/pkg/PackageId.java @@ -0,0 +1,23 @@ +package com.upkeep.domain.model.pkg; + +import java.util.UUID; + +public record PackageId(UUID value) { + public static PackageId generate() { + return new PackageId(UUID.randomUUID()); + } + + public static PackageId from(String value) { + return new PackageId(UUID.fromString(value)); + } + + public static PackageId from(UUID value) { + return new PackageId(value); + } + + @Override + public String toString() { + return value.toString(); + } +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportListRequest.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportListRequest.java new file mode 100644 index 00000000..4e72efee --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportListRequest.java @@ -0,0 +1,9 @@ +package com.upkeep.infrastructure.adapter.in.rest.pkg; + +import java.util.List; + +public record ImportListRequest( + List packageNames +) { +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportListResultResponse.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportListResultResponse.java new file mode 100644 index 00000000..f96f02ec --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportListResultResponse.java @@ -0,0 +1,14 @@ +package com.upkeep.infrastructure.adapter.in.rest.pkg; + +import java.util.List; + +public record ImportListResultResponse( + int importedCount, + int skippedCount, + int invalidCount, + List importedNames, + List skippedNames, + List invalidNames +) { +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportLockfileRequest.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportLockfileRequest.java new file mode 100644 index 00000000..e9f757f4 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportLockfileRequest.java @@ -0,0 +1,8 @@ +package com.upkeep.infrastructure.adapter.in.rest.pkg; + +public record ImportLockfileRequest( + String fileContent, + String filename +) { +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportResultResponse.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportResultResponse.java new file mode 100644 index 00000000..fc2f074b --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportResultResponse.java @@ -0,0 +1,13 @@ +package com.upkeep.infrastructure.adapter.in.rest.pkg; + +import java.util.List; + +public record ImportResultResponse( + int importedCount, + int skippedCount, + int totalParsed, + List importedNames, + List skippedNames +) { +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/PackageListResponse.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/PackageListResponse.java new file mode 100644 index 00000000..16622536 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/PackageListResponse.java @@ -0,0 +1,19 @@ +package com.upkeep.infrastructure.adapter.in.rest.pkg; + +import java.util.List; + +public record PackageListResponse( + List packages, + long totalCount, + int page, + int size +) { + public record PackageItemResponse( + String id, + String name, + String registry, + String importedAt + ) { + } +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/PackageResource.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/PackageResource.java new file mode 100644 index 00000000..80d0d7d1 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/PackageResource.java @@ -0,0 +1,149 @@ +package com.upkeep.infrastructure.adapter.in.rest.pkg; + +import com.upkeep.application.port.in.pkg.ImportPackagesFromLockfileUseCase; +import com.upkeep.application.port.in.pkg.ImportPackagesFromLockfileUseCase.ImportFromLockfileCommand; +import com.upkeep.application.port.in.pkg.ImportPackagesFromLockfileUseCase.ImportResult; +import com.upkeep.application.port.in.pkg.ImportPackagesFromListUseCase; +import com.upkeep.application.port.in.pkg.ImportPackagesFromListUseCase.ImportFromListCommand; +import com.upkeep.application.port.in.pkg.ImportPackagesFromListUseCase.ImportListResult; +import com.upkeep.application.port.in.pkg.ListCompanyPackagesUseCase; +import com.upkeep.application.port.in.pkg.ListCompanyPackagesUseCase.ListPackagesQuery; +import com.upkeep.application.port.in.pkg.ListCompanyPackagesUseCase.PackageListResult; +import com.upkeep.application.port.out.auth.TokenService; +import com.upkeep.application.port.out.auth.TokenService.TokenClaims; +import com.upkeep.infrastructure.adapter.in.rest.common.response.ApiError; +import com.upkeep.infrastructure.adapter.in.rest.common.response.ApiResponse; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.CookieParam; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api/companies/{companyId}/packages") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class PackageResource { + + private static final String ACCESS_TOKEN_COOKIE = "access_token"; + + private final ImportPackagesFromLockfileUseCase importFromLockfileUseCase; + private final ImportPackagesFromListUseCase importFromListUseCase; + private final ListCompanyPackagesUseCase listPackagesUseCase; + private final TokenService tokenService; + + @Inject + public PackageResource(ImportPackagesFromLockfileUseCase importFromLockfileUseCase, + ImportPackagesFromListUseCase importFromListUseCase, + ListCompanyPackagesUseCase listPackagesUseCase, + TokenService tokenService) { + this.importFromLockfileUseCase = importFromLockfileUseCase; + this.importFromListUseCase = importFromListUseCase; + this.listPackagesUseCase = listPackagesUseCase; + this.tokenService = tokenService; + } + + @GET + public Response listPackages(@CookieParam(ACCESS_TOKEN_COOKIE) String accessToken, + @PathParam("companyId") String companyId, + @QueryParam("search") String search, + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("50") int size) { + TokenClaims claims = validateToken(accessToken); + if (claims == null) { + return unauthorizedResponse(); + } + + PackageListResult result = listPackagesUseCase.execute( + new ListPackagesQuery(companyId, claims.userId(), search, page, size) + ); + + PackageListResponse response = new PackageListResponse( + result.packages().stream() + .map(p -> new PackageListResponse.PackageItemResponse( + p.id(), p.name(), p.registry(), p.importedAt())) + .toList(), + result.totalCount(), + result.page(), + result.size() + ); + + return Response.ok(ApiResponse.success(response)).build(); + } + + @POST + @Path("/import/lockfile") + public Response importFromLockfile(@CookieParam(ACCESS_TOKEN_COOKIE) String accessToken, + @PathParam("companyId") String companyId, + ImportLockfileRequest request) { + TokenClaims claims = validateToken(accessToken); + if (claims == null) { + return unauthorizedResponse(); + } + + ImportResult result = importFromLockfileUseCase.execute( + new ImportFromLockfileCommand(companyId, claims.userId(), + request.fileContent(), request.filename()) + ); + + ImportResultResponse response = new ImportResultResponse( + result.importedCount(), + result.skippedCount(), + result.totalParsed(), + result.importedNames(), + result.skippedNames() + ); + + return Response.status(201) + .entity(ApiResponse.success(response)) + .build(); + } + + @POST + @Path("/import/list") + public Response importFromList(@CookieParam(ACCESS_TOKEN_COOKIE) String accessToken, + @PathParam("companyId") String companyId, + ImportListRequest request) { + TokenClaims claims = validateToken(accessToken); + if (claims == null) { + return unauthorizedResponse(); + } + + ImportListResult result = importFromListUseCase.execute( + new ImportFromListCommand(companyId, claims.userId(), request.packageNames()) + ); + + ImportListResultResponse response = new ImportListResultResponse( + result.importedCount(), + result.skippedCount(), + result.invalidCount(), + result.importedNames(), + result.skippedNames(), + result.invalidNames() + ); + + return Response.status(201) + .entity(ApiResponse.success(response)) + .build(); + } + + private TokenClaims validateToken(String accessToken) { + if (accessToken == null || accessToken.isBlank()) { + return null; + } + return tokenService.validateAccessToken(accessToken); + } + + private Response unauthorizedResponse() { + return Response.status(401) + .entity(ApiResponse.error(ApiError.of("UNAUTHORIZED", "Authentication required", null))) + .build(); + } +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/parser/LockfileParserAdapter.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/parser/LockfileParserAdapter.java new file mode 100644 index 00000000..69814042 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/parser/LockfileParserAdapter.java @@ -0,0 +1,143 @@ +package com.upkeep.infrastructure.adapter.out.parser; + +import com.upkeep.application.port.out.pkg.LockfileParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +@ApplicationScoped +public class LockfileParserAdapter implements LockfileParser { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + + @Override + public List parse(String content, String filename) { + if (filename.equals("package-lock.json")) { + return parsePackageLockJson(content); + } else if (filename.equals("yarn.lock")) { + return parseYarnLock(content); + } + throw new IllegalArgumentException("Unsupported lockfile: " + filename); + } + + @Override + public boolean supports(String filename) { + return "package-lock.json".equals(filename) || "yarn.lock".equals(filename); + } + + private List parsePackageLockJson(String content) { + try { + JsonNode root = OBJECT_MAPPER.readTree(content); + List packageNames = new ArrayList<>(); + + // v2/v3 format: "packages" field with "node_modules/..." keys + JsonNode packages = root.get("packages"); + if (packages != null && packages.isObject()) { + Iterator> fields = packages.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + String key = entry.getKey(); + if (key.isEmpty()) { + continue; // root package entry + } + String name = extractPackageNameFromNodeModulesPath(key); + if (name != null && !packageNames.contains(name)) { + packageNames.add(name); + } + } + return packageNames; + } + + // v1 format: "dependencies" field + JsonNode dependencies = root.get("dependencies"); + if (dependencies != null && dependencies.isObject()) { + parseDependenciesV1(dependencies, packageNames); + } + + return packageNames; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse package-lock.json: " + e.getMessage(), e); + } + } + + private void parseDependenciesV1(JsonNode node, List names) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + if (!names.contains(entry.getKey())) { + names.add(entry.getKey()); + } + // Recurse into nested dependencies + JsonNode nested = entry.getValue().get("dependencies"); + if (nested != null && nested.isObject()) { + parseDependenciesV1(nested, names); + } + } + } + + private String extractPackageNameFromNodeModulesPath(String path) { + // "node_modules/@scope/name" -> "@scope/name" + // "node_modules/name" -> "name" + // "node_modules/a/node_modules/b" -> "b" (last segment) + int lastNodeModules = path.lastIndexOf("node_modules/"); + if (lastNodeModules < 0) { + return null; + } + return path.substring(lastNodeModules + "node_modules/".length()); + } + + private List parseYarnLock(String content) { + List packageNames = new ArrayList<>(); + String[] lines = content.split("\n"); + + for (String line : lines) { + if (line.startsWith("#") || line.isBlank() || line.startsWith(" ") || line.startsWith("\t")) { + continue; + } + + // Yarn.lock top-level entries end with ":" + // Formats: `lodash@^4.17.21:` or `"@types/node@^18.0.0":` + String trimmed = line.trim(); + if (!trimmed.endsWith(":")) { + continue; + } + trimmed = trimmed.substring(0, trimmed.length() - 1); + + // Remove surrounding quotes if present + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + trimmed = trimmed.substring(1, trimmed.length() - 1); + } + + // Extract package name: everything before the last `@` that separates name from version + // For scoped: @types/node@^18.0.0 → name=@types/node + // For regular: lodash@^4.17.21 → name=lodash + String name = extractPackageNameFromYarnEntry(trimmed); + if (name != null && !name.isEmpty() && !packageNames.contains(name)) { + packageNames.add(name); + } + } + + return packageNames; + } + + private String extractPackageNameFromYarnEntry(String entry) { + // Scoped packages start with @, so the version separator is the SECOND @ + int atIndex; + if (entry.startsWith("@")) { + atIndex = entry.indexOf('@', 1); + } else { + atIndex = entry.indexOf('@'); + } + if (atIndex <= 0) { + return null; + } + return entry.substring(0, atIndex); + } +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageEntity.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageEntity.java new file mode 100644 index 00000000..e6d912c3 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageEntity.java @@ -0,0 +1,32 @@ +package com.upkeep.infrastructure.adapter.out.persistence.pkg; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "packages") +public class PackageEntity extends PanacheEntityBase { + + @Id + @Column(name = "id", nullable = false) + public UUID id; + + @Column(name = "company_id", nullable = false) + public UUID companyId; + + @Column(name = "name", nullable = false) + public String name; + + @Column(name = "registry", nullable = false, length = 50) + public String registry; + + @Column(name = "imported_at", nullable = false) + public Instant importedAt; +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageJpaRepository.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageJpaRepository.java new file mode 100644 index 00000000..f9c65157 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageJpaRepository.java @@ -0,0 +1,81 @@ +package com.upkeep.infrastructure.adapter.out.persistence.pkg; + +import com.upkeep.application.port.out.pkg.PackageRepository; +import com.upkeep.domain.model.company.CompanyId; +import com.upkeep.domain.model.pkg.Package; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@ApplicationScoped +public class PackageJpaRepository implements PackageRepository, PanacheRepositoryBase { + + @Override + public void save(Package pkg) { + persist(PackageMapper.toEntity(pkg)); + } + + @Override + public void saveAll(List packages) { + List entities = packages.stream() + .map(PackageMapper::toEntity) + .toList(); + persist(entities); + } + + @Override + public List findByCompanyId(CompanyId companyId, int offset, int limit) { + return find("companyId = ?1 ORDER BY name ASC", companyId.value()) + .page(offset / Math.max(limit, 1), limit) + .list() + .stream() + .map(PackageMapper::toDomain) + .toList(); + } + + @Override + public List findByCompanyIdAndNameContaining(CompanyId companyId, String search, int offset, int limit) { + return find("companyId = ?1 AND LOWER(name) LIKE LOWER(?2) ORDER BY name ASC", + companyId.value(), "%" + search + "%") + .page(offset / Math.max(limit, 1), limit) + .list() + .stream() + .map(PackageMapper::toDomain) + .toList(); + } + + @Override + public long countByCompanyId(CompanyId companyId) { + return count("companyId", companyId.value()); + } + + @Override + public long countByCompanyIdAndNameContaining(CompanyId companyId, String search) { + return count("companyId = ?1 AND LOWER(name) LIKE LOWER(?2)", + companyId.value(), "%" + search + "%"); + } + + @Override + public Set findExistingNamesByCompanyId(CompanyId companyId, Set names) { + if (names.isEmpty()) { + return Set.of(); + } + List existing = getEntityManager() + .createQuery("SELECT p.name FROM PackageEntity p WHERE p.companyId = :companyId AND p.name IN :names", + String.class) + .setParameter("companyId", companyId.value()) + .setParameter("names", names) + .getResultList(); + return new HashSet<>(existing); + } + + @Override + public boolean existsByCompanyIdAndName(CompanyId companyId, String name) { + return count("companyId = ?1 AND name = ?2", companyId.value(), name) > 0; + } +} + diff --git a/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageMapper.java b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageMapper.java new file mode 100644 index 00000000..562eb0c6 --- /dev/null +++ b/apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageMapper.java @@ -0,0 +1,32 @@ +package com.upkeep.infrastructure.adapter.out.persistence.pkg; + +import com.upkeep.domain.model.company.CompanyId; +import com.upkeep.domain.model.pkg.Package; +import com.upkeep.domain.model.pkg.PackageId; + +public final class PackageMapper { + + private PackageMapper() { + } + + public static PackageEntity toEntity(Package pkg) { + PackageEntity entity = new PackageEntity(); + entity.id = pkg.getId().value(); + entity.companyId = pkg.getCompanyId().value(); + entity.name = pkg.getName(); + entity.registry = pkg.getRegistry(); + entity.importedAt = pkg.getImportedAt(); + return entity; + } + + public static Package toDomain(PackageEntity entity) { + return Package.reconstitute( + PackageId.from(entity.id), + CompanyId.from(entity.companyId), + entity.name, + entity.registry, + entity.importedAt + ); + } +} + diff --git a/apps/api/src/main/resources/db/migration/V9__create_packages_table.sql b/apps/api/src/main/resources/db/migration/V9__create_packages_table.sql new file mode 100644 index 00000000..b8638fff --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V9__create_packages_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE packages ( + id UUID PRIMARY KEY, + company_id UUID NOT NULL REFERENCES companies(id), + name VARCHAR(255) NOT NULL, + registry VARCHAR(50) NOT NULL DEFAULT 'npm', + imported_at TIMESTAMP WITH TIME ZONE NOT NULL, + UNIQUE(company_id, name, registry) +); + +CREATE INDEX idx_packages_company_id ON packages(company_id); +CREATE INDEX idx_packages_company_id_name ON packages(company_id, name); + diff --git a/apps/api/src/test/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImplTest.java b/apps/api/src/test/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImplTest.java index 46d32dc9..65b452e9 100644 --- a/apps/api/src/test/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImplTest.java +++ b/apps/api/src/test/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImplTest.java @@ -2,8 +2,10 @@ import com.upkeep.application.port.in.GetCompanyDashboardUseCase.CompanyDashboard; import com.upkeep.application.port.in.GetCompanyDashboardUseCase.GetCompanyDashboardQuery; +import com.upkeep.application.port.out.budget.BudgetRepository; import com.upkeep.application.port.out.company.CompanyRepository; import com.upkeep.application.port.out.membership.MembershipRepository; +import com.upkeep.application.port.out.pkg.PackageRepository; import com.upkeep.domain.exception.CompanyNotFoundException; import com.upkeep.domain.exception.MembershipNotFoundException; import com.upkeep.domain.model.company.Company; @@ -25,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -33,6 +36,8 @@ class GetCompanyDashboardUseCaseImplTest { private CompanyRepository companyRepository; private MembershipRepository membershipRepository; + private BudgetRepository budgetRepository; + private PackageRepository packageRepository; private GetCompanyDashboardUseCaseImpl useCase; private static final String CUSTOMER_ID = UUID.randomUUID().toString(); @@ -42,7 +47,9 @@ class GetCompanyDashboardUseCaseImplTest { void setUp() { companyRepository = mock(CompanyRepository.class); membershipRepository = mock(MembershipRepository.class); - useCase = new GetCompanyDashboardUseCaseImpl(companyRepository, membershipRepository); + budgetRepository = mock(BudgetRepository.class); + packageRepository = mock(PackageRepository.class); + useCase = new GetCompanyDashboardUseCaseImpl(companyRepository, membershipRepository, budgetRepository, packageRepository); } @Test @@ -54,6 +61,7 @@ void shouldReturnDashboardSuccessfully() { when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) .thenReturn(Optional.of(membership)); when(membershipRepository.countByCompanyId(any(CompanyId.class))).thenReturn(2L); + when(budgetRepository.existsByCompanyId(any(CompanyId.class))).thenReturn(false); GetCompanyDashboardQuery query = new GetCompanyDashboardQuery(CUSTOMER_ID, COMPANY_ID); @@ -110,6 +118,7 @@ void shouldReturnCorrectTotalMembers() { when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) .thenReturn(Optional.of(membership)); when(membershipRepository.countByCompanyId(any(CompanyId.class))).thenReturn(4L); + when(budgetRepository.existsByCompanyId(any(CompanyId.class))).thenReturn(false); GetCompanyDashboardQuery query = new GetCompanyDashboardQuery(CUSTOMER_ID, COMPANY_ID); @@ -127,6 +136,7 @@ void shouldReturnCorrectUserRoleForMember() { when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) .thenReturn(Optional.of(membership)); when(membershipRepository.countByCompanyId(any(CompanyId.class))).thenReturn(1L); + when(budgetRepository.existsByCompanyId(any(CompanyId.class))).thenReturn(false); GetCompanyDashboardQuery query = new GetCompanyDashboardQuery(CUSTOMER_ID, COMPANY_ID); @@ -135,6 +145,24 @@ void shouldReturnCorrectUserRoleForMember() { assertEquals(Role.MEMBER, result.userRole()); } + @Test + void shouldReturnHasBudgetTrueWhenBudgetExists() { + Company company = createTestCompany(); + Membership membership = createTestMembership(Role.OWNER); + + when(companyRepository.findById(any(CompanyId.class))).thenReturn(Optional.of(company)); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + when(membershipRepository.countByCompanyId(any(CompanyId.class))).thenReturn(1L); + when(budgetRepository.existsByCompanyId(any(CompanyId.class))).thenReturn(true); + + GetCompanyDashboardQuery query = new GetCompanyDashboardQuery(CUSTOMER_ID, COMPANY_ID); + + CompanyDashboard result = useCase.execute(query); + + assertTrue(result.stats().hasBudget()); + } + private Company createTestCompany() { return Company.reconstitute( CompanyId.from(COMPANY_ID), diff --git a/apps/api/src/test/java/com/upkeep/application/usecase/ImportPackagesFromListUseCaseImplTest.java b/apps/api/src/test/java/com/upkeep/application/usecase/ImportPackagesFromListUseCaseImplTest.java new file mode 100644 index 00000000..8ece227d --- /dev/null +++ b/apps/api/src/test/java/com/upkeep/application/usecase/ImportPackagesFromListUseCaseImplTest.java @@ -0,0 +1,125 @@ +package com.upkeep.application.usecase; + +import com.upkeep.application.port.in.pkg.ImportPackagesFromListUseCase.ImportFromListCommand; +import com.upkeep.application.port.in.pkg.ImportPackagesFromListUseCase.ImportListResult; +import com.upkeep.application.port.out.membership.MembershipRepository; +import com.upkeep.application.port.out.pkg.PackageRepository; +import com.upkeep.domain.exception.MembershipNotFoundException; +import com.upkeep.domain.model.company.CompanyId; +import com.upkeep.domain.model.customer.CustomerId; +import com.upkeep.domain.model.membership.Membership; +import com.upkeep.domain.model.membership.MembershipId; +import com.upkeep.domain.model.membership.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ImportPackagesFromListUseCaseImplTest { + + private PackageRepository packageRepository; + private MembershipRepository membershipRepository; + private ImportPackagesFromListUseCaseImpl useCase; + + private static final String CUSTOMER_ID = UUID.randomUUID().toString(); + private static final String COMPANY_ID = UUID.randomUUID().toString(); + + @BeforeEach + void setUp() { + packageRepository = mock(PackageRepository.class); + membershipRepository = mock(MembershipRepository.class); + useCase = new ImportPackagesFromListUseCaseImpl(packageRepository, membershipRepository); + } + + @Test + void shouldImportValidPackages() { + Membership membership = createTestMembership(); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + when(packageRepository.findExistingNamesByCompanyId(any(CompanyId.class), anySet())) + .thenReturn(Set.of()); + + ImportFromListCommand command = new ImportFromListCommand( + COMPANY_ID, CUSTOMER_ID, List.of("lodash", "express", "react")); + + ImportListResult result = useCase.execute(command); + + assertEquals(3, result.importedCount()); + assertEquals(0, result.skippedCount()); + assertEquals(0, result.invalidCount()); + } + + @Test + void shouldSkipDuplicatesAndReportInvalid() { + Membership membership = createTestMembership(); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + when(packageRepository.findExistingNamesByCompanyId(any(CompanyId.class), anySet())) + .thenReturn(Set.of("lodash")); + + ImportFromListCommand command = new ImportFromListCommand( + COMPANY_ID, CUSTOMER_ID, List.of("lodash", "express", "INVALID NAME!!", "@types/node")); + + ImportListResult result = useCase.execute(command); + + assertEquals(2, result.importedCount()); + assertEquals(1, result.skippedCount()); + assertEquals(1, result.invalidCount()); + assertTrue(result.skippedNames().contains("lodash")); + assertTrue(result.invalidNames().contains("INVALID NAME!!")); + assertTrue(result.importedNames().contains("express")); + assertTrue(result.importedNames().contains("@types/node")); + } + + @Test + void shouldThrowWhenUserNotMember() { + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.empty()); + + ImportFromListCommand command = new ImportFromListCommand( + COMPANY_ID, CUSTOMER_ID, List.of("lodash")); + + assertThrows(MembershipNotFoundException.class, () -> useCase.execute(command)); + } + + @Test + void shouldSkipEmptyAndBlankNames() { + Membership membership = createTestMembership(); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + when(packageRepository.findExistingNamesByCompanyId(any(CompanyId.class), anySet())) + .thenReturn(Set.of()); + + ImportFromListCommand command = new ImportFromListCommand( + COMPANY_ID, CUSTOMER_ID, List.of("lodash", "", " ", "react")); + + ImportListResult result = useCase.execute(command); + + assertEquals(2, result.importedCount()); + assertEquals(0, result.invalidCount()); + } + + private Membership createTestMembership() { + return Membership.reconstitute( + MembershipId.from(UUID.randomUUID()), + CustomerId.from(CUSTOMER_ID), + CompanyId.from(COMPANY_ID), + Role.MEMBER, + Instant.now(), + Instant.now() + ); + } +} + diff --git a/apps/api/src/test/java/com/upkeep/application/usecase/ImportPackagesFromLockfileUseCaseImplTest.java b/apps/api/src/test/java/com/upkeep/application/usecase/ImportPackagesFromLockfileUseCaseImplTest.java new file mode 100644 index 00000000..1dd6dd80 --- /dev/null +++ b/apps/api/src/test/java/com/upkeep/application/usecase/ImportPackagesFromLockfileUseCaseImplTest.java @@ -0,0 +1,152 @@ +package com.upkeep.application.usecase; + +import com.upkeep.application.port.in.pkg.ImportPackagesFromLockfileUseCase.ImportFromLockfileCommand; +import com.upkeep.application.port.in.pkg.ImportPackagesFromLockfileUseCase.ImportResult; +import com.upkeep.application.port.out.membership.MembershipRepository; +import com.upkeep.application.port.out.pkg.LockfileParser; +import com.upkeep.application.port.out.pkg.PackageRepository; +import com.upkeep.domain.exception.MembershipNotFoundException; +import com.upkeep.domain.model.company.CompanyId; +import com.upkeep.domain.model.customer.CustomerId; +import com.upkeep.domain.model.membership.Membership; +import com.upkeep.domain.model.membership.MembershipId; +import com.upkeep.domain.model.membership.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ImportPackagesFromLockfileUseCaseImplTest { + + private PackageRepository packageRepository; + private MembershipRepository membershipRepository; + private LockfileParser lockfileParser; + private ImportPackagesFromLockfileUseCaseImpl useCase; + + private static final String CUSTOMER_ID = UUID.randomUUID().toString(); + private static final String COMPANY_ID = UUID.randomUUID().toString(); + + @BeforeEach + void setUp() { + packageRepository = mock(PackageRepository.class); + membershipRepository = mock(MembershipRepository.class); + lockfileParser = mock(LockfileParser.class); + useCase = new ImportPackagesFromLockfileUseCaseImpl(packageRepository, membershipRepository, lockfileParser); + } + + @Test + void shouldImportNewPackagesFromLockfile() { + Membership membership = createTestMembership(); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + when(lockfileParser.supports("package-lock.json")).thenReturn(true); + when(lockfileParser.parse(any(), eq("package-lock.json"))) + .thenReturn(List.of("lodash", "express", "react")); + when(packageRepository.findExistingNamesByCompanyId(any(CompanyId.class), anySet())) + .thenReturn(Set.of()); + + ImportFromLockfileCommand command = new ImportFromLockfileCommand( + COMPANY_ID, CUSTOMER_ID, "{}", "package-lock.json"); + + ImportResult result = useCase.execute(command); + + assertEquals(3, result.importedCount()); + assertEquals(0, result.skippedCount()); + assertEquals(3, result.totalParsed()); + verify(packageRepository).saveAll(anyList()); + } + + @Test + void shouldSkipExistingPackages() { + Membership membership = createTestMembership(); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + when(lockfileParser.supports("package-lock.json")).thenReturn(true); + when(lockfileParser.parse(any(), eq("package-lock.json"))) + .thenReturn(List.of("lodash", "express", "react")); + when(packageRepository.findExistingNamesByCompanyId(any(CompanyId.class), anySet())) + .thenReturn(Set.of("lodash")); + + ImportFromLockfileCommand command = new ImportFromLockfileCommand( + COMPANY_ID, CUSTOMER_ID, "{}", "package-lock.json"); + + ImportResult result = useCase.execute(command); + + assertEquals(2, result.importedCount()); + assertEquals(1, result.skippedCount()); + assertTrue(result.skippedNames().contains("lodash")); + } + + @Test + void shouldThrowWhenUserNotMember() { + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.empty()); + + ImportFromLockfileCommand command = new ImportFromLockfileCommand( + COMPANY_ID, CUSTOMER_ID, "{}", "package-lock.json"); + + assertThrows(MembershipNotFoundException.class, () -> useCase.execute(command)); + } + + @Test + void shouldThrowForUnsupportedLockfile() { + Membership membership = createTestMembership(); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + when(lockfileParser.supports("unsupported.txt")).thenReturn(false); + + ImportFromLockfileCommand command = new ImportFromLockfileCommand( + COMPANY_ID, CUSTOMER_ID, "{}", "unsupported.txt"); + + assertThrows(IllegalArgumentException.class, () -> useCase.execute(command)); + } + + @Test + void shouldNotSaveWhenAllPackagesExist() { + Membership membership = createTestMembership(); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + when(lockfileParser.supports("package-lock.json")).thenReturn(true); + when(lockfileParser.parse(any(), eq("package-lock.json"))) + .thenReturn(List.of("lodash", "express")); + when(packageRepository.findExistingNamesByCompanyId(any(CompanyId.class), anySet())) + .thenReturn(Set.of("lodash", "express")); + + ImportFromLockfileCommand command = new ImportFromLockfileCommand( + COMPANY_ID, CUSTOMER_ID, "{}", "package-lock.json"); + + ImportResult result = useCase.execute(command); + + assertEquals(0, result.importedCount()); + assertEquals(2, result.skippedCount()); + verify(packageRepository, never()).saveAll(anyList()); + } + + private Membership createTestMembership() { + return Membership.reconstitute( + MembershipId.from(UUID.randomUUID()), + CustomerId.from(CUSTOMER_ID), + CompanyId.from(COMPANY_ID), + Role.MEMBER, + Instant.now(), + Instant.now() + ); + } +} + diff --git a/apps/api/src/test/java/com/upkeep/application/usecase/ListCompanyPackagesUseCaseImplTest.java b/apps/api/src/test/java/com/upkeep/application/usecase/ListCompanyPackagesUseCaseImplTest.java new file mode 100644 index 00000000..a4951a60 --- /dev/null +++ b/apps/api/src/test/java/com/upkeep/application/usecase/ListCompanyPackagesUseCaseImplTest.java @@ -0,0 +1,109 @@ +package com.upkeep.application.usecase; + +import com.upkeep.application.port.in.pkg.ListCompanyPackagesUseCase.ListPackagesQuery; +import com.upkeep.application.port.in.pkg.ListCompanyPackagesUseCase.PackageListResult; +import com.upkeep.application.port.out.membership.MembershipRepository; +import com.upkeep.application.port.out.pkg.PackageRepository; +import com.upkeep.domain.exception.MembershipNotFoundException; +import com.upkeep.domain.model.company.CompanyId; +import com.upkeep.domain.model.customer.CustomerId; +import com.upkeep.domain.model.membership.Membership; +import com.upkeep.domain.model.membership.MembershipId; +import com.upkeep.domain.model.membership.Role; +import com.upkeep.domain.model.pkg.Package; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ListCompanyPackagesUseCaseImplTest { + + private PackageRepository packageRepository; + private MembershipRepository membershipRepository; + private ListCompanyPackagesUseCaseImpl useCase; + + private static final String CUSTOMER_ID = UUID.randomUUID().toString(); + private static final String COMPANY_ID = UUID.randomUUID().toString(); + + @BeforeEach + void setUp() { + packageRepository = mock(PackageRepository.class); + membershipRepository = mock(MembershipRepository.class); + useCase = new ListCompanyPackagesUseCaseImpl(packageRepository, membershipRepository); + } + + @Test + void shouldListPackagesWithPagination() { + Membership membership = createTestMembership(); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + + CompanyId companyId = CompanyId.from(COMPANY_ID); + Package pkg = Package.reconstitute( + com.upkeep.domain.model.pkg.PackageId.generate(), + companyId, "lodash", "npm", Instant.now()); + + when(packageRepository.findByCompanyId(any(CompanyId.class), anyInt(), anyInt())) + .thenReturn(List.of(pkg)); + when(packageRepository.countByCompanyId(any(CompanyId.class))).thenReturn(1L); + + ListPackagesQuery query = new ListPackagesQuery(COMPANY_ID, CUSTOMER_ID, null, 0, 50); + + PackageListResult result = useCase.execute(query); + + assertEquals(1, result.packages().size()); + assertEquals("lodash", result.packages().get(0).name()); + assertEquals(1L, result.totalCount()); + } + + @Test + void shouldFilterBySearch() { + Membership membership = createTestMembership(); + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.of(membership)); + when(packageRepository.findByCompanyIdAndNameContaining(any(CompanyId.class), eq("react"), anyInt(), anyInt())) + .thenReturn(List.of()); + when(packageRepository.countByCompanyIdAndNameContaining(any(CompanyId.class), eq("react"))).thenReturn(0L); + + ListPackagesQuery query = new ListPackagesQuery(COMPANY_ID, CUSTOMER_ID, "react", 0, 50); + + PackageListResult result = useCase.execute(query); + + assertEquals(0, result.packages().size()); + verify(packageRepository).findByCompanyIdAndNameContaining(any(CompanyId.class), eq("react"), anyInt(), anyInt()); + } + + @Test + void shouldThrowWhenUserNotMember() { + when(membershipRepository.findByCustomerIdAndCompanyId(any(CustomerId.class), any(CompanyId.class))) + .thenReturn(Optional.empty()); + + ListPackagesQuery query = new ListPackagesQuery(COMPANY_ID, CUSTOMER_ID, null, 0, 50); + + assertThrows(MembershipNotFoundException.class, () -> useCase.execute(query)); + } + + private Membership createTestMembership() { + return Membership.reconstitute( + MembershipId.from(UUID.randomUUID()), + CustomerId.from(CUSTOMER_ID), + CompanyId.from(COMPANY_ID), + Role.MEMBER, + Instant.now(), + Instant.now() + ); + } +} + diff --git a/apps/api/src/test/java/com/upkeep/domain/model/pkg/PackageTest.java b/apps/api/src/test/java/com/upkeep/domain/model/pkg/PackageTest.java new file mode 100644 index 00000000..23d063ab --- /dev/null +++ b/apps/api/src/test/java/com/upkeep/domain/model/pkg/PackageTest.java @@ -0,0 +1,66 @@ +package com.upkeep.domain.model.pkg; + +import com.upkeep.domain.model.company.CompanyId; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PackageTest { + + @Test + void shouldCreatePackageWithValidName() { + CompanyId companyId = CompanyId.generate(); + Package pkg = Package.create(companyId, "lodash"); + + assertNotNull(pkg.getId()); + assertEquals(companyId, pkg.getCompanyId()); + assertEquals("lodash", pkg.getName()); + assertEquals("npm", pkg.getRegistry()); + assertNotNull(pkg.getImportedAt()); + } + + @Test + void shouldCreateScopedPackage() { + CompanyId companyId = CompanyId.generate(); + Package pkg = Package.create(companyId, "@types/node"); + + assertEquals("@types/node", pkg.getName()); + } + + @Test + void shouldRejectInvalidPackageName() { + CompanyId companyId = CompanyId.generate(); + assertThrows(IllegalArgumentException.class, () -> Package.create(companyId, "INVALID NAME!!")); + } + + @Test + void shouldRejectNullName() { + assertFalse(Package.isValidNpmPackageName(null)); + } + + @Test + void shouldRejectBlankName() { + assertFalse(Package.isValidNpmPackageName("")); + assertFalse(Package.isValidNpmPackageName(" ")); + } + + @Test + void shouldValidateNpmPackageNames() { + assertTrue(Package.isValidNpmPackageName("lodash")); + assertTrue(Package.isValidNpmPackageName("express")); + assertTrue(Package.isValidNpmPackageName("@types/node")); + assertTrue(Package.isValidNpmPackageName("@babel/core")); + assertTrue(Package.isValidNpmPackageName("my-package")); + assertTrue(Package.isValidNpmPackageName("my_package")); + assertTrue(Package.isValidNpmPackageName("my.package")); + + assertFalse(Package.isValidNpmPackageName("UPPERCASE")); + assertFalse(Package.isValidNpmPackageName("has spaces")); + assertFalse(Package.isValidNpmPackageName(".starts-with-dot")); + } +} + diff --git a/apps/api/src/test/java/com/upkeep/infrastructure/adapter/out/parser/LockfileParserAdapterTest.java b/apps/api/src/test/java/com/upkeep/infrastructure/adapter/out/parser/LockfileParserAdapterTest.java new file mode 100644 index 00000000..728143a6 --- /dev/null +++ b/apps/api/src/test/java/com/upkeep/infrastructure/adapter/out/parser/LockfileParserAdapterTest.java @@ -0,0 +1,140 @@ +package com.upkeep.infrastructure.adapter.out.parser; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LockfileParserAdapterTest { + + private LockfileParserAdapter parser; + + @BeforeEach + void setUp() { + parser = new LockfileParserAdapter(); + } + + @Test + void shouldSupportPackageLockJson() { + assertTrue(parser.supports("package-lock.json")); + } + + @Test + void shouldSupportYarnLock() { + assertTrue(parser.supports("yarn.lock")); + } + + @Test + void shouldNotSupportUnknownFiles() { + assertFalse(parser.supports("pom.xml")); + assertFalse(parser.supports("go.sum")); + } + + @Test + void shouldParsePackageLockJsonV3() { + String content = """ + { + "name": "my-project", + "lockfileVersion": 3, + "packages": { + "": { + "name": "my-project", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/lodash": { + "version": "4.17.21" + }, + "node_modules/@types/node": { + "version": "18.0.0" + }, + "node_modules/express": { + "version": "4.18.2" + } + } + } + """; + + List result = parser.parse(content, "package-lock.json"); + + assertEquals(3, result.size()); + assertTrue(result.contains("lodash")); + assertTrue(result.contains("@types/node")); + assertTrue(result.contains("express")); + } + + @Test + void shouldParsePackageLockJsonV1() { + String content = """ + { + "name": "my-project", + "lockfileVersion": 1, + "dependencies": { + "lodash": { + "version": "4.17.21" + }, + "express": { + "version": "4.18.2", + "dependencies": { + "debug": { + "version": "2.6.9" + } + } + } + } + } + """; + + List result = parser.parse(content, "package-lock.json"); + + assertEquals(3, result.size()); + assertTrue(result.contains("lodash")); + assertTrue(result.contains("express")); + assertTrue(result.contains("debug")); + } + + @Test + void shouldParseYarnLock() { + String content = """ + # yarn lockfile v1 + + lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz" + + "@types/node@^18.0.0": + version "18.0.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.0.tgz" + + express@^4.18.0: + version "4.18.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz" + """; + + List result = parser.parse(content, "yarn.lock"); + + assertEquals(3, result.size()); + assertTrue(result.contains("lodash")); + assertTrue(result.contains("@types/node")); + assertTrue(result.contains("express")); + } + + @Test + void shouldThrowForInvalidJson() { + assertThrows(IllegalArgumentException.class, + () -> parser.parse("not valid json", "package-lock.json")); + } + + @Test + void shouldThrowForUnsupportedFile() { + assertThrows(IllegalArgumentException.class, + () -> parser.parse("{}", "unsupported.txt")); + } +} + diff --git a/apps/audit.md b/apps/audit.md deleted file mode 100644 index b6334e5b..00000000 --- a/apps/audit.md +++ /dev/null @@ -1,578 +0,0 @@ -# 🔥 APOCALYPSE.MD - Audit Draconien du Projet Upkeep API - -**Date :** 30 Janvier 2026 -**Auditeur :** L'Architecte Draconien -**Sujet :** Audit impitoyable du projet Quarkus Upkeep API - ---- - -## 1. LE VERDICT GLOBAL - -### Note : 7.2 / 10 - -**Résumé Cinglant :** - -Ce projet est *correct*. Pas brillant, pas désastreux — correct. L'architecture hexagonale est respectée dans ses grandes lignes, ce qui est déjà mieux que 80% des projets que j'audite. Cependant, derrière cette façade de propreté se cachent des compromis architecturaux, des violations subtiles de SOLID, et des incohérences qui trahissent un manque de rigueur dans l'application des principes. - -Le domaine est relativement pur, mais des annotations Jakarta se sont infiltrées dans la couche application. Les use cases sont parfois trop permissifs avec leurs responsabilités. La gestion des transactions est déléguée aveuglément à l'infrastructure. Les tests sont présents mais manquent de cas limites critiques. - -**Ce n'est pas un désastre, mais ce n'est pas non plus l'œuvre d'un artisan du code.** - ---- - -## 2. L'ARCHITECTURE - -### 2.1 Structure des Packages - -``` -com.upkeep/ -├── domain/ ✅ Pur (à quelques exceptions près) -│ ├── exception/ ✅ Correct -│ └── model/ ✅ Bien organisé par sous-domaine -├── application/ ⚠️ Pollution détectée -│ ├── port/in/ ✅ Correct -│ ├── port/out/ ✅ Correct -│ └── usecase/ ⚠️ Annotations Jakarta présentes -└── infrastructure/ ✅ Bien isolée - └── adapter/ - ├── in/rest/ ✅ Correct - └── out/ ✅ Correct -``` - -### 2.2 Critique Architecturale - -**Points Positifs :** - -- La séparation en couches est claire et respectée -- Les Value Objects sont utilisés correctement (`Email`, `Password`, `CustomerId`, etc.) -- Les entités de domaine utilisent des factory methods (`create()`, `reconstitute()`) -- Les ports (interfaces) sont bien définis et séparent les préoccupations -- L'infrastructure est correctement isolée avec des mappers dédiés - -**Points Négatifs Critiques :** - -1. **Pollution de la couche Application** - Les use cases sont annotés avec `@ApplicationScoped` et `@Transactional` (Jakarta). C'est une violation du principe de pureté. La couche application devrait être agnostique du framework. Un décorateur transactionnel devrait être dans l'infrastructure. - -2. **Dépendance inversée incorrecte** - `TokenService` dans `application/port/out/auth/` retourne des records `TokenClaims` et `RefreshResult` qui contiennent des primitives. Acceptable, mais ces types devraient être dans le domaine si on veut être rigoureux. - -3. **Absence d'un module d'entrée clair** - Pas de classe `Main` ou de configuration explicite de l'assemblage des dépendances. Quarkus fait tout automagiquement, ce qui masque les dépendances réelles. - ---- - -## 3. ANALYSE FICHIER PAR FICHIER - -### 3.1 COUCHE DOMAINE - -#### `domain/model/customer/Customer.java` - -**Lignes 64-65 :** - -```java -public void updateTimestamp() { - this.updatedAt = Instant.now(); -} -``` - -**Verdict :** 🟡 Cette méthode couple l'entité au temps système. Un `Clock` devrait être injecté ou le timestamp passé en paramètre pour permettre les tests déterministes. - ---- - -#### `domain/model/customer/Email.java` - -**Ligne 23 :** - -```java - value = normalizedValue; -``` - -**Verdict :** 🔴 **ERREUR SUBTILE !** Dans un record Java, la réassignation du paramètre `value` dans le constructeur compact ne modifie PAS la valeur stockée. Le record stockera toujours la valeur originale, pas `normalizedValue`. Ce bug signifie que les emails ne sont PAS normalisés en lowercase. - -**Correction requise :** Utiliser un constructeur canonique ou une factory method. - ---- - -#### `domain/model/invitation/Invitation.java` - -**Lignes 86-89 :** - -```java -public void accept() { - if (!canBeAccepted()) { - throw new IllegalStateException("Invitation cannot be accepted"); - } -``` - -**Verdict :** 🟡 `IllegalStateException` est une exception technique, pas une exception métier. Devrait être une `DomainException` dédiée comme `InvitationCannotBeAcceptedException`. - ---- - -#### `domain/model/budget/Money.java` - -**Ligne 29 :** - -```java -long cents = amount.multiply(BigDecimal.valueOf(100)).longValue(); -``` - -**Verdict :** 🟡 `longValue()` tronque silencieusement. Si quelqu'un passe `BigDecimal("10.999")`, on perd de la précision. Devrait utiliser `longValueExact()` ou vérifier qu'il n'y a pas de décimales au-delà de 2 chiffres. - ---- - -#### `domain/exception/DomainValidationException.java` - -**Verdict :** ✅ Propre, bien conçu, pas de dépendance framework. - ---- - -#### `domain/model/audit/AuditEvent.java` - -**Lignes 37-39 :** - -```java -this.payload = new HashMap<>(payload); -``` - -**Verdict :** ✅ Copie défensive correcte. Bien. - -**Ligne 55 :** - -```java -Instant.now() -``` - -**Verdict :** 🟡 Encore une fois, couplage au temps système. Devrait accepter un `Clock` ou un `Instant` en paramètre. - ---- - -### 3.2 COUCHE APPLICATION - -#### `application/usecase/RegisterCustomerUseCaseImpl.java` - -**Lignes 14-15 :** - -```java -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.transaction.Transactional; -``` - -**Verdict :** 🔴 **VIOLATION ARCHITECTURALE MAJEURE !** Les annotations Jakarta n'ont rien à faire dans la couche application. Le use case devrait être un POJO pur. La gestion transactionnelle devrait être dans un décorateur ou dans l'adaptateur repository. - -**Lignes 36-40 :** - -```java -if (!command.password().equals(command.confirmPassword())) { - throw new DomainValidationException("Passwords do not match", List.of( - new FieldError("confirmPassword", "Passwords do not match") - )); -} -``` - -**Verdict :** 🟡 Cette validation devrait être dans le `RegisterCommand` lui-même (self-validating command) ou dans un validateur dédié, pas dans le use case. - ---- - -#### `application/usecase/AuthenticateCustomerUseCaseImpl.java` - -**Mêmes violations Jakarta (lignes 12-13).** - -**Lignes 33-34 :** - -```java -Email email = new Email(command.email()); -Password password = new Password(command.password()); -``` - -**Verdict :** 🟡 Si le constructeur de `Email` ou `Password` lance une `DomainValidationException`, le message d'erreur exposera des détails de validation inutiles pour une authentification. Pour la sécurité, on devrait catch et transformer en `InvalidCredentialsException` pour ne pas révéler si c'est l'email ou le password qui est invalide. - ---- - -#### `application/usecase/CreateCompanyUseCaseImpl.java` - -**Ligne 23 :** - -```java -@Inject -public CreateCompanyUseCaseImpl(...) -``` - -**Verdict :** 🟡 Incohérence de style. Certains use cases utilisent `@Inject` explicitement, d'autres non (injection par constructeur implicite). Choisissez un style et tenez-vous-y. - ---- - -#### `application/usecase/AcceptInvitationUseCaseImpl.java` - -**Lignes 51-52 :** - -```java -throw new IllegalStateException("Invitation cannot be accepted"); -``` - -**Verdict :** 🔴 Exception technique dans un flux métier. Devrait être une `DomainException`. - -**Lignes 58-60 :** - -```java -if (membershipRepository.existsByCustomerIdAndCompanyId(customerId, invitation.getCompanyId())) { - invitation.accept(); - invitationRepository.save(invitation); - throw new AlreadyMemberException(); -} -``` - -**Verdict :** 🟡 Logique étrange : on accepte l'invitation PUIS on lance une exception. L'ordre des opérations est contre-intuitif et potentiellement bugué si la transaction échoue après le save. - ---- - -#### `application/usecase/OAuthLoginUseCaseImpl.java` - -**Ligne 38 :** - -```java -throw new IllegalStateException("User not found for OAuth provider link"); -``` - -**Verdict :** 🔴 Encore `IllegalStateException`. Ce cas représente une incohérence de données (un lien OAuth existe mais l'utilisateur non). Devrait être une exception métier dédiée ou une erreur système loggée différemment. - ---- - -#### `application/usecase/SetCompanyBudgetUseCaseImpl.java` - -**Verdict :** ✅ Relativement propre. Bonne séparation des responsabilités avec l'audit. - ---- - -#### `application/port/in/RegisterCustomerUseCase.java` - -**Verdict :** ✅ Interface propre avec records imbriqués. Pattern Command/Result bien appliqué. - ---- - -#### `application/port/out/auth/TokenService.java` - -**Verdict :** 🟡 L'interface expose `Customer` en paramètre (entité du domaine). C'est acceptable mais certains pourraient arguer qu'on devrait passer uniquement les données nécessaires (userId, email, accountType) pour découpler davantage. - ---- - -### 3.3 COUCHE INFRASTRUCTURE - -#### `infrastructure/adapter/in/rest/auth/AuthResource.java` - -**Lignes 36-43 :** - -```java -@ConfigProperty(name = "jwt.access-token-expiry-seconds", defaultValue = "900") -int accessTokenExpirySeconds; - -@ConfigProperty(name = "jwt.refresh-token-expiry-seconds", defaultValue = "604800") -int refreshTokenExpirySeconds; - -@ConfigProperty(name = "app.use-secure-cookies", defaultValue = "true") -boolean useSecureCookies; -``` - -**Verdict :** 🟡 Injection de configuration directement dans le Resource. Devrait être encapsulé dans un objet de configuration dédié (`CookieConfiguration`) pour respecter le SRP. - -**Lignes 118-127 :** - -```java -try { - TokenClaims claims = tokenService.validateAccessToken(accessToken); - MeResponse response = new MeResponse(claims.userId(), claims.email(), claims.accountType()); - return Response.ok(ApiResponse.success(response)).build(); -} catch (Exception e) { - return Response.status(401) - .entity(ApiResponse.error(new ApiError( - "INVALID_TOKEN", "Invalid or expired token", null, null))) - .build(); -} -``` - -**Verdict :** 🔴 `catch (Exception e)` est un anti-pattern. On catch TOUT, y compris les NPE, les erreurs de runtime, etc. Devrait catch uniquement l'exception spécifique de validation de token. - ---- - -#### `infrastructure/adapter/in/rest/company/CompanyResource.java` - -**Lignes 70-73 :** - -```java -TokenClaims claims = validateToken(accessToken); -if (claims == null) { - return unauthorizedResponse(); -} -``` - -**Verdict :** 🟡 Ce pattern se répète dans CHAQUE méthode. C'est une violation flagrante de DRY. Devrait utiliser un `ContainerRequestFilter` JAX-RS pour l'authentification centralisée. - ---- - -#### `infrastructure/adapter/out/persistence/customer/CustomerEntity.java` - -**Lignes 21-36 :** - -```java -public UUID id; -public String email; -public String passwordHash; -``` - -**Verdict :** 🟡 Champs publics. Panache le permet, mais c'est discutable pour l'encapsulation. De plus, l'entité importe `AccountType` du domaine (ligne 3). Ce n'est pas grave mais certains puristes créeraient un enum séparé pour l'infrastructure. - ---- - -#### `infrastructure/adapter/out/persistence/customer/CustomerMapper.java` - -**Lignes 31-37 :** - -```java -return Customer.reconstitute( - new CustomerId(entity.id), - new Email(entity.email), - hash, - entity.accountType, - entity.createdAt, - entity.updatedAt -); -``` - -**Verdict :** 🟡 Le mapper appelle le constructeur de `Email` qui fait de la validation. Si une email invalide est en base (données legacy, migration ratée), le mapper crashera. Le mapper devrait utiliser une méthode `Email.reconstitute()` qui bypass la validation. - ---- - -#### `infrastructure/adapter/out/security/JwtTokenService.java` - -**Lignes 29-33 :** - -```java -@ConfigProperty(name = "jwt.access-token-expiry-seconds", defaultValue = "900") -int accessTokenExpirySeconds; - -@ConfigProperty(name = "jwt.refresh-token-expiry-seconds", defaultValue = "604800") -int refreshTokenExpirySeconds; -``` - -**Verdict :** 🟡 Duplication avec `AuthResource.java`. Ces valeurs devraient être dans un objet de configuration partagé. - ---- - -#### `infrastructure/adapter/out/oauth/GitHubOAuthAdapter.java` - -**Ligne 45 :** - -```java -this.httpClient = HttpClient.newHttpClient(); -``` - -**Verdict :** 🔴 Création d'un `HttpClient` dans le constructeur. Ce client devrait être injecté pour permettre les tests et le pooling. De plus, `HttpClient.newHttpClient()` crée un client par défaut sans timeout configuré — potentiel blocage infini sur les appels GitHub. - -**Lignes 69-74 :** - -```java -HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(TOKEN_URL)) - ... - .POST(HttpRequest.BodyPublishers.ofString(formBody)) - .build(); -``` - -**Verdict :** 🟡 Pas de timeout configuré sur les requêtes. En production, un GitHub lent pourrait bloquer indéfiniment les threads. - ---- - -#### `infrastructure/adapter/out/email/MockEmailService.java` - -**Verdict :** ✅ C'est un mock, pas de critique. Mais attention : en production, il faudra une vraie implémentation. Y a-t-il un TODO ou une issue trackée pour ça ? - ---- - -#### `infrastructure/adapter/in/rest/common/exception/GlobalExceptionMapper.java` - -**Lignes 53-145 (pattern switch):** - -```java -return switch (exception) { - case DomainValidationException e -> Response... - case InvalidCredentialsException e -> Response... - // ... 15+ cas -``` - -**Verdict :** 🟡 Ce switch gigantesque viole l'Open/Closed Principle. Chaque nouvelle exception nécessite de modifier ce fichier. Une map de handlers ou un pattern de visiteur serait plus extensible. - ---- - -### 3.4 TESTS - -#### `test/.../RegisterCustomerUseCaseImplTest.java` - -**Verdict :** ✅ Tests bien structurés avec des cas nominaux et des cas d'erreur. - -**Manques identifiés :** - -- Pas de test pour email en majuscules (normalisation) -- Pas de test pour password avec caractères spéciaux Unicode -- Pas de test pour les cas de concurrence (deux inscriptions simultanées) - ---- - -#### `test/.../PasswordTest.java` - -**Verdict :** ✅ Excellents tests paramétrés. Bonne couverture des cas limites. - ---- - -#### `test/.../AuthResourceTest.java` - -**Verdict :** ✅ Tests d'intégration complets avec `@QuarkusTest`. - -**Manques identifiés :** - -- Pas de test pour le rate limiting (s'il existe) -- Pas de test pour les cookies avec `SameSite` et `Secure` -- Pas de test de timeout sur les endpoints - ---- - -### 3.5 CONFIGURATION - -#### `application.properties` - -**Lignes 19-21 :** - -```properties -quarkus.datasource.username=upkeep -quarkus.datasource.password=upkeep -``` - -**Verdict :** 🟡 Credentials en dur dans la config par défaut. Devrait être `${DB_USERNAME:upkeep}` pour forcer l'utilisation de variables d'environnement. - -**Verdict Global :** Configuration bien organisée avec des profils (dev/test/prod). Bien. - ---- - -#### `checkstyle.xml` - -**Verdict :** ✅ Configuration stricte et raisonnable. `AvoidStarImport` est activé. Bien. - ---- - -#### `pom.xml` - -**Verdict :** ✅ Dépendances bien gérées, versions centralisées. Pas de conflits visibles. - ---- - -## 4. LA LISTE DES PÉCHÉS CAPITAUX - -### 🔴 VIOLATIONS CRITIQUES - -| # | Violation | Fichier | Impact | -|---|-----------|---------|--------| -| 1 | Annotations Jakarta dans la couche Application | `*UseCaseImpl.java` | Couplage framework, non-testable en isolation | -| 2 | Bug dans Email.java (normalisation cassée) | `Email.java:23` | Emails non normalisés, duplicates possibles | -| 3 | `catch (Exception e)` fourre-tout | `AuthResource.java:125` | Masque les erreurs, comportement imprévisible | -| 4 | HttpClient non injecté, sans timeout | `GitHubOAuthAdapter.java:45` | Blocage potentiel, non-testable | -| 5 | `IllegalStateException` dans le domaine | Multiple | Exceptions techniques dans le métier | - -### 🟡 VIOLATIONS MODÉRÉES - -| # | Violation | Fichier | Impact | -|---|-----------|---------|--------| -| 1 | Validation répétée dans use cases (token check) | `CompanyResource.java` | Violation DRY | -| 2 | Couplage au temps système (`Instant.now()`) | Multiple | Tests non-déterministes | -| 3 | GlobalExceptionMapper switch géant | `GlobalExceptionMapper.java` | Violation OCP | -| 4 | Duplication config (expiry seconds) | Auth/JwtTokenService | Violation DRY | -| 5 | Mapper qui valide à la reconstitution | `CustomerMapper.java` | Crash sur données legacy | -| 6 | Incohérence @Inject explicite/implicite | Use cases | Style incohérent | - -### ⚪ VIOLATIONS MINEURES - -| # | Violation | Fichier | -|---|-----------|---------| -| 1 | Champs publics dans les entités Panache | `*Entity.java` | -| 2 | Credentials en dur (même avec profil) | `application.properties` | -| 3 | Pas de TODO pour le vrai EmailService | `MockEmailService.java` | - ---- - -## 5. ULTIMATUM - ACTIONS IMMÉDIATES - -### PRIORITÉ ABSOLUE (Bugs) - -1. **CORRIGER `Email.java`** — Le bug de normalisation est silencieux et dangereux. Réécrire avec un constructeur canonique ou une factory : - -```java - public record Email(String value) { - public Email { - // validation... - } - public static Email of(String raw) { - return new Email(validated(raw.toLowerCase().trim())); - } - } -``` - -2. **Remplacer `catch (Exception e)`** — Utiliser une exception spécifique ou au minimum logger l'exception originale avant de la transformer. - -### PRIORITÉ HAUTE (Architecture) - -3. **Extraire les annotations Jakarta des use cases** — Créer des décorateurs transactionnels dans l'infrastructure : - -```java - // Dans infrastructure - @ApplicationScoped - @Transactional - public class TransactionalRegisterCustomerUseCase implements RegisterCustomerUseCase { - @Inject RegisterCustomerUseCaseImpl delegate; - public RegisterResult execute(RegisterCommand cmd) { return delegate.execute(cmd); } - } -``` - -4. **Centraliser l'authentification** — Implémenter un `ContainerRequestFilter` pour éviter la duplication du code de validation de token dans chaque endpoint. - -5. **Injecter `HttpClient` dans `GitHubOAuthAdapter`** — Configurer des timeouts : - -```java - HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); -``` - -### PRIORITÉ MOYENNE (Qualité) - -6. **Remplacer `IllegalStateException` par des exceptions métier** — Créer `InvitationCannotBeAcceptedException`, `InconsistentOAuthStateException`, etc. - -7. **Injecter `Clock` pour le temps** — Tous les `Instant.now()` devraient utiliser un `Clock` injectable : - -```java - private final Clock clock; - // ... - Instant now = clock.instant(); -``` - -8. **Créer `Email.reconstitute(String)` et `Password.reconstitute(String)`** — Pour la reconstitution depuis la base sans revalidation. - -9. **Refactorer `GlobalExceptionMapper`** — Utiliser une `Map, ExceptionHandler>` pour l'extensibilité. - -### PRIORITÉ BASSE (Hygiène) - -10. **Unifier le style d'injection** — Soit `@Inject` partout, soit injection par constructeur implicite partout. - -11. **Extraire la configuration dans des objets dédiés** — `CookieConfiguration`, `JwtConfiguration`, etc. - -12. **Ajouter les tests manquants** — Concurrence, normalisation email, timeouts. - ---- - -## CONCLUSION - -Ce projet a les fondations d'une bonne architecture hexagonale, mais l'exécution souffre de compromis trop nombreux. Le bug dans `Email.java` est particulièrement préoccupant car il passe inaperçu à tous les tests. - -La pollution de la couche application par Jakarta est la violation la plus systémique. Quarkus rend cette pratique facile, mais facile ne veut pas dire correct. - -**Ce code peut aller en production, mais chaque compromis aujourd'hui deviendra une dette technique demain.** - -*L'Architecte Draconien a parlé.* - ---- - -> *"Un code propre n'est pas celui qui fonctionne. C'est celui qui communique son intention avec clarté et qui résiste au changement avec grâce."* diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c6801fb4..bbd2d4c1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,6 +8,7 @@ import {CompanyDashboardPage} from './pages/CompanyDashboardPage' import {TeamSettingsPage} from './pages/TeamSettingsPage' import {AcceptInvitationPage} from './pages/AcceptInvitationPage' import {BudgetPage} from './pages/BudgetPage' +import {PackagesPage} from './pages/PackagesPage' import {AuthProvider} from '@/features/auth' import {CompanyProvider} from './features/company' import {ProtectedRoute} from '@/features/auth' @@ -101,6 +102,14 @@ function App() { } /> + + + + } + /> setBudget(companyId, data), - onSuccess: () => { + onSuccess: async () => { queryClient.invalidateQueries({ queryKey: ['budget', companyId] }); + await refreshDashboard(); toast({ title: 'Success', description: 'Budget set successfully!', diff --git a/apps/web/src/features/packages/FileDropzone.tsx b/apps/web/src/features/packages/FileDropzone.tsx new file mode 100644 index 00000000..89cfa457 --- /dev/null +++ b/apps/web/src/features/packages/FileDropzone.tsx @@ -0,0 +1,93 @@ +import { useCallback, useRef, useState } from 'react'; +import { Upload } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface FileDropzoneProps { + onFileAccepted: (file: File) => void; + isLoading?: boolean; +} + +export function FileDropzone({ onFileAccepted, isLoading }: FileDropzoneProps) { + const [isDragActive, setIsDragActive] = useState(false); + const inputRef = useRef(null); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + if (isAcceptedFile(file)) { + onFileAccepted(file); + } + } + }, [onFileAccepted]); + + const handleClick = useCallback(() => { + inputRef.current?.click(); + }, []); + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + onFileAccepted(files[0]); + } + if (inputRef.current) { + inputRef.current.value = ''; + } + }, [onFileAccepted]); + + return ( +
+ + +

+ {isLoading ? 'Importing...' : 'Drop your lockfile here or click to browse'} +

+

+ package-lock.json or yarn.lock +

+
+ ); +} + +function isAcceptedFile(file: File): boolean { + return file.name === 'package-lock.json' || file.name === 'yarn.lock'; +} + diff --git a/apps/web/src/features/packages/PackageCard.tsx b/apps/web/src/features/packages/PackageCard.tsx new file mode 100644 index 00000000..26eb2fd1 --- /dev/null +++ b/apps/web/src/features/packages/PackageCard.tsx @@ -0,0 +1,45 @@ +import { Badge, Card } from '@/components/ui'; +import { Package as PackageIcon } from 'lucide-react'; +import { formatCurrency } from '@/lib/utils'; + +interface PackageCardProps { + name: string; + registry: string; + importedAt: string; + allocationCents?: number; + claimStatus?: 'claimed' | 'unclaimed'; + currency?: string; +} + +export function PackageCard({ + name, + registry, + allocationCents, + claimStatus = 'unclaimed', + currency, +}: PackageCardProps) { + return ( + +
+
+ +
+

{name}

+
+ + {registry} + + + {claimStatus} + +
+
+
+ {allocationCents != null && currency && ( +

{formatCurrency(allocationCents, currency)}

+ )} +
+
+ ); +} + diff --git a/apps/web/src/features/packages/PastePackagesDialog.tsx b/apps/web/src/features/packages/PastePackagesDialog.tsx new file mode 100644 index 00000000..4aba27f4 --- /dev/null +++ b/apps/web/src/features/packages/PastePackagesDialog.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { importFromList } from './api'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Label, +} from '@/components/ui'; +import { useToast } from '@/hooks/use-toast'; +import { ClipboardPaste } from 'lucide-react'; + +interface PastePackagesDialogProps { + companyId: string; + onSuccess: () => void; +} + +export function PastePackagesDialog({ companyId, onSuccess }: PastePackagesDialogProps) { + const [open, setOpen] = useState(false); + const [text, setText] = useState(''); + const { toast } = useToast(); + + const { mutate, isPending } = useMutation({ + mutationFn: () => { + const packageNames = text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return importFromList(companyId, { packageNames }); + }, + onSuccess: (result) => { + const messages: string[] = []; + if (result.importedCount > 0) { + messages.push(`${result.importedCount} new`); + } + if (result.skippedCount > 0) { + messages.push(`${result.skippedCount} already existed`); + } + if (result.invalidCount > 0) { + messages.push(`${result.invalidCount} invalid`); + } + + toast({ + title: 'Import complete', + description: messages.join(', '), + }); + setText(''); + setOpen(false); + onSuccess(); + }, + onError: (error) => { + toast({ + title: 'Import failed', + description: error instanceof Error ? error.message : 'An error occurred', + variant: 'destructive', + }); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (text.trim()) { + mutate(); + } + }; + + return ( + + + + + +
+ + Add Packages + + Paste one package name per line. Invalid names will be reported. + + +
+ +