diff --git a/.gitignore b/.gitignore index b6b09312e..31df2b31d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ CLAUDE.md # Tooling residue: Python helper scripts used while editing this repo. __pycache__/ *.pyc + +# Local parallel-work checkouts (per-developer, never committed) +.worktrees/ +.worktreeinclude diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/ArtifactIngestionClient.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/ArtifactIngestionClient.kt index 43d3e838f..418fe90d9 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/ArtifactIngestionClient.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/ArtifactIngestionClient.kt @@ -5,6 +5,7 @@ import com.sprintstart.sprintstartbackend.ingestion.model.dto.request.AiArtifact import com.sprintstart.sprintstartbackend.ingestion.model.dto.request.ArtifactProjectsAiSyncRequest import com.sprintstart.sprintstartbackend.ingestion.model.dto.request.RunArtifactsAiSyncRequest import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.AiArtifactSummaryStreamMessage +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactIngestStatusAiResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactProjectsAiSyncResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ProjectMembershipsDeletedAiResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.RunArtifactsIngestResponse @@ -19,6 +20,7 @@ import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.server.ResponseStatusException import java.net.URI +import java.time.Duration import java.util.UUID /** @@ -104,6 +106,33 @@ class ArtifactIngestionClient( ) } + /** + * Reads the AI index state of artifacts. Read-only on the AI side. + * + * Bounded by [INGEST_STATUS_TIMEOUT]: the Knowledge Base asks on every page view, so a hung AI + * service must cost the user a short wait, not a request that never returns. The shared + * client only has a connect timeout. + * + * @param artifactIds Ids to look up (the AI accepts at most 100), sent as repeated + * `artifact_ids` query parameters. + * @return One item per distinct requested id, in request order; `unknown` for ids without record. + * @throws IngestionResponseException when the AI service returns a non-successful HTTP response. + * @throws java.io.IOException when the AI service is unreachable or does not answer in time. + * @throws kotlinx.serialization.SerializationException when the body has an unexpected shape. + */ + suspend fun fetchIngestStatus(artifactIds: Collection): ArtifactIngestStatusAiResponse { + val query = artifactIds.joinToString("&") { "artifact_ids=$it" } + return try { + webClient + .get() + .uri(uri("/api/v1/ingest/status?$query")) + .sync(timeout = INGEST_STATUS_TIMEOUT) + .perform() + } catch (@Suppress("SwallowedException") e: WebClientException) { + throw IngestionResponseException("Failed to read ingest status (HTTP ${e.statusCode}): ${e.body}") + } + } + /** * Opens an SSE stream for a summary of [artifactId]. * @@ -143,4 +172,9 @@ class ArtifactIngestionClient( } private fun uri(path: String): URI = URI.create("${applicationConfig.ai.baseUrl}$path") + + companion object { + /** Upper bound for one AI status lookup; the metadata read behind it is a keyed select. */ + val INGEST_STATUS_TIMEOUT: Duration = Duration.ofSeconds(3) + } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactController.kt index e5aaba7be..91fbacab6 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactController.kt @@ -1,10 +1,20 @@ package com.sprintstart.sprintstartbackend.ingestion.controller +import com.sprintstart.sprintstartbackend.ingestion.external.model.SourceSystem +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactFilterCriteria +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactSort +import com.sprintstart.sprintstartbackend.ingestion.model.dto.UploadFormat +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactAiStatusResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactContentRedirectResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactContentResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactFacetsResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactPageResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactResponse +import com.sprintstart.sprintstartbackend.ingestion.model.entity.ArtifactType +import com.sprintstart.sprintstartbackend.ingestion.service.ArtifactAiStatusService import com.sprintstart.sprintstartbackend.ingestion.service.ArtifactQueryService import com.sprintstart.sprintstartbackend.ingestion.service.ArtifactService +import com.sprintstart.sprintstartbackend.ingestion.service.MAX_AI_STATUS_IDS import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.responses.ApiResponse @@ -12,6 +22,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.constraints.Max import jakarta.validation.constraints.Min +import org.springframework.format.annotation.DateTimeFormat import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -25,12 +36,23 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException import java.net.URI +import java.time.LocalDate import java.util.UUID private const val DEFAULT_PAGE = "1" private const val DEFAULT_SIZE = "20" private const val MAX_PAGE_SIZE = 100L +private const val FROM_DESCRIPTION = + "First activity day to include (last content change, else import), ISO yyyy-MM-dd, " + + "read as a UTC calendar day (inclusive). Must not be after `to`, else 400." +private const val TO_DESCRIPTION = + "Last activity day to include (last content change, else import), ISO yyyy-MM-dd, " + + "read as a UTC calendar day (inclusive)." +private const val LANGUAGES_DESCRIPTION = + "Language display names to keep (repeatable, case-insensitive), e.g. Kotlin. " + + "Artifacts without a language are excluded while set." /** * Read-only HTTP entry point for opening one artifact. @@ -48,6 +70,7 @@ private const val MAX_PAGE_SIZE = 100L class ArtifactController( private val artifactService: ArtifactService, private val artifactQueryService: ArtifactQueryService, + private val artifactAiStatusService: ArtifactAiStatusService, ) { /** * Returns a paginated artifact list across all projects for administrative callers. @@ -89,26 +112,190 @@ class ArtifactController( summary = "Get project artifacts", description = "Returns a paginated artifact list limited to one project visible to the " + - "authenticated user. When a filter is provided, the search is performed " + + "authenticated user. When a filter or criteria are provided, the search is performed " + "case-insensitively across the configured searchable fields.", ) @ApiResponses( value = [ ApiResponse(responseCode = "200", description = "Project artifact page returned successfully"), + ApiResponse( + responseCode = "400", + description = "Invalid query or pagination parameters, unknown sort, malformed date, or from after to", + ), ApiResponse(responseCode = "403", description = "Caller has no access to the project"), ], ) fun getProjectArtifacts( @RequestParam(defaultValue = DEFAULT_PAGE) @Min(1) page: Int, @RequestParam(defaultValue = DEFAULT_SIZE) @Min(1) @Max(MAX_PAGE_SIZE) size: Int, - @RequestParam(defaultValue = "") filter: String, + // `filter` predates `search` and kept its own contract (a fragment matched against title, + // type, source system and metadata) until the Knowledge Base moved server-side. It is now + // an alias of `search`: nothing in this repo sends it, and it stays only so an outside + // client that does is not broken by a query whose meaning it cannot see changing. + @Parameter(description = "Deprecated alias of `search`; send `search` instead") + @RequestParam(defaultValue = "") + filter: String, + @Parameter( + description = "Case-insensitive match against the artifact's title, source id and source url", + ) + @RequestParam(required = false) + search: String?, + @RequestParam(required = false) types: Set?, + @RequestParam(required = false) sources: Set?, + @RequestParam(required = false) repositories: Set?, + @RequestParam(required = false) format: UploadFormat?, + @Parameter( + description = "Row order: ADDED_DESC (newest import first, the default), " + + "CHANGED_DESC (latest content change first, falling back to the import time) or " + + "TITLE_ASC (case-insensitive, untitled last). Any other value is rejected with 400.", + ) + @RequestParam(defaultValue = "ADDED_DESC") + sort: ArtifactSort, + @Parameter(description = FROM_DESCRIPTION) + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + from: LocalDate?, + @Parameter(description = TO_DESCRIPTION) + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + to: LocalDate?, + @Parameter(description = LANGUAGES_DESCRIPTION) + @RequestParam(required = false) + languages: Set?, @Parameter( description = "UUID of the project whose artifacts should be returned", ) @PathVariable projectId: UUID, @Parameter(hidden = true) @AuthenticationPrincipal jwt: Jwt, - ): ResponseEntity = + ): ResponseEntity { + val effectiveSearch = search ?: (if (filter.isNotBlank()) filter else null) + val criteria = ArtifactFilterCriteria( + search = effectiveSearch, + types = types, + sources = sources, + repositories = repositories, + format = format, + from = from, + to = to, + languages = languages, + ) + return ResponseEntity.ok( + artifactQueryService.getProjectArtifacts(page, size, criteria, sort, projectId, jwt.subject), + ) + } + + @GetMapping("projects/{projectId}/artifacts/facets") + @PreAuthorize("hasRole('USER')") + @Operation( + summary = "Get project artifact facets", + description = "Returns aggregated counts for artifact facets scoped to a project.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Facet counts returned successfully"), + ApiResponse( + responseCode = "400", + description = "Invalid facet query parameters, malformed date, or from after to", + ), + ApiResponse(responseCode = "403", description = "Caller has no access to the project"), + ], + ) + fun getProjectArtifactFacets( + @RequestParam(required = false) search: String?, + @RequestParam(required = false) types: Set?, + @RequestParam(required = false) sources: Set?, + @RequestParam(required = false) repositories: Set?, + @RequestParam(required = false) format: UploadFormat?, + @Parameter(description = FROM_DESCRIPTION) + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + from: LocalDate?, + @Parameter(description = TO_DESCRIPTION) + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + to: LocalDate?, + @Parameter(description = LANGUAGES_DESCRIPTION) + @RequestParam(required = false) + languages: Set?, + @Parameter( + description = "UUID of the project whose artifact facets should be calculated", + ) @PathVariable projectId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + val criteria = ArtifactFilterCriteria( + search = search, + types = types, + sources = sources, + repositories = repositories, + format = format, + from = from, + to = to, + languages = languages, + ) + return ResponseEntity.ok( + artifactQueryService.getProjectArtifactFacets(projectId, criteria, jwt.subject), + ) + } + + /** + * Returns the AI index state of the artifacts shown on one Knowledge Base page. + * + * Separate from the list on purpose: the list never waits for, or fails because of, the AI + * service; the frontend asks for chips after the page rendered. The id cap is checked here, + * at the HTTP edge, before any project lookup. AI trouble is never a 5xx: it comes back as + * `aiAvailable = false`. + */ + @GetMapping("projects/{projectId}/artifacts/ai-status") + @PreAuthorize("hasRole('USER')") + @Operation( + summary = "Get AI index status of project artifacts", + description = "Per-id AI index state; ids outside the project are omitted.", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Status per visible id; aiAvailable=false when the AI could not be asked", + ), + ApiResponse(responseCode = "400", description = "More than 100 ids, or a malformed id"), + ApiResponse(responseCode = "403", description = "Caller has no access to the project"), + ], + ) + suspend fun getProjectArtifactAiStatus( + @Parameter(description = "Artifact ids to check (repeatable, at most 100)") + @RequestParam(required = false) + ids: List?, + @Parameter(description = "UUID of the project the artifacts belong to") + @PathVariable + projectId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + val requested = ids.orEmpty() + if (requested.size > MAX_AI_STATUS_IDS) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "At most $MAX_AI_STATUS_IDS ids per request") + } + return ResponseEntity.ok(artifactAiStatusService.getAiStatus(jwt.subject, projectId, requested)) + } + + @GetMapping("projects/{projectId}/artifacts/{artifactId}") + @PreAuthorize("hasRole('USER')") + @Operation( + summary = "Get single artifact", + description = "Returns metadata for one artifact when the caller has access to the requested project.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Artifact returned successfully"), + ApiResponse(responseCode = "403", description = "Caller has no access to the project"), + ApiResponse(responseCode = "404", description = "Artifact not found in project"), + ], + ) + fun getArtifact( + @Parameter(description = "UUID of the project that scopes artifact access") @PathVariable projectId: UUID, + @Parameter(description = "UUID of the artifact to return") @PathVariable artifactId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity = ResponseEntity.ok( - artifactQueryService.getProjectArtifacts(page, size, filter, projectId, jwt.subject), + artifactQueryService.getArtifact(projectId, artifactId, jwt.subject), ) /** diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/ArtifactAiIndexStatus.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/ArtifactAiIndexStatus.kt new file mode 100644 index 000000000..1d386810a --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/ArtifactAiIndexStatus.kt @@ -0,0 +1,35 @@ +package com.sprintstart.sprintstartbackend.ingestion.model.dto + +/** + * AI index state of one artifact, as shown by the Knowledge Base status chip. + * + * The backend owns this enum rather than forwarding the AI's lowercase strings, so the frontend + * gets a closed set and a new or misspelt AI value can never reach it: [fromAi] maps anything + * unrecognised to [UNKNOWN]. + */ +enum class ArtifactAiIndexStatus { + /** Embedded and searchable by the assistant (AI `indexed`, recorded as `completed`). */ + INDEXED, + + /** Ingestion is still running. */ + PROCESSING, + + /** The last ingestion attempt failed. */ + FAILED, + + /** Removed from the index on purpose. */ + DEINDEXED, + + /** The AI holds no record, reports a value we do not know, or could not be asked. */ + UNKNOWN, + ; + + companion object { + /** + * Maps the AI's status string, case-insensitively; null or unknown values become [UNKNOWN]. + */ + fun fromAi(value: String?): ArtifactAiIndexStatus = + entries.firstOrNull { it != UNKNOWN && it.name.equals(value?.trim(), ignoreCase = true) } + ?: UNKNOWN + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/ArtifactFilterCriteria.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/ArtifactFilterCriteria.kt new file mode 100644 index 000000000..30ef235d8 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/ArtifactFilterCriteria.kt @@ -0,0 +1,42 @@ +package com.sprintstart.sprintstartbackend.ingestion.model.dto + +import com.sprintstart.sprintstartbackend.ingestion.external.model.SourceSystem +import com.sprintstart.sprintstartbackend.ingestion.model.entity.ArtifactType +import java.time.LocalDate + +/** + * File formats recognized for UPLOAD-sourced artifacts. + */ +enum class UploadFormat { + PDF, + MARKDOWN, + IMAGE, + OTHER, +} + +/** + * Filter criteria for project-scoped artifact searches and facet calculations. + * + * Encapsulates full-text search, type filtering, source filtering, repository selection, + * upload format selection, and an activity-date window. + * + * Activity is `COALESCE(lastChangedAt, ingestedAt)`: the last content change, or the import when + * the artifact never changed (the same key the `CHANGED_DESC` sort uses). + * + * @property from First day (inclusive) of the activity window, read as a UTC calendar day: rows + * with `activity >= from 00:00Z` match. Null leaves the window open at the start. + * @property to Last day (inclusive) of the activity window, read as a UTC calendar day: rows with + * `activity < (to + 1 day) 00:00Z` match. Null leaves the window open at the end. + * @property languages Language display names to keep, matched case-insensitively against the + * stored name. Narrows every source: artifacts without a language drop out while it is set. + */ +data class ArtifactFilterCriteria( + val search: String? = null, + val types: Set? = null, + val sources: Set? = null, + val repositories: Set? = null, + val format: UploadFormat? = null, + val from: LocalDate? = null, + val to: LocalDate? = null, + val languages: Set? = null, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/ArtifactSort.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/ArtifactSort.kt new file mode 100644 index 000000000..94e8d5813 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/ArtifactSort.kt @@ -0,0 +1,18 @@ +package com.sprintstart.sprintstartbackend.ingestion.model.dto + +/** + * Orderings offered by the project artifact list. + * + * Every ordering ends with `id ASC` as a tie-break, so a page boundary never splits or repeats + * rows that share the leading sort key. Facet counts are order independent and ignore this. + */ +enum class ArtifactSort { + /** Newest first import: `ingestedAt DESC, id ASC`. The default. */ + ADDED_DESC, + + /** Most recently changed: `coalesce(lastChangedAt, ingestedAt) DESC, id ASC`. */ + CHANGED_DESC, + + /** Alphabetical: `lower(title) ASC NULLS LAST, id ASC`. */ + TITLE_ASC, +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactAiStatusResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactAiStatusResponse.kt new file mode 100644 index 000000000..dcae226b1 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactAiStatusResponse.kt @@ -0,0 +1,31 @@ +package com.sprintstart.sprintstartbackend.ingestion.model.dto.response + +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactAiIndexStatus +import java.util.UUID + +/** + * AI index state of one visible artifact. + * + * @property updatedAt The AI's ISO timestamp of the last recorded change, passed through verbatim; + * null when the AI holds no record or was unreachable. + * @property chunkCount Chunks the AI recorded; null when unknown. + */ +data class ArtifactAiStatusItemResponse( + val artifactId: UUID, + val status: ArtifactAiIndexStatus, + val updatedAt: String?, + val chunkCount: Int?, +) + +/** + * Answer of `GET /projects/{projectId}/artifacts/ai-status`. + * + * @property aiAvailable False when the AI service could not be asked (unreachable, timeout, + * non-2xx, unreadable body). Every item is then UNKNOWN with nulls, and the frontend hides the + * chip instead of claiming "Not indexed". + * @property items One entry per requested id that belongs to the project, in request order. + */ +data class ArtifactAiStatusResponse( + val aiAvailable: Boolean, + val items: List, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactFacetsResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactFacetsResponse.kt new file mode 100644 index 000000000..a4f5e5da5 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactFacetsResponse.kt @@ -0,0 +1,24 @@ +package com.sprintstart.sprintstartbackend.ingestion.model.dto.response + +/** + * A single facet option with its corresponding artifact count. + */ +data class FacetCountResponse( + val value: String, + val count: Long, +) + +/** + * Aggregated facet counts for artifact types, sources, upload formats, repositories, and languages. + * + * @property languages Language display names ("Kotlin", "YAML", ...), count descending then value + * ascending. Null, "Markdown" and "Plain Text" are left out; the format facet covers documents. + * Selected languages always appear, with count 0 when nothing matches. + */ +data class ArtifactFacetsResponse( + val types: List, + val sources: List, + val formats: List, + val repositories: List, + val languages: List, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactIngestStatusAiResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactIngestStatusAiResponse.kt new file mode 100644 index 000000000..7eb1a4374 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactIngestStatusAiResponse.kt @@ -0,0 +1,33 @@ +package com.sprintstart.sprintstartbackend.ingestion.model.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One artifact's index state as the AI service reports it on `GET /api/v1/ingest/status`. + * + * Kept as raw strings on purpose: this is the AI wire format, and mapping onto the backend's own + * [com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactAiIndexStatus] happens in the + * service, so a status the AI adds later degrades to UNKNOWN instead of failing deserialization. + * + * @property artifactId The requested id, echoed verbatim. + * @property status Lowercase AI status (indexed, processing, failed, deindexed, unknown). + * @property updatedAt ISO timestamp of the last recorded change; null when the AI holds no record. + * @property chunkCount Chunks recorded for the artifact; null when the AI holds no record. + */ +@Serializable +data class ArtifactIngestStatusAiItem( + @SerialName("artifact_id") + val artifactId: String, + val status: String, + @SerialName("updated_at") + val updatedAt: String? = null, + @SerialName("chunk_count") + val chunkCount: Int? = null, +) + +/** AI response body of `GET /api/v1/ingest/status`: one item per distinct requested id. */ +@Serializable +data class ArtifactIngestStatusAiResponse( + val items: List, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactResponse.kt index 2896fb1bd..58972cfd9 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/dto/response/ArtifactResponse.kt @@ -20,4 +20,10 @@ data class ArtifactResponse( val lastChangedAt: Instant?, val metadata: String, val sourceVersion: String? = null, + /** + * Display name of the artifact's programming or document language (for example "Kotlin", + * "Markdown", "Plain Text"), derived from the file extension at ingestion; null when it has no + * file extension to go by, as with issues, pull requests and pages. + */ + val language: String? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/entity/Artifact.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/entity/Artifact.kt index 1aef89f67..11005bc02 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/entity/Artifact.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/entity/Artifact.kt @@ -9,6 +9,7 @@ import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.FetchType import jakarta.persistence.Id +import jakarta.persistence.Index import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import java.time.Instant @@ -48,6 +49,12 @@ class Artifact( @CollectionTable( name = "artifact_projects", joinColumns = [JoinColumn(name = "artifact_id")], + indexes = [ + Index( + name = "idx_artifact_projects_project", + columnList = "project_id, artifact_id", + ), + ], ) @Column(name = "project_id", nullable = false) // Add companion obj to Artifact to have Artifact.create diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/mapper/ArtifactMapper.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/mapper/ArtifactMapper.kt index aa5859e2c..9fb82f33c 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/mapper/ArtifactMapper.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/model/mapper/ArtifactMapper.kt @@ -18,6 +18,7 @@ class ArtifactMapper { ingestedAt = artifact.ingestedAt, lastChangedAt = artifact.lastChangedAt, sourceVersion = artifact.sourceVersion, + language = artifact.language, ) } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepository.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepository.kt new file mode 100644 index 000000000..5615969b1 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepository.kt @@ -0,0 +1,61 @@ +package com.sprintstart.sprintstartbackend.ingestion.repository + +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactFilterCriteria +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactSort +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactFacetsResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactResponse +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import java.util.UUID + +/** + * Custom repository fragment providing multi-criteria paginated projection queries + * and aggregated facet calculations for project artifacts. + */ +interface ArtifactFacetRepository { + /** + * Resolves a paginated list of artifact projections using dynamic criteria without hydrating + * the heavyweight `content` column. + * + * @param projectId Scopes artifacts to the target project. + * @param criteria Filter criteria containing search text, types, sources, repos, and formats. + * @param sort Row order. Applied here rather than through [pageable], whose sort is ignored: + * the criteria query owns ordering so every [ArtifactSort] keeps its `id ASC` tie-break. + * @param pageable Requested page number and size. + * @return Paginated page of artifact response projections. + */ + fun findProjectArtifactsWithCriteria( + projectId: UUID, + criteria: ArtifactFilterCriteria, + sort: ArtifactSort, + pageable: Pageable, + ): Page + + /** + * Resolves one artifact's metadata projection, scoped to a project the caller can see. + * + * Used to open a deep-linked artifact that is not on the page currently loaded, so it must + * not hydrate `content` either — see [findProjectArtifactsWithCriteria]. + * + * @param projectId Scopes the artifact to the target project. + * @param artifactId The artifact to resolve. + * @return The artifact projection, or null when it is not linked to that project. + */ + fun findProjectArtifactById( + projectId: UUID, + artifactId: UUID, + ): ArtifactResponse? + + /** + * Calculates aggregated counts for types, sources, upload formats, repositories, and languages + * using the "count each would add" model (own-facet-excluded, other-facets-applied). + * + * @param projectId Scopes facet calculations to the target project. + * @param criteria The currently active filter criteria. + * @return Aggregated facet counts. + */ + fun findFacets( + projectId: UUID, + criteria: ArtifactFilterCriteria, + ): ArtifactFacetsResponse +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepositoryImpl.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepositoryImpl.kt new file mode 100644 index 000000000..ceaa685d5 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepositoryImpl.kt @@ -0,0 +1,586 @@ +package com.sprintstart.sprintstartbackend.ingestion.repository + +import com.sprintstart.sprintstartbackend.ingestion.external.model.SourceSystem +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactFilterCriteria +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactSort +import com.sprintstart.sprintstartbackend.ingestion.model.dto.UploadFormat +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactFacetsResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.FacetCountResponse +import com.sprintstart.sprintstartbackend.ingestion.model.entity.Artifact +import com.sprintstart.sprintstartbackend.ingestion.model.entity.ArtifactType +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import jakarta.persistence.criteria.CompoundSelection +import jakarta.persistence.criteria.CriteriaBuilder +import jakarta.persistence.criteria.Join +import jakarta.persistence.criteria.Nulls +import jakarta.persistence.criteria.Order +import jakarta.persistence.criteria.Predicate +import jakarta.persistence.criteria.Root +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.UUID + +private enum class FacetKind { + TYPES, + SOURCES, + FORMATS, + REPOSITORIES, + LANGUAGES, +} + +private val SOURCES_EXCLUDED_FACETS = setOf(FacetKind.SOURCES, FacetKind.FORMATS, FacetKind.REPOSITORIES) + +/** Lower-cased language names the facet never offers: the format facet covers documents. */ +private val DOCUMENT_LANGUAGES = setOf("markdown", "plain text") + +/** Selected languages, trimmed, blanks dropped, deduplicated ignoring case. */ +private fun ArtifactFilterCriteria.selectedLanguages(): List = + languages + .orEmpty() + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinctBy { it.lowercase() } + +/** + * Shapes counted `(display name, count)` language groups into facet options. + * + * Document languages are dropped unless selected; a selected language with no match is added at + * count 0, so its chip stays visible. Ordered by count descending, then value ascending. + */ +internal fun languageFacetOptions( + counted: List>, + selected: List, +): List { + val selectedLower = selected.map { it.lowercase() }.toSet() + val countedLower = counted.map { it.first.lowercase() }.toSet() + val shown = counted.filter { (value, _) -> + value.lowercase() !in DOCUMENT_LANGUAGES || value.lowercase() in selectedLower + } + val unmatched = selected.filter { it.lowercase() !in countedLower }.map { it to 0L } + return (shown + unmatched) + .map { (value, count) -> FacetCountResponse(value, count) } + .sortedWith(compareByDescending { it.count }.thenBy { it.value }) +} + +@Repository +@Transactional(readOnly = true) +// One function per facet dimension plus the shared predicate/order builders; splitting them would +// scatter the own-dimension exclusion rule that must stay identical across list and facets. +@Suppress("TooManyFunctions") +class ArtifactFacetRepositoryImpl( + @PersistenceContext private val entityManager: EntityManager, +) : ArtifactFacetRepository { + override fun findProjectArtifactsWithCriteria( + projectId: UUID, + criteria: ArtifactFilterCriteria, + sort: ArtifactSort, + pageable: Pageable, + ): Page { + val cb = entityManager.criteriaBuilder + + // 1. Data Query (Projection - D3: does not hydrate content TEXT column) + val query = cb.createQuery(ArtifactResponse::class.java) + val root = query.from(Artifact::class.java) + val projectJoin = root.join("projectIdsInternal") + + query.select(artifactProjection(cb, root)) + + val predicates = buildPredicates(cb, root, projectJoin, projectId, criteria, null) + query.where(*predicates.toTypedArray()) + + // D2: Deterministic sort -- the requested key, then id ASC as the tie-break. + query.orderBy(orderFor(cb, root, sort)) + + val typedQuery = entityManager.createQuery(query) + typedQuery.firstResult = pageable.offset.toInt() + typedQuery.maxResults = pageable.pageSize + val items = typedQuery.resultList + + // 2. Count Query + val countQuery = cb.createQuery(Long::class.java) + val countRoot = countQuery.from(Artifact::class.java) + val countJoin = countRoot.join("projectIdsInternal") + + countQuery.select(cb.countDistinct(countRoot.get("id"))) + val countPredicates = buildPredicates(cb, countRoot, countJoin, projectId, criteria, null) + countQuery.where(*countPredicates.toTypedArray()) + + val totalElements = entityManager.createQuery(countQuery).singleResult ?: 0L + + return PageImpl(items, pageable, totalElements) + } + + /** + * Builds the ORDER BY clause for [sort], always ending in `id ASC`. + * + * The id tie-break keeps offset pagination stable: rows sharing the leading key (same + * import instant, same title, or no title at all) keep one fixed order across pages. + */ + private fun orderFor( + cb: CriteriaBuilder, + root: Root, + sort: ArtifactSort, + ): List { + val leading = when (sort) { + ArtifactSort.ADDED_DESC -> cb.desc(root.get("ingestedAt")) + ArtifactSort.CHANGED_DESC -> cb.desc( + cb.coalesce(root.get("lastChangedAt"), root.get("ingestedAt")), + ) + // Nulls.LAST is spelled out: databases disagree on where NULL sorts by default. + ArtifactSort.TITLE_ASC -> cb.asc(cb.lower(root.get("title")), Nulls.LAST) + } + return listOf(leading, cb.asc(root.get("id"))) + } + + override fun findProjectArtifactById( + projectId: UUID, + artifactId: UUID, + ): ArtifactResponse? { + val cb = entityManager.criteriaBuilder + val query = cb.createQuery(ArtifactResponse::class.java) + val root = query.from(Artifact::class.java) + val projectJoin = root.join("projectIdsInternal") + + query.select(artifactProjection(cb, root)) + query.where( + cb.equal(root.get("id"), artifactId), + cb.equal(projectJoin, projectId), + ) + + return entityManager + .createQuery(query) + .setMaxResults(1) + .resultList + .firstOrNull() + } + + /** + * The response projection shared by every artifact read. + * + * Deliberately not the entity: `Artifact` carries the eagerly fetched `content` TEXT column, + * so hydrating it to answer a metadata question drags whole file bodies across JDBC. Keep the + * field list here and nowhere else — [ArtifactResponse] is the only shape either query builds. + */ + private fun artifactProjection( + cb: CriteriaBuilder, + root: Root, + ): CompoundSelection = cb.construct( + ArtifactResponse::class.java, + root.get("id"), + root.get("title"), + root.get("sourceSystem"), + root.get("sourceId"), + root.get("sourceUrl"), + root.get("artifactType"), + root.get("ingestedAt"), + root.get("lastChangedAt"), + root.get("metadata"), + root.get("sourceVersion"), + root.get("language"), + ) + + override fun findFacets( + projectId: UUID, + criteria: ArtifactFilterCriteria, + ): ArtifactFacetsResponse { + val cb = entityManager.criteriaBuilder + return ArtifactFacetsResponse( + types = computeTypeFacets(cb, projectId, criteria), + sources = computeSourceFacets(cb, projectId, criteria), + formats = computeFormatFacets(cb, projectId, criteria), + repositories = computeRepositoryFacets(cb, projectId, criteria), + languages = computeLanguageFacets(cb, projectId, criteria), + ) + } + + private fun computeTypeFacets( + cb: CriteriaBuilder, + projectId: UUID, + criteria: ArtifactFilterCriteria, + ): List { + val typeCountsMap = mutableMapOf() + val query = cb.createQuery(Array::class.java) + val root = query.from(Artifact::class.java) + val join = root.join("projectIdsInternal") + query.multiselect( + root.get("artifactType"), + cb.countDistinct(root.get("id")), + ) + val preds = buildPredicates(cb, root, join, projectId, criteria, FacetKind.TYPES) + query.where(*preds.toTypedArray()) + query.groupBy(root.get("artifactType")) + + for (row in entityManager.createQuery(query).resultList) { + val type = row[0] as ArtifactType + val count = (row[1] as Number).toLong() + typeCountsMap[type] = count + } + criteria.types?.forEach { type -> + typeCountsMap.putIfAbsent(type, 0L) + } + return typeCountsMap.map { (type, count) -> + FacetCountResponse(type.name, count) + } + } + + private fun computeSourceFacets( + cb: CriteriaBuilder, + projectId: UUID, + criteria: ArtifactFilterCriteria, + ): List { + val sourceCountsMap = mutableMapOf() + val query = cb.createQuery(Array::class.java) + val root = query.from(Artifact::class.java) + val join = root.join("projectIdsInternal") + query.multiselect( + root.get("sourceSystem"), + cb.countDistinct(root.get("id")), + ) + val preds = buildPredicates(cb, root, join, projectId, criteria, FacetKind.SOURCES) + query.where(*preds.toTypedArray()) + query.groupBy(root.get("sourceSystem")) + + for (row in entityManager.createQuery(query).resultList) { + val source = row[0] as SourceSystem + val count = (row[1] as Number).toLong() + sourceCountsMap[source] = count + } + criteria.sources?.forEach { source -> + sourceCountsMap.putIfAbsent(source, 0L) + } + return sourceCountsMap.map { (source, count) -> + FacetCountResponse(source.name, count) + } + } + + private fun computeFormatFacets( + cb: CriteriaBuilder, + projectId: UUID, + criteria: ArtifactFilterCriteria, + ): List { + val formatCountsMap = mutableMapOf( + UploadFormat.PDF to 0L, + UploadFormat.MARKDOWN to 0L, + UploadFormat.IMAGE to 0L, + UploadFormat.OTHER to 0L, + ) + val query = cb.createQuery(Array::class.java) + val root = query.from(Artifact::class.java) + val join = root.join("projectIdsInternal") + query.multiselect( + root.get("title"), + root.get("sourceUrl"), + root.get("sourceId"), + root.get("mime"), + root.get("language"), + ) + val preds = buildPredicates(cb, root, join, projectId, criteria, FacetKind.FORMATS).toMutableList() + preds.add(cb.equal(root.get("sourceSystem"), SourceSystem.UPLOAD)) + query.where(*preds.toTypedArray()) + + for (row in entityManager.createQuery(query).resultList) { + val title = row[0] as? String + val sourceUrl = row[1] as? String + val sourceId = row[2] as String + val mime = row[3] as? String + val language = row[4] as? String + val format = classifyUploadFormat(title, sourceUrl, sourceId, mime, language) + formatCountsMap[format] = (formatCountsMap[format] ?: 0L) + 1L + } + + return formatCountsMap + .filter { (fmt, count) -> count > 0L || criteria.format == fmt } + .map { (fmt, count) -> FacetCountResponse(fmt.name, count) } + } + + private fun computeRepositoryFacets( + cb: CriteriaBuilder, + projectId: UUID, + criteria: ArtifactFilterCriteria, + ): List { + val repoCountsMap = mutableMapOf() + val orgCountsMap = mutableMapOf() + val query = cb.createQuery(Array::class.java) + val root = query.from(Artifact::class.java) + val join = root.join("projectIdsInternal") + query.multiselect( + root.get("sourceId"), + root.get("artifactType"), + ) + val preds = buildPredicates(cb, root, join, projectId, criteria, FacetKind.REPOSITORIES).toMutableList() + preds.add(cb.equal(root.get("sourceSystem"), SourceSystem.GITHUB)) + query.where(*preds.toTypedArray()) + + for (row in entityManager.createQuery(query).resultList) { + val sourceId = row[0] as String + val artifactType = row[1] as ArtifactType + + if (artifactType == ArtifactType.ORG_METADATA) { + val orgLogin = sourceId.trim().lowercase() + orgCountsMap[orgLogin] = (orgCountsMap[orgLogin] ?: 0L) + 1L + } else { + val repo = extractRepositoryFromSourceId(sourceId) + if (repo != null) { + repoCountsMap[repo] = (repoCountsMap[repo] ?: 0L) + 1L + } + } + } + for (repo in repoCountsMap.keys.toList()) { + val owner = repo.substringBefore('/').trim().lowercase() + val orgCount = orgCountsMap[owner] ?: 0L + if (orgCount > 0L) { + repoCountsMap[repo] = (repoCountsMap[repo] ?: 0L) + orgCount + } + } + criteria.repositories?.forEach { repo -> + repoCountsMap.putIfAbsent(repo, 0L) + } + return repoCountsMap + .filter { (repo, count) -> count > 0L || criteria.repositories?.contains(repo) == true } + .entries + .sortedBy { it.key } + .map { (repo, count) -> FacetCountResponse(repo, count) } + } + + /** + * Counts artifacts per language under every active filter except the language one. + * + * Groups by lower(language) so the counts use the same case folding as the filter predicate; + * min(language) then picks one stored spelling to display for the group. + */ + private fun computeLanguageFacets( + cb: CriteriaBuilder, + projectId: UUID, + criteria: ArtifactFilterCriteria, + ): List { + val query = cb.createQuery(Array::class.java) + val root = query.from(Artifact::class.java) + val join = root.join("projectIdsInternal") + val language = root.get("language") + query.multiselect(cb.least(language), cb.countDistinct(root.get("id"))) + val preds = buildPredicates(cb, root, join, projectId, criteria, FacetKind.LANGUAGES) + query.where(*(preds + cb.isNotNull(language)).toTypedArray()) + query.groupBy(cb.lower(language)) + + val counted = entityManager.createQuery(query).resultList.map { row -> + (row[0] as String) to (row[1] as Number).toLong() + } + return languageFacetOptions(counted, criteria.selectedLanguages()) + } + + private fun buildPredicates( + cb: CriteriaBuilder, + root: Root, + projectJoin: Join, + projectId: UUID, + criteria: ArtifactFilterCriteria, + exclude: FacetKind?, + ): List { + val predicates = mutableListOf() + predicates.add(cb.equal(projectJoin, projectId)) + + if (!criteria.search.isNullOrBlank()) { + val pattern = "%${criteria.search.trim().lowercase()}%" + val titleMatch = cb.like(cb.lower(root.get("title")), pattern) + val sourceIdMatch = cb.like(cb.lower(root.get("sourceId")), pattern) + val sourceUrlMatch = cb.like(cb.lower(root.get("sourceUrl")), pattern) + predicates.add(cb.or(titleMatch, sourceIdMatch, sourceUrlMatch)) + } + + if (exclude != FacetKind.TYPES && !criteria.types.isNullOrEmpty()) { + predicates.add(root.get("artifactType").`in`(criteria.types)) + } + + if (exclude !in SOURCES_EXCLUDED_FACETS && !criteria.sources.isNullOrEmpty()) { + predicates.add(root.get("sourceSystem").`in`(criteria.sources)) + } + + if (exclude != FacetKind.FORMATS && criteria.format != null) { + val notUpload = cb.notEqual(root.get("sourceSystem"), SourceSystem.UPLOAD) + val uploadMatchesFormat = buildUploadFormatPredicate(cb, root, criteria.format) + predicates.add(cb.or(notUpload, uploadMatchesFormat)) + } + + if (exclude != FacetKind.REPOSITORIES && !criteria.repositories.isNullOrEmpty()) { + val notGithub = cb.notEqual(root.get("sourceSystem"), SourceSystem.GITHUB) + val githubMatchesRepo = buildGithubRepoPredicate(cb, root, criteria.repositories) + predicates.add(cb.or(notGithub, githubMatchesRepo)) + } + + val languages = criteria.selectedLanguages() + if (exclude != FacetKind.LANGUAGES && languages.isNotEmpty()) { + val languageLower = cb.lower(root.get("language")) + predicates.add(languageLower.`in`(languages.map { it.lowercase() })) + } + + // No facet counts the activity date, so the window applies to every query alike -- which is + // what keeps facet counts equal to the list's totalElements under the same filter. + predicates.addAll(buildActivityWindowPredicates(cb, root, criteria.from, criteria.to)) + + return predicates + } + + /** + * Restricts an artifact's last activity to the inclusive UTC calendar-day window `[from, to]`. + * + * Activity is `COALESCE(lastChangedAt, ingestedAt)`: the last content change, or the import for + * an artifact that never changed. It is the same key `CHANGED_DESC` sorts by, so "changed in + * the last 7 days" and "most recently changed first" always agree. An artifact imported long + * ago but edited inside the window matches; one imported inside the window and changed after + * it does not, because its latest activity lies outside. + * + * The end bound is `< start of the day after [to]` rather than `<= end of [to]`, so activity + * in the last microsecond of that day still matches, whatever precision the column keeps. + */ + private fun buildActivityWindowPredicates( + cb: CriteriaBuilder, + root: Root, + from: LocalDate?, + to: LocalDate?, + ): List { + val activityAt = cb.coalesce(root.get("lastChangedAt"), root.get("ingestedAt")) + return listOfNotNull( + from?.let { cb.greaterThanOrEqualTo(activityAt, it.atStartOfDay(ZoneOffset.UTC).toInstant()) }, + to?.let { cb.lessThan(activityAt, it.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()) }, + ) + } + + private fun buildUploadFormatPredicate( + cb: CriteriaBuilder, + root: Root, + format: UploadFormat, + ): Predicate { + // Nullable columns fold to "" exactly as `classifyUploadFormat` does, so this predicate + // and the Kotlin classifier that produces the facet counts can never disagree. Without + // the coalesce, `NOT(OR(...))` — the OTHER bucket — evaluates to NULL rather than TRUE + // for a row whose mime, language and title are unset, and an upload the facet counts as + // OTHER would come back from the filter as nothing at all. + val titleLower = cb.coalesce(cb.lower(root.get("title")), "") + val sourceUrlLower = cb.coalesce(cb.lower(root.get("sourceUrl")), "") + val sourceIdLower = cb.lower(root.get("sourceId")) + val mimeLower = cb.coalesce(cb.lower(root.get("mime")), "") + val languageLower = cb.coalesce(cb.lower(root.get("language")), "") + + val isPdf = cb.or( + cb.equal(mimeLower, "application/pdf"), + cb.like(titleLower, "%.pdf"), + cb.like(sourceUrlLower, "%.pdf"), + cb.like(sourceIdLower, "%.pdf"), + ) + + val isMarkdown = cb.or( + languageLower.`in`("markdown", "md"), + cb.like(mimeLower, "%markdown%"), + cb.like(titleLower, "%.md"), + cb.like(titleLower, "%.markdown"), + cb.like(sourceUrlLower, "%.md"), + cb.like(sourceUrlLower, "%.markdown"), + cb.like(sourceIdLower, "%.md"), + cb.like(sourceIdLower, "%.markdown"), + ) + + val imageExtPredicates = IMAGE_EXTENSIONS.flatMap { ext -> + listOf(cb.like(titleLower, "%$ext"), cb.like(sourceUrlLower, "%$ext")) + } + val isImage = cb.or( + cb.like(mimeLower, "image/%"), + *imageExtPredicates.toTypedArray(), + ) + + return when (format) { + UploadFormat.PDF -> isPdf + UploadFormat.MARKDOWN -> isMarkdown + UploadFormat.IMAGE -> isImage + UploadFormat.OTHER -> cb.not(cb.or(isPdf, isMarkdown, isImage)) + } + } + + private fun buildGithubRepoPredicate( + cb: CriteriaBuilder, + root: Root, + repositories: Set, + ): Predicate { + val sourceId = root.get("sourceId") + val artifactType = root.get("artifactType") + + val repoPrefixPredicates = repositories.map { repo -> + cb.like(sourceId, "github:$repo:%") + } + val isNonOrgRepoMatch = cb.and( + cb.notEqual(artifactType, ArtifactType.ORG_METADATA), + cb.or(*repoPrefixPredicates.toTypedArray()), + ) + + val owners = repositories.map { it.substringBefore('/').trim().lowercase() }.toSet() + val isOrgMatch = cb.and( + cb.equal(artifactType, ArtifactType.ORG_METADATA), + cb.lower(sourceId).`in`(owners), + ) + + return cb.or(isNonOrgRepoMatch, isOrgMatch) + } + + companion object { + private val IMAGE_EXTENSIONS = listOf( + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".bmp", + ".avif", + ) + + fun extractRepositoryFromSourceId(sourceId: String): String? { + if (!sourceId.startsWith("github:")) return null + val parts = sourceId.split(':') + return if (parts.size >= 3) parts[1] else null + } + + private fun isPdf(title: String, url: String, id: String, mime: String): Boolean = + mime == "application/pdf" || title.endsWith(".pdf") || url.endsWith(".pdf") || id.endsWith(".pdf") + + private fun isMarkdown(title: String, url: String, id: String, mime: String, lang: String): Boolean = + lang in listOf("markdown", "md") || + mime.contains("markdown") || + title.endsWith(".md") || + title.endsWith(".markdown") || + url.endsWith(".md") || + url.endsWith(".markdown") || + id.endsWith(".md") || + id.endsWith(".markdown") + + private fun isImage(title: String, url: String, mime: String): Boolean = + mime.startsWith("image/") || IMAGE_EXTENSIONS.any { title.endsWith(it) || url.endsWith(it) } + + fun classifyUploadFormat( + title: String?, + sourceUrl: String?, + sourceId: String, + mime: String?, + language: String?, + ): UploadFormat { + val titleLower = title?.lowercase() ?: "" + val sourceUrlLower = sourceUrl?.lowercase() ?: "" + val sourceIdLower = sourceId.lowercase() + val mimeLower = mime?.lowercase() ?: "" + val languageLower = language?.lowercase() ?: "" + + return when { + isPdf(titleLower, sourceUrlLower, sourceIdLower, mimeLower) -> UploadFormat.PDF + isMarkdown(titleLower, sourceUrlLower, sourceIdLower, mimeLower, languageLower) -> UploadFormat.MARKDOWN + isImage(titleLower, sourceUrlLower, mimeLower) -> UploadFormat.IMAGE + else -> UploadFormat.OTHER + } + } + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactRepository.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactRepository.kt index 959c238b9..a041aea67 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactRepository.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactRepository.kt @@ -6,6 +6,7 @@ import com.sprintstart.sprintstartbackend.ingestion.model.entity.ArtifactType import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import java.time.Instant @@ -18,7 +19,10 @@ import java.util.UUID * are asked of artifacts, not a repository doing too many things. */ @Suppress("TooManyFunctions") -interface ArtifactRepository : JpaRepository { +interface ArtifactRepository : + JpaRepository, + JpaSpecificationExecutor, + ArtifactFacetRepository { fun findBySourceId(sourceId: String): Artifact? /** @@ -163,6 +167,28 @@ interface ArtifactRepository : JpaRepository { ) fun findProjectIdsByArtifactIdIn(@Param("artifactIds") artifactIds: Collection): Set + /** + * Returns which of [artifactIds] belong to the project. + * + * Selects ids only, so a status lookup never loads artifact content. Callers use it to drop ids + * from other projects before asking the AI service about them. + * + * @param artifactIds The ids to check; callers must not pass an empty collection. + */ + @Query( + """ + SELECT DISTINCT a.id + FROM Artifact a + JOIN a.projectIdsInternal p + WHERE p = :projectId + AND a.id IN :artifactIds + """, + ) + fun findIdsInProject( + @Param("projectId") projectId: UUID, + @Param("artifactIds") artifactIds: Collection, + ): Set + /** * Returns one artifact page limited to artifacts linked to the given project. */ diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactAiStatusService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactAiStatusService.kt new file mode 100644 index 000000000..603a9a407 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactAiStatusService.kt @@ -0,0 +1,109 @@ +package com.sprintstart.sprintstartbackend.ingestion.service + +import com.sprintstart.sprintstartbackend.ingestion.ArtifactIngestionClient +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactAiIndexStatus +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactAiStatusItemResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactAiStatusResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactIngestStatusAiItem +import com.sprintstart.sprintstartbackend.ingestion.repository.ArtifactRepository +import com.sprintstart.sprintstartbackend.user.external.UserApi +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import java.util.UUID + +/** Most ids one status request may ask about: one Knowledge Base page, and the AI's own cap. */ +const val MAX_AI_STATUS_IDS = 100 + +/** + * Tells the Knowledge Base whether each visible artifact is indexed for the AI assistant. + * + * A thin proxy over the AI service's read-only status endpoint. It never fails the page: an AI + * outage turns into `aiAvailable = false` with UNKNOWN items, so the list still renders and the + * frontend just hides the chips. Not `@Transactional`: the only database work is one id-only + * lookup, and that annotation does not apply to suspend functions. + */ +@Service +class ArtifactAiStatusService( + private val artifactRepository: ArtifactRepository, + private val artifactIngestionClient: ArtifactIngestionClient, + private val userApi: UserApi, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + /** + * Returns the AI index state of the requested artifacts that belong to the project. + * + * @param authId JWT subject; must have access to the project (same check as the list). + * @param projectId The project whose artifacts are asked about. + * @param artifactIds Requested ids; duplicates collapse, foreign or unknown ids are omitted + * silently so the endpoint never confirms that an id exists elsewhere. + * @return Items in request order. Empty (and no AI call) when nothing visible was asked. + * @throws ResponseStatusException `403` when the user has no access to the project. + */ + suspend fun getAiStatus(authId: String, projectId: UUID, artifactIds: List): ArtifactAiStatusResponse { + if (!userApi.userHasAccessToProject(authId, projectId)) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "No access to project with id $projectId") + } + val visibleIds = visibleIds(projectId, artifactIds) + if (visibleIds.isEmpty()) { + return ArtifactAiStatusResponse(aiAvailable = true, items = emptyList()) + } + return fetchStatuses(projectId, visibleIds) + } + + private fun visibleIds(projectId: UUID, artifactIds: List): List { + val requested = artifactIds.distinct() + if (requested.isEmpty()) return emptyList() + val inProject = artifactRepository.findIdsInProject(projectId, requested) + return requested.filter { it in inProject } + } + + private suspend fun fetchStatuses(projectId: UUID, visibleIds: List): ArtifactAiStatusResponse { + val aiItems = try { + artifactIngestionClient.fetchIngestStatus(visibleIds).items + } catch (e: Exception) { + // A cancelled request must stay cancelled; anything else means "AI not answering". + currentCoroutineContext().ensureActive() + logger.warn( + "AI status lookup for {} artifact(s) of project {} failed, reporting UNKNOWN: {}: {}", + visibleIds.size, + projectId, + e.javaClass.simpleName, + e.message, + ) + null + } + if (aiItems == null) { + return ArtifactAiStatusResponse(aiAvailable = false, items = visibleIds.map(::unknownItem)) + } + val byId = aiItems.associateBy { it.artifactId.lowercase() } + return ArtifactAiStatusResponse( + aiAvailable = true, + items = visibleIds.map { id -> byId[id.toString()]?.let { toItem(id, it) } ?: unknownItem(id) }, + ) + } + + private fun toItem(id: UUID, aiItem: ArtifactIngestStatusAiItem): ArtifactAiStatusItemResponse { + val status = ArtifactAiIndexStatus.fromAi(aiItem.status) + // UNKNOWN always carries nulls, whatever the AI sent, so it has a single meaning downstream. + if (status == ArtifactAiIndexStatus.UNKNOWN) return unknownItem(id) + return ArtifactAiStatusItemResponse( + artifactId = id, + status = status, + updatedAt = aiItem.updatedAt, + chunkCount = aiItem.chunkCount, + ) + } + + private fun unknownItem(id: UUID) = + ArtifactAiStatusItemResponse( + artifactId = id, + status = ArtifactAiIndexStatus.UNKNOWN, + updatedAt = null, + chunkCount = null, + ) +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactQueryService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactQueryService.kt index b06602daf..aaec78bdc 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactQueryService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactQueryService.kt @@ -1,6 +1,10 @@ package com.sprintstart.sprintstartbackend.ingestion.service +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactFilterCriteria +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactSort +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactFacetsResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactPageResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.PageMetadata import com.sprintstart.sprintstartbackend.ingestion.model.entity.Artifact import com.sprintstart.sprintstartbackend.ingestion.model.mapper.ArtifactMapper @@ -69,18 +73,17 @@ class ArtifactQueryService( } /** - * Returns one paginated artifact list limited to a single project visible to the caller. - * - * The method first validates project access and then delegates to project-scoped repository - * queries using the same filter semantics as the global artifact search. + * Returns one paginated artifact list limited to a single project visible to the caller using criteria. * * @param page The 1-based page number to return. * @param size The maximum number of artifacts to include in one page. - * @param filter Optional case-insensitive text used to narrow the result set. + * @param criteria Filter criteria containing search string, types, sources, repos, and formats. + * @param sort Row order; the repository applies it together with the `id ASC` tie-break. * @param projectId The SprintStart project that scopes the artifact listing. * @param authId The authenticated caller subject from the JWT. * @return One project-scoped artifact page together with pagination metadata. - * @throws ResponseStatusException `403` when the caller has no access to the project. + * @throws ResponseStatusException `400` when `from` is after `to`, `403` when the caller has no + * access to the project. * @throws IllegalArgumentException when Spring Data rejects the requested page or page size. */ @Transactional(readOnly = true) @@ -88,25 +91,21 @@ class ArtifactQueryService( fun getProjectArtifacts( page: Int, size: Int, - filter: String?, + criteria: ArtifactFilterCriteria, + sort: ArtifactSort, projectId: UUID, authId: String, ): ArtifactPageResponse { + requireValidDateWindow(criteria) ensureAccessToProject(authId, projectId) - val pageable = PageRequest.of( - page - 1, - size, - Sort.by("ingestedAt").descending(), - ) + // Unsorted on purpose: the criteria repository owns ORDER BY (see ArtifactSort) and + // would ignore a Pageable sort, so passing one here would only suggest otherwise. + val pageable = PageRequest.of(page - 1, size) - val result: Page = - if (filter.isNullOrBlank()) { - artifactRepository.findAllByProjectId(projectId, pageable) - } else { - artifactRepository.searchByProjectId(projectId, filter.trim(), pageable) - } + val result: Page = + artifactRepository.findProjectArtifactsWithCriteria(projectId, criteria, sort, pageable) return ArtifactPageResponse( - items = result.content.map { artifactMapper.toResponse(it) }, + items = result.content, page = PageMetadata( number = page.toLong(), size = size.toLong(), @@ -118,6 +117,73 @@ class ArtifactQueryService( ) } + /** + * Returns aggregated facet counts for a project based on the supplied criteria. + * + * @param projectId The SprintStart project that scopes the artifact listing. + * @param criteria Active filter criteria. + * @param authId The authenticated caller subject from the JWT. + * @return Aggregated facet counts. + * @throws ResponseStatusException `400` when `from` is after `to`, `403` when the caller has no + * access to the project. + */ + @Transactional(readOnly = true) + @Tracked("Retrieving artifact facets for project") + fun getProjectArtifactFacets( + projectId: UUID, + criteria: ArtifactFilterCriteria, + authId: String, + ): ArtifactFacetsResponse { + requireValidDateWindow(criteria) + ensureAccessToProject(authId, projectId) + return artifactRepository.findFacets(projectId, criteria) + } + + /** + * Retrieves a single artifact by its ID within the project scope. + * + * @param projectId The SprintStart project that scopes the artifact. + * @param artifactId The ID of the artifact to retrieve. + * @param authId The authenticated caller subject from the JWT. + * @return The artifact response DTO. + * @throws ResponseStatusException `403` if access is denied, `404` if not found in project. + */ + @Transactional(readOnly = true) + @Tracked("Retrieving single artifact for project") + fun getArtifact( + projectId: UUID, + artifactId: UUID, + authId: String, + ): ArtifactResponse { + ensureAccessToProject(authId, projectId) + // A projection, not the entity: opening a deep link needs metadata only, and + // `Artifact.content` is an eagerly fetched TEXT column (see + // ArtifactFacetRepositoryImpl.artifactProjection). + return artifactRepository.findProjectArtifactById(projectId, artifactId) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Artifact $artifactId not found in project $projectId", + ) + } + + /** + * Rejects an activity-date window whose start lies after its end. + * + * Such a window can match nothing, so answering it with an empty page would hide a client bug + * (typically swapped bounds) behind a plausible "no results". List and facets both call this, + * so the two endpoints can never disagree on whether a window is valid. + * + * @param criteria The filter whose `from`/`to` bounds are checked; open bounds always pass. + * @throws ResponseStatusException `400` when `from` is after `to`. + */ + private fun requireValidDateWindow(criteria: ArtifactFilterCriteria) { + val from = criteria.from ?: return + val to = criteria.to ?: return + if (from.isAfter(to)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "`from` ($from) must not be after `to` ($to)") + } + } + /** * Verifies that the authenticated caller may read artifacts for the requested project. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/shared/web/RequestBuilder.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/shared/web/RequestBuilder.kt index 44d517809..56d9a63f6 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/shared/web/RequestBuilder.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/shared/web/RequestBuilder.kt @@ -3,6 +3,7 @@ package com.sprintstart.sprintstartbackend.shared.web import java.net.URI import java.net.http.HttpRequest.BodyPublishers.noBody import java.net.http.HttpRequest.BodyPublishers.ofString +import java.time.Duration /** * Immutable accumulator for HTTP request parameters, constructed via [WebClient]. @@ -32,6 +33,7 @@ class RequestBuilder( val uri: URI? = null, val headers: Map = emptyMap(), val rawBody: String? = null, + val timeout: Duration? = null, ) { // ── URI ────────────────────────────────────────────────────────────────── @@ -75,8 +77,16 @@ class RequestBuilder( /** * Returns a [SyncExecution] context for a standard request/response cycle. * Call `.perform()` on the result to fire the request. + * + * @param timeout Optional bound for this single request (response headers must arrive within + * it). The shared [java.net.http.HttpClient] only has a connect timeout, and a coroutine + * `withTimeout` cannot interrupt the blocking `send` on [kotlinx.coroutines.Dispatchers.IO], + * so a caller that must answer fast even when the peer hangs passes one here. On expiry the + * send throws [java.net.http.HttpTimeoutException]. It is a parameter of the execution step + * rather than a builder method because it says how to run the request, not what to send. */ - fun sync(): SyncExecution = SyncExecution(this) + fun sync(timeout: Duration? = null): SyncExecution = + SyncExecution(if (timeout == null) this else copy(timeout = timeout)) /** * Returns a [StreamExecution] context for SSE / chunked streaming responses. @@ -92,6 +102,7 @@ class RequestBuilder( uri: URI? = this.uri, headers: Map = this.headers, rawBody: String? = this.rawBody, + timeout: Duration? = this.timeout, ): RequestBuilder = RequestBuilder( method = method, httpClient = this.httpClient, @@ -99,6 +110,7 @@ class RequestBuilder( uri = uri, headers = headers, rawBody = rawBody, + timeout = timeout, ) @PublishedApi @@ -111,11 +123,13 @@ class RequestBuilder( noBody() } + val requestTimeout = timeout return java.net.http.HttpRequest .newBuilder() .uri(uri) .method(method.uppercase(), bodyPublisher) .apply { headers.forEach { (k, v) -> header(k, v) } } + .apply { if (requestTimeout != null) timeout(requestTimeout) } .build() } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/controller/UploadController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/controller/UploadController.kt index 8442e1cfb..e811e9168 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/controller/UploadController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/controller/UploadController.kt @@ -2,6 +2,7 @@ package com.sprintstart.sprintstartbackend.upload.controller import com.sprintstart.sprintstartbackend.upload.model.dto.request.DeleteArtifactsRequest import com.sprintstart.sprintstartbackend.upload.model.dto.request.UploadArtifactsRequest +import com.sprintstart.sprintstartbackend.upload.model.dto.response.DeleteUploadsResponse import com.sprintstart.sprintstartbackend.upload.model.dto.response.UploadArtifactResponse import com.sprintstart.sprintstartbackend.upload.model.dto.response.UploadListItemResponse import com.sprintstart.sprintstartbackend.upload.service.UploadService @@ -113,12 +114,16 @@ class UploadController( * * @param jwt Authenticated JWT used to resolve the current user. * @param request Deletion metadata containing artifact ids and the target project. - * @return No content when the deletion batch has been processed. + * @return 200 with the deleted ids and a failure entry per id that was not deleted. Missing, + * foreign-project and storage-failed ids land in `failed`; they never fail the whole request. */ @Operation(summary = "Delete project uploads") @ApiResponses( value = [ - ApiResponse(responseCode = "204", description = "Deletion batch processed"), + ApiResponse( + responseCode = "200", + description = "Deletion batch processed; body lists deletedIds and per-id failures", + ), ApiResponse(responseCode = "401", description = "Authentication required"), ApiResponse(responseCode = "403", description = "Insufficient role or project access"), ApiResponse(responseCode = "404", description = "Authenticated user not found"), @@ -135,15 +140,12 @@ class UploadController( @Valid @RequestPart("request") request: DeleteArtifactsRequest, - ): ResponseEntity { - uploadService.deleteUpload( - authId = jwt.subject, - artifactIds = request.artifactIds, - projectId = request.projectId, + ): ResponseEntity = + ResponseEntity.ok( + uploadService.deleteUpload( + authId = jwt.subject, + artifactIds = request.artifactIds, + projectId = request.projectId, + ), ) - - return ResponseEntity - .noContent() - .build() - } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/model/dto/response/DeleteUploadsResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/model/dto/response/DeleteUploadsResponse.kt new file mode 100644 index 000000000..6cd67bb77 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/model/dto/response/DeleteUploadsResponse.kt @@ -0,0 +1,27 @@ +package com.sprintstart.sprintstartbackend.upload.model.dto.response + +import java.util.UUID + +/** + * Result of a bulk upload deletion: what was removed and what was not. + * + * @property deletedIds Artifact ids that were deleted, in request order. + * @property failed One entry per requested id that was not deleted, with the reason. + */ +data class DeleteUploadsResponse( + val deletedIds: List, + val failed: List, +) + +/** + * One artifact the deletion batch skipped. + * + * @property artifactId The requested artifact id. + * @property error Client-safe reason: "Artifact with id not found." for missing or + * foreign ids, the generic "Artifact could not be deleted." for storage failures. Never a raw + * exception message, which could leak storage paths. + */ +data class DeleteUploadFailure( + val artifactId: UUID, + val error: String, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/service/UploadService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/service/UploadService.kt index c006a9673..b4969d701 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/service/UploadService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/upload/service/UploadService.kt @@ -8,6 +8,8 @@ import com.sprintstart.sprintstartbackend.upload.external.events.ingestion.Uploa import com.sprintstart.sprintstartbackend.upload.external.events.ingestion.UploadBatchFinishedEvent import com.sprintstart.sprintstartbackend.upload.external.events.ingestion.UploadFileDeletedEvent import com.sprintstart.sprintstartbackend.upload.external.events.ingestion.UploadStartedEvent +import com.sprintstart.sprintstartbackend.upload.model.dto.response.DeleteUploadFailure +import com.sprintstart.sprintstartbackend.upload.model.dto.response.DeleteUploadsResponse import com.sprintstart.sprintstartbackend.upload.model.dto.response.UploadArtifactResponse import com.sprintstart.sprintstartbackend.upload.model.dto.response.UploadListItemResponse import com.sprintstart.sprintstartbackend.upload.model.entity.UploadedArtifact @@ -15,6 +17,7 @@ import com.sprintstart.sprintstartbackend.upload.repository.LinkedImageRepositor import com.sprintstart.sprintstartbackend.upload.repository.UploadedArtifactRepository import com.sprintstart.sprintstartbackend.upload.service.storage.ArtifactStorageService import com.sprintstart.sprintstartbackend.user.external.UserApi +import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -24,6 +27,14 @@ import org.springframework.web.server.ResponseStatusException import java.security.MessageDigest import java.util.UUID +/** + * Client-facing reason for a storage failure during deletion. + * + * Exception messages can leak storage paths or driver internals, so the HTTP response always + * uses this text; the raw message stays in the deletion event outcome and the warn log. + */ +private const val DELETE_FAILED_REASON = "Artifact could not be deleted." + /** * Coordinates project upload storage, upload metadata persistence, and ingestion events. * @@ -41,6 +52,8 @@ class UploadService( private val artifactLinkingService: ArtifactLinkingService, private val publisher: ApplicationEventPublisher, ) { + private val logger = LoggerFactory.getLogger(javaClass) + /** * Uploads artifacts into a project as the authenticated PM or admin. * @@ -177,6 +190,10 @@ class UploadService( * @param authId The authenticated user's external auth id. * @param artifactIds The uploaded artifact ids requested for deletion. * @param projectId The project that owns the artifacts being deleted. + * @return The ids deleted, in request order, and one failure entry (id and reason) for every + * id that was skipped. Not-found ids report which id was missing; storage failures always + * report the generic [DELETE_FAILED_REASON], while the raw exception message goes only to + * the batch-finished event outcome and a warn log. * @throws ResponseStatusException `403` when the authenticated user cannot access the project. * @throws ResponseStatusException `404` when the authenticated user has no local projection. */ @@ -186,11 +203,13 @@ class UploadService( authId: String, artifactIds: Set, projectId: UUID, - ) { + ): DeleteUploadsResponse { val removerId = resolveCurrentUserId(userApi, authId) requireProjectAccess(userApi, authId, projectId) val deleteArtifactOutcomes = mutableSetOf() + val deletedIds = mutableListOf() + val failed = mutableListOf() val transactionId = UUID.randomUUID() publisher.publishEvent(UploadStartedEvent(transactionId = transactionId, projectId = projectId)) @@ -198,14 +217,16 @@ class UploadService( artifactIds.forEach { artifactId -> val artifact = uploadedArtifactRepository.findByIdAndProjectId(artifactId, projectId) if (artifact == null) { + val reason = "Artifact with id $artifactId not found." deleteArtifactOutcomes.add( UploadArtifactOperationOutcome( id = artifactId, filename = "unknown", status = UploadArtifactStatus.FAILED, - error = "Artifact with id $artifactId not found.", + error = reason, ), ) + failed.add(DeleteUploadFailure(artifactId = artifactId, error = reason)) return@forEach } @@ -223,6 +244,8 @@ class UploadService( error = e.message, ), ) + logger.warn("Storage delete failed for artifact {}: {}", artifactId, e.message) + failed.add(DeleteUploadFailure(artifactId = artifactId, error = DELETE_FAILED_REASON)) return@forEach } @@ -233,6 +256,7 @@ class UploadService( ), ) uploadedArtifactRepository.delete(artifact) + deletedIds.add(artifactId) } publisher.publishEvent( @@ -242,6 +266,8 @@ class UploadService( deleteArtifactOutcomes = deleteArtifactOutcomes, ), ) + + return DeleteUploadsResponse(deletedIds = deletedIds, failed = failed) } /** diff --git a/src/main/resources/db/migration/V20__add_artifact_project_index.sql b/src/main/resources/db/migration/V20__add_artifact_project_index.sql new file mode 100644 index 000000000..bc5c784c2 --- /dev/null +++ b/src/main/resources/db/migration/V20__add_artifact_project_index.sql @@ -0,0 +1,12 @@ +-- Reference-only script for the project-to-artifact join-table index. +-- +-- Nothing executes this file. The backend has no Flyway; schema changes reach a +-- database through Hibernate (`spring.jpa.hibernate.ddl-auto: update`), which +-- creates the index declared on `Artifact.projectIdsInternal` on the next boot. +-- It is kept so the index stays reviewable, and so a production database can be +-- brought in line by hand. +-- +-- Run it OUTSIDE a transaction — CONCURRENTLY is rejected inside one — during a +-- quiet window: it takes a SHARE UPDATE EXCLUSIVE lock and never blocks writes. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_artifact_projects_project + ON artifact_projects(project_id, artifact_id); diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/ArtifactIngestionClientTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/ArtifactIngestionClientTest.kt new file mode 100644 index 000000000..6a4ec5102 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/ArtifactIngestionClientTest.kt @@ -0,0 +1,106 @@ +package com.sprintstart.sprintstartbackend.ingestion + +import com.sprintstart.sprintstartbackend.AiConfig +import com.sprintstart.sprintstartbackend.ApplicationConfig +import com.sprintstart.sprintstartbackend.CryptoConfig +import com.sprintstart.sprintstartbackend.GithubConfig +import com.sprintstart.sprintstartbackend.UploadConfig +import com.sprintstart.sprintstartbackend.shared.web.WebClient +import com.sprintstart.sprintstartbackend.upload.model.exceptions.IngestionResponseException +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException +import java.net.http.HttpClient +import java.net.http.HttpTimeoutException +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class ArtifactIngestionClientTest { + private val mockWebServer = MockWebServer() + private lateinit var client: ArtifactIngestionClient + + @BeforeEach + fun setUp() { + mockWebServer.start() + val webClient = WebClient(HttpClient.newBuilder().build(), Json { ignoreUnknownKeys = true }) + val applicationConfig = ApplicationConfig( + ai = AiConfig(baseUrl = mockWebServer.url("/").toString().removeSuffix("/")), + github = GithubConfig(baseUrl = "https://github.example.com"), + crypto = CryptoConfig(masterKey = "test-master-key", salt = "test-salt"), + upload = UploadConfig(directory = "/tmp/uploads", maxFileSizeBytes = 100), + ) + client = ArtifactIngestionClient(webClient, applicationConfig) + } + + @AfterEach + fun tearDown() { + mockWebServer.shutdown() + } + + @Test + fun `fetchIngestStatus sends a GET with repeated artifact_ids and parses the snake_case body`() = runTest { + val first = UUID.randomUUID() + val second = UUID.randomUUID() + mockWebServer.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"items":[""" + + """{"artifact_id":"$first","status":"indexed","updated_at":"2026-09-20""" + + """T10:00:00+00:00","chunk_count":3,"extra":1},""" + + """{"artifact_id":"$second","status":"unknown","updated_at":null,"chunk_count":null}]}""", + ), + ) + + val response = client.fetchIngestStatus(listOf(first, second)) + + val request = mockWebServer.takeRequest() + assertEquals("GET", request.method) + assertEquals("/api/v1/ingest/status?artifact_ids=$first&artifact_ids=$second", request.path) + assertEquals(listOf(first.toString(), second.toString()), response.items.map { it.artifactId }) + assertEquals("indexed", response.items[0].status) + assertEquals("2026-09-20T10:00:00+00:00", response.items[0].updatedAt) + assertEquals(3, response.items[0].chunkCount) + assertNull(response.items[1].updatedAt) + assertNull(response.items[1].chunkCount) + } + + @Test + fun `fetchIngestStatus turns a non-2xx answer into IngestionResponseException`() = runTest { + mockWebServer.enqueue(MockResponse().setResponseCode(503).setBody("down")) + + val error = assertFailsWith { + client.fetchIngestStatus(listOf(UUID.randomUUID())) + } + + assertEquals("Failed to read ingest status (HTTP 503): down", error.message) + } + + @Test + fun `fetchIngestStatus rejects a body that is not the status shape`() = runTest { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("""{"artifacts":[]}""")) + + assertFailsWith { client.fetchIngestStatus(listOf(UUID.randomUUID())) } + } + + @Test + fun `fetchIngestStatus surfaces an unreachable AI service as IOException`() = runTest { + mockWebServer.shutdown() + + assertFailsWith { client.fetchIngestStatus(listOf(UUID.randomUUID())) } + } + + @Test + fun `fetchIngestStatus gives up on a hung AI service after the status timeout`() = runTest { + mockWebServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + + assertFailsWith { client.fetchIngestStatus(listOf(UUID.randomUUID())) } + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactAiStatusControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactAiStatusControllerTest.kt new file mode 100644 index 000000000..26621d041 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactAiStatusControllerTest.kt @@ -0,0 +1,148 @@ +package com.sprintstart.sprintstartbackend.ingestion.controller + +import com.ninjasquad.springmockk.MockkBean +import com.sprintstart.sprintstartbackend.config.SecurityConfig +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactAiIndexStatus +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactAiStatusItemResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactAiStatusResponse +import com.sprintstart.sprintstartbackend.ingestion.service.ArtifactAiStatusService +import com.sprintstart.sprintstartbackend.ingestion.service.ArtifactQueryService +import com.sprintstart.sprintstartbackend.ingestion.service.ArtifactService +import io.mockk.coEvery +import io.mockk.coVerify +import org.hamcrest.Matchers.hasKey +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.http.HttpStatus +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.request +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.server.ResponseStatusException +import java.util.UUID + +/** + * Web-layer contract of `GET /projects/{projectId}/artifacts/ai-status`: role guard, id cap and + * the JSON shape the Knowledge Base chip reads. Service behaviour has its own test. + * [SecurityConfig] is imported so `@PreAuthorize` is live; without it the slice has no method + * security and the role guard would go untested. + */ +@WebMvcTest(controllers = [ArtifactController::class]) +@Import(SecurityConfig::class) +@AutoConfigureMockMvc +class ArtifactAiStatusControllerTest( + @Autowired private val mockMvc: MockMvc, +) { + @MockkBean + private lateinit var artifactAiStatusService: ArtifactAiStatusService + + @MockkBean + private lateinit var artifactQueryService: ArtifactQueryService + + @MockkBean + private lateinit var artifactService: ArtifactService + + private val projectId = UUID.randomUUID() + private val path = "/api/v1/projects/$projectId/artifacts/ai-status" + private val userJwt = jwt().jwt { it.subject("user-1") }.authorities(SimpleGrantedAuthority("ROLE_USER")) + + @Test + fun `returns aiAvailable and one item per visible id in the contract shape`() { + val indexed = UUID.randomUUID() + val unknown = UUID.randomUUID() + coEvery { artifactAiStatusService.getAiStatus("user-1", projectId, listOf(indexed, unknown)) } returns + ArtifactAiStatusResponse( + aiAvailable = true, + items = listOf( + ArtifactAiStatusItemResponse(indexed, ArtifactAiIndexStatus.INDEXED, "2026-09-20T10:00:00Z", 7), + ArtifactAiStatusItemResponse(unknown, ArtifactAiIndexStatus.UNKNOWN, null, null), + ), + ) + + performAsync(get("$path?ids=$indexed&ids=$unknown").with(userJwt)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.aiAvailable").value(true)) + .andExpect(jsonPath("$.items.length()").value(2)) + .andExpect(jsonPath("$.items[0].artifactId").value(indexed.toString())) + .andExpect(jsonPath("$.items[0].status").value("INDEXED")) + .andExpect(jsonPath("$.items[0].updatedAt").value("2026-09-20T10:00:00Z")) + .andExpect(jsonPath("$.items[0].chunkCount").value(7)) + .andExpect(jsonPath("$.items[1].status").value("UNKNOWN")) + .andExpect(jsonPath("$.items[1]", hasKey("updatedAt"))) + .andExpect(jsonPath("$.items[1].updatedAt").isEmpty) + .andExpect(jsonPath("$.items[1].chunkCount").isEmpty) + } + + @Test + fun `treats a missing ids parameter as an empty request`() { + coEvery { artifactAiStatusService.getAiStatus("user-1", projectId, emptyList()) } returns + ArtifactAiStatusResponse(aiAvailable = true, items = emptyList()) + + performAsync(get(path).with(userJwt)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.aiAvailable").value(true)) + .andExpect(jsonPath("$.items").isEmpty) + } + + @Test + fun `rejects more than 100 ids with 400 before asking the service`() { + val query = (1..101).joinToString("&") { "ids=${UUID.randomUUID()}" } + + performAsync(get("$path?$query").with(userJwt)) + .andExpect(status().isBadRequest) + + coVerify(exactly = 0) { artifactAiStatusService.getAiStatus(any(), any(), any()) } + } + + @Test + fun `accepts exactly 100 ids`() { + val ids = List(100) { UUID.randomUUID() } + coEvery { artifactAiStatusService.getAiStatus("user-1", projectId, ids) } returns + ArtifactAiStatusResponse(aiAvailable = false, items = emptyList()) + + performAsync(get("$path?${ids.joinToString("&") { "ids=$it" }}").with(userJwt)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.aiAvailable").value(false)) + } + + @Test + fun `passes the project access denial through as 403`() { + val id = UUID.randomUUID() + coEvery { artifactAiStatusService.getAiStatus("user-1", projectId, listOf(id)) } throws + ResponseStatusException(HttpStatus.FORBIDDEN, "No access to project with id $projectId") + + performAsync(get("$path?ids=$id").with(userJwt)) + .andExpect(status().isForbidden) + } + + @Test + fun `rejects a caller without the USER role`() { + // Method security on a suspend handler is evaluated inside the coroutine, hence async. + performAsync(get("$path?ids=${UUID.randomUUID()}").with(jwt().jwt { it.subject("user-1") })) + .andExpect(status().isForbidden) + + coVerify(exactly = 0) { artifactAiStatusService.getAiStatus(any(), any(), any()) } + } + + @Test + fun `rejects a malformed id with 400`() { + mockMvc + .perform(get("$path?ids=not-a-uuid").with(userJwt)) + .andExpect(status().isBadRequest) + } + + /** Suspend handlers answer through an async dispatch; this runs both legs. */ + private fun performAsync(builder: MockHttpServletRequestBuilder): ResultActions { + val started = mockMvc.perform(builder).andExpect(request().asyncStarted()).andReturn() + return mockMvc.perform(asyncDispatch(started)) + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactControllerTest.kt index fcf12a6a7..f0bce41f8 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/controller/ArtifactControllerTest.kt @@ -2,12 +2,18 @@ package com.sprintstart.sprintstartbackend.ingestion.controller import com.ninjasquad.springmockk.MockkBean import com.sprintstart.sprintstartbackend.ingestion.external.model.SourceSystem +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactFilterCriteria +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactSort +import com.sprintstart.sprintstartbackend.ingestion.model.dto.UploadFormat import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactContentRedirectResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactContentResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactFacetsResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactPageResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.FacetCountResponse import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.PageMetadata import com.sprintstart.sprintstartbackend.ingestion.model.entity.ArtifactType +import com.sprintstart.sprintstartbackend.ingestion.service.ArtifactAiStatusService import com.sprintstart.sprintstartbackend.ingestion.service.ArtifactQueryService import com.sprintstart.sprintstartbackend.ingestion.service.ArtifactService import io.mockk.every @@ -27,6 +33,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.Instant +import java.time.LocalDate import java.util.UUID @WebMvcTest(controllers = [ArtifactController::class]) @@ -40,6 +47,10 @@ class ArtifactControllerTest( @MockkBean private lateinit var artifactService: ArtifactService + // Only a constructor dependency here; the ai-status endpoint is covered by ArtifactAiStatusControllerTest. + @MockkBean + private lateinit var artifactAiStatusService: ArtifactAiStatusService + @Test fun `getAllArtifacts uses default pagination and empty filter`() { every { artifactQueryService.getAllArtifacts(1, 20, "") } returns response() @@ -126,6 +137,258 @@ class ArtifactControllerTest( } } + @Test + fun `getProjectArtifacts forwards criteria with repeatable params and pagination`() { + val projectId = UUID.randomUUID() + val criteria = ArtifactFilterCriteria( + search = "test", + types = setOf(ArtifactType.FILE, ArtifactType.ISSUE), + sources = setOf(SourceSystem.GITHUB), + repositories = setOf("owner/repo"), + format = UploadFormat.PDF, + ) + every { + artifactQueryService.getProjectArtifacts(1, 20, criteria, ArtifactSort.ADDED_DESC, projectId, "auth-user") + } returns response() + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts") + .param("page", "1") + .param("size", "20") + .param("search", "test") + .param("types", "FILE", "ISSUE") + .param("sources", "GITHUB") + .param("repositories", "owner/repo") + .param("format", "PDF") + .with( + jwt() + .jwt { it.subject("auth-user") } + .authorities(SimpleGrantedAuthority("ROLE_USER")), + ), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.items[0].title").value("README.md")) + + verify(exactly = 1) { + artifactQueryService.getProjectArtifacts(1, 20, criteria, ArtifactSort.ADDED_DESC, projectId, "auth-user") + } + } + + @Test + fun `getProjectArtifacts binds an explicit sort`() { + val projectId = UUID.randomUUID() + every { + artifactQueryService.getProjectArtifacts( + 1, + 20, + ArtifactFilterCriteria(), + ArtifactSort.CHANGED_DESC, + projectId, + "auth-user", + ) + } returns response() + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts") + .param("sort", "CHANGED_DESC") + .with(userJwt()), + ).andExpect(status().isOk) + + verify(exactly = 1) { + artifactQueryService.getProjectArtifacts( + 1, + 20, + ArtifactFilterCriteria(), + ArtifactSort.CHANGED_DESC, + projectId, + "auth-user", + ) + } + } + + @Test + fun `getProjectArtifacts rejects an unknown sort with 400`() { + val projectId = UUID.randomUUID() + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts") + .param("sort", "RANDOM") + .with(userJwt()), + ).andExpect(status().isBadRequest) + + verify(exactly = 0) { + artifactQueryService.getProjectArtifacts(any(), any(), any(), any(), any(), any()) + } + } + + @Test + fun `getProjectArtifacts binds from and to as ISO calendar days`() { + val projectId = UUID.randomUUID() + val criteria = ArtifactFilterCriteria(from = LocalDate.of(2026, 3, 1), to = LocalDate.of(2026, 3, 31)) + every { + artifactQueryService.getProjectArtifacts(1, 20, criteria, ArtifactSort.ADDED_DESC, projectId, "auth-user") + } returns response() + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts") + .param("from", "2026-03-01") + .param("to", "2026-03-31") + .with(userJwt()), + ).andExpect(status().isOk) + + verify(exactly = 1) { + artifactQueryService.getProjectArtifacts(1, 20, criteria, ArtifactSort.ADDED_DESC, projectId, "auth-user") + } + } + + @Test + fun `getProjectArtifacts binds repeated languages and returns each language`() { + val projectId = UUID.randomUUID() + val criteria = ArtifactFilterCriteria(languages = setOf("Kotlin", "yaml")) + every { + artifactQueryService.getProjectArtifacts(1, 20, criteria, ArtifactSort.ADDED_DESC, projectId, "auth-user") + } returns response() + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts") + .param("languages", "Kotlin", "yaml") + .with(userJwt()), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.items[0].language").value("Markdown")) + } + + @Test + fun `getProjectArtifacts rejects a malformed date with 400`() { + val projectId = UUID.randomUUID() + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts") + .param("from", "01.03.2026") + .with(userJwt()), + ).andExpect(status().isBadRequest) + + verify(exactly = 0) { + artifactQueryService.getProjectArtifacts(any(), any(), any(), any(), any(), any()) + } + } + + @Test + fun `getProjectArtifactFacets binds the same date window and languages as the list`() { + val projectId = UUID.randomUUID() + val day = LocalDate.of(2026, 3, 1) + val criteria = ArtifactFilterCriteria(from = day, to = day, languages = setOf("Kotlin")) + every { + artifactQueryService.getProjectArtifactFacets(projectId, criteria, "auth-user") + } returns facets() + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts/facets") + .param("from", "2026-03-01") + .param("to", "2026-03-01") + .param("languages", "Kotlin") + .with(userJwt()), + ).andExpect(status().isOk) + + verify(exactly = 1) { + artifactQueryService.getProjectArtifactFacets(projectId, criteria, "auth-user") + } + } + + @Test + fun `getProjectArtifactFacets returns facet counts and never binds to single artifact route`() { + val projectId = UUID.randomUUID() + val facets = ArtifactFacetsResponse( + types = listOf(FacetCountResponse("FILE", 10)), + sources = listOf(FacetCountResponse("GITHUB", 10)), + formats = listOf(FacetCountResponse("PDF", 2)), + repositories = listOf(FacetCountResponse("owner/repo", 8)), + languages = listOf(FacetCountResponse("Kotlin", 6)), + ) + every { + artifactQueryService.getProjectArtifactFacets(projectId, any(), "auth-user") + } returns facets + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts/facets") + .with( + jwt() + .jwt { it.subject("auth-user") } + .authorities(SimpleGrantedAuthority("ROLE_USER")), + ), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.types[0].value").value("FILE")) + .andExpect(jsonPath("$.types[0].count").value(10)) + .andExpect(jsonPath("$.sources[0].value").value("GITHUB")) + .andExpect(jsonPath("$.formats[0].value").value("PDF")) + .andExpect(jsonPath("$.repositories[0].value").value("owner/repo")) + .andExpect(jsonPath("$.languages[0].value").value("Kotlin")) + .andExpect(jsonPath("$.languages[0].count").value(6)) + + verify(exactly = 1) { + artifactQueryService.getProjectArtifactFacets(projectId, any(), "auth-user") + } + verify(exactly = 0) { + artifactQueryService.getArtifact(any(), any(), any()) + } + } + + @Test + fun `getArtifact returns single artifact when found`() { + val projectId = UUID.randomUUID() + val artifactId = UUID.randomUUID() + val artifactResponse = response().items.single().copy(id = artifactId) + every { + artifactQueryService.getArtifact(projectId, artifactId, "auth-user") + } returns artifactResponse + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts/$artifactId") + .with( + jwt() + .jwt { it.subject("auth-user") } + .authorities(SimpleGrantedAuthority("ROLE_USER")), + ), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(artifactId.toString())) + .andExpect(jsonPath("$.title").value("README.md")) + + verify(exactly = 1) { + artifactQueryService.getArtifact(projectId, artifactId, "auth-user") + } + } + + @Test + fun `getArtifact returns 404 when artifact not found in project`() { + val projectId = UUID.randomUUID() + val artifactId = UUID.randomUUID() + every { + artifactQueryService.getArtifact(projectId, artifactId, "auth-user") + } throws org.springframework.web.server + .ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND) + + mockMvc + .perform( + get("/api/v1/projects/$projectId/artifacts/$artifactId") + .with( + jwt() + .jwt { it.subject("auth-user") } + .authorities(SimpleGrantedAuthority("ROLE_USER")), + ), + ).andExpect(status().isNotFound) + + verify(exactly = 1) { + artifactQueryService.getArtifact(projectId, artifactId, "auth-user") + } + } + private fun response() = ArtifactPageResponse( items = listOf( ArtifactResponse( @@ -138,6 +401,7 @@ class ArtifactControllerTest( ingestedAt = Instant.parse("2026-01-02T03:04:05Z"), lastChangedAt = Instant.parse("2026-01-09T03:04:05Z"), metadata = """{"repositoryFullName":"owner/repo"}""", + language = "Markdown", ), ), page = PageMetadata( @@ -149,4 +413,16 @@ class ArtifactControllerTest( hasPrevious = false, ), ) + + private fun userJwt() = jwt() + .jwt { it.subject("auth-user") } + .authorities(SimpleGrantedAuthority("ROLE_USER")) + + private fun facets() = ArtifactFacetsResponse( + types = emptyList(), + sources = emptyList(), + formats = emptyList(), + repositories = emptyList(), + languages = emptyList(), + ) } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepositoryImplTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepositoryImplTest.kt new file mode 100644 index 000000000..7460a0ccd --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepositoryImplTest.kt @@ -0,0 +1,127 @@ +package com.sprintstart.sprintstartbackend.ingestion.repository + +import com.sprintstart.sprintstartbackend.ingestion.model.dto.UploadFormat +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.FacetCountResponse +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ArtifactFacetRepositoryImplTest { + @Test + fun `extractRepositoryFromSourceId extracts owner and repo correctly`() { + val standard = "github:SprintStartProject/sprintstart-backend:FILE:README.md" + assertThat(ArtifactFacetRepositoryImpl.extractRepositoryFromSourceId(standard)) + .isEqualTo("SprintStartProject/sprintstart-backend") + + val issue = "github:owner/repo:ISSUE:42" + assertThat(ArtifactFacetRepositoryImpl.extractRepositoryFromSourceId(issue)) + .isEqualTo("owner/repo") + + val invalid = "jira:INSTANCE:ISSUE:101" + assertThat(ArtifactFacetRepositoryImpl.extractRepositoryFromSourceId(invalid)).isNull() + + val empty = "" + assertThat(ArtifactFacetRepositoryImpl.extractRepositoryFromSourceId(empty)).isNull() + } + + @Test + fun `classifyUploadFormat correctly classifies PDF`() { + val pdfMime = ArtifactFacetRepositoryImpl.classifyUploadFormat( + title = "document", + sourceUrl = null, + sourceId = "uuid", + mime = "application/pdf", + language = null, + ) + assertThat(pdfMime).isEqualTo(UploadFormat.PDF) + + val pdfExt = ArtifactFacetRepositoryImpl.classifyUploadFormat( + title = "guide.pdf", + sourceUrl = null, + sourceId = "uuid", + mime = null, + language = null, + ) + assertThat(pdfExt).isEqualTo(UploadFormat.PDF) + } + + @Test + fun `classifyUploadFormat buckets a row with no mime and an unknown extension as OTHER`() { + // The facet counts come from this classifier while the filter runs as SQL. The predicate + // folds null columns to "" for exactly this row, so a plain .txt upload stays reachable + // through the OTHER filter instead of being counted but unfilterable. + val other = ArtifactFacetRepositoryImpl.classifyUploadFormat( + title = "notes.txt", + sourceUrl = null, + sourceId = "6f1e2f2c-0000-4000-8000-000000000000", + mime = null, + language = null, + ) + assertThat(other).isEqualTo(UploadFormat.OTHER) + } + + @Test + fun `classifyUploadFormat correctly classifies Markdown`() { + val mdLang = ArtifactFacetRepositoryImpl.classifyUploadFormat( + title = "README", + sourceUrl = null, + sourceId = "uuid", + mime = null, + language = "markdown", + ) + assertThat(mdLang).isEqualTo(UploadFormat.MARKDOWN) + + val mdExt = ArtifactFacetRepositoryImpl.classifyUploadFormat( + title = "notes.md", + sourceUrl = null, + sourceId = "uuid", + mime = "text/plain", + language = null, + ) + assertThat(mdExt).isEqualTo(UploadFormat.MARKDOWN) + } + + @Test + fun `classifyUploadFormat correctly classifies Images`() { + val imgMime = ArtifactFacetRepositoryImpl.classifyUploadFormat( + title = "diagram", + sourceUrl = null, + sourceId = "uuid", + mime = "image/png", + language = null, + ) + assertThat(imgMime).isEqualTo(UploadFormat.IMAGE) + + val imgExt = ArtifactFacetRepositoryImpl.classifyUploadFormat( + title = "architecture.svg", + sourceUrl = null, + sourceId = "uuid", + mime = null, + language = null, + ) + assertThat(imgExt).isEqualTo(UploadFormat.IMAGE) + } + + @Test + fun `classifyUploadFormat falls back to Other`() { + val other = ArtifactFacetRepositoryImpl.classifyUploadFormat( + title = "archive.zip", + sourceUrl = null, + sourceId = "uuid", + mime = "application/zip", + language = null, + ) + assertThat(other).isEqualTo(UploadFormat.OTHER) + } + + @Test + fun `languageFacetOptions keeps a selected document language with its real count`() { + val counted = listOf("Markdown" to 3L, "Kotlin" to 3L, "Plain Text" to 9L) + + val options = languageFacetOptions(counted, selected = listOf("markdown")) + + assertThat(options).containsExactly( + FacetCountResponse("Kotlin", 3), + FacetCountResponse("Markdown", 3), + ) + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepositoryQueryTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepositoryQueryTest.kt new file mode 100644 index 000000000..380b32bec --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactFacetRepositoryQueryTest.kt @@ -0,0 +1,315 @@ +package com.sprintstart.sprintstartbackend.ingestion.repository + +import com.sprintstart.sprintstartbackend.ingestion.external.model.SourceSystem +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactFilterCriteria +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactSort +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.FacetCountResponse +import com.sprintstart.sprintstartbackend.ingestion.model.entity.Artifact +import com.sprintstart.sprintstartbackend.ingestion.model.entity.ArtifactType +import com.sprintstart.sprintstartbackend.ingestion.model.entity.IngestionRun +import com.sprintstart.sprintstartbackend.ingestion.model.entity.IngestionRunStatus +import com.sprintstart.sprintstartbackend.shared.crypto.CryptoConfiguration +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.data.domain.PageRequest +import org.springframework.test.context.ActiveProfiles +import java.time.Instant +import java.time.LocalDate +import java.util.UUID + +/** + * Runs the criteria queries behind the project artifact list and its facets against H2. + * + * Ordering, date bounds and case folding are decided by the SQL the criteria API renders, which a + * mocked repository cannot see; these tests pin that SQL's behaviour instead of the builder calls. + */ +@ActiveProfiles("test") +@DataJpaTest +// A JPA slice loads no @Configuration of its own, but the entity graph reaches an +// AttributeConverter that needs the encryptor. +@Import(CryptoConfiguration::class) +class ArtifactFacetRepositoryQueryTest { + @Autowired + private lateinit var repository: ArtifactRepository + + @Autowired + private lateinit var entityManager: EntityManager + + private lateinit var run: IngestionRun + + private val projectId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + run = IngestionRun( + id = UUID.randomUUID(), + sourceSystem = SourceSystem.GITHUB, + status = IngestionRunStatus.COMPLETED, + ) + entityManager.persist(run) + } + + // ========================== sort ========================== + + @Test + fun `ADDED_DESC lists the newest import first and breaks ties by id`() { + val old = store(id = id(1), ingestedAt = BASE.minusSeconds(60)) + val newerSecond = store(id = id(3), ingestedAt = BASE) + val newerFirst = store(id = id(2), ingestedAt = BASE) + flush() + + assertThat(listIds(sort = ArtifactSort.ADDED_DESC)) + .containsExactly(newerFirst.id, newerSecond.id, old.id) + } + + @Test + fun `CHANGED_DESC orders by the latest change and falls back to the import time`() { + // Imported first but changed last: the change, not the import, decides its place. + val changedLate = store(id = id(1), ingestedAt = BASE, lastChangedAt = BASE.plusSeconds(500)) + val neverChanged = store(id = id(4), ingestedAt = BASE.plusSeconds(300)) + val neverChangedTie = store(id = id(2), ingestedAt = BASE.plusSeconds(300)) + val changedEarly = store(id = id(3), ingestedAt = BASE, lastChangedAt = BASE.plusSeconds(100)) + flush() + + assertThat(listIds(sort = ArtifactSort.CHANGED_DESC)) + .containsExactly(changedLate.id, neverChangedTie.id, neverChanged.id, changedEarly.id) + } + + @Test + fun `TITLE_ASC ignores case, puts untitled artifacts last and breaks ties by id`() { + val untitledSecond = store(id = id(6), title = null) + val beta = store(id = id(1), title = "beta") + val untitledFirst = store(id = id(2), title = null) + val lowerAlpha = store(id = id(5), title = "alpha") + val upperAlpha = store(id = id(3), title = "Alpha") + flush() + + // "Alpha" and "alpha" fold to one key, so only the id decides between them. + assertThat(listIds(sort = ArtifactSort.TITLE_ASC)).containsExactly( + upperAlpha.id, + lowerAlpha.id, + beta.id, + untitledFirst.id, + untitledSecond.id, + ) + } + + @Test + fun `the id tie-break keeps rows sharing a sort key stable across pages`() { + val first = store(id = id(1), title = null) + val second = store(id = id(2), title = null) + val third = store(id = id(3), title = null) + flush() + + val pages = (0..2).flatMap { page -> + list(sort = ArtifactSort.TITLE_ASC, page = page, size = 1).content.map { it.id } + } + + assertThat(pages).containsExactly(first.id, second.id, third.id) + } + + @Test + fun `sorting leaves the total count untouched`() { + repeat(3) { store() } + store(project = UUID.randomUUID()) + flush() + + ArtifactSort.entries.forEach { sort -> + assertThat(list(sort = sort).totalElements).isEqualTo(3) + } + } + + // ========================== date window ========================== + + @Test + fun `the date window includes both boundary days in full and nothing beyond`() { + val justBefore = store(ingestedAt = Instant.parse("2026-03-09T23:59:59.999999Z")) + val firstInstant = store(ingestedAt = Instant.parse("2026-03-10T00:00:00Z")) + val lastInstant = store(ingestedAt = Instant.parse("2026-03-12T23:59:59.999999Z")) + val justAfter = store(ingestedAt = Instant.parse("2026-03-13T00:00:00Z")) + flush() + + val window = ArtifactFilterCriteria(from = LocalDate.of(2026, 3, 10), to = LocalDate.of(2026, 3, 12)) + + assertThat(listIds(window)).containsExactlyInAnyOrder(firstInstant.id, lastInstant.id) + assertThat(listIds(window)).doesNotContain(justBefore.id, justAfter.id) + } + + @Test + fun `an open bound leaves that side of the window unrestricted`() { + val early = store(ingestedAt = Instant.parse("2020-01-01T00:00:00Z")) + val onDay = store(ingestedAt = Instant.parse("2026-03-10T08:00:00Z")) + val late = store(ingestedAt = Instant.parse("2030-01-01T00:00:00Z")) + flush() + val day = LocalDate.of(2026, 3, 10) + + assertThat(listIds(ArtifactFilterCriteria(from = day))).containsExactlyInAnyOrder(onDay.id, late.id) + assertThat(listIds(ArtifactFilterCriteria(to = day))).containsExactlyInAnyOrder(early.id, onDay.id) + } + + @Test + fun `the date window matches the last change, not the first import`() { + val window = ArtifactFilterCriteria(from = LocalDate.of(2026, 3, 10), to = LocalDate.of(2026, 3, 12)) + val oldButEditedInside = + store( + ingestedAt = Instant.parse("2025-01-01T00:00:00Z"), + lastChangedAt = Instant.parse("2026-03-11T10:00:00Z"), + ) + val importedInsideChangedAfter = + store( + ingestedAt = Instant.parse("2026-03-10T10:00:00Z"), + lastChangedAt = Instant.parse("2026-03-20T10:00:00Z"), + ) + val neverChangedInside = store(ingestedAt = Instant.parse("2026-03-12T10:00:00Z")) + flush() + + assertThat(listIds(window)).containsExactlyInAnyOrder(oldButEditedInside.id, neverChangedInside.id) + assertThat(listIds(window)).doesNotContain(importedInsideChangedAfter.id) + } + + @Test + fun `facets count under the same date window as the list`() { + store(ingestedAt = Instant.parse("2026-03-10T08:00:00Z")) + store(ingestedAt = Instant.parse("2026-03-10T09:00:00Z"), type = ArtifactType.ISSUE) + store(ingestedAt = Instant.parse("2026-03-11T08:00:00Z")) + flush() + val window = ArtifactFilterCriteria(from = LocalDate.of(2026, 3, 10), to = LocalDate.of(2026, 3, 10)) + + val facets = repository.findFacets(projectId, window) + + assertThat(facets.types.sumOf { it.count }).isEqualTo(list(window).totalElements).isEqualTo(2) + assertThat(facets.sources.sumOf { it.count }).isEqualTo(list(window).totalElements) + } + + // ========================== languages ========================== + + @Test + fun `the language filter ignores case and drops artifacts without a language`() { + val kotlin = store(language = "Kotlin") + store(language = "YAML") + store(language = null, type = ArtifactType.ISSUE) + flush() + + val found = list(ArtifactFilterCriteria(languages = setOf("KOTLIN"))) + + assertThat(found.content.map { it.id }).containsExactly(kotlin.id) + assertThat(found.content.single().language).isEqualTo("Kotlin") + } + + @Test + fun `the language facet skips its own filter but applies the others`() { + store(language = "Kotlin") + store(language = "Kotlin") + store(language = "YAML") + store(language = "Shell", type = ArtifactType.PULL_REQUEST) + flush() + val criteria = ArtifactFilterCriteria(types = setOf(ArtifactType.FILE), languages = setOf("YAML")) + + val languages = repository.findFacets(projectId, criteria).languages + + // Kotlin still counts although only YAML is selected; Shell is outside the FILE type. + assertThat(languages).containsExactly( + FacetCountResponse("Kotlin", 2), + FacetCountResponse("YAML", 1), + ) + } + + @Test + fun `the language facet hides documents and keeps a selected language without matches`() { + store(language = "Kotlin") + store(language = "Markdown") + store(language = "Plain Text") + store(language = null, type = ArtifactType.ISSUE) + flush() + + val languages = repository + .findFacets(projectId, ArtifactFilterCriteria(languages = setOf("Rust"))) + .languages + + assertThat(languages).containsExactly( + FacetCountResponse("Kotlin", 1), + FacetCountResponse("Rust", 0), + ) + } + + @Test + fun `spellings differing only in case count as one language, matching the list total`() { + store(language = "Kotlin") + store(language = "kotlin") + store(language = "YAML") + flush() + val criteria = ArtifactFilterCriteria(languages = setOf("KOTLIN")) + + val kotlin = repository + .findFacets(projectId, criteria) + .languages + .filter { it.value.equals("kotlin", ignoreCase = true) } + + assertThat(kotlin).hasSize(1) + assertThat(kotlin.single().count).isEqualTo(list(criteria).totalElements).isEqualTo(2) + } + + // ========================== helpers ========================== + + private fun list( + criteria: ArtifactFilterCriteria = ArtifactFilterCriteria(), + sort: ArtifactSort = ArtifactSort.ADDED_DESC, + page: Int = 0, + size: Int = 50, + ) = repository.findProjectArtifactsWithCriteria(projectId, criteria, sort, PageRequest.of(page, size)) + + private fun listIds( + criteria: ArtifactFilterCriteria = ArtifactFilterCriteria(), + sort: ArtifactSort = ArtifactSort.ADDED_DESC, + ): List = list(criteria, sort).content.map { it.id } + + /** Ids whose natural order is unambiguous on every database: `...0001` sorts before `...0002`. */ + private fun id(n: Long): UUID = UUID(0L, n) + + @Suppress("LongParameterList") + private fun store( + id: UUID = UUID.randomUUID(), + title: String? = "file-$id", + ingestedAt: Instant = BASE, + lastChangedAt: Instant? = null, + language: String? = null, + sourceSystem: SourceSystem = SourceSystem.GITHUB, + type: ArtifactType = ArtifactType.FILE, + project: UUID = projectId, + ): Artifact { + val artifact = Artifact( + id = id, + sourceSystem = sourceSystem, + sourceId = "github:acme/repo:$type:$id", + sourceUrl = "https://github.com/acme/repo", + artifactType = type, + title = title, + content = "content", + mime = null, + language = language, + createdAtSource = null, + updatedAtSource = null, + ingestedAt = ingestedAt, + lastChangedAt = lastChangedAt, + ingestionRun = run, + hash = null, + ) + artifact.addProjectId(project) + entityManager.persist(artifact) + return artifact + } + + private fun flush() { + entityManager.flush() + entityManager.clear() + } + + private companion object { + val BASE: Instant = Instant.parse("2026-03-10T12:00:00Z") + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactRepositoryIdsInProjectTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactRepositoryIdsInProjectTest.kt new file mode 100644 index 000000000..d0eb574d6 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/repository/ArtifactRepositoryIdsInProjectTest.kt @@ -0,0 +1,74 @@ +package com.sprintstart.sprintstartbackend.ingestion.repository + +import com.sprintstart.sprintstartbackend.ingestion.external.model.SourceSystem +import com.sprintstart.sprintstartbackend.ingestion.model.entity.Artifact +import com.sprintstart.sprintstartbackend.ingestion.model.entity.ArtifactType +import com.sprintstart.sprintstartbackend.ingestion.model.entity.IngestionRun +import com.sprintstart.sprintstartbackend.ingestion.model.entity.IngestionRunStatus +import com.sprintstart.sprintstartbackend.shared.crypto.CryptoConfiguration +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +/** + * The ai-status endpoint trusts this query to hide foreign artifacts, so it meets a real database: + * a mocked repository could not show that the project join actually filters. + */ +@ActiveProfiles("test") +@DataJpaTest +@Import(CryptoConfiguration::class) +class ArtifactRepositoryIdsInProjectTest { + @Autowired + private lateinit var repository: ArtifactRepository + + @Autowired + private lateinit var entityManager: EntityManager + + @Test + fun `returns only the requested ids that are linked to the project`() { + val projectId = UUID.randomUUID() + val otherProject = UUID.randomUUID() + val mine = store("mine").apply { addProjectId(projectId) } + val shared = store("shared").apply { addProjectIds(setOf(projectId, otherProject)) } + val foreign = store("foreign").apply { addProjectId(otherProject) } + store("unrequested").addProjectId(projectId) + entityManager.flush() + + val found = repository.findIdsInProject( + projectId, + listOf(mine.id, shared.id, foreign.id, UUID.randomUUID()), + ) + + assertThat(found).containsExactlyInAnyOrder(mine.id, shared.id) + } + + private fun store(name: String): Artifact { + val run = IngestionRun( + id = UUID.randomUUID(), + sourceSystem = SourceSystem.UPLOAD, + status = IngestionRunStatus.COMPLETED, + ) + entityManager.persist(run) + val artifact = Artifact( + sourceSystem = SourceSystem.UPLOAD, + sourceId = "upload:$name", + sourceUrl = null, + artifactType = ArtifactType.FILE, + title = name, + content = "content", + mime = null, + language = null, + createdAtSource = null, + updatedAtSource = null, + ingestionRun = run, + hash = null, + ) + entityManager.persist(artifact) + return artifact + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactAiStatusServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactAiStatusServiceTest.kt new file mode 100644 index 000000000..b4e728600 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactAiStatusServiceTest.kt @@ -0,0 +1,150 @@ +package com.sprintstart.sprintstartbackend.ingestion.service + +import com.sprintstart.sprintstartbackend.ingestion.ArtifactIngestionClient +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactAiIndexStatus +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactIngestStatusAiItem +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactIngestStatusAiResponse +import com.sprintstart.sprintstartbackend.ingestion.repository.ArtifactRepository +import com.sprintstart.sprintstartbackend.upload.model.exceptions.IngestionResponseException +import com.sprintstart.sprintstartbackend.user.external.UserApi +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.net.ConnectException +import java.net.http.HttpTimeoutException +import java.util.UUID +import kotlin.test.assertFailsWith + +class ArtifactAiStatusServiceTest { + private val artifactRepository = mockk() + private val artifactIngestionClient = mockk() + private val userApi = mockk() + private val service = ArtifactAiStatusService(artifactRepository, artifactIngestionClient, userApi) + + private val authId = "user-1" + private val projectId = UUID.randomUUID() + + init { + every { userApi.userHasAccessToProject(authId, projectId) } returns true + } + + @Test + fun `rejects a user without project access before any lookup`() = runTest { + every { userApi.userHasAccessToProject(authId, projectId) } returns false + + val error = assertFailsWith { + service.getAiStatus(authId, projectId, listOf(UUID.randomUUID())) + } + + assertThat(error.statusCode).isEqualTo(HttpStatus.FORBIDDEN) + verify(exactly = 0) { artifactRepository.findIdsInProject(any(), any()) } + coVerify(exactly = 0) { artifactIngestionClient.fetchIngestStatus(any()) } + } + + @Test + fun `answers an empty request as available and empty without any lookup`() = runTest { + val result = service.getAiStatus(authId, projectId, emptyList()) + + assertThat(result.aiAvailable).isTrue() + assertThat(result.items).isEmpty() + verify(exactly = 0) { artifactRepository.findIdsInProject(any(), any()) } + coVerify(exactly = 0) { artifactIngestionClient.fetchIngestStatus(any()) } + } + + @Test + fun `does not ask the AI when every requested id belongs to another project`() = runTest { + val foreign = UUID.randomUUID() + every { artifactRepository.findIdsInProject(projectId, listOf(foreign)) } returns emptySet() + + val result = service.getAiStatus(authId, projectId, listOf(foreign)) + + assertThat(result.aiAvailable).isTrue() + assertThat(result.items).isEmpty() + coVerify(exactly = 0) { artifactIngestionClient.fetchIngestStatus(any()) } + } + + @Test + fun `omits foreign ids, collapses duplicates and asks the AI only about visible ones`() = runTest { + val first = UUID.randomUUID() + val second = UUID.randomUUID() + val foreign = UUID.randomUUID() + every { artifactRepository.findIdsInProject(projectId, listOf(first, foreign, second)) } returns + setOf(second, first) + coEvery { artifactIngestionClient.fetchIngestStatus(listOf(first, second)) } returns + aiResponse(aiItem(first, "indexed"), aiItem(second, "processing")) + + val result = service.getAiStatus(authId, projectId, listOf(first, foreign, second, first)) + + assertThat(result.aiAvailable).isTrue() + assertThat(result.items.map { it.artifactId }).containsExactly(first, second) + assertThat(result.items.map { it.status }) + .containsExactly(ArtifactAiIndexStatus.INDEXED, ArtifactAiIndexStatus.PROCESSING) + } + + @Test + fun `reports aiAvailable false with UNKNOWN items when the AI cannot be asked`() = runTest { + val id = UUID.randomUUID() + every { artifactRepository.findIdsInProject(projectId, listOf(id)) } returns setOf(id) + val failures = listOf( + ConnectException("Connection refused"), + HttpTimeoutException("request timed out"), + IngestionResponseException("Failed to read ingest status (HTTP 503): down"), + SerializationException("Unexpected JSON token"), + ) + + failures.forEach { failure -> + coEvery { artifactIngestionClient.fetchIngestStatus(listOf(id)) } throws failure + + val result = service.getAiStatus(authId, projectId, listOf(id)) + + assertThat(result.aiAvailable).`as`(failure.javaClass.simpleName).isFalse() + val item = result.items.single() + assertThat(item.artifactId).isEqualTo(id) + assertThat(item.status).isEqualTo(ArtifactAiIndexStatus.UNKNOWN) + assertThat(item.updatedAt).isNull() + assertThat(item.chunkCount).isNull() + } + } + + @Test + fun `maps AI statuses and degrades unknown, unrecognised and missing ones to UNKNOWN`() = runTest { + val ids = List(7) { UUID.randomUUID() } + every { artifactRepository.findIdsInProject(projectId, ids) } returns ids.toSet() + val aiStatuses = listOf("indexed", "PROCESSING", "failed", "deindexed", "unknown", "reindexing") + coEvery { artifactIngestionClient.fetchIngestStatus(ids) } returns + aiResponse(*ids.zip(aiStatuses).map { (id, status) -> aiItem(id, status) }.toTypedArray()) + + val result = service.getAiStatus(authId, projectId, ids) + + assertThat(result.aiAvailable).isTrue() + assertThat(result.items.map { it.status }).containsExactly( + ArtifactAiIndexStatus.INDEXED, + ArtifactAiIndexStatus.PROCESSING, + ArtifactAiIndexStatus.FAILED, + ArtifactAiIndexStatus.DEINDEXED, + ArtifactAiIndexStatus.UNKNOWN, + ArtifactAiIndexStatus.UNKNOWN, + ArtifactAiIndexStatus.UNKNOWN, + ) + assertThat(result.items[0].updatedAt).isEqualTo("2026-09-20T10:00:00+00:00") + assertThat(result.items[0].chunkCount).isEqualTo(4) + assertThat(result.items.drop(4).map { it.updatedAt to it.chunkCount }).containsOnly(null to null) + } + + private fun aiItem(id: UUID, status: String) = ArtifactIngestStatusAiItem( + artifactId = id.toString(), + status = status, + updatedAt = "2026-09-20T10:00:00+00:00", + chunkCount = 4, + ) + + private fun aiResponse(vararg items: ArtifactIngestStatusAiItem) = ArtifactIngestStatusAiResponse(items.toList()) +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactQueryServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactQueryServiceTest.kt index 6dcb527e6..f80683a57 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactQueryServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/ingestion/service/ArtifactQueryServiceTest.kt @@ -1,6 +1,11 @@ package com.sprintstart.sprintstartbackend.ingestion.service import com.sprintstart.sprintstartbackend.ingestion.external.model.SourceSystem +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactFilterCriteria +import com.sprintstart.sprintstartbackend.ingestion.model.dto.ArtifactSort +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactFacetsResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactResponse +import com.sprintstart.sprintstartbackend.ingestion.model.dto.response.FacetCountResponse import com.sprintstart.sprintstartbackend.ingestion.model.entity.Artifact import com.sprintstart.sprintstartbackend.ingestion.model.entity.ArtifactType import com.sprintstart.sprintstartbackend.ingestion.model.entity.IngestionRun @@ -14,10 +19,14 @@ import io.mockk.slot import io.mockk.verify import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException import java.time.Instant +import java.time.LocalDate import java.util.UUID class ArtifactQueryServiceTest { @@ -77,6 +86,143 @@ class ArtifactQueryServiceTest { verify(exactly = 0) { artifactRepository.findAll(any()) } } + @Test + fun `getProjectArtifacts forwards criteria and enforces access`() { + val projectId = UUID.randomUUID() + val authId = "auth-1" + val criteria = com.sprintstart.sprintstartbackend.ingestion.model.dto + .ArtifactFilterCriteria(search = "doc") + val pageable = slot() + val responseItem = com.sprintstart.sprintstartbackend.ingestion.model.dto.response.ArtifactResponse( + id = UUID.randomUUID(), + title = "doc.md", + sourceSystem = SourceSystem.GITHUB, + sourceId = "github:owner/repo:FILE:doc.md", + sourceUrl = null, + artifactType = ArtifactType.FILE, + ingestedAt = Instant.now(), + lastChangedAt = null, + metadata = "{}", + ) + every { userApi.userHasAccessToProject(authId, projectId) } returns true + every { + artifactRepository.findProjectArtifactsWithCriteria( + projectId, + criteria, + ArtifactSort.TITLE_ASC, + capture(pageable), + ) + } returns PageImpl(listOf(responseItem), PageRequest.of(0, 20), 1) + + val result = service.getProjectArtifacts(1, 20, criteria, ArtifactSort.TITLE_ASC, projectId, authId) + + assertThat(result.items).hasSize(1) + assertThat(result.items.single().title).isEqualTo("doc.md") + // The repository owns ORDER BY, so the Pageable carries only the page window. + assertThat(pageable.captured.pageNumber).isEqualTo(0) + assertThat(pageable.captured.pageSize).isEqualTo(20) + assertThat(pageable.captured.sort.isUnsorted).isTrue() + } + + @Test + fun `getProjectArtifactFacets returns facets from repository`() { + val projectId = UUID.randomUUID() + val authId = "auth-1" + val criteria = ArtifactFilterCriteria() + val facets = ArtifactFacetsResponse( + types = listOf(FacetCountResponse("FILE", 5)), + sources = listOf(FacetCountResponse("GITHUB", 5)), + formats = emptyList(), + repositories = listOf(FacetCountResponse("owner/repo", 5)), + languages = emptyList(), + ) + every { userApi.userHasAccessToProject(authId, projectId) } returns true + every { artifactRepository.findFacets(projectId, criteria) } returns facets + + val result = service.getProjectArtifactFacets(projectId, criteria, authId) + + assertThat(result.types.single().value).isEqualTo("FILE") + assertThat(result.repositories.single().value).isEqualTo("owner/repo") + } + + @Test + fun `getProjectArtifacts rejects from after to with 400 before any lookup`() { + val criteria = ArtifactFilterCriteria(from = LocalDate.of(2026, 3, 2), to = LocalDate.of(2026, 3, 1)) + + val error = assertThrows { + service.getProjectArtifacts(1, 20, criteria, ArtifactSort.ADDED_DESC, UUID.randomUUID(), "auth-1") + } + + assertThat(error.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + verify(exactly = 0) { userApi.userHasAccessToProject(any(), any()) } + verify(exactly = 0) { artifactRepository.findProjectArtifactsWithCriteria(any(), any(), any(), any()) } + } + + @Test + fun `getProjectArtifactFacets rejects from after to with 400 like the list`() { + val criteria = ArtifactFilterCriteria(from = LocalDate.of(2026, 3, 2), to = LocalDate.of(2026, 3, 1)) + + val error = assertThrows { + service.getProjectArtifactFacets(UUID.randomUUID(), criteria, "auth-1") + } + + assertThat(error.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + verify(exactly = 0) { artifactRepository.findFacets(any(), any()) } + } + + @Test + fun `getArtifact returns the projected artifact when found`() { + val projectId = UUID.randomUUID() + val artifactId = UUID.randomUUID() + val authId = "auth-1" + every { userApi.userHasAccessToProject(authId, projectId) } returns true + every { + artifactRepository.findProjectArtifactById(projectId, artifactId) + } returns ArtifactResponse( + id = artifactId, + title = "README.md", + sourceSystem = SourceSystem.GITHUB, + sourceId = "github:owner/repo:FILE:README.md", + sourceUrl = "https://github.com/owner/repo/blob/main/README.md", + artifactType = ArtifactType.FILE, + ingestedAt = Instant.parse("2026-06-19T09:16:30Z"), + lastChangedAt = null, + metadata = """{"repositoryFullName":"owner/repo"}""", + ) + + val result = service.getArtifact(projectId, artifactId, authId) + + assertThat(result.id).isEqualTo(artifactId) + assertThat(result.title).isEqualTo("README.md") + } + + @Test + fun `a one-day window where from equals to is accepted`() { + val projectId = UUID.randomUUID() + val day = LocalDate.of(2026, 3, 1) + val criteria = ArtifactFilterCriteria(from = day, to = day) + every { userApi.userHasAccessToProject("auth-1", projectId) } returns true + every { artifactRepository.findFacets(projectId, criteria) } returns + ArtifactFacetsResponse(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) + + service.getProjectArtifactFacets(projectId, criteria, "auth-1") + + verify(exactly = 1) { artifactRepository.findFacets(projectId, criteria) } + } + + @Test + fun `getArtifact throws 404 when not found in project`() { + val projectId = UUID.randomUUID() + val artifactId = UUID.randomUUID() + val authId = "auth-1" + every { userApi.userHasAccessToProject(authId, projectId) } returns true + every { artifactRepository.findProjectArtifactById(projectId, artifactId) } returns null + + org.junit.jupiter.api.assertThrows { + service.getArtifact(projectId, artifactId, authId) + } + } + private fun artifact() = Artifact( id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), sourceSystem = SourceSystem.GITHUB, diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/shared/web/RequestBuilderTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/shared/web/RequestBuilderTest.kt index 21ebaf67c..2b0c25702 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/shared/web/RequestBuilderTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/shared/web/RequestBuilderTest.kt @@ -8,6 +8,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.net.http.HttpClient +import java.time.Duration import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -139,4 +140,39 @@ class RequestBuilderTest { assertEquals("application/json", recorded.getHeader("Content-Type")) assertEquals("""{"key":"value"}""", recorded.body.readUtf8()) } + + // ── Timeout ─────────────────────────────────────────────────────────────── + + @Test + fun `no request timeout is set unless asked for`() { + val request = webClient.get().uri("https://ai.test/x").buildHttpRequest() + + assertEquals(false, request.timeout().isPresent) + } + + @Test + fun `sync(timeout) applies the timeout to the built request`() { + val execution = webClient + .get() + .uri("https://ai.test/x") + .header("X-Trace", "1") + .sync(timeout = Duration.ofSeconds(2)) + + val request = execution.builder + .buildHttpRequest() + + assertEquals(Duration.ofSeconds(2), request.timeout().get()) + } + + @Test + fun `sync() without a timeout leaves the request unbounded`() { + val request = webClient + .get() + .uri("https://ai.test/x") + .sync() + .builder + .buildHttpRequest() + + assertEquals(false, request.timeout().isPresent) + } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/upload/controller/UploadControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/upload/controller/UploadControllerTest.kt index 6454a2060..2c5129594 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/upload/controller/UploadControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/upload/controller/UploadControllerTest.kt @@ -2,6 +2,8 @@ package com.sprintstart.sprintstartbackend.upload.controller import com.ninjasquad.springmockk.MockkBean import com.sprintstart.sprintstartbackend.config.SecurityConfig +import com.sprintstart.sprintstartbackend.upload.model.dto.response.DeleteUploadFailure +import com.sprintstart.sprintstartbackend.upload.model.dto.response.DeleteUploadsResponse import com.sprintstart.sprintstartbackend.upload.model.dto.response.UploadArtifactResponse import com.sprintstart.sprintstartbackend.upload.model.dto.response.UploadListItemResponse import com.sprintstart.sprintstartbackend.upload.service.UploadService @@ -229,33 +231,42 @@ class UploadControllerTest { // ========================== deleteUpload ========================== @Test - fun `deleteUpload returns 204 for PM`() { + fun `deleteUpload returns 200 with deleted ids and failures for PM`() { + val missingId = UUID.randomUUID() every { uploadService.deleteUpload(authId, setOf(artifactId), projectId) - } returns Unit + } returns DeleteUploadsResponse( + deletedIds = listOf(artifactId), + failed = listOf(DeleteUploadFailure(missingId, "Artifact with id $missingId not found.")), + ) mockMvc .perform( multipart(HttpMethod.DELETE, "/api/v1/uploads") .file(deleteRequest) .with(pmJwt), - ).andExpect(status().isNoContent) + ).andExpect(status().isOk) + .andExpect(jsonPath("$.deletedIds[0]").value(artifactId.toString())) + .andExpect(jsonPath("$.failed[0].artifactId").value(missingId.toString())) + .andExpect(jsonPath("$.failed[0].error").value("Artifact with id $missingId not found.")) verify { uploadService.deleteUpload(authId, setOf(artifactId), projectId) } } @Test - fun `deleteUpload returns 204 for admin`() { + fun `deleteUpload returns 200 for admin`() { every { uploadService.deleteUpload(authId, setOf(artifactId), projectId) - } returns Unit + } returns DeleteUploadsResponse(deletedIds = listOf(artifactId), failed = emptyList()) mockMvc .perform( multipart(HttpMethod.DELETE, "/api/v1/uploads") .file(deleteRequest) .with(adminJwt), - ).andExpect(status().isNoContent) + ).andExpect(status().isOk) + .andExpect(jsonPath("$.deletedIds[0]").value(artifactId.toString())) + .andExpect(jsonPath("$.failed").isEmpty) verify { uploadService.deleteUpload(authId, setOf(artifactId), projectId) } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/upload/service/UploadServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/upload/service/UploadServiceTest.kt index 3e5c5e329..1b5664961 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/upload/service/UploadServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/upload/service/UploadServiceTest.kt @@ -1,6 +1,9 @@ package com.sprintstart.sprintstartbackend.upload.service +import com.sprintstart.sprintstartbackend.upload.external.events.ingestion.UploadBatchDeletionFinishedEvent import com.sprintstart.sprintstartbackend.upload.external.events.ingestion.UploadStartedEvent +import com.sprintstart.sprintstartbackend.upload.model.dto.response.DeleteUploadFailure +import com.sprintstart.sprintstartbackend.upload.model.dto.response.DeleteUploadsResponse import com.sprintstart.sprintstartbackend.upload.model.entity.UploadedArtifact import com.sprintstart.sprintstartbackend.upload.repository.LinkedImageRepository import com.sprintstart.sprintstartbackend.upload.repository.UploadedArtifactRepository @@ -146,8 +149,9 @@ class UploadServiceTest { every { storageService.delete("uploads/guide.md") } returns Unit every { uploadedArtifactRepository.delete(artifact) } returns Unit - service.deleteUpload(authId, setOf(artifactId), projectId) + val result = service.deleteUpload(authId, setOf(artifactId), projectId) + assertEquals(DeleteUploadsResponse(deletedIds = listOf(artifactId), failed = emptyList()), result) verify(exactly = 1) { uploadedArtifactRepository.findByIdAndProjectId(artifactId, projectId) } verify(exactly = 1) { storageService.delete("uploads/guide.md") } verify(exactly = 1) { uploadedArtifactRepository.delete(artifact) } @@ -159,21 +163,55 @@ class UploadServiceTest { every { userApi.userHasAccessToProject(authId, projectId) } returns true every { uploadedArtifactRepository.findByIdAndProjectId(artifactId, projectId) } returns null - service.deleteUpload(authId, setOf(artifactId), projectId) + val result = service.deleteUpload(authId, setOf(artifactId), projectId) + assertEquals(emptyList(), result.deletedIds) + assertEquals( + listOf(DeleteUploadFailure(artifactId, "Artifact with id $artifactId not found.")), + result.failed, + ) verify(exactly = 1) { uploadedArtifactRepository.findByIdAndProjectId(artifactId, projectId) } verify(exactly = 0) { storageService.delete(any()) } verify(exactly = 0) { uploadedArtifactRepository.delete(any()) } } - private fun artifact(): UploadedArtifact = + @Test + fun `deleteUpload reports a storage failure with the generic reason and keeps the raw one in the event`() { + val brokenId = UUID.randomUUID() + val broken = artifact(id = brokenId, storagePath = "uploads/broken.md") + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { userApi.userHasAccessToProject(authId, projectId) } returns true + every { uploadedArtifactRepository.findByIdAndProjectId(brokenId, projectId) } returns broken + every { uploadedArtifactRepository.findByIdAndProjectId(artifactId, projectId) } returns artifact() + every { storageService.delete("uploads/broken.md") } throws IllegalStateException("disk gone: /srv/uploads") + every { storageService.delete("uploads/guide.md") } returns Unit + every { uploadedArtifactRepository.delete(any()) } returns Unit + + val result = service.deleteUpload(authId, linkedSetOf(brokenId, artifactId), projectId) + + assertEquals(listOf(artifactId), result.deletedIds) + assertEquals(listOf(DeleteUploadFailure(brokenId, "Artifact could not be deleted.")), result.failed) + verify(exactly = 1) { + publisher.publishEvent( + match { event -> + event.deleteArtifactOutcomes.map { it.id to it.error } == + listOf(brokenId to "disk gone: /srv/uploads") + }, + ) + } + } + + private fun artifact( + id: UUID = artifactId, + storagePath: String = "uploads/guide.md", + ): UploadedArtifact = UploadedArtifact( - id = artifactId, + id = id, filename = "guide.md", hash = "hash", uploadedAt = Instant.now(), mime = "text/markdown", - storagePath = "uploads/guide.md", + storagePath = storagePath, uploaderId = userId, projectId = projectId, )