diff --git a/.github/workflows/backend-fast.yml b/.github/workflows/backend-fast.yml index c86f6dcc3..6540beb16 100644 --- a/.github/workflows/backend-fast.yml +++ b/.github/workflows/backend-fast.yml @@ -87,15 +87,15 @@ jobs: - name: Install dependencies run: | - mvn clean install -DskipTests --pl ${{ matrix.module }} --am -Psql + mvn clean install -DskipTests --pl ${{ matrix.module }} --am - name: Run tests for module ${{ matrix.module }} run: | - mvn $MAVEN_CLI_OPTS test --pl ${{ matrix.module }} -Dspring.profiles.active=test,gix,sql,postgres -Psql + mvn $MAVEN_CLI_OPTS test --pl ${{ matrix.module }} - name: Surefire report for module ${{ matrix.module }} if: always() - run: mvn $MAVEN_CLI_OPTS surefire-report:report-only --pl ${{ matrix.module }} -am -Dspring.profiles.active=test,gix,sql,postgres -Psql + run: mvn $MAVEN_CLI_OPTS surefire-report:report-only --pl ${{ matrix.module }} -am - name: Upload surefire report if: always() @@ -146,18 +146,18 @@ jobs: - name: Install dependencies run: | - mvn clean install -DskipTests --pl ${{ matrix.module }} --am -Parangodb + mvn clean install -DskipTests --pl ${{ matrix.module }} --am - name: Run tests for module ${{ matrix.module }} run: | ARANGODB_HOST=localhost \ - mvn $MAVEN_CLI_OPTS test --pl ${{ matrix.module }} -Dspring.profiles.active=test,nosql,arangodb -Parangodb + mvn $MAVEN_CLI_OPTS test --pl ${{ matrix.module }} - name: Surefire report for module ${{ matrix.module }} if: always() run: | ARANGODB_HOST=localhost \ - mvn $MAVEN_CLI_OPTS surefire-report:report-only --pl ${{ matrix.module }} -am -Dspring.profiles.active=test,nosql,arangodb -Parangodb + mvn $MAVEN_CLI_OPTS surefire-report:report-only --pl ${{ matrix.module }} -am - name: Upload surefire report if: always() diff --git a/binocular-backend-new/README.md b/binocular-backend-new/README.md new file mode 100644 index 000000000..11baff650 --- /dev/null +++ b/binocular-backend-new/README.md @@ -0,0 +1,6 @@ +# Binocular Backend + +## Requirements +- Docker running in the background, otherwise tests will fail (tests in `infrastructure-sql/arangodb/tests` use TestContainers internally) + +© INSO/BUSY 09/2025 diff --git a/binocular-backend-new/cli/pom.xml b/binocular-backend-new/cli/pom.xml index 6fcaef39f..c0bbc2d83 100644 --- a/binocular-backend-new/cli/pom.xml +++ b/binocular-backend-new/cli/pom.xml @@ -62,6 +62,13 @@ domain ${project.version} + + com.inso-world.binocular + domain + ${project.version} + test + tests + com.inso-world.binocular ffi @@ -80,6 +87,11 @@ test + + org.springframework.boot + spring-boot-configuration-processor + true + org.springframework.shell spring-shell-starter @@ -123,7 +135,8 @@ org.testcontainers - postgresql + testcontainers-postgresql + test @@ -163,6 +176,25 @@ all-open + + + kapt + + kapt + + + + ${project.basedir}/src/main/kotlin + + + + org.springframework.boot + spring-boot-configuration-processor + + + + + org.jetbrains.kotlin diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/commands/Index.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/commands/Index.kt index 6f67feefc..d32c2f2aa 100644 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/commands/Index.kt +++ b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/commands/Index.kt @@ -3,6 +3,8 @@ package com.inso_world.binocular.cli.commands import com.inso_world.binocular.cli.service.ProjectService import com.inso_world.binocular.cli.service.RepositoryService import com.inso_world.binocular.cli.service.VcsService +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -24,27 +26,23 @@ open class Index( private var logger: Logger = LoggerFactory.getLogger(Index::class.java) } - @Command(command = ["hello"], description = "Hello World") - fun helloWorld() { -// BinocularFfi().hello() - } @Command(command = ["commits"]) open fun commits( - @Option(longNames = ["repo_path"], required = false) repoPath: String?, + repoPath: String, @Option( longNames = ["branch"], shortNames = ['b'], required = true, - ) branchName: String, + ) @NotNull @NotEmpty branchName: String, @Option( longNames = ["project_name"], shortNames = ['n'], required = true, description = "Custom name of the project.", - ) projectName: String, + ) @NotNull @NotEmpty projectName: String, ) { - val path = repoPath?.let { Paths.get(it).toRealPath() } + val path = repoPath.let { Paths.get(it).toRealPath() } logger.trace(">>> index($path, $branchName)") logger.debug("Project '$projectName'") val project = this.projectService.getOrCreateProject(projectName) diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/commands/WebServer.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/commands/WebServer.kt deleted file mode 100644 index ef82c74e0..000000000 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/commands/WebServer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.inso_world.binocular.cli - -import org.jline.utils.AttributedString -import org.jline.utils.AttributedStyle -import org.springframework.context.annotation.Bean -import org.springframework.shell.command.annotation.Command -import org.springframework.shell.jline.PromptProvider - - -@Command(command = ["server"]) -class WebServer { - - @Command(command = ["start"]) - fun start(): String { -// Thread { -// SpringApplicationBuilder(BinocularWebApplication::class.java) -// .web(WebApplicationType.SERVLET) // Enable the web environment -// .run() -// }.start() - return "Web server started." - } -} diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/config/AppConfig.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/config/AppConfig.kt index e03eb26c5..8a8db1778 100644 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/config/AppConfig.kt +++ b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/config/AppConfig.kt @@ -1,19 +1,21 @@ package com.inso_world.binocular.cli.config -import org.springframework.boot.context.properties.ConfigurationProperties +import com.inso_world.binocular.core.BinocularConfig import org.springframework.context.annotation.Configuration @Configuration -@ConfigurationProperties(prefix = "binocular") -class BinocularCliConfiguration { - lateinit var index: IndexConfig - lateinit var archive: ArchiveConfig +internal class BinocularConfiguration : BinocularConfig() { + lateinit var cli: CliConfig } -class IndexConfig( - val path: String, +class CliConfig( + val index: IndexConfig +) + +class ScmConfig( + val path: String ) -class ArchiveConfig( - val path: String? = null, +class IndexConfig( + val scm: ScmConfig ) diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/Issue.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/Issue.kt deleted file mode 100644 index f4a279fc2..000000000 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/Issue.kt +++ /dev/null @@ -1,80 +0,0 @@ -// package com.inso_world.binocular.cli.entity -// -// import com.inso_world.binocular.cli.archive.IssuePojo -// import jakarta.persistence.CascadeType -// import jakarta.persistence.Column -// import jakarta.persistence.Entity -// import jakarta.persistence.FetchType -// import jakarta.persistence.GeneratedValue -// import jakarta.persistence.GenerationType -// import jakarta.persistence.Id -// import jakarta.persistence.JoinColumn -// import jakarta.persistence.Lob -// import jakarta.persistence.ManyToOne -// import jakarta.persistence.OneToMany -// import jakarta.persistence.Table -// import jakarta.persistence.UniqueConstraint -// import java.time.LocalDateTime -// import kotlin.io.encoding.ExperimentalEncodingApi -// -// @Entity -// @Table(name = "issues", uniqueConstraints = [UniqueConstraint(columnNames = arrayOf("iid", "fk_project_id"))]) -// data class Issue( -// @Id -// @GeneratedValue(strategy = GenerationType.IDENTITY) -// val id: Long? = null, -// @Column(nullable = true) -// val gitlabId: Long? = null, -// @Column(nullable = false) -// val title: String, -// @ManyToOne(fetch = FetchType.LAZY, optional = true) -// // TODO check why user 1771 is not in the data -// @JoinColumn(name = "fk_author_project_member_id", referencedColumnName = "id") -// var author: ProjectMember? = null, -// @ManyToOne(fetch = FetchType.LAZY, optional = false) -// @JoinColumn(name = "fk_project_id", referencedColumnName = "id") -// var project: Project? = null, -// @Column(name = "created_at", nullable = false) -// val createdAt: LocalDateTime, -// @Column(name = "updated_at", nullable = false) -// val updatedAt: LocalDateTime, -// @Column(nullable = true, columnDefinition = "TEXT") -// @Lob -// val description: String? = null, -// @Column(nullable = false) -// val iid: Int, -// // @Column(name = "updated_by_id") -// // val updatedById: Long? = null, -// // @Column(nullable = false) -// // val confidential: Boolean = false, -// @Column(name = "due_date") -// val dueDate: LocalDateTime? = null, -// @Column(name = "time_estimate") -// val timeEstimate: Int? = null, -// @Column(name = "closed_at") -// val closedAt: LocalDateTime? = null, -// @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "issue", orphanRemoval = true) -// val timelogs: MutableSet = emptySet().toMutableSet(), -// ) { -// fun addTimelog(tl: TimeLog) { -// timelogs.add(tl) -// tl.issue = this -// } -// -// override fun toString(): String = -// "Issue(id=$id, title='$title', createdAt=$createdAt, updatedAt=$updatedAt, description=$description, iid=$iid, dueDate=$dueDate, timeEstimate=$timeEstimate, closedAt=$closedAt)" -// } -// -// @OptIn(ExperimentalEncodingApi::class) -// fun IssuePojo.toEntity(): Issue = -// Issue( -// gitlabId = this.id, -// title = this.title, -// createdAt = this.createdAt, -// updatedAt = this.updatedAt, -// description = this.description, -// iid = this.iid, -// dueDate = this.dueDate, -// timeEstimate = this.timeEstimate, -// closedAt = this.closedAt, -// ) diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/MergeRequest.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/MergeRequest.kt deleted file mode 100644 index 77959d51c..000000000 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/MergeRequest.kt +++ /dev/null @@ -1,79 +0,0 @@ -// package com.inso_world.binocular.cli.entity -// -// import com.inso_world.binocular.cli.archive.MergeRequestPojo -// import jakarta.persistence.CascadeType -// import jakarta.persistence.Column -// import jakarta.persistence.Entity -// import jakarta.persistence.FetchType -// import jakarta.persistence.GeneratedValue -// import jakarta.persistence.GenerationType -// import jakarta.persistence.Id -// import jakarta.persistence.JoinColumn -// import jakarta.persistence.Lob -// import jakarta.persistence.ManyToOne -// import jakarta.persistence.OneToMany -// import jakarta.persistence.Table -// import jakarta.persistence.UniqueConstraint -// import java.time.LocalDateTime -// -// @Entity -// @Table(name = "merge_requests", uniqueConstraints = [UniqueConstraint(columnNames = ["iid", "fk_project_id"])]) -// data class MergeRequest( -// @Id -// @GeneratedValue(strategy = GenerationType.IDENTITY) -// val id: Long? = null, -// @Column(nullable = false) -// val targetBranchName: String, -// @ManyToOne(fetch = FetchType.LAZY) -// var targetBranch: Branch? = null, -// @Column(nullable = false) -// val sourceBranchName: String, -// @ManyToOne(fetch = FetchType.LAZY) -// var sourceBranch: Branch? = null, -// @Column(name = "created_at", nullable = false) -// val createdAt: LocalDateTime, -// @Column(name = "updated_at", nullable = false) -// val updatedAt: LocalDateTime, -// @Column(nullable = false) -// val title: String, -// @Column(nullable = false) -// val iid: Int, -// @Lob -// @Column(nullable = false, columnDefinition = "TEXT") -// val description: String, -// @ManyToOne(fetch = FetchType.LAZY, optional = false) -// @JoinColumn(name = "fk_project_id", referencedColumnName = "id") -// var project: Project? = null, -// @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "mergeRequest", orphanRemoval = true) -// val timelogs: MutableSet = emptySet().toMutableSet(), -// ) { -// fun addTimelog(tl: TimeLog) { -// timelogs.add(tl) -// tl.mergeRequest = this -// } -// -// fun addSourceBranch(branch: Branch) { -// branch.mergeRequests.add(this) -// this.sourceBranch = branch -// } -// -// fun addTargetBranch(branch: Branch) { -// branch.mergeRequests.add(this) -// this.targetBranch = branch -// } -// -// override fun toString(): String = -// "MergeRequest(id=$id, targetBranch='$targetBranch', sourceBranch='$sourceBranch', createdAt=$createdAt, updatedAt=$updatedAt, title='$title', iid=$iid, description='$description')" -// } -// -// fun MergeRequestPojo.toEntity(): MergeRequest = -// MergeRequest( -// // id = this.id, -// targetBranchName = this.targetBranch, -// sourceBranchName = this.sourceBranch, -// createdAt = this.createdAt, -// updatedAt = this.updatedAt, -// title = this.title, -// description = this.description, -// iid = this.iid, -// ) diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/ProjectFeature.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/ProjectFeature.kt deleted file mode 100644 index 53b17b9fd..000000000 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/ProjectFeature.kt +++ /dev/null @@ -1,33 +0,0 @@ -// package com.inso_world.binocular.cli.entity -// -// import com.inso_world.binocular.cli.archive.ProjectFeaturePojo -// import jakarta.persistence.Column -// import jakarta.persistence.Entity -// import jakarta.persistence.FetchType -// import jakarta.persistence.Id -// import jakarta.persistence.JoinColumn -// import jakarta.persistence.OneToOne -// import jakarta.persistence.Table -// import java.time.LocalDateTime -// -// @Entity -// @Table(name = "project_features") -// data class ProjectFeature( -// @Id -// val id: Long? = null, -// @OneToOne(fetch = FetchType.LAZY, optional = false) -// @JoinColumn(name = "fk_project", referencedColumnName = "id") -// val project: Project, -// @Column(name = "created_at", nullable = false) -// val createdAt: LocalDateTime, -// @Column(name = "updated_at", nullable = false) -// val updatedAt: LocalDateTime, -// ) -// -// fun ProjectFeaturePojo.toEntity(project: Project): ProjectFeature = -// ProjectFeature( -// id = this.id, -// createdAt = this.createdAt, -// updatedAt = this.updatedAt, -// project = project, -// ) diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/ProjectMember.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/ProjectMember.kt deleted file mode 100644 index cacedc19d..000000000 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/ProjectMember.kt +++ /dev/null @@ -1,99 +0,0 @@ -// package com.inso_world.binocular.cli.entity -// -// import com.inso_world.binocular.cli.archive.ProjectMemberPojo -// import jakarta.persistence.Column -// import jakarta.persistence.Entity -// import jakarta.persistence.FetchType -// import jakarta.persistence.GeneratedValue -// import jakarta.persistence.GenerationType -// import jakarta.persistence.Id -// import jakarta.persistence.JoinColumn -// import jakarta.persistence.JoinTable -// import jakarta.persistence.ManyToMany -// import jakarta.persistence.ManyToOne -// import jakarta.persistence.Table -// import jakarta.persistence.UniqueConstraint -// import java.time.LocalDateTime -// import java.util.Objects -// -// @Entity -// @Table( -// name = "project_members", -// uniqueConstraints = [ -// UniqueConstraint(columnNames = ["project_id", "email"]), -// ], -// ) -// data class ProjectMember( -// @Id -// @GeneratedValue(strategy = GenerationType.IDENTITY) -// val id: Long? = null, -// @Column(name = "access_level", nullable = false) -// val accessLevel: Int, -// @Column(name = "source_type", nullable = false) -// val sourceType: String, -// @Column(name = "user_id", nullable = false) -// val userId: Long, -// @Column(name = "created_at", nullable = false) -// val createdAt: LocalDateTime, -// @Column(name = "updated_at", nullable = false) -// val updatedAt: LocalDateTime, -// @Column(name = "created_by_id") -// val createdById: Long? = null, -// @Column(name = "username", nullable = false, unique = true) -// val username: String, -// @ManyToMany(targetEntity = Project::class, fetch = FetchType.LAZY) -// @JoinTable( -// name = "member_project", -// joinColumns = [JoinColumn(name = "project_members_id", nullable = false)], -// inverseJoinColumns = [JoinColumn(name = "project_id", nullable = false)], -// uniqueConstraints = [UniqueConstraint(columnNames = ["project_id", "project_members_id"])], -// ) -// var projects: MutableSet = emptySet().toMutableSet(), -// @ManyToOne(fetch = FetchType.LAZY, optional = true) -// @JoinColumn(name = "fk_user") -// var user: User? = null, -// ) { -// override fun toString(): String = -// "ProjectMember(id=$id, accessLevel=$accessLevel, sourceType='$sourceType', userId=$userId, createdAt=$createdAt, updatedAt=$updatedAt, createdById=$createdById, username=$username)" -// -// override fun equals(other: Any?): Boolean { -// if (this === other) return true -// if (javaClass != other?.javaClass) return false -// -// other as ProjectMember -// -// if (id != other.id) return false -// if (accessLevel != other.accessLevel) return false -// if (userId != other.userId) return false -// if (createdById != other.createdById) return false -// if (sourceType != other.sourceType) return false -// if (createdAt != other.createdAt) return false -// if (updatedAt != other.updatedAt) return false -// if (username != other.username) return false -// -// return true -// } -// -// override fun hashCode(): Int { -// var result = id?.let { Objects.hash(it) } ?: 0 -// result = 31 * result + Objects.hash(accessLevel) -// result = 31 * result + Objects.hash(userId) -// result = 31 * result + (createdById?.let { Objects.hash(it) } ?: 0) -// result = 31 * result + Objects.hash(sourceType) -// result = 31 * result + Objects.hash(createdAt) -// result = 31 * result + Objects.hash(updatedAt) -// result = 31 * result + Objects.hash(username) -// return result -// } -// } -// -// fun ProjectMemberPojo.toEntity(): ProjectMember = -// ProjectMember( -// accessLevel = this.accessLevel, -// sourceType = this.sourceType, -// userId = this.userId, -// createdAt = this.createdAt, -// updatedAt = this.updatedAt, -// createdById = this.createdById, -// username = this.user.username ?: throw IllegalStateException("Username null"), -// ) diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/TimeLog.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/TimeLog.kt deleted file mode 100644 index 69657335d..000000000 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/entity/TimeLog.kt +++ /dev/null @@ -1,73 +0,0 @@ -// package com.inso_world.binocular.cli.entity -// -// import com.inso_world.binocular.cli.archive.TimeLogPojo -// import jakarta.persistence.Column -// import jakarta.persistence.Entity -// import jakarta.persistence.FetchType -// import jakarta.persistence.GeneratedValue -// import jakarta.persistence.GenerationType -// import jakarta.persistence.Id -// import jakarta.persistence.JoinColumn -// import jakarta.persistence.ManyToOne -// import jakarta.persistence.PrePersist -// import jakarta.persistence.Table -// import jakarta.persistence.UniqueConstraint -// import java.time.LocalDateTime -// -// @Entity -// @Table( -// name = "timelogs", -// uniqueConstraints = [ -// UniqueConstraint(columnNames = arrayOf("id", "fk_project")), -// UniqueConstraint(columnNames = arrayOf("createdAt", "fk_project")), -// // UniqueConstraint(columnNames = arrayOf("spentAt", "fk_project")), -// ], -// ) -// data class TimeLog( -// @Id -// @GeneratedValue(strategy = GenerationType.IDENTITY) -// val id: Long? = null, -// @Column(name = "gitlab_id", nullable = true) -// val gitlabId: Long? = null, -// @ManyToOne(fetch = FetchType.LAZY, optional = false) -// @JoinColumn(name = "fk_project", referencedColumnName = "id") -// var project: Project? = null, -// @Column(name = "time_spent", nullable = false) -// val timeSpent: Long, -// @ManyToOne(fetch = FetchType.LAZY) -// @JoinColumn(name = "fk_project_member", referencedColumnName = "id") -// var projectMember: ProjectMember? = null, -// @Column(name = "created_at", nullable = false) -// val createdAt: LocalDateTime, -// @Column(name = "updated_at", nullable = false) -// val updatedAt: LocalDateTime, -// @Column(name = "spent_at") -// val spentAt: LocalDateTime? = null, -// @ManyToOne(fetch = FetchType.LAZY, optional = true) -// @JoinColumn(name = "fk_issue", referencedColumnName = "id") -// var issue: Issue? = null, -// @ManyToOne(fetch = FetchType.LAZY, optional = true) -// @JoinColumn(name = "fk_merge_request", referencedColumnName = "id") -// var mergeRequest: MergeRequest? = null, -// ) { -// override fun hashCode(): Int = super.hashCode() -// -// @PrePersist -// fun prePersist() { -// if (!(issue == null).xor(mergeRequest == null)) { -// throw NullPointerException() -// } -// } -// -// override fun toString(): String = -// "TimeLog(spentAt=$spentAt, updatedAt=$updatedAt, createdAt=$createdAt, projectMember=$projectMember, timeSpent=$timeSpent, project=$project, id=$id)" -// } -// -// fun TimeLogPojo.toEntity(): TimeLog = -// TimeLog( -// gitlabId = id, -// timeSpent = timeSpent, -// createdAt = createdAt, -// updatedAt = updatedAt, -// spentAt = spentAt, -// ) diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/index/vcs/VcsCommit.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/index/vcs/VcsCommit.kt deleted file mode 100644 index f1ab30211..000000000 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/index/vcs/VcsCommit.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.inso_world.binocular.cli.index.vcs - -import com.inso_world.binocular.model.Commit -import java.time.LocalDateTime -import java.util.Objects - -@Deprecated("legacy") -data class VcsCommit( - val sha: String, - val message: String, - val branch: String, - val committer: VcsPerson?, - val author: VcsPerson?, - val commitTime: LocalDateTime?, - val authorTime: LocalDateTime?, - val parents: MutableSet = mutableSetOf(), -) { - override fun toString(): String = - "VcsCommit(sha='$sha', message='$message', branch=$branch, parents=${parents.map { - it.sha - }}, commitTime=$commitTime, authorTime=$authorTime)" - - fun toDomain(): Commit { - val cmt = - Commit( - sha = this.sha, - message = this.message, - commitDateTime = this.commitTime ?: LocalDateTime.now(), - authorDateTime = this.authorTime, - ) - return cmt - } - - override fun hashCode(): Int = Objects.hash(sha, message, branch, commitTime, authorTime) -} - -fun traverseGraph( - cmt: VcsCommit, - visited: MutableSet = mutableSetOf(), -): List { - if (!visited.add(cmt.sha)) { - return emptyList() // Skip if we've already visited this commit - } - return listOf(cmt.sha) + cmt.parents.flatMap { traverseGraph(it, visited) } -} diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/index/vcs/VcsPerson.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/index/vcs/VcsPerson.kt deleted file mode 100644 index 8388ce7c6..000000000 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/index/vcs/VcsPerson.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.inso_world.binocular.cli.index.vcs - -import com.inso_world.binocular.model.User -import java.util.Objects - -@Deprecated("legacy") -data class VcsPerson( - val name: String, - val email: String, -) { - fun toEntity(): User = - User( - name = this.name, - email = this.email, - ) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as VcsPerson - - if (name != other.name) return false - if (email != other.email) return false - - return true - } - - override fun hashCode(): Int { - var result = Objects.hashCode(name) - result = 31 * result + Objects.hashCode(email) - return result - } -} diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/CommitService.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/CommitService.kt index 4672fd946..ed146c4f0 100644 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/CommitService.kt +++ b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/CommitService.kt @@ -1,8 +1,10 @@ package com.inso_world.binocular.cli.service import com.inso_world.binocular.cli.exception.ServiceException +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.exception.BinocularInfrastructureException import com.inso_world.binocular.core.service.CommitInfrastructurePort +import com.inso_world.binocular.core.service.RepositoryInfrastructurePort import com.inso_world.binocular.core.service.exception.NotFoundException import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.Repository @@ -15,31 +17,37 @@ import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import java.util.stream.Collectors +import java.util.stream.Stream +import kotlin.streams.asSequence @Service class CommitService( @Autowired private val commitPort: CommitInfrastructurePort, + @Autowired private val repositoryPort: RepositoryInfrastructurePort, ) { - private val logger: Logger = LoggerFactory.getLogger(CommitService::class.java) + companion object { + val logger by logger() + } fun checkExisting( repo: Repository, minedCommits: Collection, ): Pair, Collection> { - val allShas: List = + val allShas: Set = minedCommits .stream() .map { m -> m.sha } - .collect(Collectors.toList()) + .collect(Collectors.toSet()) - val existingEntities: Iterable = + val existingEntities: Sequence = try { - commitPort.findExistingSha(repo, allShas) + repositoryPort.findExistingCommits(repo, allShas) +// commitPort.findExistingSha(repo, allShas) } catch (_: NotFoundException) { - emptyList() + sequenceOf() } - val refIdsToRemove = existingEntities.map { it.sha } + val refIdsToRemove = existingEntities.map { it.sha }.asSequence() val missingShas = minedCommits.filterNot { it.sha in refIdsToRemove } // .stream().collect(Collectors.toSet()) return Pair(existingEntities.toList(), missingShas.toList()) diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/FfiService.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/FfiService.kt deleted file mode 100644 index d8a626e91..000000000 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/FfiService.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.inso_world.binocular.cli.service - -import com.inso_world.binocular.cli.config.BinocularCliConfiguration -import com.inso_world.binocular.cli.exception.ServiceException -import com.inso_world.binocular.core.exception.BinocularIndexerException -import com.inso_world.binocular.core.index.GitIndexer -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Repository -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.stereotype.Service -import kotlin.io.path.Path -import kotlin.io.path.exists - -@Service -@Deprecated("will be removed") -internal class FfiService( - @Autowired val binocularCliConfiguration: BinocularCliConfiguration, -) { - private var logger: Logger = LoggerFactory.getLogger(FfiService::class.java) - - @Autowired - private lateinit var gitIndexer: GitIndexer - - fun findAllBranches(repo: Repository): List = - try { - gitIndexer - .findAllBranches(repo) - .parallelStream() - .toList() - } catch (e: BinocularIndexerException) { - throw ServiceException(e) - } - - fun findRepo(path: String?): Repository { - val path = - run { - path ?: this.binocularCliConfiguration.index.path - }.let { indexPath -> - val path = Path(indexPath).toRealPath() - require(path.exists()) { - "Path $path does not exist" - } - path - } - logger.trace("Searching repository... at '{}'", path.toRealPath()) - return try { - gitIndexer.findRepo(path) - } catch (e: BinocularIndexerException) { - throw ServiceException(e) - } - } - - fun traverseAllOnBranch( - repo: Repository, - branch: Branch, - ): List = - try { - gitIndexer.traverseBranch(repo, branch) - } catch (e: BinocularIndexerException) { - throw ServiceException(e) - } -} diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/RepositoryService.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/RepositoryService.kt index 0d2f0c7c6..4afdd9f99 100644 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/RepositoryService.kt +++ b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/RepositoryService.kt @@ -1,21 +1,42 @@ package com.inso_world.binocular.cli.service +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.service.RepositoryInfrastructurePort import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.CommitDiff +import com.inso_world.binocular.model.Developer import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User -import org.slf4j.Logger -import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import java.nio.file.Paths +/** + * Service for managing repository operations including commit canonicalization and persistence. + * + * This service handles the coordination between the Git indexer output (commits with their + * signatures and developers already set) and the persistence layer. With the new domain model + * using [Developer] and [com.inso_world.binocular.model.Signature], commits come pre-built + * with immutable author/committer information. + * + * ## Key Responsibilities + * - Canonicalize commits against existing repository state + * - Wire parent-child relationships + * - Deduplicate developers by git signature + * - Persist repository changes + * + * ## Domain Model Integration + * The new domain model auto-registers entities: + * - [Commit] → [Repository.commits] and developer's authored/committed collections + * - [Developer] → [Repository.developers] + * - [Branch] → [Repository.branches] + * + * This means transformCommits focuses on deduplication and parent wiring rather than + * establishing back-links. + */ @Service class RepositoryService { companion object { - private val logger: Logger = LoggerFactory.getLogger(RepositoryService::class.java) + private val logger by logger() } @Autowired @@ -24,232 +45,208 @@ class RepositoryService { @Autowired private lateinit var commitService: CommitService + fun findBranch(repo: Repository, name: String): Branch? { + return this.repositoryPort.findBranch(repo, name) + } + /** - * Deduplicate incoming commits against the repository by uniqueKey() and - * wire up relationships (author/committer, branches, parents/children) - * so that all references point to the canonical objects in `repo`. + * Canonicalizes incoming commits against the repository's existing state. + * + * With the new domain model where [Commit]s use immutable [com.inso_world.binocular.model.Signature]s, + * this method focuses on: + * 1. Deduplicating commits by SHA (uniqueKey) + * 2. Deduplicating developers by git signature + * 3. Wiring parent-child relationships between commits * - * Returns the canonicalized commits corresponding to the input order. + * Note: Since commits are created with their signatures already set, we cannot + * "rewrite" author/committer. Instead, we ensure consistency by using canonical + * developer instances from the repository. + * + * @param repo The repository to canonicalize commits into + * @param commits The incoming commits (already with signatures set) + * @return The canonicalized commits corresponding to input order */ internal fun transformCommits( repo: Repository, commits: Iterable, ): Collection { - // --- canonical indexes from repository state - val commitsByKey = repo.commits.associateByTo(mutableMapOf()) { it.uniqueKey() } - val usersByKey = repo.user.associateByTo(mutableMapOf()) { it.uniqueKey() } - val usersByEmail = - repo.user - .mapNotNull { u -> normalizeEmail(u.email)?.let { it to u } } - .toMap(mutableMapOf()) - - val branchesByKey = repo.branches.associateByTo(mutableMapOf()) { it.uniqueKey() } - - // --- seed indexes with any pre-attached users on incoming commits - commits.forEach { c -> - listOf(c.author, c.committer).forEach { u -> - if (u != null) { - u.repository = repo - usersByKey.putIfAbsent(u.uniqueKey(), u) - normalizeEmail(u.email)?.let { usersByEmail.putIfAbsent(it, u) } - } - } - } + // Build index of canonical commits from repository + val commitsByKey = repo.commits.associateByTo(mutableMapOf()) { it.uniqueKey } + + // Build index of canonical developers by git signature + val developersBySignature = repo.developers.associateByTo(mutableMapOf()) { it.gitSignature } + + // Also index by email for coalescing (case-insensitive) + val developersByEmail = repo.developers + .mapNotNull { dev -> normalizeEmail(dev.email)?.let { it to dev } } + .toMap(mutableMapOf()) + // Build index of canonical branches + val branchesByKey = repo.branches.associateByTo(mutableMapOf()) { it.uniqueKey } + + /** + * Get or register the canonical commit for a given incoming commit. + * If the commit already exists in the repo, returns the existing one. + * Otherwise, registers the new commit. + */ fun canonicalizeCommit(incoming: Commit): Commit { - val key = incoming.uniqueKey() + val key = incoming.uniqueKey val existing = commitsByKey[key] if (existing != null) return existing - // Add to repo first to establish repository back-link - repo.commits.add(incoming) + // Commit is new - it should already be registered via its init block + // when created by GitIndexer, but let's ensure it's in our index commitsByKey[key] = incoming return incoming } - fun canonicalizeUser(u: User?): User? { - if (u == null) return null - u.repository = repo - - // 1) prefer uniqueKey match - usersByKey[u.uniqueKey()]?.let { return it } - - // 2) coalesce by email (case-insensitive) - normalizeEmail(u.email)?.let { em -> - usersByEmail[em]?.let { existing -> return existing } + /** + * Get or register the canonical developer for a given developer. + * Uses git signature as primary key, with email as fallback for coalescing. + */ + fun canonicalizeDeveloper(dev: Developer): Developer { + // First check by git signature + developersBySignature[dev.gitSignature]?.let { return it } + + // Then check by email (case-insensitive) + normalizeEmail(dev.email)?.let { email -> + developersByEmail[email]?.let { return it } } - // 3) no match — register new canonical instance - repo.user.add(u) - usersByKey[u.uniqueKey()] = u - normalizeEmail(u.email)?.let { usersByEmail[it] = u } - return u + // New developer - register in indices + developersBySignature[dev.gitSignature] = dev + normalizeEmail(dev.email)?.let { developersByEmail[it] = dev } + return dev } - fun canonicalizeBranch(b: Branch?): Branch? { - if (b == null) return null - b.repository = repo - return branchesByKey[b.uniqueKey()] ?: run { - repo.branches.add(b) - branchesByKey[b.uniqueKey()] = b - b - } + /** + * Get or register the canonical branch. + */ + fun canonicalizeBranch(branch: Branch?): Branch? { + if (branch == null) return null + return branchesByKey.getOrPut(branch.uniqueKey) { branch } } - // Helpers to *safely* rebind author/committer to canonical instances - fun forceSetAuthor( - c: Commit, - target: User?, - ) { - if (target == null) return - val current = c.author - if (current === target) return - if (current != null && sameEmail(current, target)) { - // migrate to canonical: remove old back-link, then use setter - current.authoredCommits.remove(c) - // clear via reflection to allow setter without violating guard - setField(c, "author", null) - } - if (c.author == null) c.author = target - } + // --- Pass 1: Ensure every incoming commit has a canonical instance --- + // Commits from GitIndexer already have their signatures set, so we just + // ensure they're tracked in our index + val canonicalInOrder = commits.map { canonicalizeCommit(it) } - fun forceSetCommitter( - c: Commit, - target: User?, - ) { - if (target == null) return - val current = c.committer - if (current === target) return - if (current != null && sameEmail(current, target)) { - current.committedCommits.remove(c) - setField(c, "committer", null) - } - if (c.committer == null) c.committer = target + // --- Pass 2: Canonicalize developers --- + // While we can't change the developer on an existing commit (immutable signature), + // we ensure the developer instances are properly indexed for future lookups + commits.forEach { incoming -> + canonicalizeDeveloper(incoming.author) + canonicalizeDeveloper(incoming.committer) } - // --- pass 1: ensure every commit has a canonical instance - val canonicalInOrder = commits.map { canonicalizeCommit(it) } - - // --- pass 2: users + branches - commits.forEach { raw -> - val c = canonicalizeCommit(raw) - - val authorCanon = canonicalizeUser(raw.author) - val committerCanon = canonicalizeUser(raw.committer) - - // If both emails are equal, unify to the same instance (prefer author’s instance) - val unifiedByEmail = - if (authorCanon != null && committerCanon != null && - sameEmail(authorCanon, committerCanon) - ) { - authorCanon - } else { - null + // --- Pass 3: Wire parent-child relationships --- + // The domain model handles bidirectional linking automatically + commits.forEach { incoming -> + val canonicalCommit = canonicalizeCommit(incoming) + + // Wire parents from incoming commit's parent list + incoming.parents.forEach { parentRaw -> + val canonicalParent = canonicalizeCommit(parentRaw) + // Domain model handles children back-link automatically + if (!canonicalCommit.parents.contains(canonicalParent)) { + canonicalCommit.parents.add(canonicalParent) } - - forceSetAuthor(c, unifiedByEmail ?: authorCanon) - forceSetCommitter(c, unifiedByEmail ?: committerCanon) - - raw.branches.forEach { bRaw -> - canonicalizeBranch(bRaw)?.commits?.add(c) } - } - // --- pass 3: parents / children - commits.forEach { raw -> - val c = canonicalizeCommit(raw) - - raw.parents.forEach { pRaw -> - val p = canonicalizeCommit(pRaw) - c.parents.add(p) // back-links to children are handled by domain model - } - raw.children.forEach { chRaw -> - val ch = canonicalizeCommit(chRaw) - c.children.add(ch) + // Wire children from incoming commit's children list (if any) + incoming.children.forEach { childRaw -> + val canonicalChild = canonicalizeCommit(childRaw) + if (!canonicalCommit.children.contains(canonicalChild)) { + canonicalCommit.children.add(canonicalChild) + } } } return canonicalInOrder } - // ---------- small utilities ---------- + // ---------- Utilities ---------- private fun normalizeEmail(email: String?): String? = email?.trim()?.lowercase() - private fun sameEmail( - a: User, - b: User, - ): Boolean = + private fun sameEmail(a: Developer, b: Developer): Boolean = normalizeEmail(a.email) != null && - normalizeEmail(a.email) == normalizeEmail(b.email) - - private fun setField( - target: Any, - fieldName: String, - value: T?, - ) { - val f = target.javaClass.getDeclaredField(fieldName) - f.isAccessible = true - f.set(target, value) - } + normalizeEmail(a.email) == normalizeEmail(b.email) private fun normalizePath(path: String): String = (if (path.endsWith(".git")) path else "$path/.git").let { Paths.get(it).toRealPath().toString() } + /** + * Find a repository by its normalized git directory path. + */ fun findRepo(gitDir: String): Repository? = this.repositoryPort.findByName(normalizePath(gitDir)) + /** + * Create a new repository in the persistence layer. + * + * @throws IllegalArgumentException if repository.id is not null (already persisted) + * @throws IllegalArgumentException if repository.project is null + * @throws IllegalArgumentException if project.repo doesn't match the repository + */ fun create(repository: Repository): Repository { require(repository.id == null) { "Repository.id must be null to create repository" } require(repository.project != null) { "Repository.project must not be null to create repository" } - require(repository.project?.repo == repository) { "Mismatch in Repository and Project configuration" } + require(repository.project.repo == repository) { "Mismatch in Repository and Project configuration" } -// val find = this.findRepo(name) -// if (find == null) { -// logger.info("Repository does not exists, creating new repository") return this.repositoryPort.create(repository) -// } else { -// logger.debug("Repository already exists, returning existing repository") -// return find -// } } + /** + * Get the HEAD commit for a specific branch. + */ fun getHeadCommits( repo: Repository, branch: String, ): Commit? = this.commitService.findHeadForBranch(repo, branch) - fun update(repo: Repository): Repository = this.repositoryPort.update(repo) - - // @Transactional + /** + * Add commits to a repository, checking for existing commits first. + * + * This method: + * 1. Checks which commits already exist in the repository + * 2. Canonicalizes new commits (wires relationships) + * 3. Persists the updated repository + * + * @param repo The repository to add commits to + * @param commits The commits to add + * @return The updated repository + */ fun addCommits( repo: Repository, - commitDtos: Collection, + commits: Collection, ): Repository { - val existingCommitEntities = this.commitService.checkExisting(repo, commitDtos) + val existingCommitEntities = this.commitService.checkExisting(repo, commits) logger.debug("Existing commits: ${existingCommitEntities.first.count()}") logger.trace("New commits to add: ${existingCommitEntities.second.count()}") - if (existingCommitEntities.second.isNotEmpty()) { - // these commits are new so always added, also to an existing branch - this.transformCommits(repo, existingCommitEntities.second) +// if (existingCommitEntities.second.isNotEmpty()) { + // Canonicalize new commits and wire relationships + this.transformCommits(repo, existingCommitEntities.second) - logger.debug("Commit transformation finished") - logger.debug("${repo.commits.count { it.message?.isEmpty() == true }} Commits have empty messages") - logger.trace( - "Empty message commits: {}", - repo.commits.filter { it.message?.isEmpty() == true }.map { it.sha }, - ) + logger.debug("Commit transformation finished") + logger.debug("${repo.commits.count { it.message?.isEmpty() == true }} Commits have empty messages") + logger.trace( + "Empty message commits: {}", + repo.commits.filter { it.message?.isEmpty() == true }.map { it.sha }, + ) - val newRepo = update(repo) + val newRepo = this.repositoryPort.update(repo) - logger.debug("Commits successfully added. New Commit count is ${repo.commits.count()} for project ${repo.project?.name}") - return newRepo - } else { - logger.info("No new commits were found, skipping update") - return repo - } + logger.debug("Commits successfully added. New Commit count is ${repo.commits.count()} for project ${repo.project.name}") + return newRepo +// } else { +// logger.info("No new commits were found, skipping update") +// return repo +// } } + } diff --git a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/VcsService.kt b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/VcsService.kt index c7be3f73d..f3b9153d6 100644 --- a/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/VcsService.kt +++ b/binocular-backend-new/cli/src/main/kotlin/com/inso_world/binocular/cli/service/VcsService.kt @@ -2,72 +2,201 @@ package com.inso_world.binocular.cli.service import com.inso_world.binocular.cli.exception.CliException import com.inso_world.binocular.cli.exception.ServiceException +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.index.GitIndexer +import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.CommitDiff import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository -import org.slf4j.Logger -import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service +import kotlin.io.path.Path -// +/** + * Service for indexing VCS (Version Control System) data from Git repositories. + * + * This service coordinates between the Git indexer (FFI layer) and the repository + * service (persistence layer) to efficiently index commits, diffs, and blame data. + * + * ## Incremental Re-indexing + * + * The service implements incremental re-indexing to avoid duplicate work: + * 1. For existing branches, it detects the last indexed HEAD commit + * 2. Traverses only from the current branch HEAD to the known HEAD + * 3. Skips processing entirely if no new commits exist + * + * This optimization is critical for large repositories where full traversal + * would be prohibitively expensive. + * + * ## Example Usage + * ```kotlin + * vcsService.indexRepository("/path/to/repo", "main", project) + * // On re-index, only new commits since last index are processed + * ``` + */ @Service class VcsService( @Autowired private val repoService: RepositoryService, ) { companion object { - private var logger: Logger = LoggerFactory.getLogger(VcsService::class.java) + private val logger by logger() } @Autowired private lateinit var gitIndexer: GitIndexer - @Autowired - private lateinit var ffiService: FfiService - + /** + * Indexes a Git repository, fetching commits for the specified branch. + * + * This method implements incremental indexing: + * - If the repository/branch was previously indexed, only new commits are fetched + * - If this is a fresh index, all commits on the branch are fetched + * + * @param repoPath Path to the Git repository (can be worktree or .git directory) + * @param branch Name of the branch to index + * @param project The project this repository belongs to + * @throws CliException if repository operations fail + * @throws IllegalArgumentException if repoPath is null or branch doesn't exist + */ fun indexRepository( repoPath: String?, branch: String, project: Project, ) { logger.trace(">>> indexRepository({}, {}, {})", repoPath, branch, project) - val vcsRepo = - try { - this.repoService.findRepo( - requireNotNull(repoPath) { - "Repository path is empty/null" - }, - ) ?: run { - logger.trace("Repository $repoPath not indexed, looking for .git path") - val repo = this.ffiService.findRepo(repoPath) - project.repo = repo - repo.project = project - this.repoService.create(repo) - } - } catch (e: ServiceException) { - throw CliException(e) - } + + val vcsRepo = findOrCreateRepository(repoPath, project) logger.info("Found repository: ${vcsRepo.localPath}") - val branch = - requireNotNull( - this.gitIndexer.findAllBranches(vcsRepo).find { it.name == branch }, - ) { - "Branch not found: $branch" + + val gitBranch = repoService.findBranch(vcsRepo, branch) + + // Check for incremental indexing opportunity + val existingHead = findExistingBranchHead(vcsRepo, branch) + + val (branchResult, commits) = if (existingHead != null && gitBranch != null) { + // Incremental: traverse from current HEAD to existing HEAD + performIncrementalTraversal(vcsRepo, gitBranch, existingHead) + } else { + // Full traversal: no existing commits for this branch + performFullTraversal(vcsRepo, branch) + } + + if (commits.isEmpty()) { + logger.info("No new commits found for branch '$branch' - repository is up to date") + return + } + + logCommitStatistics(commits, branch) + repoService.addCommits(vcsRepo, commits) + + logger.trace("<<< indexRepository({}, {}, {})", repoPath, branch, project) + } + + /** + * Finds an existing repository or creates a new one. + */ + private fun findOrCreateRepository(repoPath: String?, project: Project): Repository { + return try { + repoService.findRepo( + requireNotNull(repoPath) { "Repository path is empty/null" }, + ) ?: run { + logger.trace("Repository $repoPath not indexed, looking for .git path") + val repo = gitIndexer.findRepo(Path(repoPath), project) + repoService.create(repo) } + } catch (e: ServiceException) { + throw CliException(e) + } + } + + /** + * Finds a branch by name in the repository. + * + * @throws IllegalArgumentException if branch doesn't exist + */ + private fun findBranch(repo: Repository, branchName: String): Branch { + return requireNotNull( + gitIndexer.findAllBranches(repo).find { it.name == branchName }, + ) { "Branch not found: $branchName" } + } - val vcsCommits = this.ffiService.traverseAllOnBranch(vcsRepo, branch) + /** + * Finds the existing HEAD commit for a branch if it was previously indexed. + * + * @return The existing HEAD commit SHA, or null if branch was never indexed + */ + private fun findExistingBranchHead(repo: Repository, branchName: String): Commit? { + // Check if we have any commits for this branch + val existingHead = repoService.getHeadCommits(repo, branchName) - val shas = vcsCommits.map { it.sha } - val parentShas = vcsCommits.flatMap { it.parents } + if (existingHead != null) { + logger.debug("Found existing HEAD for branch '$branchName': ${existingHead.sha.take(8)}") + } else { + logger.debug("No existing commits found for branch '$branchName' - performing full traversal") + } + + return existingHead + } + + /** + * Performs incremental traversal from current branch HEAD to the existing indexed HEAD. + * + * This optimization avoids re-traversing commits that are already indexed. + * Only commits between the current HEAD and the last known HEAD are returned. + * + * @param repo The repository to traverse + * @param branch The branch being indexed + * @param existingHead The previously indexed HEAD commit + * @return Pair of the branch and new commits (empty if no new commits) + */ + private fun performIncrementalTraversal( + repo: Repository, + branch: Branch, + existingHead: Commit, + ): Pair> { + val currentHead = branch.head + + // Quick check: if HEAD hasn't changed, no new commits + if (currentHead.sha == existingHead.sha) { + logger.debug("Branch HEAD unchanged (${currentHead.sha.take(8)}) - no new commits") + return Pair(branch, emptyList()) + } + + logger.info("Incremental indexing: ${currentHead.sha.take(8)} → ${existingHead.sha.take(8)}") + + // Use traverse with stop point to get only new commits + val newCommits = gitIndexer.traverse(repo, currentHead, existingHead) + + // Filter out the stop commit if it was included + val filteredCommits = newCommits.filter { it.sha != existingHead.sha } + + logger.info("Found ${filteredCommits.size} new commit(s) on branch '${branch.name}'") + + return Pair(branch, filteredCommits) + } + + /** + * Performs full branch traversal when no existing commits are found. + */ + private fun performFullTraversal( + repo: Repository, + branch: String, + ): Pair> { + logger.info("Full traversal for branch '${branch}'") + return gitIndexer.traverseBranch(repo, branch) + } + + /** + * Logs commit statistics for debugging and monitoring. + */ + private fun logCommitStatistics(commits: List, branchName: String) { + val shas = commits.map { it.sha } + val parentShas = commits.flatMap { it.parents.map { p -> p.sha } } logger.debug( - "Existing commits: ${shas.count()}+${parentShas.count()}=${ + "Commits to process: ${shas.count()}+${parentShas.count()}=${ (shas + parentShas).distinct().count() - } commit(s) found on branch $branch", + } commit(s) found on branch $branchName", ) - this.repoService.addCommits(vcsRepo, vcsCommits) - logger.trace("<<< indexRepository({}, {}, {})", repoPath, branch, project) } } diff --git a/binocular-backend-new/cli/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/binocular-backend-new/cli/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000..fcfd41787 --- /dev/null +++ b/binocular-backend-new/cli/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,28 @@ +{ + "groups": [ + { + "name": "binocular.cli", + "type": "com.inso_world.binocular.cli.config.BinocularConfiguration", + "description": "Configuration for the cli application." + }, + { + "name": "binocular.cli.index", + "type": "com.inso_world.binocular.cli.config.IndexConfig", + "description": "Configuration for the indexer part of the cli application." + }, + { + "name": "binocular.cli.index.scm", + "type": "com.inso_world.binocular.cli.config.ScmConfig", + "description": "Configuration for the Source Code Management tool part of the cli application." + } + ], + "properties": [ + { + "name": "binocular.cli.index.scm.path", + "type": "java.lang.String", + "description": "Path to the local repository which should be indexed.", + "defaultValue": "./" + } + ], + "hints": [] +} diff --git a/binocular-backend-new/cli/src/main/resources/application-arangodb.yaml b/binocular-backend-new/cli/src/main/resources/application-arangodb.yaml index cec58aecb..2438250dc 100644 --- a/binocular-backend-new/cli/src/main/resources/application-arangodb.yaml +++ b/binocular-backend-new/cli/src/main/resources/application-arangodb.yaml @@ -1,7 +1,11 @@ binocular: - database: - database_name: "binocular-1234" - host: "localhost" - port: 8529 - user: TODO - password: TODO + arangodb: + database: + database: "binocular-1234" + host: "localhost" + port: 8529 + user: root + password: test +spring: + autoconfigure: + exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration,org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration,org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration,org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration diff --git a/binocular-backend-new/cli/src/main/resources/application.yaml b/binocular-backend-new/cli/src/main/resources/application.yaml index 13ed8770f..a226cd7bb 100644 --- a/binocular-backend-new/cli/src/main/resources/application.yaml +++ b/binocular-backend-new/cli/src/main/resources/application.yaml @@ -1,6 +1,8 @@ binocular: - index: - path: "/Users/rise/Repositories/test_repositories/Binocular" + cli: + index: + scm: + path: "../../.git" spring: profiles: active: postgres,gix diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/commands/VcsIndexCommandsTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/commands/VcsIndexCommandsTest.kt index efb518e0a..a39c93831 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/commands/VcsIndexCommandsTest.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/commands/VcsIndexCommandsTest.kt @@ -6,25 +6,34 @@ import com.inso_world.binocular.cli.service.RepositoryService import com.inso_world.binocular.core.integration.base.BaseFixturesIntegrationTest import com.inso_world.binocular.core.integration.base.InfrastructureDataSetup import com.inso_world.binocular.infrastructure.sql.SqlTestConfig -import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User +import com.inso_world.binocular.model.Signature import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import org.junit.jupiter.api.assertAll import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertNotNull import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration import java.time.LocalDateTime import java.util.concurrent.TimeUnit +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.exists @SpringBootTest( classes = [BinocularCommandLineApplication::class], @@ -35,6 +44,7 @@ import java.util.concurrent.TimeUnit SqlTestConfig.Initializer::class, ] ) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) internal class VcsIndexCommandsTest() : BaseFixturesIntegrationTest() { @all:Autowired @@ -46,14 +56,55 @@ internal class VcsIndexCommandsTest() : BaseFixturesIntegrationTest() { @all:Autowired private lateinit var idxClient: Index + + companion object { + val BINOCULAR_REPO_PATH = Path("../../.git") + @JvmStatic + @BeforeAll + fun checkPath() { + assertThat(BINOCULAR_REPO_PATH).exists() + } + } + + @BeforeEach + fun setup() { + cleanup() + } + @AfterEach fun cleanup() { this.infrastructureDataSetup.teardown() } + /** + * Helper to create a test commit with the new Signature-based constructor. + */ + private fun createTestCommit( + sha: String, + message: String?, + repository: Repository, + developerName: String = "Test User", + developerEmail: String = "test@example.com", + timestamp: LocalDateTime = LocalDateTime.now().minusHours(1) + ): Commit { + val developer = Developer( + name = developerName, + email = developerEmail, + repository = repository + ) + val signature = Signature(developer = developer, timestamp = timestamp) + return Commit( + sha = sha, + message = message, + authorSignature = signature, + repository = repository, + ) + } + @Nested inner class BinocularRepo { - @AfterEach + + @BeforeEach fun `cleanup inner`() { cleanup() } @@ -61,36 +112,61 @@ internal class VcsIndexCommandsTest() : BaseFixturesIntegrationTest() { @Test fun `index branch origin-feature-5`() { idxClient.commits( - repoPath = "../../", + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), branchName = "origin/feature/5", - "Binocular", + projectName = "Binocular", ) } + @Test + fun `index branch origin-main`() { + idxClient.commits( + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), + branchName = "origin/main", + projectName = "Binocular", + ) + + val repo = repoService.findRepo(BINOCULAR_REPO_PATH.absolutePathString()) + assertNotNull(repo) + assertThat(repo.commits).hasSize(1881) + } + + @Test + fun `index branch origin-develop`() { + idxClient.commits( + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), + branchName = "origin/develop", + projectName = "Binocular", + ) + + val repo = repoService.findRepo(BINOCULAR_REPO_PATH.absolutePathString()) + assertNotNull(repo) + assertThat(repo.commits).hasSize(2310) + } + @Test fun `index branch origin-feature-6`() { idxClient.commits( - repoPath = "../../", + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), branchName = "origin/feature/6", - "Binocular", + projectName = "Binocular", ) } @Test fun `index branch origin-feature-6 and then origin-feature-5`() { - val path = "../../" idxClient.commits( - repoPath = path, + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), branchName = "origin/feature/6", - "Binocular", + projectName = "Binocular", ) // assertThat(repoService.f?.commits).hasSize(207) assertDoesNotThrow { idxClient.commits( - repoPath = path, + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), branchName = "origin/feature/5", - "Binocular", + projectName = "Binocular", ) } // assertThat(repoService.findRepo(path)?.commits).hasSize(219) @@ -98,18 +174,17 @@ internal class VcsIndexCommandsTest() : BaseFixturesIntegrationTest() { @Test fun `index branch origin-feature-6 and then again`() { - val path = "../../.git" idxClient.commits( - repoPath = path, + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), branchName = "origin/feature/6", - "Binocular", + projectName = "Binocular", ) // assertThat(repoService.f?.commits).hasSize(207) assertDoesNotThrow { idxClient.commits( - repoPath = path, + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), branchName = "origin/feature/6", - "Binocular", + projectName = "Binocular", ) } // assertThat(repoService.findRepo(path)?.commits).hasSize(219) @@ -117,18 +192,17 @@ internal class VcsIndexCommandsTest() : BaseFixturesIntegrationTest() { @Test fun `index branch origin-feature-6 and then origin-feature-9`() { - val path = "../../.git" idxClient.commits( - repoPath = path, + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), branchName = "origin/feature/6", - "Binocular", + projectName = "Binocular", ) // assertThat(repoService.findRepo(path)?.commits).hasSize(207) assertDoesNotThrow { idxClient.commits( - repoPath = path, + repoPath = BINOCULAR_REPO_PATH.absolutePathString(), branchName = "origin/feature/9", - "Binocular", + projectName = "Binocular", ) } // assertThat(repoService.findRepo(path)?.commits).hasSize(219) @@ -137,7 +211,7 @@ internal class VcsIndexCommandsTest() : BaseFixturesIntegrationTest() { @Nested inner class OctoRepo { - @AfterEach + @BeforeEach fun `cleanup inner`() { cleanup() } @@ -148,7 +222,7 @@ internal class VcsIndexCommandsTest() : BaseFixturesIntegrationTest() { idxClient.commits( repoPath = "$FIXTURES_PATH/$OCTO_REPO", branchName = "master", - OCTO_PROJECT_NAME, + projectName = OCTO_PROJECT_NAME, ) } val repo = repoService.findRepo("$FIXTURES_PATH/$OCTO_REPO") @@ -161,261 +235,230 @@ internal class VcsIndexCommandsTest() : BaseFixturesIntegrationTest() { { assertThat(repo.branches.map { it.name }).containsOnly("master") }, { assertThat(repo.commits).isNotEmpty() }, { assertThat(repo.commits).hasSize(19) }, - { assertThat(repo.user).hasSize(3) }, + { assertThat(repo.developers).hasSize(3) }, ) } } - @ParameterizedTest - @CsvSource( - "master,14", - "origin/master,13", - ) - @Timeout(value = 10, unit = TimeUnit.SECONDS) - fun `SIMPLE_REPO, index commits -b master`( - branchName: String, - noOfCommits: Int, - ) { -// val session = client.interactive().run() -// -// session.write( -// session.writeSequence().text("index").space().text("commits").space().text("-b").space().text(branchName).space() -// .text("--repo_path").space().text("$FIXTURES_PATH/$SIMPLE_REPO").carriageReturn().build() -// ) - idxClient.commits( - repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", - branchName = branchName, - SIMPLE_PROJECT_NAME, - ) + @Nested + inner class SimpleRepo { -// you can then assert that the session isComplete() or simply proceed with your DB checks -// await().atMost(5, TimeUnit.SECONDS).untilAsserted { - val repo = this.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") - assertAll( - { assertThat(repo).isNotNull() }, -// { assertThat(repo?.id).isNotNull() }, - { assertThat(repo?.branches).isNotEmpty() }, - { assertThat(repo?.branches).hasSize(1) }, - { assertThat(repo?.branches?.map { it.name }).contains(branchName) }, - { assertThat(repo?.commits).isNotEmpty() }, - { assertThat(repo?.commits).hasSize(noOfCommits) }, - { assertThat(repo?.user).hasSize(3) }, - ) -// } -// } - } + @BeforeEach + fun setup() { + cleanup() + } - @Test - fun `repeated commit indexing with different branches`() { - idxClient.commits( - repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", - branchName = "origin/master", - SIMPLE_PROJECT_NAME, + @ParameterizedTest + @CsvSource( + "master,14", + "origin/master,13", ) -// val session = client.interactive().run() -// -// session.write( -// session.writeSequence().text("index").space().text("commits").space().text("-b").space().text("origin/master") -// .space().text("--repo_path").space().text("$FIXTURES_PATH/$SIMPLE_REPO").space().carriageReturn().build() -// ) - - // Check no 1. -// await().atMost(5, TimeUnit.SECONDS).untilAsserted { - val repo1 = this.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") - - assertAll( - { assertThat(repo1).isNotNull() }, -// { assertThat(repo1?.id).isNotNull() }, - { assertThat(repo1?.branches).isNotEmpty() }, - { assertThat(repo1?.branches).hasSize(1) }, - { assertThat(repo1?.branches?.map { it.name }).contains("origin/master") }, - { assertThat(repo1?.commits).isNotEmpty() }, - { assertThat(repo1?.commits).hasSize(13) }, - { assertThat(repo1?.user).hasSize(3) }, - ) -// } - -// session.write( -// session.writeSequence().text("index").space().text("commits").space().text("-b").space().text("master").space() -// .text("--repo_path").space().text("$FIXTURES_PATH/$SIMPLE_REPO").space().carriageReturn().build() -// ) - idxClient.commits( - repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", - branchName = "master", - SIMPLE_PROJECT_NAME, - ) -// await().atLeast(5, TimeUnit.SECONDS).atMost(20, TimeUnit.SECONDS) -// .untilAsserted { - val repo2 = this.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") - - assertAll( - { assertThat(repo2).isNotNull() }, -// { assertThat(repo2?.id).isNotNull() }, - { assertThat(repo2?.branches).isNotEmpty() }, - { assertThat(repo2?.branches).hasSize(2) }, - { assertThat(repo2?.branches?.map { it.name }).containsAll(listOf("origin/master", "master")) }, - { assertThat(repo2?.commits).isNotEmpty() }, - { assertThat(repo2?.commits).hasSize(14) }, - { assertThat(repo2?.user).hasSize(3) }, - ) -// } - } + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `index commits -b master`( + branchName: String, + noOfCommits: Int, + ) { + idxClient.commits( + repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", + branchName = branchName, + projectName = SIMPLE_PROJECT_NAME, + ) - @ParameterizedTest - @CsvSource( - "master,14", - "origin/master,13", - ) - fun `repeated commit indexing, should not change anything`( - branchName: String, - numberOfCommits: Int, - ) { - idxClient.commits( - repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", - branchName = branchName, - SIMPLE_PROJECT_NAME, - ) + val repo = this@VcsIndexCommandsTest.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + assertAll( + { assertThat(repo).isNotNull() }, + { assertThat(repo?.branches).isNotEmpty() }, + { assertThat(repo?.branches).hasSize(1) }, + { assertThat(repo?.branches?.map { it.name }).contains(branchName) }, + { assertThat(repo?.commits).isNotEmpty() }, + { assertThat(repo?.commits).hasSize(noOfCommits) }, + { assertThat(repo?.developers).hasSize(3) }, + ) + } - val repo1 = - run { - val repo = requireNotNull( - this.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") - ) - assertAll( - { assertThat(repo).isNotNull() }, - { assertThat(repo.branches).isNotEmpty() }, - { assertThat(repo.branches).hasSize(1) }, - { assertThat(repo.branches.map { it.name }).contains(branchName) }, - { assertThat(repo.commits).isNotEmpty() }, - { assertThat(repo.commits).hasSize(numberOfCommits) }, - { assertThat(repo.user).hasSize(3) }, - ) - repo - } + @Test + fun `repeated commit indexing with different branches`() { + idxClient.commits( + repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", + branchName = "origin/master", + projectName = SIMPLE_PROJECT_NAME, + ) - idxClient.commits( - repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", - branchName = branchName, - SIMPLE_PROJECT_NAME, - ) + val repo1 = this@VcsIndexCommandsTest.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") - val repo2 = - run { - val repo = this.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + assertAll( + { assertThat(repo1).isNotNull() }, + { assertThat(repo1?.branches).isNotEmpty() }, + { assertThat(repo1?.branches).hasSize(1) }, + { assertThat(repo1?.branches?.map { it.name }).contains("origin/master") }, + { assertThat(repo1?.commits).isNotEmpty() }, + { assertThat(repo1?.commits).hasSize(13) }, + { assertThat(repo1?.developers).hasSize(3) }, + ) - assertAll( - { assertThat(repo).isNotNull() }, -// { assertThat(repo?.id).isNotNull() }, - { assertThat(repo?.branches).isNotEmpty() }, - { assertThat(repo?.branches).hasSize(1) }, - { assertThat(repo?.branches?.map { it.name }).contains(branchName) }, - { assertThat(repo?.commits).isNotEmpty() }, - { assertThat(repo?.commits).hasSize(numberOfCommits) }, - { assertThat(repo?.user).hasSize(3) }, - ) - repo - } + idxClient.commits( + repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", + branchName = "master", + projectName = SIMPLE_PROJECT_NAME, + ) + + val repo2 = this@VcsIndexCommandsTest.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") - run { assertAll( - { assertThat(repo1).isNotNull() }, { assertThat(repo2).isNotNull() }, - { assertThat(repo1).usingRecursiveComparison().ignoringCollectionOrder().isEqualTo(repo2) }, + { assertThat(repo2?.branches).isNotEmpty() }, + { assertThat(repo2?.branches).hasSize(2) }, + { assertThat(repo2?.branches?.map { it.name }).containsAll(listOf("origin/master", "master")) }, + { assertThat(repo2?.commits).isNotEmpty() }, + { assertThat(repo2?.commits).hasSize(14) }, + { assertThat(repo2?.developers).hasSize(3) }, ) } - } - @Test - fun `discover new commit on branch with new user for committer`() { -// val session = client.interactive().run() -// session.write( -// session.writeSequence().text("index").space().text("commits").space().text("-b").space().text("master").space() -// .text("--repo_path").space().text("$FIXTURES_PATH/$SIMPLE_REPO").space().carriageReturn().build() -// ) - idxClient.commits( - repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", - branchName = "master", - SIMPLE_PROJECT_NAME, + @ParameterizedTest + @CsvSource( + "master,14", + "origin/master,13", ) - val repo1: Repository = -// await().atMost(20, TimeUnit.SECONDS).untilAsserted { + fun `repeated commit indexing, should not change anything`( + branchName: String, + numberOfCommits: Int, + ) { + idxClient.commits( + repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", + branchName = branchName, + projectName = SIMPLE_PROJECT_NAME, + ) + + val repo1 = + run { + val repo = requireNotNull( + this@VcsIndexCommandsTest.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + ) + assertAll( + { assertThat(repo).isNotNull() }, + { assertThat(repo.branches).isNotEmpty() }, + { assertThat(repo.branches).hasSize(1) }, + { assertThat(repo.branches.map { it.name }).contains(branchName) }, + { assertThat(repo.commits).isNotEmpty() }, + { assertThat(repo.commits).hasSize(numberOfCommits) }, + { assertThat(repo.developers).hasSize(3) }, + ) + repo + } + + idxClient.commits( + repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", + branchName = branchName, + projectName = SIMPLE_PROJECT_NAME, + ) + + val repo2 = + run { + val repo = this@VcsIndexCommandsTest.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + + assertAll( + { assertThat(repo).isNotNull() }, + { assertThat(repo?.branches).isNotEmpty() }, + { assertThat(repo?.branches).hasSize(1) }, + { assertThat(repo?.branches?.map { it.name }).contains(branchName) }, + { assertThat(repo?.commits).isNotEmpty() }, + { assertThat(repo?.commits).hasSize(numberOfCommits) }, + { assertThat(repo?.developers).hasSize(3) }, + ) + repo + } + run { - val repo = requireNotNull(this.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO")) - assertThat(repo).isNotNull() assertAll( - "branches", - { assertThat(repo.branches).isNotEmpty() }, - { assertThat(repo.branches).hasSize(1) }, - { assertThat(repo.branches.map { it.name }).contains("master") }, - { assertThat(repo.branches.flatMap { it.commits }).hasSize(14) }, - ) - assertAll( - "commits", - { assertThat(repo.commits).isNotEmpty() }, - { assertThat(repo.commits).hasSize(14) }, + { assertThat(repo1).isNotNull() }, + { assertThat(repo2).isNotNull() }, + { assertThat(repo1).usingRecursiveComparison().ignoringCollectionOrder().isEqualTo(repo2) }, ) - assertThat(repo.user).hasSize(3) - return@run repo -// } + } + } + + @Test + fun `discover new commit on branch with new developer for committer`() { + idxClient.commits( + repoPath = "$FIXTURES_PATH/$SIMPLE_REPO", + branchName = "master", + projectName = SIMPLE_PROJECT_NAME, + ) + val repo1: Repository = + run { + val repo = requireNotNull(this@VcsIndexCommandsTest.repoService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO")) + assertThat(repo).isNotNull() + assertAll( + "branches", + { assertThat(repo.branches).isNotEmpty() }, + { assertThat(repo.branches).hasSize(1) }, + { assertThat(repo.branches.map { it.name }).contains("master") }, + { assertThat(repo.branches.flatMap { it.commits }).hasSize(14) }, + ) + assertAll( + "commits", + { assertThat(repo.commits).isNotEmpty() }, + { assertThat(repo.commits).hasSize(14) }, + ) + assertThat(repo.developers).hasSize(3) + return@run repo + } + + val newVcsCommit = + run { + // Find existing parent commit + val parent = repo1.commits.find { it.sha == "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a" } + ?: createTestCommit( + sha = "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", + message = "parent", + repository = repo1, + developerName = "User B", + developerEmail = "b@test.com" + ) + + // Create child commit with new developer + val child = createTestCommit( + sha = "123456789a123456789b123456789c123456789d", + message = "msg1", + repository = repo1, + developerName = "User A", + developerEmail = "a@test.com" + ) + + child.parents.add(parent) + return@run child + } + repo1.branches.first().head = newVcsCommit + + assertThat( + repo1.commits.find { it.sha == "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a" } + ).isNotNull() + + val repo2 = assertDoesNotThrow { + this@VcsIndexCommandsTest.repoService.addCommits(repo1, listOf(newVcsCommit)) } - val newVcsCommit = run { - val branch = Branch(name = "master") - val parent = Commit( - id = null, - "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", - LocalDateTime.now(), - LocalDateTime.now(), - "parent", - null, - null, - "master", + assertThat(repo2).isNotNull() + assertThat(repo2.id).isNotNull() + assertAll( + "branches", + { assertThat(repo2.branches).hasSize(1) }, + { assertThat(repo2.branches.map { it.name }).contains("master") }, + { assertThat(repo2.branches.flatMap { it.commits }).hasSize(15) }, ) - parent.committer = User(name = "User B", email = "b@test.com") - val child = Commit( - id = null, - "123456789_123456789_123456789_123456789_", - LocalDateTime.now(), - LocalDateTime.now(), - "msg1", - null, - null, - null + assertAll( + "commits", + { assertThat(repo2.commits).isNotEmpty() }, + { assertThat(repo2.commits).hasSize(15) }, // new commit (new head) ) - child.committer = User(name = "User A", email = "a@test.com") - child.parents.add(parent) - branch.commits.add(child) - return@run child + assertAll( + "developers", + { assertThat(repo2.developers).hasSize(4) }, + { assertThat(repo2.developers.map { it.email }).contains("a@test.com") }, + ) // new developer a@test.com } - // TODO change to this.commitDao.findHeadForBranch(this.simpleRepo, "master") - assertThat( - repo1.commits.find { it.sha == "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a" } - ).isNotNull() - - val repo2 = assertDoesNotThrow { - this.repoService.addCommits(repo1, listOf(newVcsCommit)) - } - - run { - assertThat(repo2).isNotNull() - assertThat(repo2.id).isNotNull() - assertAll( - "branches", - { assertThat(repo2.branches).hasSize(1) }, - { assertThat(repo2.branches.map { it.name }).contains("master") }, - { assertThat(repo2.branches.flatMap { it.commits }).hasSize(15) }, - ) - assertAll( - "commits", - { assertThat(repo2.commits).isNotEmpty() }, - { assertThat(repo2.commits).hasSize(15) }, // new commit (new head) - ) - assertAll( - "user", - { assertThat(repo2.user).hasSize(4) }, - { assertThat(repo2.user.map { it.email }).contains("a@test.com") }, - ) // new user a@test.com } } } diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/CommitDaoTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/CommitDaoTest.kt index def728b5e..aae2b8fa9 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/CommitDaoTest.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/CommitDaoTest.kt @@ -3,10 +3,10 @@ package com.inso_world.binocular.cli.integration.persistence.dao.sql import com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceNoDataTest import com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceTest import com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceWithDataTest -import com.inso_world.binocular.cli.integration.utils.generateCommits import com.inso_world.binocular.cli.integration.utils.setupRepoConfig import com.inso_world.binocular.cli.integration.utils.traverseGraph import com.inso_world.binocular.cli.service.RepositoryService +import com.inso_world.binocular.core.index.GitIndexer import com.inso_world.binocular.core.integration.base.BaseFixturesIntegrationTest.Companion.FIXTURES_PATH import com.inso_world.binocular.core.integration.base.BaseFixturesIntegrationTest.Companion.OCTO_PROJECT_NAME import com.inso_world.binocular.core.integration.base.BaseFixturesIntegrationTest.Companion.OCTO_REPO @@ -15,11 +15,12 @@ import com.inso_world.binocular.core.integration.base.BaseFixturesIntegrationTes import com.inso_world.binocular.core.service.CommitInfrastructurePort import com.inso_world.binocular.core.service.ProjectInfrastructurePort import com.inso_world.binocular.core.service.RepositoryInfrastructurePort -import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.domain.data.DummyTestData import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User +import com.inso_world.binocular.model.Signature import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Disabled @@ -30,16 +31,20 @@ import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments -import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy +import org.springframework.data.util.ReflectionUtils.setField import java.time.LocalDateTime import java.util.stream.Stream internal class CommitDaoTest( @Autowired private val commitPort: CommitInfrastructurePort, ) : BasePersistenceTest() { + + @Autowired + private lateinit var indexer: GitIndexer + @Autowired private lateinit var projectPort: ProjectInfrastructurePort @@ -54,93 +59,86 @@ internal class CommitDaoTest( @JvmStatic fun invalidCommitTime(): Stream = Stream.concat( - provideInvalidPastOrPresentDateTime(), + DummyTestData.provideInvalidPastOrPresentDateTime(), Stream.of(null), ) } + /** + * Creates a test commit using the new Signature-based constructor. + */ + private fun createTestCommit( + sha: String, + message: String?, + repository: Repository, + developerName: String = "test", + developerEmail: String = "test@example.com", + timestamp: LocalDateTime = LocalDateTime.now().minusHours(1) + ): Commit { + val developer = Developer( + name = developerName, + email = developerEmail, + repository = repository + ) + val signature = Signature(developer = developer, timestamp = timestamp) + return Commit( + sha = sha, + message = message, + authorSignature = signature, + repository = repository, + ) + } + @Nested inner class CleanDatabase : BasePersistenceNoDataTest() { - lateinit var project: Project + lateinit var repository: Repository @BeforeEach fun setUp() { - project = + val project = projectPort.create( Project( name = "test", ), ) - project.repo = - repositoryPort.create( - Repository( - id = null, - localPath = "testRepository", - project = project, - ), + repository = repositoryPort.create( + Repository( + localPath = "testRepository", + project = project ) - project = projectPort.update(project) - } - - @ParameterizedTest - @CsvSource( - value = [ - "d9db3f4c2975616834504e9191a80fcd0f94ef", // 38 chars - "75c7762c536f491d3b7b4be1cfa8f22c808bdd2", // 39 chars - "cd1150dc719edcff6b660cf5ef25976445fb09b75", // 41 chars - "89e2d9a1f6c6dba6ede92619cd5935bb5b07420bef", // 42 chars - ], - ) - fun `commit with invalid sha length should fail`(invalidSha: String) { - val exception = - assertThrows { - val cmt = Commit( - sha = invalidSha, - message = "msg", - commitDateTime = LocalDateTime.of(2024, 1, 1, 1, 1), - authorDateTime = LocalDateTime.of(2024, 1, 1, 1, 1), - ) - this.project.repo?.commits?.add(cmt) - commitPort.create(cmt) - } - assertThat(exception.message).contains(".value.sha") - entityManager.clear() + ) } @ParameterizedTest - @MethodSource("com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceTest#provideBlankStrings") + @MethodSource("com.inso_world.binocular.model.validation.ValidationTestData#provideInvalidShaHex") fun `commit with invalid sha value should fail`(invalidSha: String) { - val branch = Branch( - name = "test", + val cmt = createTestCommit( + sha = "a".repeat(40), + message = "msg", + repository = repository, ) + setField(Commit::class.java.getDeclaredField("sha"), cmt, invalidSha) val exception = assertThrows { - val cmt = Commit( - sha = invalidSha, - message = "msg", - commitDateTime = LocalDateTime.of(2024, 1, 1, 1, 1), - authorDateTime = LocalDateTime.of(2024, 1, 1, 1, 1), - ) - cmt.branches.add(branch) - this.project.repo?.commits?.add(cmt) commitPort.create(cmt) } - assertThat(exception.message).contains(".value.sha") + assertThat(exception.constraintViolations).hasSize(1) + assertThat(exception.constraintViolations.first().propertyPath.toString()).contains("create.value.sha") entityManager.clear() } @ParameterizedTest - @MethodSource("com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceTest#provideBlankStrings") + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") @Disabled("can probably be deleted") fun `commit with invalid message should fail`(invalidMessage: String) { - // Then - This should fail due to validation constraint val exception = assertThrows { commitPort.create( - Commit( + createTestCommit( sha = "091618c311d7c539c0ec316d0a86a6dbee6a3943", message = invalidMessage, - ), + repository = repository, + ) ) } assertThat(exception.message).contains(".value.message") @@ -149,61 +147,35 @@ internal class CommitDaoTest( @ParameterizedTest @MethodSource( - "com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceTest#provideAllowedPastOrPresentDateTime", + "com.inso_world.binocular.domain.data.DummyTestData#provideAllowedPastOrPresentDateTime", ) fun `commit with valid commitDateTime should not fail`(validCommitTime: LocalDateTime) { - val user = - User( - name = "test", - email = "test@example.com", - ) - val branch = Branch( - name = "test", + val cmt = createTestCommit( + sha = "091618c311d7c539c0ec316d0a86a6dbee6a3943", + message = "msg", + repository = repository, + timestamp = validCommitTime, ) - val cmt = - Commit( - sha = "091618c311d7c539c0ec316d0a86a6dbee6a3943", - message = "msg", - commitDateTime = validCommitTime, - authorDateTime = null, - ) - branch.commits.add(cmt) - user.committedCommits.add(cmt) - project.repo?.commits?.add(cmt) - project.repo?.user?.add(user) - project.repo?.branches?.add(branch) + assertDoesNotThrow { - commitPort.create(cmt) + repositoryPort.update(repository) } } @ParameterizedTest @MethodSource( - "com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceTest#provideAllowedPastOrPresentDateTime", + "com.inso_world.binocular.domain.data.DummyTestData#provideAllowedPastOrPresentDateTime", ) fun `commit with valid authorDateTime should not fail`(validAuthorTime: LocalDateTime) { - val user = - User( - name = "test", - email = "test@example.com", - ) - val branch = Branch( - name = "test", + val cmt = createTestCommit( + sha = "091618c311d7c539c0ec316d0a86a6dbee6a3943", + message = "msg", + repository = repository, + timestamp = validAuthorTime, ) - val cmt = - Commit( - sha = "091618c311d7c539c0ec316d0a86a6dbee6a3943", - message = "msg", - commitDateTime = LocalDateTime.of(2024, 1, 1, 1, 1), - authorDateTime = validAuthorTime, - ) - branch.commits.add(cmt) - user.committedCommits.add(cmt) - project.repo?.commits?.add(cmt) - project.repo?.user?.add(user) - project.repo?.branches?.add(branch) + assertDoesNotThrow { - commitPort.create(cmt) + repositoryPort.update(repository) } } @@ -212,40 +184,51 @@ internal class CommitDaoTest( "com.inso_world.binocular.cli.integration.persistence.dao.sql.CommitDaoTest#invalidCommitTime", ) fun `commit with invalid commitDateTime should fail`(invalidCommitTime: LocalDateTime?) { - val cmt = - Commit( - sha = "091618c311d7c539c0ec316d0a86a6dbee6a3943", - message = "msg", - commitDateTime = invalidCommitTime, - authorDateTime = null, - ) - project.repo?.commits?.add(cmt) + val commit = createTestCommit( + sha = "091618c311d7c539c0ec316d0a86a6dbee6a3943", + message = "msg", + repository = repository, + ) + run { + val sigField = Commit::class.java.getDeclaredField("committerSignature") + sigField.isAccessible = true + val sig = sigField.get(commit) as Signature + val tsField = Signature::class.java.getDeclaredField("timestamp") + tsField.isAccessible = true + tsField.set(sig, invalidCommitTime) + } val exception = assertThrows { - commitPort.create(cmt) + repositoryPort.update(repository) } - assertThat(exception.message).contains(".value.commitDateTime") + assertThat(exception.message).contains("timestamp") entityManager.clear() } @ParameterizedTest @MethodSource( - "com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceTest#provideInvalidPastOrPresentDateTime", + "com.inso_world.binocular.domain.data.DummyTestData#provideInvalidPastOrPresentDateTime", ) fun `commit with invalid authorDateTime should fail`(invalidAuthorTime: LocalDateTime?) { - val cmt = - Commit( - sha = "091618c311d7c539c0ec316d0a86a6dbee6a3943", - message = "msg", - commitDateTime = LocalDateTime.of(2024, 1, 1, 1, 1), - authorDateTime = invalidAuthorTime, - ) - project.repo?.commits?.add(cmt) + val commit = createTestCommit( + sha = "091618c311d7c539c0ec316d0a86a6dbee6a3943", + message = "msg", + repository = repository, + ) + // Note: With the new model, timestamp is in the Signature + if (invalidAuthorTime != null) { + val sigField = Commit::class.java.getDeclaredField("authorSignature") + sigField.isAccessible = true + val sig = sigField.get(commit) as Signature + val tsField = Signature::class.java.getDeclaredField("timestamp") + tsField.isAccessible = true + tsField.set(sig, invalidAuthorTime) + } val exception = assertThrows { - commitPort.create(cmt) + repositoryPort.update(repository) } - assertThat(exception.message).contains(".value.authorDateTime") + assertThat(exception.message).contains("timestamp") entityManager.clear() } @@ -253,7 +236,6 @@ internal class CommitDaoTest( inner class SimpleRepo { @BeforeEach fun setup() { -// cleanup the stuff from upper class cleanup() } @@ -261,10 +243,11 @@ internal class CommitDaoTest( fun `index repo, expect all commits in database`() { val repo = run { val cfg = setupRepoConfig( + indexer, "${FIXTURES_PATH}/${SIMPLE_REPO}", "HEAD", + branchName = "master", projectName = SIMPLE_PROJECT_NAME, - branch = Branch(name = "master") ) requireNotNull(projectPort.create(cfg.project).repo) { "Repository could not be created" @@ -276,7 +259,6 @@ internal class CommitDaoTest( val allCommits = commitPort.findAll() assertThat(allCommits).hasSize(14) run { -// check relationship for HEAD val cmt = allCommits.find { it.sha == "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a" } ?: throw IllegalStateException("must find commit here") val child = allCommits.find { it.sha == "3d28b65c324cc8ee0bb7229fb6ac5d7f64129e90" } @@ -288,7 +270,6 @@ internal class CommitDaoTest( assertThat(child.children.toList()[0]).isSameAs(cmt) } run { -// check relationship for somewhere in the middle val suspect = allCommits.find { it.sha == "97babe02ece29439d6f71201067b2c71d3352a81" } ?: throw IllegalStateException("must find commit here") val child = allCommits.find { it.sha == "2403472fd3b2c4487f66961929f1e5895c5013e1" } @@ -337,7 +318,6 @@ internal class CommitDaoTest( inner class OctoRepo { @BeforeEach fun setup() { - // cleanup the stuff from upper class cleanup() } @@ -345,10 +325,11 @@ internal class CommitDaoTest( fun `index repo, expect all commits in database`() { val repo = run { val cfg = setupRepoConfig( + indexer, "${FIXTURES_PATH}/${OCTO_REPO}", "HEAD", + branchName = "master", projectName = OCTO_PROJECT_NAME, - branch = Branch(name = "master") ) requireNotNull(projectPort.create(cfg.project).repo) { "Repository could not be created" @@ -360,7 +341,6 @@ internal class CommitDaoTest( val allCommits = commitPort.findAll() assertThat(allCommits).hasSize(19) run { -// check relationship for HEAD val suspect = allCommits.find { it.sha == "4dedc3c738eee6b69c43cde7d89f146912532cff" } ?: throw IllegalStateException("must find suspect commit here") val parents = run { @@ -407,7 +387,6 @@ internal class CommitDaoTest( ) } run { -// check relationship for somewhere in the middle val suspect = allCommits.find { it.sha == "e236fdb066254a9a6acfbc5517b3865c09586831" } ?: throw IllegalStateException("must find commit here") val child = allCommits.find { it.sha == "abe9605d4e1fe269089f615aee4736103b5318ca" } @@ -485,7 +464,6 @@ internal class CommitDaoTest( assertAll( { assertThat(masterLeaf).isNotEmpty() }, { assertThat(masterLeaf).hasSize(1) }, -// { assertThat(masterLeaf[0].repository!!.id).isEqualTo(this.simpleRepo.id) }, { assertThat(masterLeaf[0].id).isNotNull() }, { assertThat(masterLeaf[0].sha).isEqualTo("b51199ab8b83e31f64b631e42b2ee0b1c7e3259a") }, ) @@ -513,7 +491,6 @@ internal class CommitDaoTest( ) ?: throw IllegalStateException("Head commit of master branch must be found here") assertAll( { assertThat(masterLeaf).isNotNull() }, -// { assertThat(masterLeaf!!.repository!!.id).isEqualTo(this.octoRepo.id) }, { assertThat(masterLeaf.id).isNotNull() }, { assertThat(masterLeaf.sha).isEqualTo("4dedc3c738eee6b69c43cde7d89f146912532cff") }, { assertThat(masterLeaf.parents).hasSize(4) }, @@ -527,8 +504,6 @@ internal class CommitDaoTest( ), ) }, - { assertThat(masterLeaf.branches).hasSize(1) }, - { assertThat(masterLeaf.branches.toList()[0].name).isEqualTo("master") }, ) run { val graph: MutableMap = mutableMapOf() @@ -540,49 +515,45 @@ internal class CommitDaoTest( @Test fun `octoRepo, check all leaf nodes`() { cleanup() + assertAll( + { assertThat(repositoryPort.findAll()).isEmpty() }, + { assertThat(commitPort.findAll()).isEmpty() }, + ) + + val localRepo = run { + val project = Project(name = "octo-2") + Repository("${FIXTURES_PATH}/${OCTO_REPO}", project) + + return@run requireNotNull(projectPort.create(project).repo) + } fun genBranchCommits( - localRepo: Repository?, branch: String, ): Repository { - val octoRepoConfig = - setupRepoConfig( - "${FIXTURES_PATH}/${OCTO_REPO}", - "HEAD", - branch = Branch(name = branch), - projectName = OCTO_PROJECT_NAME, - ) - var tmpRepo = - localRepo ?: run { - val r = octoRepoConfig.repo - r.project = octoRepoConfig.project - octoRepoConfig.project.repo = r - projectPort.create(octoRepoConfig.project).repo - ?: throw IllegalStateException("project not found") - } - generateCommits(repoService, octoRepoConfig, tmpRepo) - tmpRepo = repositoryPort.update(tmpRepo) - return tmpRepo + indexer.traverseBranch(localRepo, branch) + + repositoryPort.update(localRepo) + return localRepo } - var localRepo = genBranchCommits(null, "master") + genBranchCommits("master") assertAll( "check localRepo", { assertThat(localRepo.commits).hasSize(19) }, { assertThat(localRepo.branches).hasSize(1) }, { assertThat(localRepo.branches.find { it.name == "master" }?.commits).hasSize(19) }, - { assertThat(localRepo.user).hasSize(3) }, + { assertThat(localRepo.developers).hasSize(3) }, ) - localRepo = genBranchCommits(localRepo, "bugfix") + genBranchCommits("bugfix") assertAll( "check localRepo", { assertThat(localRepo.commits).hasSize(19 /*master*/ + 2 /*new on bugfix*/) }, { assertThat(localRepo.branches.find { it.name == "master" }?.commits).hasSize(19) }, { assertThat(localRepo.branches.find { it.name == "bugfix" }?.commits).hasSize(17) }, { assertThat(localRepo.branches).hasSize(2) }, - { assertThat(localRepo.user).hasSize(3) }, + { assertThat(localRepo.developers).hasSize(3) }, ) - localRepo = genBranchCommits(localRepo, "feature") + genBranchCommits("feature") assertAll( "check localRepo", { assertThat(localRepo.commits).hasSize(19 /*master*/ + 2 /*new on bugfix*/ + 2 /*new on feature*/) }, @@ -590,9 +561,9 @@ internal class CommitDaoTest( { assertThat(localRepo.branches.find { it.name == "bugfix" }?.commits).hasSize(17) }, { assertThat(localRepo.branches.find { it.name == "feature" }?.commits).hasSize(17) }, { assertThat(localRepo.branches).hasSize(3) }, - { assertThat(localRepo.user).hasSize(3) }, + { assertThat(localRepo.developers).hasSize(3) }, ) - localRepo = genBranchCommits(localRepo, "imported") + genBranchCommits("imported") assertAll( "check localRepo", { assertThat(localRepo.commits).hasSize(19 /*master*/ + 2 /*bugfix*/ + 2 /*feature*/ + 1 /*imported*/) }, @@ -601,7 +572,7 @@ internal class CommitDaoTest( { assertThat(localRepo.branches.find { it.name == "feature" }?.commits).hasSize(17) }, { assertThat(localRepo.branches.find { it.name == "imported" }?.commits).hasSize(1) }, { assertThat(localRepo.branches).hasSize(4) }, - { assertThat(localRepo.user).hasSize(4) }, + { assertThat(localRepo.developers).hasSize(4) }, ) val leafs = @@ -619,16 +590,6 @@ internal class CommitDaoTest( { assertThat(leafs).isNotEmpty() }, { assertThat(leafs).hasSize(4) }, { assertThat(leafs.map { it.sha }).containsAll(leafsItems) }, - { - assertThat(leafs.flatMap { it.branches.map { b -> b.name } }).containsAll( - listOf( - "master", - "bugfix", - "feature", - "imported", - ), - ) - }, ) } } diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/ProjectDaoTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/ProjectDaoTest.kt index 4d15adf38..b76b94079 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/ProjectDaoTest.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/ProjectDaoTest.kt @@ -8,6 +8,7 @@ import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertAll @@ -16,7 +17,7 @@ import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired -import org.springframework.dao.DataIntegrityViolationException +import org.springframework.data.util.ReflectionUtils.setField internal class ProjectDaoTest( @Autowired val repositoryInfrastructurePort: RepositoryInfrastructurePort, @@ -37,7 +38,7 @@ internal class ProjectDaoTest( @Test fun `project can exist without repository`() { // Given - val project = Project(name = "Standalone Project", description = "Project without repo") + val project = Project(name = "Standalone Project").apply { description = "Project without repo" } // When val savedProject = projectInfrastructurePort.create(project) @@ -54,9 +55,10 @@ internal class ProjectDaoTest( @Test fun `project can exist with repository`() { // When - val savedProject = projectInfrastructurePort.create(Project(name = "Project With Repo", description = "Project with repo")) + val savedProject = + projectInfrastructurePort.create(Project(name = "Project With Repo").apply { description = "Project with repo" }) savedProject.repo = - repositoryInfrastructurePort.create(Repository(id = null, localPath = "test-repo", project = savedProject)) + repositoryInfrastructurePort.create(Repository(localPath = "test-repo", project = savedProject)) // Then assertAll( @@ -69,12 +71,21 @@ internal class ProjectDaoTest( } @Test + @Disabled fun `project deletion cascades to repository`() { // Given val savedProject = - projectInfrastructurePort.create(Project(name = "To Be Deleted", description = "Will be deleted with repo")) + projectInfrastructurePort.create( + Project( + name = "To Be Deleted", + ).apply { description = "Will be deleted with repo" } + ) savedProject.repo = - repositoryInfrastructurePort.create(Repository(id = null, localPath = "cascading-repo", project = savedProject)) + repositoryInfrastructurePort.create( + Repository( + localPath = "cascading-repo", + project = savedProject + )) // updated dependencies, as not managed by JPA projectInfrastructurePort.update(savedProject) @@ -93,7 +104,11 @@ internal class ProjectDaoTest( // When val savedProject = projectInfrastructurePort.create(Project(name = "Null Desc Project")) val savedRepo = - repositoryInfrastructurePort.create(Repository(id = null, localPath = "null-desc-repo", project = savedProject)) + repositoryInfrastructurePort.create( + Repository( + localPath = "null-desc-repo", + project = savedProject + )) savedProject.repo = savedRepo // Then @@ -106,10 +121,16 @@ internal class ProjectDaoTest( } @ParameterizedTest - @MethodSource("com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceTest#provideBlankStrings") + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") fun `project with invalid name should fail`(invalidName: String) { // Given - val project = Project(name = invalidName, description = "Empty name") + val project = Project(name = "invalidName").apply { description = "Empty name" } + + setField( + Project::class.java.getDeclaredField("name"), + project, + invalidName + ) // When & Then - This should fail due to validation constraint // Note: This test documents expected behavior for invalid data @@ -120,21 +141,28 @@ internal class ProjectDaoTest( } @ParameterizedTest - @MethodSource("com.inso_world.binocular.cli.integration.persistence.dao.sql.base.BasePersistenceTest#provideAllowedStrings") + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideAllowedStrings") fun `project with allowed names should be handled`(allowedName: String) { // When - val savedProject = projectInfrastructurePort.create(Project(name = allowedName, description = "Long name project")) + val savedProject = + projectInfrastructurePort.create(Project(name = allowedName).apply { description = "Long name project" }) val savedRepo = - repositoryInfrastructurePort.create(Repository(id = null, localPath = "long-name-repo", project = savedProject)) + repositoryInfrastructurePort.create( + Repository( + localPath = "long-name-repo", + project = savedProject + )) savedProject.repo = savedRepo // Then - assertAll("check entities", + assertAll( + "check entities", { assertThat(savedProject.name).isEqualTo(allowedName) }, - { assertThat(savedRepo.project).isNotNull }, + { assertThat(savedRepo.project).isNotNull() }, { assertThat(savedRepo.project?.id).isEqualTo(savedProject.id) }, ) - assertAll("check database numbers", + assertAll( + "check database numbers", { assertThat(projectInfrastructurePort.findAll()).hasSize(1) }, { assertThat(repositoryInfrastructurePort.findAll()).hasSize(1) }, ) @@ -147,17 +175,15 @@ internal class ProjectDaoTest( projectInfrastructurePort.create( Project( name = "Duplicate Name", - description = "First project", - ), + ).apply { description = "First project" }, ) } - assertThrows { + assertThrows { projectInfrastructurePort.create( Project( name = "Duplicate Name", - description = "Second project", - ), + ).apply { description = "Second project" }, ) } assertThat(projectInfrastructurePort.findAll()).hasSize(1) diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/base/BasePersistenceTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/base/BasePersistenceTest.kt index 921b8ed64..d1f3e01c6 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/base/BasePersistenceTest.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/base/BasePersistenceTest.kt @@ -1,48 +1,5 @@ package com.inso_world.binocular.cli.integration.persistence.dao.sql.base import com.inso_world.binocular.cli.base.AbstractCliIntegrationTest -import org.junit.jupiter.params.provider.Arguments -import java.time.LocalDateTime -import java.util.stream.Stream -internal class BasePersistenceTest : AbstractCliIntegrationTest() { - companion object { - @JvmStatic - fun provideBlankStrings(): Stream = - Stream.of( - Arguments.of(""), // Empty string - Arguments.of(" "), // Spaces only - Arguments.of("\t"), // Tab only - Arguments.of("\n"), // Newline only - Arguments.of(" \t\n "), // Mixed whitespace - Arguments.of("\r\n"), // Carriage return + newline - ) - - @JvmStatic - fun provideAllowedStrings(): Stream = - Stream.of( - Arguments.of("Namé-With-Ünicode-字符-123"), - Arguments.of(" Trimmed Name "), - Arguments.of("Name-With_Special@Chars#123"), - Arguments.of("A".repeat(255)), - ) - - @JvmStatic - fun provideAllowedPastOrPresentDateTime(): Stream = - Stream.of( - Arguments.of(LocalDateTime.of(2021, 1, 1, 1, 1, 1, 1)), - Arguments.of(LocalDateTime.now()), - Arguments.of(LocalDateTime.now().minusSeconds(1)), - ) - - @JvmStatic - fun provideInvalidPastOrPresentDateTime(): Stream = - Stream.of( - Arguments.of(LocalDateTime.now().plusSeconds(10)), - Arguments.of(LocalDateTime.now().plusDays(1)), - Arguments.of(LocalDateTime.now().plusWeeks(1)), - Arguments.of(LocalDateTime.now().plusMonths(1)), - Arguments.of(LocalDateTime.now().plusYears(1)), - ) - } -} +internal class BasePersistenceTest : AbstractCliIntegrationTest() diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/base/BasePersistenceWithDataTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/base/BasePersistenceWithDataTest.kt index 4f5adab82..f67bafc35 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/base/BasePersistenceWithDataTest.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/persistence/dao/sql/base/BasePersistenceWithDataTest.kt @@ -4,6 +4,7 @@ import com.inso_world.binocular.cli.BinocularCommandLineApplication import com.inso_world.binocular.cli.integration.utils.RepositoryConfig import com.inso_world.binocular.cli.integration.utils.setupRepoConfig import com.inso_world.binocular.cli.service.RepositoryService +import com.inso_world.binocular.core.index.GitIndexer import com.inso_world.binocular.core.integration.base.BaseFixturesIntegrationTest import com.inso_world.binocular.core.integration.base.InfrastructureDataSetup import com.inso_world.binocular.core.service.BranchInfrastructurePort @@ -13,6 +14,7 @@ import com.inso_world.binocular.infrastructure.sql.SqlTestConfig import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.vcs.ReferenceCategory import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.springframework.beans.factory.annotation.Autowired @@ -31,29 +33,17 @@ import org.springframework.transaction.support.TransactionTemplate ) class BasePersistenceWithDataTest : BaseFixturesIntegrationTest() { @Autowired @Lazy - private lateinit var repoService: RepositoryService - -// @PersistenceContext -// protected lateinit var entityManager: EntityManager + private lateinit var indexer: GitIndexer @Autowired private lateinit var testDataSetupService: InfrastructureDataSetup - @Autowired - lateinit var branchPort: BranchInfrastructurePort - -// @Autowired -// lateinit var userRepository: UserRepository - @Autowired internal lateinit var repositoryPort: RepositoryInfrastructurePort @Autowired private lateinit var projectPort: ProjectInfrastructurePort - @Autowired - private lateinit var transactionTemplate: TransactionTemplate - protected lateinit var simpleRepo: Repository protected lateinit var octoRepo: Repository @@ -61,7 +51,6 @@ class BasePersistenceWithDataTest : BaseFixturesIntegrationTest() { fun setupBase() { fun prepare(repoConfig: RepositoryConfig): Project { repoConfig.project.repo = repoConfig.repo - repoConfig.project.repo?.project = repoConfig.project val project = projectPort.create(repoConfig.project) @@ -70,10 +59,12 @@ class BasePersistenceWithDataTest : BaseFixturesIntegrationTest() { prepare( setupRepoConfig( + indexer, "${FIXTURES_PATH}/${SIMPLE_REPO}", "HEAD", projectName = SIMPLE_PROJECT_NAME, - branch = Branch(name = "master") +// branch = Branch(name = "master", fullName = "refs/remotes/origin/master", category = ReferenceCategory.REMOTE_BRANCH), + branchName = "master" ), ).also { savedProject -> savedProject.repo?.let { this.simpleRepo = it } @@ -82,10 +73,11 @@ class BasePersistenceWithDataTest : BaseFixturesIntegrationTest() { prepare( setupRepoConfig( + indexer, "${FIXTURES_PATH}/${OCTO_REPO}", "HEAD", projectName = OCTO_PROJECT_NAME, - branch = Branch(name = "master") + branchName = "master" ), ).also { savedProject -> savedProject.repo?.let { this.octoRepo = it } diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/CommitServiceTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/CommitServiceTest.kt index 9c33c84d6..9207d0f15 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/CommitServiceTest.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/CommitServiceTest.kt @@ -5,7 +5,10 @@ import com.inso_world.binocular.cli.integration.service.base.BaseServiceTest import com.inso_world.binocular.cli.service.CommitService import com.inso_world.binocular.core.service.CommitInfrastructurePort import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -13,13 +16,39 @@ import org.junit.jupiter.api.assertAll import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.annotation.Autowired import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime -internal class CommitServiceTest private constructor( - @all:Autowired private val commitService: CommitService, - @all:Autowired private val commitDao: CommitInfrastructurePort, +internal class CommitServiceTest @Autowired constructor( + private val commitService: CommitService, + private val commitDao: CommitInfrastructurePort, ) : BaseServiceTest() { + + private lateinit var testDeveloper: Developer + @BeforeEach fun setup() { + // Get or create a test developer from the repository + testDeveloper = simpleRepo.developers.firstOrNull() + ?: Developer(name = "Test Committer", email = "committer@test.com", repository = simpleRepo) + } + + /** + * Creates a test commit with the new Signature-based constructor. + */ + private fun createTestCommit( + sha: String, + message: String = "", + repository: Repository = simpleRepo, + developer: Developer = testDeveloper, + timestamp: LocalDateTime = LocalDateTime.now().minusHours(1) + ): Commit { + val signature = Signature(developer = developer, timestamp = timestamp) + return Commit( + sha = sha, + message = message, + authorSignature = signature, + repository = repository, + ) } @Test @@ -56,18 +85,9 @@ internal class CommitServiceTest private constructor( @Test fun `check existing commits, passing head commit list, expect 1 existing commit`() { - val exitingHeadCommits = - Commit( - id = null, - sha = "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", // head of simple - message = "", - branch = "", -// committer = null, -// author = null, - commitDateTime = null, - authorDateTime = null, -// parents = mutableSetOf(), - ) + val exitingHeadCommits = createTestCommit( + sha = "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", // head of simple + ) val existing = commitService.checkExisting(this.simpleRepo, listOf(exitingHeadCommits)) assertAll( @@ -79,28 +99,12 @@ internal class CommitServiceTest private constructor( @Test fun `check existing commits, passing new commit and existing, expect 1 new commit, 1 missing`() { - val headOfOctoRepo = - Commit( - sha = "ed167f854e871a1566317302c158704f71f8d16c", // imported branch of octo repo - message = "", - branch = "", -// committer = null, -// author = null, - commitDateTime = null, - authorDateTime = null, -// parents = mutableSetOf(), - ) - val headOfSimpleRepo = - Commit( - sha = "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", // head of simple - message = "", - branch = "", -// committer = null, -// author = null, - commitDateTime = null, - authorDateTime = null, -// parents = mutableSetOf(), - ) + val headOfOctoRepo = createTestCommit( + sha = "ed167f854e871a1566317302c158704f71f8d16c", // imported branch of octo repo + ) + val headOfSimpleRepo = createTestCommit( + sha = "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", // head of simple + ) val existing = commitService.checkExisting(this.simpleRepo, listOf(headOfSimpleRepo, headOfOctoRepo)) assertAll( @@ -113,17 +117,9 @@ internal class CommitServiceTest private constructor( @Test fun `check existing commits, passing new commit, expect 1 new commit`() { - val exitingHeadCommits = - Commit( - sha = "ed167f854e871a1566317302c158704f71f8d16c", // imported branch of octo repo - message = "", - branch = "", -// committer = null, -// author = null, - commitDateTime = null, - authorDateTime = null, -// parents = mutableSetOf(), - ) + val exitingHeadCommits = createTestCommit( + sha = "ed167f854e871a1566317302c158704f71f8d16c", // imported branch of octo repo + ) val existing = commitService.checkExisting(this.simpleRepo, listOf(exitingHeadCommits)) assertAll( @@ -145,8 +141,8 @@ internal class CommitServiceTest private constructor( @Test fun `find all commits invalid repo`() { - assertThrows { - this.commitService.findAll(Repository(id = null, localPath = "invalid", project = simpleProject)) - } + assertThat( + this.commitService.findAll(Repository(localPath = "invalid", project = Project(name = "p"))) + ).isEmpty() } } diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/RepositoryServiceTestWithSimpleData.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/RepositoryServiceTestWithSimpleData.kt new file mode 100644 index 000000000..955975ec6 --- /dev/null +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/RepositoryServiceTestWithSimpleData.kt @@ -0,0 +1,262 @@ +package com.inso_world.binocular.cli.integration.service + +import com.inso_world.binocular.cli.integration.service.base.BaseServiceTest +import com.inso_world.binocular.cli.integration.utils.setupRepoConfig +import com.inso_world.binocular.cli.service.RepositoryService +import com.inso_world.binocular.core.data.MockTestDataProvider +import com.inso_world.binocular.core.index.GitIndexer +import com.inso_world.binocular.core.service.ProjectInfrastructurePort +import com.inso_world.binocular.core.service.UserInfrastructurePort +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy +import java.time.LocalDateTime +import kotlin.io.path.Path + +internal class RepositoryServiceTestWithSimpleData @Autowired constructor( + private val repositoryService: RepositoryService, + @Lazy private val projectPort: ProjectInfrastructurePort, + @Autowired private val userInfrastructurePort: UserInfrastructurePort, +) : BaseServiceTest() { + + @Autowired + private lateinit var indexer: GitIndexer + + private lateinit var testData: MockTestDataProvider + + @BeforeEach + fun setup() { + this.testData = MockTestDataProvider() + } + + /** + * Creates a test commit using the new Signature-based constructor. + */ + private fun createTestCommit( + sha: String, + message: String?, + repository: Repository, + developerName: String = "Test User", + developerEmail: String = "test@example.com", + timestamp: LocalDateTime = LocalDateTime.now().minusHours(1) + ): Commit { + val developer = Developer( + name = developerName, + email = developerEmail, + repository = repository + ) + val signature = Signature(developer = developer, timestamp = timestamp) + return Commit( + sha = sha, + message = message, + authorSignature = signature, + repository = repository, + ) + } + + @Test + fun `find existing simple repo`() { + val repo = this.repositoryService.findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}") + assertThat(repo).isNotNull() + assertAll( + { assertThat(repo?.id).isNotNull() }, + { assertThat(this.simpleRepo).usingRecursiveComparison().ignoringCollectionOrder().isEqualTo(repo) }, + { assertThat(repo?.commits).hasSize(14) }, + { assertThat(repo?.developers).hasSize(3) }, + { assertThat(repo?.branches).hasSize(1) }, + { assertThat(repo?.project).isNotNull() }, + { assertThat(repo?.project?.id).isNotNull() }, + ) + } + + @ParameterizedTest + @CsvSource( + "master,b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", + "origin/master,3d28b65c324cc8ee0bb7229fb6ac5d7f64129e90", + ) + fun `get head commit of main branch`( + branch: String, + headCommit: String, + ) { + this.cleanup() + + val simpleRepoConfig = + setupRepoConfig( + indexer, + "${FIXTURES_PATH}/${SIMPLE_REPO}", + "HEAD", + branch, + SIMPLE_PROJECT_NAME, + ) + val localRepo = + run { + val r = simpleRepoConfig.repo + simpleRepoConfig.project.repo = r + requireNotNull( + projectPort.create(simpleRepoConfig.project).repo + ) { + "project not found" + } + } + + val head = this.repositoryService.getHeadCommits(localRepo, branch) + assertAll( + { assertThat(head).isNotNull() }, + { assertThat(head?.sha).isEqualTo(headCommit) }, + ) + } + + @Test + fun `get head commit non existing branch`() { + assertAll( + { + assertDoesNotThrow { + this.repositoryService.getHeadCommits(this.simpleRepo, "non-existing-branch-12345657890") + } + }, + ) + } + + @Test + fun `update simple repo, add another commit, same branch`() { + val repo = requireNotNull(this.repositoryService.findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}")) { + "repository '${FIXTURES_PATH}/${SIMPLE_REPO}' not found (1)" + } + + assertAll( + { assertThat(repo.commits).hasSize(14) }, + { assertThat(repo.branches).hasSize(1) }, + { assertThat(repo.branches.toList()[0].commits).hasSize(14) }, + { assertThat(repo.developers).hasSize(3) }, + ) + + val newVcsCommit = + run { + // Find existing parent commit from the repo + val parent = repo.commits.find { it.sha == "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a" } + ?: createTestCommit( + sha = "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", + message = null, + repository = repo, + developerName = "Parent Committer", + developerEmail = "parent@test.com" + ) + + // Get a test commit from MockTestDataProvider and create proper version + val testCommit = this.testData.commits[1] + val child = createTestCommit( + sha = testCommit.sha, + message = testCommit.message, + repository = repo, + developerName = testCommit.author.name, + developerEmail = testCommit.author.email + ) + child.parents.add(parent) + repo.branches.first().head = child + + return@run child + } + + val repo2 = this.repositoryService.addCommits(repo, listOf(newVcsCommit)) + assertThat(repo2).isSameAs(repo) + + assertAll( + { assertThat(repo2.commits).hasSize(15) }, + { assertThat(repo2.branches).hasSize(1) }, + { assertThat(repo2.branches.first().commits).hasSize(15) }, + { assertThat(repo2.developers).hasSize(4) }, + ) + val repoList = repositoryPort.findAll() + assertThat(repoList).hasSize(1) + with(repoList.first()) { + assertAll( + "check database numbers", + { assertThat(this).isNotSameAs(repo2) }, + { assertThat(this.commits).hasSize(15) }, + { assertThat(this.branches).hasSize(1) }, + { assertThat(this.branches.first().commits).hasSize(15) }, + { assertThat(this.developers).hasSize(4) }, + ) + } + } + + @Test + @Disabled("needs update for new domain model - branch manipulation changed") + fun `update simple repo, add another commit, new branch`() { + run { + val repo = requireNotNull(this.repositoryService.findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}")) { + "repository '${FIXTURES_PATH}/${SIMPLE_REPO}' not found (1)" + } + + assertAll( + { assertThat(repo.commits).hasSize(14) }, + { assertThat(repo.branches).hasSize(1) }, + { assertThat(repo.branches.toList()[0].commits).hasSize(14) }, + { assertThat(repo.developers).hasSize(3) }, + ) + } + + val project = Project(name = SIMPLE_PROJECT_NAME) + + // required to manipulate history + var hashes = + run { + val path = Path("${FIXTURES_PATH}/${SIMPLE_REPO}") + val repo = indexer.findRepo(path, project) + val hashes = indexer.traverseBranch(repo, "master") + // Note: branch field manipulation no longer works the same way with new model + hashes.second + } + hashes = hashes.toMutableList() + val parent = requireNotNull(hashes.find { it.sha == "8f34ebee8f593193048f8bcbf848501bf2465865" }) { + "must find parent here" + } + hashes.add( + run { + val testCommit = this.testData.commits[0] + val cmt = createTestCommit( + sha = testCommit.sha, + message = testCommit.message, + repository = project.repo ?: throw IllegalStateException("repo must exist"), + developerName = this.testData.developers[1].name, + developerEmail = this.testData.developers[1].email + ) + cmt.parents.add(parent) + cmt + } + ) + run { + val repo = requireNotNull(this.repositoryService.findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}")) { + "repository '${FIXTURES_PATH}/${SIMPLE_REPO}' not found (1)" + } + this.repositoryService.addCommits(repo, hashes) + } + + run { + val repo = requireNotNull(this.repositoryService.findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}")) { + "repository '${FIXTURES_PATH}/${SIMPLE_REPO}' not found (1)" + } + + assertAll( + "repo2", + { assertThat(repo.commits).hasSize(15) }, + { assertThat(repo.branches).hasSize(2) }, + { assertThat(repo.developers).hasSize(5) }, + { assertThat(repo.branches.map { it.commits.count() }).containsAll(listOf(14, 3)) }, + { assertThat(repo.branches.map { it.name }).containsAll(listOf("master", "new one")) }, + ) + } + } +} diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/VcsServiceReindexingTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/VcsServiceReindexingTest.kt new file mode 100644 index 000000000..5e72aeec2 --- /dev/null +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/VcsServiceReindexingTest.kt @@ -0,0 +1,233 @@ +package com.inso_world.binocular.cli.integration.service + +import com.inso_world.binocular.cli.integration.service.base.BaseServiceTest +import com.inso_world.binocular.cli.service.RepositoryService +import com.inso_world.binocular.cli.service.VcsService +import com.inso_world.binocular.core.service.ProjectInfrastructurePort +import com.inso_world.binocular.model.Project +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.springframework.beans.factory.annotation.Autowired + +/** + * BDD-style integration tests for VcsService re-indexing functionality. + * + * These tests verify that re-indexing a repository correctly: + * 1. Avoids duplicate commits when HEAD hasn't changed + * 2. Only processes new commits during incremental updates + * 3. Properly tracks branch-level commit history + * + * Note: These tests use the existing simpleRepo/simpleProject from BaseServiceTest + * which is already indexed with the master branch. + */ +@DisplayName("VcsService Re-indexing") +internal class VcsServiceReindexingTest @Autowired constructor( + private val vcsService: VcsService, + private val repositoryService: RepositoryService, + private val projectPort: ProjectInfrastructurePort, +) : BaseServiceTest() { + + @Nested + @DisplayName("Given a repository indexed for the first time (via BaseServiceTest)") + inner class FirstTimeIndexing { + + @Test + @DisplayName("When checking the pre-indexed repository, then all commits should be present") + fun `should have all commits from initial index`() { + // Given - simpleRepo is pre-indexed by BaseServiceTest with master branch + + // When - just verify the state + val repo = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + + // Then - all commits on master should be indexed + assertAll( + { assertThat(repo).isNotNull }, + { assertThat(repo?.commits).isNotEmpty() }, + { assertThat(repo?.commits).hasSize(14) }, // simple repo has 14 commits on master + { assertThat(repo?.branches).hasSize(1) }, + { assertThat(repo?.branches?.first()?.name).isEqualTo("master") }, + ) + } + + @Test + @DisplayName("When checking the pre-indexed repository, then developers should be extracted") + fun `should have developers from initial index`() { + // Given - simpleRepo is pre-indexed + + // When - just verify the state + val repo = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + + // Then - developers should be present + assertThat(repo?.developers).isNotEmpty() + } + } + + @Nested + @DisplayName("Given a repository that was previously indexed") + inner class ReIndexing { + + @Test + @DisplayName("When re-indexing with no new commits, then commit count should remain the same") + fun `should not duplicate commits on re-index`() { + // Given - repository was already indexed by BaseServiceTest + val initialRepo = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + val initialCommitCount = initialRepo?.commits?.size ?: 0 + val initialDeveloperCount = initialRepo?.developers?.size ?: 0 + + // When - re-index the same branch + vcsService.indexRepository("$FIXTURES_PATH/$SIMPLE_REPO", "master", simpleProject) + + // Then - commit count should be unchanged + val reindexedRepo = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + assertAll( + { assertThat(reindexedRepo?.commits).hasSize(initialCommitCount) }, + { assertThat(reindexedRepo?.developers?.size).isEqualTo(initialDeveloperCount) }, + ) + } + + @Test + @DisplayName("When re-indexing, then branch count should remain the same") + fun `should not duplicate branches on re-index`() { + // Given + val initialRepo = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + val initialBranchCount = initialRepo?.branches?.size ?: 0 + + // When - re-index + vcsService.indexRepository("$FIXTURES_PATH/$SIMPLE_REPO", "master", simpleProject) + + // Then + val reindexedRepo = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + assertThat(reindexedRepo?.branches).hasSize(initialBranchCount) + } + + @Test + @DisplayName("When re-indexing multiple times, then data should remain consistent") + fun `should remain idempotent after multiple re-indexes`() { + // Given - initial state from BaseServiceTest + val initialRepo = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + val initialCount = initialRepo?.commits?.size ?: 0 + + // When - re-index 3 more times + vcsService.indexRepository("$FIXTURES_PATH/$SIMPLE_REPO", "master", simpleProject) + val afterFirst = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + val countAfterFirst = afterFirst?.commits?.size ?: 0 + + vcsService.indexRepository("$FIXTURES_PATH/$SIMPLE_REPO", "master", simpleProject) + val afterSecond = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + val countAfterSecond = afterSecond?.commits?.size ?: 0 + + vcsService.indexRepository("$FIXTURES_PATH/$SIMPLE_REPO", "master", simpleProject) + val afterThird = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + val countAfterThird = afterThird?.commits?.size ?: 0 + + // Then - all counts should be equal + assertAll( + { assertThat(initialCount).isEqualTo(14) }, + { assertThat(countAfterFirst).isEqualTo(initialCount) }, + { assertThat(countAfterSecond).isEqualTo(initialCount) }, + { assertThat(countAfterThird).isEqualTo(initialCount) }, + ) + } + } + + @Nested + @DisplayName("Given a repository with multiple branches") + inner class MultipleBranches { + + @Test + @DisplayName("When indexing a second branch, then commits should be properly tracked") + fun `should add second branch without duplicating shared commits`() { + // Given - master is already indexed + val afterMaster = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + val masterCommitCount = afterMaster?.commits?.size ?: 0 + val initialBranchCount = afterMaster?.branches?.size ?: 0 + + // When - index origin/master (different branch, shared ancestry) + vcsService.indexRepository("$FIXTURES_PATH/$SIMPLE_REPO", "origin/master", simpleProject) + val afterOrigin = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + + // Then - should have commits from both branches, properly deduplicated + assertAll( + { assertThat(afterOrigin?.branches).hasSize(initialBranchCount + 1) }, + { assertThat(afterOrigin?.branches?.map { it.name }).containsExactlyInAnyOrder("master", "origin/master") }, + // origin/master has fewer commits (13 vs 14) due to shared ancestry + // total should still be 14 since all origin/master commits are also on master + { assertThat(afterOrigin?.commits?.size).isGreaterThanOrEqualTo(masterCommitCount) }, + ) + } + + @Test + @DisplayName("When re-indexing one branch, then other branches should not be affected") + fun `should not affect other branches on re-index`() { + // Given - index origin/master as second branch + vcsService.indexRepository("$FIXTURES_PATH/$SIMPLE_REPO", "origin/master", simpleProject) + val initialRepo = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + val initialCommitCount = initialRepo?.commits?.size ?: 0 + val initialBranchCount = initialRepo?.branches?.size ?: 0 + + // When - re-index just master + vcsService.indexRepository("$FIXTURES_PATH/$SIMPLE_REPO", "master", simpleProject) + + // Then - counts should be unchanged + val afterReindex = repositoryService.findRepo("$FIXTURES_PATH/$SIMPLE_REPO") + assertAll( + { assertThat(afterReindex?.commits).hasSize(initialCommitCount) }, + { assertThat(afterReindex?.branches).hasSize(initialBranchCount) }, + ) + } + } + + @Nested + @DisplayName("Given the octo repository with merge commits") + inner class OctoRepoTests { + + @Test + @DisplayName("When indexing a repository with merges, then all commits should be tracked") + fun `should handle merge commits correctly`() { + // Given - create a separate project for octo repo + val octoProject = projectPort.create(Project(name = "octo-test-${System.nanoTime()}")) + + // When - index octo repo which has merge commits + vcsService.indexRepository("$FIXTURES_PATH/$OCTO_REPO", "master", octoProject) + + // Then + val repo = repositoryService.findRepo("$FIXTURES_PATH/$OCTO_REPO") + assertAll( + { assertThat(repo).isNotNull }, + { assertThat(repo?.commits).isNotEmpty() }, + { assertThat(repo?.commits).hasSize(19) }, // octo repo has 19 commits on master + { assertThat(repo?.branches?.first()?.name).isEqualTo("master") }, + ) + } + + @Test + @DisplayName("When re-indexing octo repo, then merge parent relationships should be preserved") + fun `should preserve merge relationships on re-index`() { + // Given - create a project and index octo repo + val octoProject = projectPort.create(Project(name = "octo-merge-test-${System.nanoTime()}")) + vcsService.indexRepository("$FIXTURES_PATH/$OCTO_REPO", "master", octoProject) + val initialRepo = repositoryService.findRepo("$FIXTURES_PATH/$OCTO_REPO") + + // Find the merge commit (HEAD of octo repo) - it's an octopus merge + val mergeCommit = initialRepo?.commits?.find { it.sha == "4dedc3c738eee6b69c43cde7d89f146912532cff" } + val initialParentCount = mergeCommit?.parents?.size ?: 0 + + // When - re-index + vcsService.indexRepository("$FIXTURES_PATH/$OCTO_REPO", "master", octoProject) + + // Then - parent relationships should be preserved + val reindexedRepo = repositoryService.findRepo("$FIXTURES_PATH/$OCTO_REPO") + val reindexedMerge = reindexedRepo?.commits?.find { it.sha == "4dedc3c738eee6b69c43cde7d89f146912532cff" } + + assertAll( + { assertThat(reindexedMerge).isNotNull }, + { assertThat(reindexedMerge?.parents).hasSize(initialParentCount) }, + { assertThat(initialParentCount).isEqualTo(4) }, // octopus merge has 4 parents + ) + } + } +} diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/VcsServiceTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/VcsServiceTest.kt new file mode 100644 index 000000000..43d0d2c63 --- /dev/null +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/VcsServiceTest.kt @@ -0,0 +1,6 @@ +package com.inso_world.binocular.cli.integration.service + +import com.inso_world.binocular.cli.integration.service.base.BaseServiceTest + +internal class VcsServiceTest() : BaseServiceTest() { +} diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/base/BaseServiceTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/base/BaseServiceTest.kt index eb6e6608b..276dcda38 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/base/BaseServiceTest.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/service/base/BaseServiceTest.kt @@ -13,15 +13,14 @@ import com.inso_world.binocular.infrastructure.sql.SqlTestConfig import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.vcs.ReferenceCategory import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.Lazy import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration -import org.springframework.test.context.junit.jupiter.SpringExtension @SpringBootTest( classes = [BinocularCommandLineApplication::class], @@ -74,7 +73,7 @@ internal class BaseServiceTest : BaseFixturesIntegrationTest() { data.setUp( projectName = SIMPLE_PROJECT_NAME, repoPath = "${FIXTURES_PATH}/${SIMPLE_REPO}", - branch = Branch(name = "master") + branchName = "master" ) data.project } @@ -83,7 +82,6 @@ internal class BaseServiceTest : BaseFixturesIntegrationTest() { "Repository could not be created with Project" } this.simpleProject.repo = this.simpleRepo - this.simpleRepo.project = this.simpleProject } @AfterEach diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/BinocularCommands.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/BinocularCommands.kt index 5a3b67406..fb46413dc 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/BinocularCommands.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/BinocularCommands.kt @@ -15,7 +15,7 @@ import java.util.concurrent.TimeUnit internal class BinocularCommands : BaseShellWithDataTest() { - @all:Autowired + @Autowired private lateinit var client: ShellTestClient private lateinit var session: ShellTestClient.InteractiveShellSession diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/BuiltinCommands.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/BuiltinCommands.kt index 6aa68bba0..81131216c 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/BuiltinCommands.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/BuiltinCommands.kt @@ -6,16 +6,13 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertAll -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.shell.test.ShellAssertions import org.springframework.shell.test.ShellTestClient import java.util.concurrent.TimeUnit -import kotlin.run -internal class BuiltinCommands( - @all:Autowired val client: ShellTestClient, +internal class BuiltinCommands @Autowired constructor( + val client: ShellTestClient, ) : BaseShellNoDataTest() { private lateinit var session: ShellTestClient.InteractiveShellSession diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/base/BaseShellWithDataTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/base/BaseShellWithDataTest.kt index 8e8bf8506..7bfee9d89 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/base/BaseShellWithDataTest.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/shell/base/BaseShellWithDataTest.kt @@ -56,22 +56,19 @@ internal open class BaseShellWithDataTest : BaseFixturesIntegrationTest() { this.projectRepository.create( Project( name = SIMPLE_PROJECT_NAME, - description = "desc", - ), + ).apply { description = "desc" }, ) advancedProject = this.projectRepository.create( Project( name = ADVANCED_PROJECT_NAME, - description = "desc", - ), + ).apply { description = "desc" }, ) octoProject = this.projectRepository.create( Project( name = OCTO_PROJECT_NAME, - description = "desc", - ), + ).apply { description = "desc" }, ) } diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/utils/RepositoryConfig.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/utils/RepositoryConfig.kt index f795c22e9..02ab1180e 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/utils/RepositoryConfig.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/utils/RepositoryConfig.kt @@ -1,55 +1,45 @@ package com.inso_world.binocular.cli.integration.utils import com.inso_world.binocular.cli.service.RepositoryService -import com.inso_world.binocular.ffi.BinocularFfi -import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.core.index.GitIndexer +import com.inso_world.binocular.ffi.GixIndexer import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.core.io.ClassPathResource import kotlin.io.path.Path + internal data class RepositoryConfig( val repo: Repository, - val startCommit: String, + val startCommit: Commit, val hashes: List, val project: Project, ) internal fun setupRepoConfig( + indexer: GitIndexer, path: String, startSha: String? = "HEAD", - branch: Branch, + branchName: String, projectName: String, ): RepositoryConfig { - val ffi = BinocularFfi() - val repo = run { - val p = Path(path) - return@run ffi.findRepo(p) - } - require(repo.branches.add(branch)) - val cmt = ffi.findCommit(repo, startSha ?: "HEAD") - val hashes = ffi.traverseBranch(repo,branch) val project = Project( name = projectName, ) - project.repo = repo - repo.project = project + val repo = run { + val p = Path(path) + return@run indexer.findRepo(p, project) + } + val (branch, commits) = indexer.traverseBranch(repo, branchName) + val cmt = indexer.findCommit(repo, startSha ?: "HEAD") return RepositoryConfig( repo = repo, startCommit = cmt, - hashes = hashes, + hashes = commits, project = project, ) } - -@Deprecated("legacy") -internal fun generateCommits( - svc: RepositoryService, - repoConfig: RepositoryConfig, - concreteRepo: Repository, -): List = - svc.transformCommits( - concreteRepo, - repoConfig.hashes - ).toList() diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/utils/TestDataProvider.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/utils/TestDataProvider.kt index f50e57c64..ea4bced55 100644 --- a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/utils/TestDataProvider.kt +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/integration/utils/TestDataProvider.kt @@ -1,12 +1,8 @@ package com.inso_world.binocular.cli.integration.utils import com.inso_world.binocular.core.index.GitIndexer -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User -import java.time.LocalDateTime import kotlin.io.path.Path internal class RealDataProvider( @@ -19,18 +15,14 @@ internal class RealDataProvider( fun setUp( projectName: String, repoPath: String, - startSha: String? = "HEAD", - branch: Branch, + branchName: String, ) { project = Project( name = projectName, ) - repo = idx.findRepo(Path(repoPath).toRealPath()) + repo = idx.findRepo(Path(repoPath).toRealPath(), project) project.repo = repo - repo.project = project - require(repo.branches.add(branch)) -// val cmt = idx.findCommit(repo, startSha ?: "HEAD") - idx.traverseBranch(repo,branch) + idx.traverseBranch(repo, branchName) } } diff --git a/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/unit/service/RepositoryServiceTest.kt b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/unit/service/RepositoryServiceTest.kt new file mode 100644 index 000000000..9f92695e1 --- /dev/null +++ b/binocular-backend-new/cli/src/test/kotlin/com/inso_world/binocular/cli/unit/service/RepositoryServiceTest.kt @@ -0,0 +1,424 @@ +package com.inso_world.binocular.cli.unit.service + +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.vcs.ReferenceCategory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import java.time.LocalDateTime + +/** + * BDD unit tests for [RepositoryService] using the new domain model with [Developer] and [Signature]. + * + * These tests verify behavior without database integration, focusing on domain logic. + */ +@DisplayName("RepositoryService") +internal class RepositoryServiceTest : BaseUnitTest() { + + private lateinit var project: Project + private lateinit var repository: Repository + + @BeforeEach + fun setUp() { + project = Project(name = "test-project") + repository = Repository(localPath = "/test/repo", project = project) + } + + @Nested + @DisplayName("Given a repository with existing developers") + inner class DeveloperDeduplication { + + private lateinit var existingDeveloper: Developer + + @BeforeEach + fun setUp() { + existingDeveloper = Developer( + name = "Alice", + email = "alice@example.com", + repository = repository + ) + } + + @Test + @DisplayName("When creating a commit with an existing developer, then the developer should be reused") + fun `should reuse existing developer when git signature matches`() { + // Given - developer already in repository + assertThat(repository.developers).contains(existingDeveloper) + + // When - create commit with same git signature + val timestamp = LocalDateTime.now().minusHours(1) + val signature = Signature(developer = existingDeveloper, timestamp = timestamp) + val commit = Commit( + sha = "a".repeat(40), + authorSignature = signature, + message = "Test commit", + repository = repository + ) + + // Then - commit uses the same developer instance + assertAll( + { assertThat(commit.author).isSameAs(existingDeveloper) }, + { assertThat(commit.committer).isSameAs(existingDeveloper) }, + { assertThat(repository.developers).hasSize(1) }, + { assertThat(existingDeveloper.authoredCommits).contains(commit) }, + { assertThat(existingDeveloper.committedCommits).contains(commit) } + ) + } + + @Test + @DisplayName("When creating a commit with a new developer, then a new developer should be registered") + fun `should register new developer when git signature differs`() { + // Given - existing developer already in repository + val initialDeveloperCount = repository.developers.size + + // When - create commit with different developer + val newDeveloper = Developer( + name = "Bob", + email = "bob@example.com", + repository = repository + ) + val timestamp = LocalDateTime.now().minusHours(1) + val signature = Signature(developer = newDeveloper, timestamp = timestamp) + val commit = Commit( + sha = "b".repeat(40), + authorSignature = signature, + message = "Another commit", + repository = repository + ) + + // Then - both developers are registered + assertAll( + { assertThat(repository.developers).hasSize(initialDeveloperCount + 1) }, + { assertThat(repository.developers).contains(existingDeveloper) }, + { assertThat(repository.developers).contains(newDeveloper) }, + { assertThat(commit.author).isSameAs(newDeveloper) } + ) + } + } + + @Nested + @DisplayName("Given commits with parent-child relationships") + inner class ParentChildRelationships { + + private lateinit var developer: Developer + private lateinit var timestamp: LocalDateTime + + @BeforeEach + fun setUp() { + developer = Developer( + name = "Carol", + email = "carol@example.com", + repository = repository + ) + timestamp = LocalDateTime.now().minusHours(2) + } + + @Test + @DisplayName("When adding a parent to a commit, then bidirectional relationship should be established") + fun `should establish bidirectional parent-child relationship`() { + // Given - parent commit (using valid hex SHA) + val parentCommit = Commit( + sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + authorSignature = Signature(developer = developer, timestamp = timestamp), + message = "Parent commit", + repository = repository + ) + + // When - create child and add parent + val childCommit = Commit( + sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + authorSignature = Signature(developer = developer, timestamp = timestamp.plusMinutes(30)), + message = "Child commit", + repository = repository + ) + childCommit.parents.add(parentCommit) + + // Then - bidirectional relationship established + assertAll( + { assertThat(childCommit.parents).contains(parentCommit) }, + { assertThat(parentCommit.children).contains(childCommit) } + ) + } + + @Test + @DisplayName("When building a diamond structure, then all relationships should be consistent") + fun `should handle diamond parent-child structure`() { + // Given - diamond: root -> A,B -> merge + val root = Commit( + sha = "1".repeat(40), + authorSignature = Signature(developer = developer, timestamp = timestamp), + message = "Root", + repository = repository + ) + + val branchA = Commit( + sha = "2".repeat(40), + authorSignature = Signature(developer = developer, timestamp = timestamp.plusMinutes(10)), + message = "Branch A", + repository = repository + ) + branchA.parents.add(root) + + val branchB = Commit( + sha = "3".repeat(40), + authorSignature = Signature(developer = developer, timestamp = timestamp.plusMinutes(15)), + message = "Branch B", + repository = repository + ) + branchB.parents.add(root) + + // When - create merge commit with both branches as parents + val merge = Commit( + sha = "4".repeat(40), + authorSignature = Signature(developer = developer, timestamp = timestamp.plusMinutes(20)), + message = "Merge", + repository = repository + ) + merge.parents.add(branchA) + merge.parents.add(branchB) + + // Then - all relationships correct + assertAll( + { assertThat(root.parents).isEmpty() }, + { assertThat(root.children).containsExactlyInAnyOrder(branchA, branchB) }, + { assertThat(branchA.parents).containsExactly(root) }, + { assertThat(branchA.children).containsExactly(merge) }, + { assertThat(branchB.parents).containsExactly(root) }, + { assertThat(branchB.children).containsExactly(merge) }, + { assertThat(merge.parents).containsExactlyInAnyOrder(branchA, branchB) }, + { assertThat(merge.children).isEmpty() } + ) + } + } + + @Nested + @DisplayName("Given commits with different author and committer") + inner class AuthorCommitterDifference { + + @Test + @DisplayName("When author and committer differ, then both developers should be tracked") + fun `should track both author and committer when different`() { + // Given - two different developers + val author = Developer( + name = "Author", + email = "author@example.com", + repository = repository + ) + val committer = Developer( + name = "Committer", + email = "committer@example.com", + repository = repository + ) + + val timestamp = LocalDateTime.now().minusHours(1) + + // When - create commit with different author and committer + val commit = Commit( + sha = "a".repeat(40), + authorSignature = Signature(developer = author, timestamp = timestamp), + committerSignature = Signature(developer = committer, timestamp = timestamp.plusMinutes(5)), + message = "Cherry-picked commit", + repository = repository + ) + + // Then - both are tracked correctly + assertAll( + { assertThat(commit.author).isSameAs(author) }, + { assertThat(commit.committer).isSameAs(committer) }, + { assertThat(commit.author).isNotSameAs(commit.committer) }, + { assertThat(author.authoredCommits).contains(commit) }, + { assertThat(committer.committedCommits).contains(commit) }, + { assertThat(author.committedCommits).doesNotContain(commit) }, + { assertThat(committer.authoredCommits).doesNotContain(commit) }, + { assertThat(repository.developers).containsExactlyInAnyOrder(author, committer) } + ) + } + + @Test + @DisplayName("When author and committer are the same, then only one developer should be used") + fun `should use same developer instance when author equals committer`() { + // Given - single developer + val developer = Developer( + name = "Dev", + email = "dev@example.com", + repository = repository + ) + + val timestamp = LocalDateTime.now().minusHours(1) + + // When - create commit with same author and committer + val commit = Commit( + sha = "b".repeat(40), + authorSignature = Signature(developer = developer, timestamp = timestamp), + // committerSignature defaults to authorSignature + message = "Normal commit", + repository = repository + ) + + // Then - same developer instance used + assertAll( + { assertThat(commit.author).isSameAs(commit.committer) }, + { assertThat(developer.authoredCommits).contains(commit) }, + { assertThat(developer.committedCommits).contains(commit) }, + { assertThat(repository.developers).hasSize(1) } + ) + } + } + + @Nested + @DisplayName("Given a repository receiving commits from GitIndexer") + inner class CommitRegistration { + + @Test + @DisplayName("When commits are created with repository reference, then they auto-register") + fun `commits should auto-register with repository`() { + // Given - empty repository + assertThat(repository.commits).isEmpty() + + // When - create commits + val developer = Developer( + name = "Dev", + email = "dev@example.com", + repository = repository + ) + val timestamp = LocalDateTime.now().minusHours(1) + + val commit1 = Commit( + sha = "1".repeat(40), + authorSignature = Signature(developer = developer, timestamp = timestamp), + message = "First", + repository = repository + ) + val commit2 = Commit( + sha = "2".repeat(40), + authorSignature = Signature(developer = developer, timestamp = timestamp.plusMinutes(10)), + message = "Second", + repository = repository + ) + + // Then - commits auto-registered + assertAll( + { assertThat(repository.commits).hasSize(2) }, + { assertThat(repository.commits).containsExactlyInAnyOrder(commit1, commit2) } + ) + } + + @Test + @DisplayName("When same commit SHA is created twice, then deduplication should occur") + fun `should not duplicate commits with same SHA`() { + // Given - developer and commit + val developer = Developer( + name = "Dev", + email = "dev@example.com", + repository = repository + ) + val timestamp = LocalDateTime.now().minusHours(1) + val sha = "a".repeat(40) + + val commit1 = Commit( + sha = sha, + authorSignature = Signature(developer = developer, timestamp = timestamp), + message = "Original", + repository = repository + ) + + // When - try to add same SHA again via repository.commits + val initialSize = repository.commits.size + val added = repository.commits.add(commit1) + + // Then - no duplication + assertAll( + { assertThat(added).isFalse() }, + { assertThat(repository.commits).hasSize(initialSize) } + ) + } + } + + @Nested + @DisplayName("Given branch operations") + inner class BranchOperations { + + @Test + @DisplayName("When a branch is created, then it should reference HEAD commit and be registered") + fun `branch should register with repository and track head`() { + // Given - commit for branch head + val developer = Developer( + name = "Dev", + email = "dev@example.com", + repository = repository + ) + val timestamp = LocalDateTime.now().minusHours(1) + val headCommit = Commit( + sha = "cccccccccccccccccccccccccccccccccccccccc", + authorSignature = Signature(developer = developer, timestamp = timestamp), + message = "HEAD", + repository = repository + ) + + // When - create branch + val branch = Branch( + name = "main", + fullName = "refs/heads/main", + repository = repository, + head = headCommit, + category = ReferenceCategory.LOCAL_BRANCH + ) + + // Then - branch registered with correct head + assertAll( + { assertThat(repository.branches).contains(branch) }, + { assertThat(branch.head).isSameAs(headCommit) }, + { assertThat(branch.latestCommit).isEqualTo(headCommit.sha) } + ) + } + } + + @Nested + @DisplayName("Given findRepo operation") + inner class FindRepoOperation { + + @Test + @DisplayName("When finding repository by path, then path should be normalized") + fun `path normalization should handle git suffix`() { + // Test the normalizePath helper logic + val pathWithGit = "/path/to/repo/.git" + val pathWithoutGit = "/path/to/repo" + + // Both should normalize to the same effective path + val normalizedWithGit = if (pathWithGit.endsWith(".git")) pathWithGit else "$pathWithGit/.git" + val normalizedWithoutGit = if (pathWithoutGit.endsWith(".git")) pathWithoutGit else "$pathWithoutGit/.git" + + assertThat(normalizedWithGit).isEqualTo("/path/to/repo/.git") + assertThat(normalizedWithoutGit).isEqualTo("/path/to/repo/.git") + } + } + + @Nested + @DisplayName("Given create repository operation") + inner class CreateOperation { + + @Test + @DisplayName("When creating repository with valid data, then validation should pass") + fun `should validate repository has no id and has project`() { + // Given - create a fresh project for this test (different from shared one) + val freshProject = Project(name = "fresh-project") + val validRepo = Repository(localPath = "/test/path", project = freshProject) + + // Then - validation passes + assertAll( + { assertThat(validRepo.id).isNull() }, + { assertThat(validRepo.project).isNotNull() }, + { assertThat(validRepo.project.repo).isSameAs(validRepo) } + ) + } + } +} diff --git a/binocular-backend-new/cli/src/test/resources/application-gix.yaml b/binocular-backend-new/cli/src/test/resources/application-gix.yaml new file mode 100644 index 000000000..5e155c955 --- /dev/null +++ b/binocular-backend-new/cli/src/test/resources/application-gix.yaml @@ -0,0 +1,4 @@ +binocular: + gix: + skip-merges: false + use-mailmap: true diff --git a/binocular-backend-new/cli/src/test/resources/application-postgres.yaml b/binocular-backend-new/cli/src/test/resources/application-postgres.yaml index 482240326..78fbd03db 100644 --- a/binocular-backend-new/cli/src/test/resources/application-postgres.yaml +++ b/binocular-backend-new/cli/src/test/resources/application-postgres.yaml @@ -11,6 +11,7 @@ spring: defer-datasource-initialization: false hibernate: ddl-auto: none + show-sql: true liquibase: change-log: classpath:db/changelog/db.changelog-master.yaml drop-first: true diff --git a/binocular-backend-new/cli/src/test/resources/application.yaml b/binocular-backend-new/cli/src/test/resources/application.yaml index 4eaca8c63..c3da28174 100644 --- a/binocular-backend-new/cli/src/test/resources/application.yaml +++ b/binocular-backend-new/cli/src/test/resources/application.yaml @@ -1,6 +1,8 @@ binocular: - index: - path: "./src/test/resources/fixtures/simple" + cli: + index: + scm: + path: "./src/test/resources/fixtures/simple" spring: profiles: active: test,gix,sql,postgres diff --git a/binocular-backend-new/cli/src/test/resources/fixtures/dummy_orphan.sh b/binocular-backend-new/cli/src/test/resources/fixtures/dummy_orphan.sh deleted file mode 100755 index 261cd92f7..000000000 --- a/binocular-backend-new/cli/src/test/resources/fixtures/dummy_orphan.sh +++ /dev/null @@ -1,366 +0,0 @@ -#!/bin/bash -# This script creates a dummy Git repository with multiple commits, -# including file operations, different authors/timestamps, classical branch merges, -# an octopus merge (merging three branches in a single merge commit), -# and an imported orphan commit that simulates a commit merged from another remote. -# -# Usage: ./make-dummy-repo.sh /path/to/repo-directory -# -# The script runs quietly (unless an error occurs). - -set -euo pipefail - -# Force Git to use UTC for all timestamps -export TZ=UTC - -# Verify that exactly one argument (the target directory) is provided. -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -REPO_DIR="$1" -mkdir -p "$REPO_DIR" -cd "$REPO_DIR" - -# Initialize an empty Git repository quietly. -git init -q - -# Determine the default branch name (could be 'main' or 'master') -MAIN_BRANCH="master" - -############################################################################### -# Commits 1-15: Initial history with various operations and authors -############################################################################### - -# Commit 1: Initial commit by Alice – create file1.txt -echo "Hello, world!" > file1.txt -git add file1.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T12:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T12:00:00" \ -git commit -m "Initial commit" -q - -# Commit 2: Append to file1.txt by Bob -echo "Additional content" >> file1.txt -git add file1.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T13:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T13:00:00" \ -git commit -m "Append to file1.txt" -q - -# Commit 3: Add file2.txt by Carol -echo "This is file2" > file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T14:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T14:00:00" \ -git commit -m "Add file2.txt" -q - -# Commit 4: Modify file2.txt by Alice -echo "More content for file2" >> file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T15:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T15:00:00" \ -git commit -m "Modify file2.txt" -q - -# Commit 5: Rename file1.txt to file1-renamed.txt by Bob -git mv file1.txt file1-renamed.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T16:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T16:00:00" \ -git commit -m "Rename file1.txt to file1-renamed.txt" -q - -# Commit 6: Delete file2.txt by Carol -git rm file2.txt -q -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T17:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T17:00:00" \ -git commit -m "Delete file2.txt" -q - -# Commit 7: Create file3.txt by Alice (with differing author/committer times) -echo "Content of file3" > file3.txt -git add file3.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T18:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T18:05:00" \ -git commit -m "Create file3.txt" -q - -# Commit 8: Update file3.txt with more content by Bob -echo "Appending more to file3" >> file3.txt -git add file3.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T19:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T19:00:00" \ -git commit -m "Update file3.txt with more content" -q - -# Commit 9: Create directory 'dir1' and add file4.txt inside by Carol -mkdir dir1 -echo "Inside dir1" > dir1/file4.txt -git add dir1/file4.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T20:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T20:00:00" \ -git commit -m "Create dir1 and add file4.txt" -q - -# Commit 10: Rename file in dir1: file4.txt to file4-renamed.txt by Alice -git mv dir1/file4.txt dir1/file4-renamed.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T21:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T21:00:00" \ -git commit -m "Rename file4.txt to file4-renamed.txt in dir1" -q - -# Commit 11: Create binary file file5.bin by Bob -head -c 100 /dev/urandom > file5.bin -git add file5.bin -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T22:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T22:00:00" \ -git commit -m "Add binary file file5.bin" -q - -# Commit 12: Delete file3.txt by Carol -git rm file3.txt -q -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T23:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T23:00:00" \ -git commit -m "Delete file3.txt" -q - -# Commit 13: Modify file1-renamed.txt by inserting a line in the middle by Alice -awk 'NR==1{print; print "Inserted line"; next} {print}' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T00:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T00:00:00" \ -git commit -m "Modify file1-renamed.txt by inserting a line" -q - -# Commit 14: Re-add file2.txt with new content by Bob (with mismatched author/committer times) -echo "Recreated file2" > file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T00:30:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T01:00:00" \ -git commit -m "Re-add file2.txt with new content" -q - -# Commit 15: Final update modifying multiple files by Carol -echo "Final update to file1-renamed.txt" >> file1-renamed.txt -echo "Final update to file2.txt" >> file2.txt -git add file1-renamed.txt file2.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T02:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T02:00:00" \ -git commit -m "Final update: modify multiple files" -q - -############################################################################### -# Orphan Commit from another remote repository (independent history) -############################################################################### -# Create an orphan branch "imported" with no ancestors. -git checkout --orphan imported -q -# Remove any tracked files from the index (ignore errors if none exist) -git rm -rf . > /dev/null 2>&1 || true -# Add a new file unique to the imported history. -echo "Imported commit content" > imported.txt -git add imported.txt -GIT_AUTHOR_NAME="Dave" \ -GIT_AUTHOR_EMAIL="dave@example.com" \ -GIT_AUTHOR_DATE="2023-01-03T00:00:00" \ -GIT_COMMITTER_NAME="Dave" \ -GIT_COMMITTER_EMAIL="dave@example.com" \ -GIT_COMMITTER_DATE="2023-01-03T00:00:00" \ -git commit -m "Imported commit: independent history from another remote" -q - -# Merge the imported orphan branch into the main branch, allowing unrelated histories. -git checkout -q "$MAIN_BRANCH" -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-03T01:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-03T01:00:00" \ -git merge --allow-unrelated-histories imported -m "Merge imported history from remote" -q - -############################################################################### -# Classical Merge Commits -############################################################################### - -# ----- Branch "feature" ----- -# Create branch "feature" from the current HEAD. -git checkout -b feature -q - -# Commit 16: On branch 'feature', append a line to file1-renamed.txt. -echo "Feature update: appended line" >> file1-renamed.txt -git add file1-renamed.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T03:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T03:00:00" \ -git commit -m "Feature: update file1-renamed.txt" -q - -# Commit 17: On branch 'feature', create a new file file6.txt. -echo "Content for file6 from feature branch" > file6.txt -git add file6.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T03:30:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T03:30:00" \ -git commit -m "Feature: add file6.txt" -q - -# Switch back to the main branch. -git checkout -q "$MAIN_BRANCH" - -# Merge branch 'feature' into main with a merge commit. -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T04:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T04:00:00" \ -git merge --no-ff feature -m "Merge branch 'feature'" -q - -# ----- Branch "bugfix" ----- -# Create branch "bugfix" from the current HEAD. -git checkout -b bugfix -q - -# Commit 18: On branch 'bugfix', modify file2.txt by appending a bugfix line. -echo "Bugfix: corrected a typo in file2.txt" >> file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T04:30:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T04:30:00" \ -git commit -m "Bugfix: update file2.txt with correction" -q - -# Commit 19: Further modify file2.txt on the 'bugfix' branch. -echo "Bugfix: final adjustment to file2.txt" >> file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T05:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T05:00:00" \ -git commit -m "Bugfix: further update to file2.txt" -q - -# Switch back to the main branch. -git checkout -q "$MAIN_BRANCH" - -# Merge branch 'bugfix' into main with a merge commit. -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T05:30:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T05:30:00" \ -git merge --no-ff bugfix -m "Merge branch 'bugfix'" -q - -############################################################################### -# Octopus Merge: Merge three branches in one commit -############################################################################### - -# Create branch "octo1" from main and add a new file. -git checkout -b octo1 -q -echo "Change from octo1" > octo1.txt -git add octo1.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T06:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T06:00:00" \ -git commit -m "Octo1: Add octo1.txt" -q - -# Return to main branch. -git checkout -q "$MAIN_BRANCH" - -# Create branch "octo2" from main and add a new file. -git checkout -b octo2 -q -echo "Change from octo2" > octo2.txt -git add octo2.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T06:30:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T06:30:00" \ -git commit -m "Octo2: Add octo2.txt" -q - -# Return to main branch. -git checkout -q "$MAIN_BRANCH" - -# Create branch "octo3" from main and add a new file. -git checkout -b octo3 -q -echo "Change from octo3" > octo3.txt -git add octo3.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T07:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T07:00:00" \ -git commit -m "Octo3: Add octo3.txt" -q - -# Return to main branch before merging. -git checkout -q "$MAIN_BRANCH" - -# Perform the octopus merge (merging octo1, octo2, and octo3 in one commit). -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T07:30:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T07:30:00" \ -git merge --no-ff octo1 octo2 octo3 -m "Octopus merge of octo1, octo2, and octo3" -q - -# End of script. -exit 0 diff --git a/binocular-backend-new/cli/src/test/resources/fixtures/dummy_repo.sh b/binocular-backend-new/cli/src/test/resources/fixtures/dummy_repo.sh deleted file mode 100755 index 4a07dedd0..000000000 --- a/binocular-backend-new/cli/src/test/resources/fixtures/dummy_repo.sh +++ /dev/null @@ -1,335 +0,0 @@ -#!/bin/bash -# This script creates a dummy Git repository with multiple commits, -# including file operations, different authors/timestamps, classical branch merges, -# and one octopus merge (merging three branches in a single merge commit). -# -# Usage: ./make-dummy-repo.sh /path/to/repo-directory -# -# The script runs quietly (unless an error occurs). - -set -euo pipefail - -# Verify that exactly one argument (the target directory) is provided. -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -REPO_DIR="$1" -mkdir -p "$REPO_DIR" -cd "$REPO_DIR" - -# Initialize an empty Git repository quietly. -git init -q - -# Determine the default branch name (could be 'main' or 'master') -MAIN_BRANCH="master" - -############################################################################### -# Commits 1-15: Initial history with various operations and authors -############################################################################### - -# Commit 1: Initial commit by Alice – create file1.txt -echo "Hello, world!" > file1.txt -git add file1.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T12:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T12:00:00" \ -git commit -m "Initial commit" -q - -# Commit 2: Append to file1.txt by Bob -echo "Additional content" >> file1.txt -git add file1.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T13:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T13:00:00" \ -git commit -m "Append to file1.txt" -q - -# Commit 3: Add file2.txt by Carol -echo "This is file2" > file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T14:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T14:00:00" \ -git commit -m "Add file2.txt" -q - -# Commit 4: Modify file2.txt by Alice -echo "More content for file2" >> file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T15:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T15:00:00" \ -git commit -m "Modify file2.txt" -q - -# Commit 5: Rename file1.txt to file1-renamed.txt by Bob -git mv file1.txt file1-renamed.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T16:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T16:00:00" \ -git commit -m "Rename file1.txt to file1-renamed.txt" -q - -# Commit 6: Delete file2.txt by Carol -git rm file2.txt -q -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T17:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T17:00:00" \ -git commit -m "Delete file2.txt" -q - -# Commit 7: Create file3.txt by Alice (with differing author/committer times) -echo "Content of file3" > file3.txt -git add file3.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T18:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T18:05:00" \ -git commit -m "Create file3.txt" -q - -# Commit 8: Update file3.txt with more content by Bob -echo "Appending more to file3" >> file3.txt -git add file3.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T19:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T19:00:00" \ -git commit -m "Update file3.txt with more content" -q - -# Commit 9: Create directory 'dir1' and add file4.txt inside by Carol -mkdir dir1 -echo "Inside dir1" > dir1/file4.txt -git add dir1/file4.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T20:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T20:00:00" \ -git commit -m "Create dir1 and add file4.txt" -q - -# Commit 10: Rename file in dir1: file4.txt to file4-renamed.txt by Alice -git mv dir1/file4.txt dir1/file4-renamed.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T21:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T21:00:00" \ -git commit -m "Rename file4.txt to file4-renamed.txt in dir1" -q - -# Commit 11: Create binary file file5.bin by Bob -head -c 100 /dev/urandom > file5.bin -git add file5.bin -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T22:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T22:00:00" \ -git commit -m "Add binary file file5.bin" -q - -# Commit 12: Delete file3.txt by Carol -git rm file3.txt -q -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-01T23:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-01T23:00:00" \ -git commit -m "Delete file3.txt" -q - -# Commit 13: Modify file1-renamed.txt by inserting a line in the middle by Alice -# (Using an awk-based, portable approach to insert "Inserted line" after the first line) -awk 'NR==1{print; print "Inserted line"; next} {print}' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T00:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T00:00:00" \ -git commit -m "Modify file1-renamed.txt by inserting a line" -q - -# Commit 14: Re-add file2.txt with new content by Bob (with mismatched author/committer times) -echo "Recreated file2" > file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T00:30:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T01:00:00" \ -git commit -m "Re-add file2.txt with new content" -q - -# Commit 15: Final update modifying multiple files by Carol -echo "Final update to file1-renamed.txt" >> file1-renamed.txt -echo "Final update to file2.txt" >> file2.txt -git add file1-renamed.txt file2.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T02:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T02:00:00" \ -git commit -m "Final update: modify multiple files" -q - -############################################################################### -# Classical Merge Commits -############################################################################### - -# ----- Branch "feature" ----- -# Create branch "feature" from the current HEAD. -git checkout -b feature -q - -# Commit 16: On branch 'feature', append a line to file1-renamed.txt. -echo "Feature update: appended line" >> file1-renamed.txt -git add file1-renamed.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T03:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T03:00:00" \ -git commit -m "Feature: update file1-renamed.txt" -q - -# Commit 17: On branch 'feature', create a new file file6.txt. -echo "Content for file6 from feature branch" > file6.txt -git add file6.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T03:30:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T03:30:00" \ -git commit -m "Feature: add file6.txt" -q - -# Switch back to the main branch. -git checkout -q "$MAIN_BRANCH" - -# Merge branch 'feature' into main with a merge commit. -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T04:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T04:00:00" \ -git merge --no-ff feature -m "Merge branch 'feature'" -q - -# ----- Branch "bugfix" ----- -# Create branch "bugfix" from the current HEAD. -git checkout -b bugfix -q - -# Commit 18: On branch 'bugfix', modify file2.txt by appending a bugfix line. -echo "Bugfix: corrected a typo in file2.txt" >> file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T04:30:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T04:30:00" \ -git commit -m "Bugfix: update file2.txt with correction" -q - -# Commit 19: Further modify file2.txt on the 'bugfix' branch. -echo "Bugfix: final adjustment to file2.txt" >> file2.txt -git add file2.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T05:00:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T05:00:00" \ -git commit -m "Bugfix: further update to file2.txt" -q - -# Switch back to the main branch. -git checkout -q "$MAIN_BRANCH" - -# Merge branch 'bugfix' into main with a merge commit. -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T05:30:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T05:30:00" \ -git merge --no-ff bugfix -m "Merge branch 'bugfix'" -q - -############################################################################### -# Octopus Merge: Merge three branches in one commit -############################################################################### - -# Create branch "octo1" from main and add a new file. -git checkout -b octo1 -q -echo "Change from octo1" > octo1.txt -git add octo1.txt -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T06:00:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T06:00:00" \ -git commit -m "Octo1: Add octo1.txt" -q - -# Return to main branch. -git checkout -q "$MAIN_BRANCH" - -# Create branch "octo2" from main and add a new file. -git checkout -b octo2 -q -echo "Change from octo2" > octo2.txt -git add octo2.txt -GIT_AUTHOR_NAME="Bob" \ -GIT_AUTHOR_EMAIL="bob@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T06:30:00" \ -GIT_COMMITTER_NAME="Bob" \ -GIT_COMMITTER_EMAIL="bob@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T06:30:00" \ -git commit -m "Octo2: Add octo2.txt" -q - -# Return to main branch. -git checkout -q "$MAIN_BRANCH" - -# Create branch "octo3" from main and add a new file. -git checkout -b octo3 -q -echo "Change from octo3" > octo3.txt -git add octo3.txt -GIT_AUTHOR_NAME="Carol" \ -GIT_AUTHOR_EMAIL="carol@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T07:00:00" \ -GIT_COMMITTER_NAME="Carol" \ -GIT_COMMITTER_EMAIL="carol@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T07:00:00" \ -git commit -m "Octo3: Add octo3.txt" -q - -# Return to main branch before merging. -git checkout -q "$MAIN_BRANCH" - -# Perform the octopus merge (merging octo1, octo2, and octo3 in one commit). -GIT_AUTHOR_NAME="Alice" \ -GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_AUTHOR_DATE="2023-01-02T07:30:00" \ -GIT_COMMITTER_NAME="Alice" \ -GIT_COMMITTER_EMAIL="alice@example.com" \ -GIT_COMMITTER_DATE="2023-01-02T07:30:00" \ -git merge --no-ff octo1 octo2 octo3 -m "Octopus merge of octo1, octo2, and octo3" -q - -# End of script. -exit 0 diff --git a/binocular-backend-new/core/pom.xml b/binocular-backend-new/core/pom.xml index 9cc0652b4..28140ec81 100644 --- a/binocular-backend-new/core/pom.xml +++ b/binocular-backend-new/core/pom.xml @@ -54,7 +54,18 @@ domain ${project.version} - + + com.inso-world.binocular + domain + ${project.version} + tests + test + + + org.springframework.boot + spring-boot-starter-test + test + org.junit.jupiter junit-jupiter-params @@ -85,6 +96,11 @@ kotlin-reflect ${kotlin.version} + + org.aspectj + aspectjweaver + 1.9.24 + @@ -131,6 +147,16 @@ test-jar + + + **/*.class + **/*.sh + **/*.kotlin_module + + + **/octo/** + + diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/BinocularConfig.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/BinocularConfig.kt new file mode 100644 index 000000000..da114fc0c --- /dev/null +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/BinocularConfig.kt @@ -0,0 +1,8 @@ +package com.inso_world.binocular.core + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationProperties(prefix = "binocular") +open class BinocularConfig diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/delegates/Delegates.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/delegates/Delegates.kt index 4943bcdcd..8dc2b2eea 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/delegates/Delegates.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/delegates/Delegates.kt @@ -5,16 +5,48 @@ import org.slf4j.LoggerFactory import kotlin.reflect.KClass import kotlin.reflect.full.companionObject +/** + * Returns a thread-safe, lazily initialized SLF4J [Logger] bound to the receiver’s + * declaring class. + * + * Works seamlessly from Kotlin *companion objects* by resolving the enclosing class, + * so logs are emitted under the outer class (e.g., `Foo`) rather than `Foo$Companion`. + * + * ### Usage + * ``` + * class Foo { + * companion object { + * private val log by logger() + * } + * fun run() { log.info("hello") } + * } + * ``` + * + * ### Implementation notes + * - Uses [lazy] with the default [LazyThreadSafetyMode.SYNCHRONIZED]. + * - Resolves the correct class via [unwrapCompanionClass]. + */ fun R.logger(): Lazy { return lazy { LoggerFactory.getLogger(unwrapCompanionClass(this.javaClass).name) } } -// unwrap companion class to enclosing class given a Kotlin Class +/** + * Resolves the declaring [KClass] for a possibly companion-object class. + * + * @param ofClass Kotlin class that may represent a companion object. + * @return the enclosing class if [ofClass] is a companion object; otherwise [ofClass] itself. + * @see unwrapCompanionClass for the `java.lang.Class` variant. + */ internal fun unwrapCompanionClass(ofClass: KClass): KClass<*> { return unwrapCompanionClass(ofClass.java).kotlin } -// unwrap companion class to enclosing class given a Java Class +/** + * Unwraps companion class to enclosing class given a Java Class. + * + * @param ofClass Java class that may represent a companion object. + * @return the enclosing class if [ofClass] is a companion object; otherwise [ofClass] itself. + */ fun unwrapCompanionClass(ofClass: Class): Class<*> { return ofClass.enclosingClass?.takeIf { ofClass.enclosingClass.kotlin.companionObject?.java == ofClass diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/index/GitIndexer.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/index/GitIndexer.kt index bc169734e..0fca1bbef 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/index/GitIndexer.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/index/GitIndexer.kt @@ -2,30 +2,47 @@ package com.inso_world.binocular.core.index import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.CommitDiff +import com.inso_world.binocular.model.Revision +import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.vcs.ReferenceCategory +import com.inso_world.binocular.model.vcs.Remote import jakarta.validation.Valid import java.nio.file.Path +/** + * Port describing every SCM mining capability Binocular needs to populate the Software Engineering ONtology (SEON). + * + * + */ interface GitIndexer { - fun findRepo(path: Path): Repository + /** + * Discovers a repository rooted at [path] and links it to the owning [project]. + * + * Implementations should normalize [path], detect bare/worktree repositories, and populate + * [Repository.project] to serve as the SEON `seon:SoftwareRepository` anchor. + */ + fun findRepo(path: Path, project: Project): Repository + fun traverseBranch( + repo: Repository, + branchName: String, + ): Pair> fun traverseBranch( repo: Repository, branch: Branch, - ): List - + ): Pair> = traverseBranch(repo, branch.fullName) fun findAllBranches(repo: Repository): List - + /** + * Finds a commit by hash. + */ fun findCommit( repo: Repository, hash: String, - ): String - + ): Commit fun traverse( repo: Repository, - sourceCmt: String, - trgtCmt: String? = null, + source: Commit, + target: Commit? = null, ): List - } diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/EntityMapper.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/EntityMapper.kt index 540f0531a..a87729d9a 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/EntityMapper.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/EntityMapper.kt @@ -3,28 +3,118 @@ package com.inso_world.binocular.core.persistence.mapper import jakarta.validation.Valid /** - * Generic interface for mapping between domain models and database entities. + * Generic interface for bidirectional mapping between domain models and persistence-layer entities. * - * @param D The domain model type - * @param E The database entity type + * This interface is a core component of the hexagonal architecture, enabling the infrastructure layer + * to convert between rich domain models (from the `domain` module) and database-specific entities + * (e.g., JPA entities, ArangoDB documents). + * + * ## Semantics + * - Mappers are stateless and thread-safe + * - All mapping operations validate their inputs and outputs using Jakarta Bean Validation + * - Identity mapping: `toDomain(toEntity(domain))` should be logically equivalent to `domain` + * - Mappers should handle circular references and graph structures correctly + * + * ## Invariants & Requirements + * - Domain models (`D`) must be immutable or follow immutable semantics + * - Entity types (`E`) are typically mutable JPA/ArangoDB entities with persistence annotations + * - Implementations must preserve domain invariants during conversion + * - Lazy-loaded relationships should be handled via [RelationshipProxyFactory] to avoid N+1 queries + * + * ## Trade-offs & Guidance + * - **Performance**: Use [toDomainList] for batch conversions as it may leverage optimizations + * like [MappingContext] to track already-mapped objects and avoid duplicate work + * - **Graph Mapping**: For complex object graphs with bidirectional relationships, use [MappingContext] + * within your mapper implementation to maintain object identity and prevent infinite loops + * - **Lazy Loading**: When mapping relationships, prefer creating lazy proxies via [RelationshipProxyFactory] + * rather than eagerly loading entire object graphs + * + * @param D The domain model type (from the `domain` module) + * @param E The database entity type (from an infrastructure module) + * + * ## Example + * ```kotlin + * class CommitMapper( + * private val proxyFactory: RelationshipProxyFactory, + * private val repositoryDao: RepositoryDao + * ) : EntityMapper { + * + * override fun toEntity(domain: Commit): CommitEntity = + * CommitEntity( + * id = domain.sha, + * message = domain.message, + * authorDate = domain.date + * ) + * + * override fun toDomain(entity: CommitEntity): Commit = + * Commit( + * sha = entity.id, + * message = entity.message, + * date = entity.authorDate, + * repository = proxyFactory.createLazyReference { + * // Lazy-load repository when accessed + * repositoryDao.findById(entity.repositoryId) + * } + * ) + * } + * ``` + * + * @see RelationshipProxyFactory + * @see MappingContext */ interface EntityMapper { /** - * Converts a domain model to a database entity + * Converts a domain model to a database entity. + * + * This is typically used when persisting or updating domain objects. The resulting entity + * should be suitable for persistence via a DAO or repository. + * + * ## Semantics + * - Creates a new entity instance; does not mutate the input domain model + * - May not populate relationship fields if they require database IDs not yet available + * - Validates the domain model before conversion + * + * @param domain The domain model to convert (must be valid) + * @return A validated database entity ready for persistence + * @throws jakarta.validation.ValidationException if domain validation fails */ fun toEntity( - @Valid domain: D, + @Valid domain: D ): @Valid E /** - * Converts a database entity to a domain model + * Converts a database entity to a domain model. + * + * This is typically used when loading data from the database. The resulting domain model + * should be a complete, valid domain object that can be used by the application layer. + * + * ## Semantics + * - Creates a new domain model instance; does not mutate the input entity + * - Should create lazy proxies for relationships to avoid N+1 queries + * - Validates the entity before conversion and the resulting domain model + * + * @param entity The database entity to convert (must be valid) + * @return A validated domain model + * @throws jakarta.validation.ValidationException if entity or domain validation fails */ fun toDomain( @Valid entity: E, ): @Valid D /** - * Converts a list of database entities to a list of domain models + * Converts multiple database entities to domain models in a single batch operation. + * + * This method may provide optimizations over calling [toDomain] individually, such as: + * - Using [MappingContext] to track already-mapped objects and maintain identity + * - Batching database queries for lazy-loaded relationships + * - Reducing memory allocations for temporary objects + * + * The default implementation creates a fresh [MappingContext] and maps each entity sequentially. + * Override this method if your mapper requires custom batch-loading logic. + * + * @param entities An iterable of database entities to convert + * @return A list of validated domain models in the same order as the input + * @throws jakarta.validation.ValidationException if any entity or domain validation fails */ fun toDomainList( @Valid entities: Iterable, diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingContext.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingContext.kt new file mode 100644 index 000000000..140b4de80 --- /dev/null +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingContext.kt @@ -0,0 +1,140 @@ +package com.inso_world.binocular.core.persistence.mapper.context + +import com.inso_world.binocular.model.AbstractDomainObject +import org.springframework.context.annotation.Scope +import org.springframework.context.annotation.ScopedProxyMode +import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap +import kotlin.reflect.KClass +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + +/** + * Per-mapping-session, bidirectional identity map used while converting between *domain* objects + * (D : AbstractDomainObject<*, K>) and *persistence entities* (E). + * + * ─ Domain ➜ Entity: keyed by (domainClass, domain.uniqueKey : K) + * ─ Entity ➜ Domain: keyed by (entityClass, entity.id) + * + * This design prevents key collisions across different types that share the same id/value. + * Both caches are thread-safe but intended to be short-lived (scoped per @MappingSession). + * + * Overwrite policy: first-write-wins. + */ +@Component +@Scope(value = "mapping", proxyMode = ScopedProxyMode.TARGET_CLASS) +open class MappingContext { + + // ---------- Keys ---------- + // For entities without ids, use object identity + private data class EntityObjectKey(val type: KClass<*>, val objectId: Int) + private data class DomainKey(val type: KClass<*>, val key: Any) + private data class EntityKey(val type: KClass<*>, val id: Any) + + // ---------- Caches ---------- + private val d2e = ConcurrentHashMap() + private val e2d = ConcurrentHashMap() + + // Fallback for unpersisted entities + private val e2dByObjectIdentity = ConcurrentHashMap() + + // ====================== Domain -> Entity ====================== + + /** + * Returns the previously remembered entity for this domain object, if any. + * Uses (domain::class, domain.uniqueKey) as the cache key. + */ + @Suppress("UNCHECKED_CAST") + open fun , E : Any> findEntity(domain: D): E? = + d2e[DomainKey(domain::class, domain.uniqueKey)] as? E + + // ====================== Entity -> Domain ====================== + + /** + * Returns the previously remembered domain object for this entity, if any. + * Uses (entity::class, entity.id) as the cache key. + */ + @Suppress("UNCHECKED_CAST") + open fun findDomain(entity: E): D? { + // 1. Try using database id first + resolveEntityId(entity)?.let { id -> + return e2d[EntityKey(entity::class, id)] as? D + } + + // 2. Fallback to object identity for unpersisted entities + val objKey = EntityObjectKey(entity::class, System.identityHashCode(entity)) + return e2dByObjectIdentity[objKey] as? D + } + + // ========================= Remember =========================== + + /** + * Remember the association between a domain object and an entity. + * - Domain side: keyed by (domain class, business key K). + * - Entity side: keyed by (entity class, technical id), if an id could be resolved. + * First-write-wins. + */ + open fun , E : Any> remember(domain: D, entity: E) { + // Always remember domain -> entity + d2e.computeIfAbsent(DomainKey(domain::class, domain.uniqueKey)) { entity } + // Try to remember entity -> domain using database id + resolveEntityId(entity)?.let { id -> + e2d.computeIfAbsent(EntityKey(entity::class, id)) { domain } + } ?: run { + // Fallback: use object identity for unpersisted entities + val objKey = EntityObjectKey(entity::class, System.identityHashCode(entity)) + e2dByObjectIdentity.computeIfAbsent(objKey) { domain } + } + require(d2e.size == (e2d.size + e2dByObjectIdentity.size)) { + "Context sizes do not match: ${d2e.size} != (${e2d.size} + ${e2dByObjectIdentity.size})" + } + } + + // ====================== Id resolution ========================= + + private fun resolveEntityId(e: Any): Any? { + readKProperty(e, "uniqueKey")?.let { return it } + + // 1) Try Kotlin property named "id" + readKProperty(e, "id")?.let { return it } + + // 2) Try Java field named "id" + readField(e, "id")?.let { return it } + + // 3) Try property/field annotated with Id (jakarta/javax/spring-data) + val idAnnotations = setOf( + "jakarta.persistence.Id", "javax.persistence.Id", "org.springframework.data.annotation.Id" + ) + + // Kotlin properties with Id annotation + e::class.memberProperties.firstOrNull { p -> + p.annotations.any { it.annotationClass.qualifiedName in idAnnotations } + }?.let { p -> + p.isAccessible = true + return p.getter.call(e) + } + + // Java fields with Id annotation + e.javaClass.declaredFields.firstOrNull { f -> + f.annotations.any { it.annotationClass.qualifiedName in idAnnotations } + }?.let { f -> + f.isAccessible = true + return f.get(e) + } + + return null + } + + private fun readKProperty(obj: Any, name: String): Any? = runCatching { + val prop = obj::class.memberProperties.firstOrNull { it.name == name } ?: return null + prop.isAccessible = true + prop.getter.call(obj) + }.getOrNull() + + private fun readField(obj: Any, name: String): Any? = runCatching { + val field = obj.javaClass.declaredFields.firstOrNull { it.name == name } ?: return null + field.isAccessible = true + field.get(obj) + }.getOrNull() +} + diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingScope.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingScope.kt similarity index 93% rename from binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingScope.kt rename to binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingScope.kt index 389e555a8..48695d399 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingScope.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingScope.kt @@ -1,5 +1,6 @@ -package com.inso_world.binocular.infrastructure.sql.mapper.context +package com.inso_world.binocular.core.persistence.mapper.context +import com.inso_world.binocular.core.delegates.logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.ObjectFactory import org.springframework.beans.factory.config.Scope @@ -10,8 +11,10 @@ import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi @OptIn(ExperimentalAtomicApi::class) -internal class MappingScope : Scope { - private val logger = LoggerFactory.getLogger(MappingScope::class.java) +class MappingScope : Scope { + companion object { + private val logger by logger() + } /** A single, application‑wide identity map. */ private val context = ConcurrentHashMap() diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingScopeConfig.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingScopeConfig.kt similarity index 57% rename from binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingScopeConfig.kt rename to binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingScopeConfig.kt index 7902aa270..33b886d32 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingScopeConfig.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingScopeConfig.kt @@ -1,15 +1,16 @@ -package com.inso_world.binocular.infrastructure.sql.mapper.context +package com.inso_world.binocular.core.persistence.mapper.context import org.springframework.beans.factory.config.CustomScopeConfigurer import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration -internal class MappingScopeConfig { - @Bean fun mappingScope() = MappingScope() +internal open class MappingScopeConfig { + @Bean + open fun mappingScope() = MappingScope() @Bean - fun customScopeConfigurer(mappingScope: MappingScope) = + open fun customScopeConfigurer(mappingScope: MappingScope) = CustomScopeConfigurer().apply { addScope("mapping", mappingScope) } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingSession.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingSession.kt similarity index 66% rename from binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingSession.kt rename to binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingSession.kt index 3580abe53..cd5fe1c14 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingSession.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingSession.kt @@ -1,4 +1,4 @@ -package com.inso_world.binocular.infrastructure.sql.mapper.context +package com.inso_world.binocular.core.persistence.mapper.context @Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingSessionAspect.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingSessionAspect.kt similarity index 80% rename from binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingSessionAspect.kt rename to binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingSessionAspect.kt index 083a2255e..1a0c97b03 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingSessionAspect.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/persistence/mapper/context/MappingSessionAspect.kt @@ -1,10 +1,9 @@ -package com.inso_world.binocular.infrastructure.sql.mapper.context +package com.inso_world.binocular.core.persistence.mapper.context +import com.inso_world.binocular.core.delegates.logger import org.aspectj.lang.ProceedingJoinPoint import org.aspectj.lang.annotation.Around import org.aspectj.lang.annotation.Aspect -import org.slf4j.Logger -import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component @@ -14,10 +13,9 @@ internal class MappingSessionAspect { @Autowired private lateinit var mappingScope: MappingScope -// @Autowired -// private lateinit var mappingContext: MappingContext - - private val logger: Logger = LoggerFactory.getLogger(MappingSessionAspect::class.java) + companion object { + private val logger by logger() + } @Around("@within(mappingSession) || @annotation(mappingSession)", argNames = "pjp,mappingSession") fun around( diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/AccountInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/AccountInfrastructurePort.kt index d26a08849..1af2441fe 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/AccountInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/AccountInfrastructurePort.kt @@ -8,8 +8,16 @@ import com.inso_world.binocular.model.Note /** * Interface for AccountService. * Provides methods to retrieve accounts and their related entities. + * + * @deprecated Use [ProjectInfrastructurePort] instead. Account is part of the Project aggregate + * and should be accessed through its aggregate root. */ -interface AccountInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use ProjectInfrastructurePort instead. Account is part of the Project aggregate.", + replaceWith = ReplaceWith("ProjectInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface AccountInfrastructurePort : BinocularInfrastructurePort { /** * Find issues by account ID. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BinocularInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BinocularInfrastructurePort.kt index def10df21..360a83fe2 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BinocularInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BinocularInfrastructurePort.kt @@ -1,61 +1,184 @@ package com.inso_world.binocular.core.service import com.inso_world.binocular.core.persistence.model.Page +import com.inso_world.binocular.model.AbstractDomainObject import jakarta.validation.Valid import org.springframework.data.domain.Pageable /** - * Interface for BinocularService. - * Provides methods to retrieve elements of type `T` and their related entities. + * Base infrastructure port interface for all domain aggregate repositories in Binocular. + * + * This interface defines the fundamental CRUD operations for persisting and retrieving domain aggregates + * following the hexagonal architecture pattern. Implementations are responsible for mapping between + * domain models and persistence-layer entities (e.g., SQL, ArangoDB, RDF). + * + * ## Semantics + * - All operations validate entities using Jakarta Bean Validation (`@Valid`) + * - Operations returning nullable types return `null` when the entity is not found + * - All operations are expected to be transactional within the infrastructure layer + * + * ## Invariants & Requirements + * - Type parameter `T` must be a valid domain model from the `domain` module + * - All input values must pass validation constraints defined in the domain model + * - Implementations must ensure referential integrity within the persistence layer + * - DELETE operations are not yet supported and throw `UnsupportedOperationException` + * + * ## Trade-offs & Guidance + * - **Repository vs Aggregate Ports**: For aggregate root operations (e.g., Repository, Project), + * prefer using the specific aggregate port (e.g., `RepositoryInfrastructurePort`) over individual + * entity ports, as aggregate ports provide operations in the context of the full aggregate boundary + * - **Pagination**: Use `findAll(Pageable)` for large datasets to avoid memory issues + * - **Batch Operations**: Use `saveAll()` for bulk inserts to reduce database round-trips + * + * @param T The domain model type this port manages + * + * ## Example + * ```kotlin + * // Implementing a new infrastructure port + * interface UserInfrastructurePort : BinocularInfrastructurePort { + * // Add domain-specific queries here + * fun findByEmail(email: String): User? + * } + * ``` + * + * @see RepositoryInfrastructurePort + * @see ProjectInfrastructurePort */ -interface BinocularInfrastructurePort { +interface BinocularInfrastructurePort, Iid> { /** - * Find all users with pagination. + * Retrieves all entities of type [T]. * - * @return Page of users + * **Warning**: This operation loads all entities into memory and should only be used for + * small datasets. For large datasets, prefer [findAll] with pagination. + * + * @return An iterable of all validated entities */ fun findAll(): Iterable<@Valid T> /** - * Find all users with pagination. + * Retrieves a paginated subset of entities of type [T]. + * + * This is the recommended way to retrieve large datasets as it limits memory usage + * and provides efficient database queries. * - * @param pageable Pagination information - * @return Page of users + * @param pageable Pagination information including page number, size, and sort order + * @return A page containing the requested entities and pagination metadata */ fun findAll(pageable: Pageable): Page<@Valid T> /** - * Find a user by ID. + * Finds a single entity by its unique identifier. + * + * @param id The unique identifier of the entity (typically a UUID or composite key string) + * @return The validated entity if found, `null` otherwise + */ + @Deprecated("", ReplaceWith("findByIid(iid)")) + fun findById(id: String): @Valid T? = throw UnsupportedOperationException("use findByIid(...) instead") + + /** + * Finds a single entity by its technical/aggregate identifier [AbstractDomainObject.iid]. + * + * ## Implementation Note - Kotlin Value Classes + * When [Iid] is a Kotlin value class (e.g., `Repository.Id`), implementations require special + * handling due to JVM name mangling. The mangled method signature prevents Spring AOP's + * `@annotation` pointcut from matching the `@MappingSession` annotation. + * + * **Required Workaround**: Implementations must use the self-injection pattern: + * 1. Inject `self` reference to the proxied bean instance + * 2. Override this method with `@JvmName` to declare the mangled signature (optional) + * 3. Delegate to a non-private (e.g. protected) internal method (e.g., `findByIidInternal`) via `self` + * 4. Apply `@MappingSession` to the internal method where Spring AOP can intercept it + * + * This ensures the mapping session scope is properly established before calling DAOs/assemblers. * - * @param id The ID of the user to find - * @return The user if found, null otherwise + * @param iid The technical/aggregate identifier of the entity (may be a value class) + * @return The validated entity if found, `null` otherwise + * @see KT-31420 */ - fun findById(id: String): @Valid T? + fun findByIid(iid: Iid): @Valid T? + /** + * Persists a new entity to the database. + * + * ## Semantics + * - The entity must not already exist (no ID or ID not yet persisted) + * - Generates and assigns a unique ID if not provided + * - Validates the entity before persisting + * + * @param value The domain model to persist (must be valid according to Jakarta validation) + * @return The persisted entity with generated ID and any database-generated values + * @throws jakarta.validation.ValidationException if the entity fails validation + */ fun create( @Valid value: T, ): @Valid T + /** + * Updates an existing entity in the database. + * + * ## Semantics + * - The entity must already exist (must have a valid ID) + * - Updates all fields of the entity + * - Validates the entity before updating + * + * @param value The domain model to update (must be valid according to Jakarta validation) + * @return The updated entity + * @throws jakarta.validation.ValidationException if the entity fails validation + * @throws com.inso_world.binocular.core.service.exception.NotFoundException if entity doesn't exist + */ fun update( @Valid value: T, ): @Valid T - fun updateAndFlush( - @Valid value: T, - ): @Valid T - /** - * Save multiple entities + * Persists or updates multiple entities in a single batch operation. + * + * This is more efficient than calling [create] or [update] individually as it reduces + * database round-trips. Implementations should use batch insert/update capabilities + * of the underlying database. + * + * @param values Collection of domain models to persist (all must be valid) + * @return An iterable of the persisted/updated entities + * @throws jakarta.validation.ValidationException if any entity fails validation */ fun saveAll( @Valid values: Collection<@Valid T>, ): Iterable<@Valid T> + /** + * Deletes an entity from the database. + * + * **Status**: Not yet implemented + * + * @param value The entity to delete + * @throws UnsupportedOperationException Always thrown until DELETE operations are implemented + */ fun delete( @Valid value: T, - ) + ) { + throw UnsupportedOperationException("DELETE operations are not yet supported") + } - fun deleteById(id: String) + /** + * Deletes an entity by its unique identifier. + * + * **Status**: Not yet implemented + * + * @param id The unique identifier of the entity to delete + * @throws UnsupportedOperationException Always thrown until DELETE operations are implemented + */ + fun deleteById(id: String) { + throw UnsupportedOperationException("DELETE operations are not yet supported") + } - fun deleteAll() + /** + * Deletes all entities of type [T] from the database. + * + * **Status**: Not yet implemented + * + * @throws UnsupportedOperationException Always thrown until DELETE operations are implemented + */ + fun deleteAll() { + throw UnsupportedOperationException("DELETE operations are not yet supported") + } } diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BranchInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BranchInfrastructurePort.kt index 9b39988ad..fdb4798b7 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BranchInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BranchInfrastructurePort.kt @@ -2,13 +2,22 @@ package com.inso_world.binocular.core.service import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.File +import com.inso_world.binocular.model.Reference import com.inso_world.binocular.model.Repository /** * Interface for BranchService. * Provides methods to retrieve branches and their related entities. + * + * @deprecated Use [RepositoryInfrastructurePort] instead. Branch is part of the Repository aggregate + * and should be accessed through its aggregate root. */ -interface BranchInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use RepositoryInfrastructurePort instead. Branch is part of the Repository aggregate.", + replaceWith = ReplaceWith("RepositoryInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface BranchInfrastructurePort : BinocularInfrastructurePort { /** * Find files by branch ID. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BuildInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BuildInfrastructurePort.kt index f9db33509..02c7c90c4 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BuildInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/BuildInfrastructurePort.kt @@ -8,8 +8,16 @@ import org.springframework.data.domain.Pageable /** * Interface for BuildService. * Provides methods to retrieve builds and their related entities. + * + * @deprecated Use [RepositoryInfrastructurePort] instead. Build is part of the Repository aggregate + * and should be accessed through its aggregate root. */ -interface BuildInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use RepositoryInfrastructurePort instead. Build is part of the Repository aggregate.", + replaceWith = ReplaceWith("RepositoryInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface BuildInfrastructurePort : BinocularInfrastructurePort { /** * Find all builds with pagination and timestamp filter. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/CommitInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/CommitInfrastructurePort.kt index 9e22b1501..3654293e3 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/CommitInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/CommitInfrastructurePort.kt @@ -13,8 +13,16 @@ import org.springframework.data.domain.Pageable /** * Interface for CommitService. * Provides methods to retrieve commits and their related entities. + * + * @deprecated Use [RepositoryInfrastructurePort] instead. Commit is part of the Repository aggregate + * and should be accessed through its aggregate root. */ -interface CommitInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use RepositoryInfrastructurePort instead. Commit is part of the Repository aggregate.", + replaceWith = ReplaceWith("RepositoryInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface CommitInfrastructurePort : BinocularInfrastructurePort { /** * Find all commits with pagination and timestamp filters. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/FileInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/FileInfrastructurePort.kt index a57e5b6f7..afe2c6df8 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/FileInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/FileInfrastructurePort.kt @@ -8,8 +8,16 @@ import com.inso_world.binocular.model.User /** * Interface for FileService. * Provides methods to retrieve files and their related entities. + * + * @deprecated Use [RepositoryInfrastructurePort] instead. File is part of the Repository aggregate + * and should be accessed through its aggregate root. */ -interface FileInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use RepositoryInfrastructurePort instead. File is part of the Repository aggregate.", + replaceWith = ReplaceWith("RepositoryInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface FileInfrastructurePort : BinocularInfrastructurePort { /** * Find branches by file ID. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/IssueInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/IssueInfrastructurePort.kt index 80a2087e5..d8df63dd1 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/IssueInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/IssueInfrastructurePort.kt @@ -10,8 +10,16 @@ import com.inso_world.binocular.model.User /** * Interface for IssueService. * Provides methods to retrieve issues and their related entities. + * + * @deprecated Use [ProjectInfrastructurePort] instead. Issue is part of the Project aggregate + * and should be accessed through its aggregate root. */ -interface IssueInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use ProjectInfrastructurePort instead. Issue is part of the Project aggregate.", + replaceWith = ReplaceWith("ProjectInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface IssueInfrastructurePort : BinocularInfrastructurePort { /** * Find accounts by issue ID. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/MergeRequestInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/MergeRequestInfrastructurePort.kt index 175d0d253..a99e1bb63 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/MergeRequestInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/MergeRequestInfrastructurePort.kt @@ -8,8 +8,16 @@ import com.inso_world.binocular.model.Note /** * Interface for MergeRequestService. * Provides methods to retrieve merge requests and their related entities. + * + * @deprecated Use [ProjectInfrastructurePort] instead. MergeRequest is part of the Project aggregate + * and should be accessed through its aggregate root. */ -interface MergeRequestInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use ProjectInfrastructurePort instead. MergeRequest is part of the Project aggregate.", + replaceWith = ReplaceWith("ProjectInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface MergeRequestInfrastructurePort : BinocularInfrastructurePort { /** * Find accounts by merge request ID. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/MilestoneInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/MilestoneInfrastructurePort.kt index c69ffe9bf..03c7e3a4b 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/MilestoneInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/MilestoneInfrastructurePort.kt @@ -7,8 +7,16 @@ import com.inso_world.binocular.model.Milestone /** * Interface for MilestoneService. * Provides methods to retrieve milestones and their related entities. + * + * @deprecated Use [ProjectInfrastructurePort] instead. Milestone is part of the Project aggregate + * and should be accessed through its aggregate root. */ -interface MilestoneInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use ProjectInfrastructurePort instead. Milestone is part of the Project aggregate.", + replaceWith = ReplaceWith("ProjectInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface MilestoneInfrastructurePort : BinocularInfrastructurePort { /** * Find issues by milestone ID. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/ModuleInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/ModuleInfrastructurePort.kt index 642bf2681..5c25b3fdb 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/ModuleInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/ModuleInfrastructurePort.kt @@ -6,8 +6,16 @@ import com.inso_world.binocular.model.File /** * Interface for ModuleService. * Provides methods to retrieve modules and their related entities. + * + * @deprecated Use [RepositoryInfrastructurePort] instead. Module is part of the Repository aggregate + * and should be accessed through its aggregate root. */ -interface ModuleInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use RepositoryInfrastructurePort instead. Module is part of the Repository aggregate.", + replaceWith = ReplaceWith("RepositoryInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface ModuleInfrastructurePort : BinocularInfrastructurePort { /** * Find commits by module ID. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/NoteInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/NoteInfrastructurePort.kt index be1214b22..3f2cb8c44 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/NoteInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/NoteInfrastructurePort.kt @@ -8,8 +8,16 @@ import com.inso_world.binocular.model.Note /** * Interface for NoteService. * Provides methods to retrieve notes and their related entities. + * + * @deprecated Use [ProjectInfrastructurePort] instead. Note is part of the Project aggregate + * and should be accessed through its aggregate root. */ -interface NoteInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use ProjectInfrastructurePort instead. Note is part of the Project aggregate.", + replaceWith = ReplaceWith("ProjectInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface NoteInfrastructurePort : BinocularInfrastructurePort { /** * Find accounts by note ID. * diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/ProjectInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/ProjectInfrastructurePort.kt index 750ec885f..912f69584 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/ProjectInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/ProjectInfrastructurePort.kt @@ -2,6 +2,6 @@ package com.inso_world.binocular.core.service import com.inso_world.binocular.model.Project -interface ProjectInfrastructurePort : BinocularInfrastructurePort { +interface ProjectInfrastructurePort : BinocularInfrastructurePort { fun findByName(name: String): Project? } diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/RepositoryInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/RepositoryInfrastructurePort.kt index 3831741eb..db4c9f634 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/RepositoryInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/RepositoryInfrastructurePort.kt @@ -1,11 +1,13 @@ package com.inso_world.binocular.core.service +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.Repository -interface RepositoryInfrastructurePort : BinocularInfrastructurePort { +interface RepositoryInfrastructurePort : BinocularInfrastructurePort { fun findByName(name: String): Repository? -// fun findAllBranches(repository: Repository): Iterable -// -// fun findAllCommits(repository: Repository): Iterable + fun findExistingCommits(repo: Repository, shas: Set): Sequence + + fun findBranch(repository: Repository, name: String): Branch? } diff --git a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/UserInfrastructurePort.kt b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/UserInfrastructurePort.kt index ef4904bdf..cb946780c 100644 --- a/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/UserInfrastructurePort.kt +++ b/binocular-backend-new/core/src/main/kotlin/com/inso_world/binocular/core/service/UserInfrastructurePort.kt @@ -9,8 +9,16 @@ import com.inso_world.binocular.model.User /** * Interface for UserService. * Provides methods to retrieve users and their related entities. + * + * @deprecated Use [RepositoryInfrastructurePort] instead. User is part of the Repository aggregate + * and should be accessed through its aggregate root. */ -interface UserInfrastructurePort : BinocularInfrastructurePort { +@Deprecated( + message = "Use RepositoryInfrastructurePort instead. User is part of the Repository aggregate.", + replaceWith = ReplaceWith("RepositoryInfrastructurePort"), + level = DeprecationLevel.WARNING +) +interface UserInfrastructurePort : BinocularInfrastructurePort { /** * Find commits by user ID. * diff --git a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/data/MockTestDataProvider.kt b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/data/MockTestDataProvider.kt index 70897d4cf..59400b486 100644 --- a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/data/MockTestDataProvider.kt +++ b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/data/MockTestDataProvider.kt @@ -1,150 +1,136 @@ package com.inso_world.binocular.core.data +import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature import com.inso_world.binocular.model.User +import com.inso_world.binocular.model.vcs.ReferenceCategory import java.time.LocalDateTime +@Deprecated(message = "", replaceWith = ReplaceWith("com.inso_world.binocular.data.MockTestDataProvider")) class MockTestDataProvider { - val testProjects = - listOf( - Project(name = "proj-pg-0"), - Project(name = "proj-pg-1"), - Project(name = "proj-pg-2"), - Project(name = "proj-pg-3"), - Project(name = "proj-pg-4"), - Project(name = "proj-for-repos"), - ) + val testProjects = listOf( + Project(name = "proj-pg-0"), + Project(name = "proj-pg-1"), + Project(name = "proj-pg-2"), + Project(name = "proj-pg-3"), + Project(name = "proj-pg-4"), + Project(name = "proj-pg-5"), + Project(name = "proj-for-repos"), + ) val projectsByName = testProjects.associateBy { requireNotNull(it.name) } - val testRepositories = - listOf( - run { - val project = requireNotNull(projectsByName["proj-pg-0"]) - val repo = Repository(localPath = "repo-pg-0", project = project) - project.repo = repo - repo - }, - run { - val project = requireNotNull(projectsByName["proj-pg-1"]) - val repo = Repository(localPath = "repo-pg-1", project = project) - project.repo = repo - repo - }, - run { - val project = requireNotNull(projectsByName["proj-pg-2"]) - val repo = Repository(localPath = "repo-pg-2", project = project) - project.repo = repo - repo - }, - run { - val project = requireNotNull(projectsByName["proj-pg-3"]) - val repo = Repository(localPath = "repo-pg-3", project = project) - project.repo = repo - repo - }, - run { - val project = requireNotNull(projectsByName["proj-pg-4"]) - val repo = Repository(localPath = "repo-pg-4", project = project) - project.repo = repo - repo - }, - run { - val project = requireNotNull(projectsByName["proj-for-repos"]) - val repo = Repository(localPath = "repo-pg-5", project = project) - project.repo = repo - repo - }, - ) - val repositoriesByPath = testRepositories.associateBy { requireNotNull(it.localPath) } - private val repository: Repository = requireNotNull(repositoriesByPath["repo-pg-0"]) - - val users: List = listOf( + val testRepositories: List = listOf( run { - val user = User(name = "User A", email = "a@test.com") - user.repository = repository - requireNotNull(repository.user.add(user)) { - "User ${user.name} already added to repository" - } - user + val project = projectsByName.getValue("proj-pg-0") + val repo = Repository(localPath = "repo-pg-0", project = project) + repo }, run { - val user = User(name = "User B", email = "b@test.com") - user.repository = repository - requireNotNull(repository.user.add(user)) { - "User ${user.name} already added to repository" - } - user + val project = projectsByName.getValue("proj-pg-1") + val repo = Repository(localPath = "repo-pg-1", project = project) + repo }, run { - val user = User(name = "Author Only", email = "author@test.com") - user.repository = repository - requireNotNull(repository.user.add(user)) { - "User ${user.name} already added to repository" - } - user - } - ) - val userByEmail = users.associateBy { requireNotNull(it.email) } - - val commits: List = listOf( + val project = projectsByName.getValue("proj-pg-2") + val repo = Repository(localPath = "repo-pg-2", project = project) + repo + }, run { - val cmt = Commit( - sha = "a".repeat(40), - message = "msg1", - commitDateTime = LocalDateTime.now(), - authorDateTime = LocalDateTime.now(), - repository = repository, - ) - cmt.committer = userByEmail["a@test.com"] - require(repository.commits.add(cmt)) { - "Commit ${cmt.sha} already added" - } - cmt + val project = projectsByName.getValue("proj-pg-3") + val repo = Repository(localPath = "repo-pg-3", project = project) + repo }, run { - val cmt = Commit( - sha = "b".repeat(40), - message = "msg2", - commitDateTime = LocalDateTime.now(), - authorDateTime = LocalDateTime.now(), - repository = repository, - ) // Empty parents list - cmt.committer = userByEmail["b@test.com"] - require(repository.commits.add(cmt)) { - "Commit ${cmt.sha} already added" - } - cmt + val project = projectsByName.getValue("proj-pg-4") + val repo = Repository(localPath = "repo-pg-4", project = project) + repo }, run { - val cmt = Commit( - sha = "c".repeat(40), - message = "msg1", - commitDateTime = LocalDateTime.now(), - authorDateTime = LocalDateTime.now(), - repository = repository, - ) - cmt.committer = userByEmail["author@test.com"] - require(repository.commits.add(cmt)) { - "Commit ${cmt.sha} already added" - } - cmt + val project = projectsByName.getValue("proj-for-repos") + val repo = Repository(localPath = "repo-pg-5", project = project) + repo }, run { - val cmt = Commit( - sha = "d".repeat(40), - message = "msg-d", - commitDateTime = LocalDateTime.now(), - authorDateTime = LocalDateTime.now(), - repository = repository, - ) - cmt.committer = userByEmail["author@test.com"] - require(repository.commits.add(cmt)) { - "Commit ${cmt.sha} already added" - } - cmt + val project = projectsByName.getValue("proj-pg-5") + val repo = Repository(localPath = "repo-empty", project = project) + repo } ) + val repositoriesByPath = testRepositories.associateBy { requireNotNull(it.localPath) } + private val repository: Repository = repositoriesByPath.getValue("repo-pg-0") + + @Deprecated("Use developers instead") + val users: List = listOf( + User(name = "User A", repository = repository).apply { this.email = "a@test.com" }, + User(name = "User B", repository = repository).apply { this.email = "b@test.com" }, + User(name = "Author Only", repository = repository).apply { this.email = "author@test.com" }, + ) + + @Deprecated("Use developerByEmail instead") + val userByEmail = users.associateBy { requireNotNull(it.email) } + + val developers: List = listOf( + Developer(name = "User A", email = "a@test.com", repository = repository), + Developer(name = "User B", email = "b@test.com", repository = repository), + Developer(name = "Author Only", email = "author@test.com", repository = repository), + ) + val developerByEmail = developers.associateBy { it.email } + + val commits: List = listOf( + Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = Signature( + developer = developerByEmail.getValue("a@test.com"), + timestamp = LocalDateTime.now().minusSeconds(1) + ), + repository = repository, + ), + Commit( + sha = "b".repeat(40), + message = "msg2", + authorSignature = Signature( + developer = developerByEmail.getValue("b@test.com"), + timestamp = LocalDateTime.now().minusSeconds(1) + ), + repository = repository, + ), + Commit( + sha = "c".repeat(40), + message = "msg1", + authorSignature = Signature( + developer = developerByEmail.getValue("a@test.com"), + timestamp = LocalDateTime.now().minusSeconds(1) + ), + repository = repository, + ), + Commit( + sha = "d".repeat(40), + message = "msg-d", + authorSignature = Signature( + developer = developerByEmail.getValue("b@test.com"), + timestamp = LocalDateTime.now().minusSeconds(1) + ), + repository = repository, + ) + ) val commitBySha = commits.associateBy(Commit::sha) + + val branches = listOf( + run { + val branch = Branch( + fullName = "refs/remotes/origin/feature/test", + name = "origin/feature/test", + repository = repository, + head = commitBySha.getValue("a".repeat(40)), + category = ReferenceCategory.REMOTE_BRANCH + ) + return@run branch + }) + + val branchByName = branches.associateBy(Branch::name) } diff --git a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/extension/Extensions.kt b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/extension/Extensions.kt new file mode 100644 index 000000000..b8282bcaa --- /dev/null +++ b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/extension/Extensions.kt @@ -0,0 +1,31 @@ +package com.inso_world.binocular.core.extension + +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.model.NonRemovingMutableSet +import java.util.concurrent.ConcurrentHashMap +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + +/** + * Test-only extension to clear the internal backing storage of [NonRemovingMutableSet]. + * + * Uses reflection because `backing` is not publicly accessible. + */ +fun NonRemovingMutableSet<*>.reset() { + val field = NonRemovingMutableSet::class.java.getDeclaredField("backing") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + val map = field.get(this) as MutableMap + map.clear() +} + +fun MappingContext.reset() { + + for (prop in arrayOf("d2e", "e2d", "e2dByObjectIdentity")) { + val field = this::class.memberProperties + .first { property -> property.name == prop } + .apply { isAccessible = true } + .getter.call(this) + (field as ConcurrentHashMap<*, *>).clear() + } +} diff --git a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/integration/base/BaseFixturesIntegrationTest.kt b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/integration/base/BaseFixturesIntegrationTest.kt index fdd6f9d54..41a582494 100644 --- a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/integration/base/BaseFixturesIntegrationTest.kt +++ b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/integration/base/BaseFixturesIntegrationTest.kt @@ -1,22 +1,91 @@ package com.inso_world.binocular.core.integration.base +import com.inso_world.binocular.core.delegates.logger import org.junit.jupiter.api.Assertions.assertDoesNotThrow -import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import java.io.File -import java.util.Locale +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.concurrent.Executors import java.util.concurrent.Future import java.util.concurrent.TimeUnit -import kotlin.collections.forEach -import kotlin.text.lowercase -import kotlin.text.replace -import kotlin.text.startsWith -import kotlin.text.substring +import kotlin.io.path.absolutePathString +import kotlin.io.path.toPath +import kotlin.io.path.walk open class BaseFixturesIntegrationTest : BaseIntegrationTest() { companion object { - const val FIXTURES_PATH = "src/test/resources/fixtures" + private val logger by logger() + + /** + * Absolute path to the fixtures directory. + * + * This path is resolved from the classpath to work correctly when: + * - Running tests directly in the core module (filesystem path) + * - Running tests in other modules (e.g., ffi) that depend on core's test-jar (JAR path) + * + * When fixtures are in a JAR, they are extracted to a temporary directory since + * the shell scripts need to be executable on the filesystem. + */ + val FIXTURES_PATH: String by lazy { + val resource = BaseFixturesIntegrationTest::class.java.getResource("/fixtures") + ?: throw IllegalStateException( + "Cannot find fixtures directory on classpath. " + + "Ensure core module's test resources are available." + ) + + try { + val uri = resource.toURI() + + // Check if fixtures are in a JAR (e.g., when accessed via test-jar dependency) + if (uri.scheme == "jar") { + logger.info("Fixtures are in JAR, extracting to temporary directory") + + // Extract fixtures from JAR to temp directory + val tempDir = Files.createTempDirectory("binocular-fixtures-") + Runtime.getRuntime().addShutdownHook(Thread { + tempDir.toFile().deleteRecursively() + }) + + // Create filesystem for the JAR + val fs = try { + FileSystems.getFileSystem(uri) + } catch (e: java.nio.file.FileSystemNotFoundException) { + FileSystems.newFileSystem(uri, emptyMap()) + } + + val fixturesInJar = fs.getPath("/fixtures") + + // Copy all fixtures to temp directory + fixturesInJar.walk().forEach { source -> + val relativePath = fixturesInJar.relativize(source).toString() + if (relativePath.isNotEmpty()) { + val target = tempDir.resolve(relativePath) + if (Files.isDirectory(source)) { + Files.createDirectories(target) + } else { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING) + // Make shell scripts executable + if (target.fileName.toString().endsWith(".sh")) { + target.toFile().setExecutable(true) + } + } + } + } + + logger.info("Fixtures extracted to: ${tempDir.absolutePathString()}") + tempDir.absolutePathString() + } else { + // Running tests directly in core module + uri.toPath().absolutePathString() + } + } catch (e: Exception) { + logger.error("Failed to resolve fixtures path from: ${resource.path}", e) + throw RuntimeException("Failed to resolve fixtures path", e) + } + } + const val SIMPLE_REPO = "simple" const val SIMPLE_PROJECT_NAME = "simple" const val ADVANCED_REPO = "advanced" @@ -24,35 +93,50 @@ open class BaseFixturesIntegrationTest : BaseIntegrationTest() { const val OCTO_REPO = "octo" const val OCTO_PROJECT_NAME = "octo" + fun execCmd(path: String, vararg cmd: String) { + val isWindows = + java.lang.System.getProperty("os.name").lowercase(java.util.Locale.getDefault()).startsWith("windows") + val builder = java.lang.ProcessBuilder() + if (isWindows) { + builder.command(*cmd) + } else { + builder.command(*cmd) + } + builder.directory(File(FIXTURES_PATH)) + logger.info("Executing command: ${builder.command()}") + logger.info("In directory: ${builder.directory()}") + val process = builder.start() + val streamGobbler: StreamGobbler = StreamGobbler(process.inputStream, System.out::println, path) + val executorService = Executors.newFixedThreadPool(1) + val future: Future<*> = executorService.submit(streamGobbler) + + val exitCode = process.waitFor() + assertDoesNotThrow { future.get(25, TimeUnit.SECONDS) } + require(0 == exitCode, { + logger.error("Command failed: ${builder.command()}") + logger.error("Command failed: exit code=$exitCode") + }) + } + @kotlin.jvm.JvmStatic @BeforeAll fun setUp() { fun createGitRepo(path: String) { - val isWindows = java.lang.System.getProperty("os.name").lowercase(java.util.Locale.getDefault()).startsWith("windows") - val builder = java.lang.ProcessBuilder() + val isWindows = java.lang.System.getProperty("os.name").lowercase(java.util.Locale.getDefault()) + .startsWith("windows") if (isWindows) { val winPath = java.io.File(FIXTURES_PATH).absolutePath val wslPath = "/mnt/" + winPath[0].lowercase() + winPath.substring(2).replace("\\", "/") - kotlin.io.println("WINDOWS: $winPath") - kotlin.io.println("WSL: $wslPath") - builder.command( + execCmd( + path = path, "wsl", "bash", "-c", "cd $wslPath && rm -rf $path ${path}_remote.git && ./$path.sh $path", ) } else { - builder.command("sh", "-c", "rm -rf $path ${path}_remote.git && ./$path.sh $path") + execCmd(path = path, "sh", "-c", "rm -rf $path ${path}_remote.git && ./$path.sh $path") } - builder.directory(File(FIXTURES_PATH)) - val process = builder.start() - val streamGobbler: StreamGobbler = StreamGobbler(process.inputStream, System.out::println, path) - val executorService = Executors.newFixedThreadPool(1) - val future: Future<*> = executorService.submit(streamGobbler) - - val exitCode = process.waitFor() - assertDoesNotThrow { future.get(25, TimeUnit.SECONDS) } - assertEquals(0, exitCode) } val executorService = Executors.newFixedThreadPool(3) @@ -65,4 +149,14 @@ open class BaseFixturesIntegrationTest : BaseIntegrationTest() { futures.forEach { it.get() } } } + + fun addCommit() { + val executorService = Executors.newFixedThreadPool(3) + SIMPLE_REPO.let { path -> + val future = executorService.submit { + execCmd(path = path, "sh", "-c", "./${path}_add_commit.sh $path") + } + future.get() + } + } } diff --git a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/integration/base/TestDataProvider.kt b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/integration/base/TestDataProvider.kt index 7c7c6449e..70bc7e88b 100644 --- a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/integration/base/TestDataProvider.kt +++ b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/integration/base/TestDataProvider.kt @@ -1,9 +1,8 @@ package com.inso_world.binocular.core.integration.base +import com.inso_world.binocular.domain.data.MockTestDataProvider import com.inso_world.binocular.model.Account -import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Build -import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.File import com.inso_world.binocular.model.Issue import com.inso_world.binocular.model.Job @@ -15,7 +14,6 @@ import com.inso_world.binocular.model.Note import com.inso_world.binocular.model.Platform import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.Stats import com.inso_world.binocular.model.User import java.time.LocalDateTime @@ -29,6 +27,11 @@ import java.time.LocalDateTime replaceWith = ReplaceWith("com.inso_world.binocular.core.data.MockTestDataProvider") ) object TestDataProvider { + private val project = Project(name = "proj-pg-0") + private val repository = Repository(localPath = "repo-pg-0", project = project) + + private val mockTestDataProvider = MockTestDataProvider(repository) + val testAccounts = listOf( Account( @@ -49,47 +52,53 @@ object TestDataProvider { ), ) - private val mainBranch = Branch("1", "main", true, true, "abc123") - private val newFeatureBranch = - Branch("2", "feature/new-feature", true, false, "def456") - val testBranches = - listOf(mainBranch, newFeatureBranch) +// private val mainBranch = Branch("main","abc123", repository = repository).apply { +// active = true +// tracksFileRenames = true +// this.id = "1" +// } +// private val newFeatureBranch = +// Branch( "feature/new-feature", true, false, "def456", repository = repository).apply { +// this.id = "2" +// } + val testBranches = mockTestDataProvider.branches +// listOf(mainBranch, newFeatureBranch) - val testCommits = - listOf( - run { - val cmt = - Commit( - "1", - "abc123", - LocalDateTime.now(), - LocalDateTime.now(), - "First commit", - null, - "https://example.com/commit/abc123", - "main", - Stats(10, 5), - ) - mainBranch.commits.add(cmt) - cmt - }, - run { - val cmt = - Commit( - "2", - "def456", - java.time.LocalDateTime.now(), - java.time.LocalDateTime.now(), - "Second commit", - null, - "https://example.com/commit/def456", - "main", - Stats(7, 3), - ) - mainBranch.commits.add(cmt) - cmt - }, - ) + val testCommits = mockTestDataProvider.commits +// listOf( +// run { +// val cmt = +// Commit( +// "abc123", +// LocalDateTime.now(), +// LocalDateTime.now(), +// "First commit", +// repository = repository, +// ).apply { +// this.id = "1" +// this.webUrl = "https://example.com/commit/abc123" +// this.branch = "main" +// this.stats = Stats(10, 5) +// } +// cmt +// }, +// run { +// val cmt = +// Commit( +// "def456", +// java.time.LocalDateTime.now(), +// java.time.LocalDateTime.now(), +// "Second commit", +// repository = repository +// ).apply { +// this.id = "2" +// this.webUrl = "https://example.com/commit/def456" +// this.branch = "main" +// this.stats = Stats(7, 3) +// } +// cmt +// }, +// ) val testBuilds = listOf( @@ -128,11 +137,11 @@ object TestDataProvider { "v1.0.0", "user2", "User Two", - java.time.LocalDateTime.now(), - java.time.LocalDateTime.now(), - java.time.LocalDateTime.now(), - java.time.LocalDateTime.now(), - java.time.LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now(), 180, listOf( Job( @@ -140,8 +149,8 @@ object TestDataProvider { "build", "failed", "build", - java.time.LocalDateTime.now(), - java.time.LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now(), "https://example.com/jobs/job2", ), ), @@ -154,21 +163,23 @@ object TestDataProvider { run { val file = File( - "1", "src/main/kotlin/com/example/Main.kt", mutableSetOf(), - ) - file.webUrl = "https://example.com/files/Main.kt" + ).apply { + this.id = "1" + this.webUrl = "https://example.com/files/Main.kt" + } return@run file }, run { val file = File( - "2", "src/main/kotlin/com/example/Utils.kt", mutableSetOf(), - ) - file.webUrl = "https://example.com/files/Utils.kt" + ).apply { + this.id = "2" + this.webUrl = "https://example.com/files/Utils.kt" + } return@run file }, ) @@ -199,9 +210,9 @@ object TestDataProvider { 102, "Add new feature", "Implement profile customization", - java.time.LocalDateTime.now(), - java.time.LocalDateTime.now(), - java.time.LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now(), listOf("enhancement", "feature"), "closed", "https://example.com/issues/102", @@ -265,23 +276,39 @@ object TestDataProvider { val testProjects = listOf( - Project(id = "1", name = "proj-pg-0"), - Project(id = "2", name = "proj-pg-1"), - Project(id = "3", name = "proj-pg-2"), - Project(id = "4", name = "proj-pg-3"), - Project(id = "5", name = "proj-pg-4"), - Project(id = "6", name = "proj-for-repos"), + Project(name = "proj-pg-0").apply { this.id = "1" }, + Project(name = "proj-pg-1").apply { this.id = "2" }, + Project(name = "proj-pg-2").apply { this.id = "3" }, + Project(name = "proj-pg-3").apply { this.id = "4" }, + Project(name = "proj-pg-4").apply { this.id = "5" }, + Project(name = "proj-pg-7").apply { this.id = "7" }, + Project(name = "proj-for-repos").apply {this.id = "6" }, ) + private val testProjectsByName = testProjects.associateBy { it.name } val testRepositories = listOf( - Repository(id = "r1", localPath = "repo-pg-0", project = testProjects.last()), - Repository(id = "r2", localPath = "repo-pg-1", project = testProjects.last()), - Repository(id = "r3", localPath = "repo-pg-2", project = testProjects.last()), - Repository(id = "r4", localPath = "repo-pg-3", project = testProjects.last()), - Repository(id = "r5", localPath = "repo-pg-4", project = testProjects.last()), - Repository(id = "r6", localPath = "repo-pg-5", project = testProjects.last()), - Repository(id = "r7", localPath = "repo-pg-6", project = testProjects.last()), + Repository(localPath = "repo-pg-0", project = testProjectsByName.getValue("proj-for-repos")).apply { + this.id = "r1" + }, + Repository(localPath = "repo-pg-1", project = testProjectsByName.getValue("proj-pg-4")).apply { + this.id = "r2" + }, + Repository(localPath = "repo-pg-2", project = testProjectsByName.getValue("proj-pg-3")).apply { + this.id = "r3" + }, + Repository(localPath = "repo-pg-3", project = testProjectsByName.getValue("proj-pg-2")).apply { + this.id = "r4" + }, + Repository(localPath = "repo-pg-4", project = testProjectsByName.getValue("proj-pg-1")).apply { + this.id = "r5" + }, + Repository(localPath = "repo-pg-5", project = testProjectsByName.getValue("proj-pg-0")).apply { + this.id = "r6" + }, + Repository(localPath = "repo-pg-6", project = testProjectsByName.getValue("proj-pg-7")).apply { + this.id = "r7" + }, ) val testNotes = @@ -314,15 +341,21 @@ object TestDataProvider { val testUsers = listOf( - User("1", "John Doe", "john.doe@example.com"), - User("2", "Jane Smith", "jane.smith@example.com"), + User("John Doe", repository = repository).apply { + this.id = "1" + this.email = "john.doe@example.com" + }, + User("Jane Smith", repository = repository).apply { + this.id = "2" + this.email = "jane.smith@example.com" + }, ) val testMilestones = listOf( Milestone( id = "1", - iid = 201, + platformIid = 201, title = "Release 1.0", description = "First stable release", createdAt = "2023-01-01T10:00:00Z", @@ -335,7 +368,7 @@ object TestDataProvider { ), Milestone( id = "2", - iid = 202, + platformIid = 202, title = "Release 2.0", description = "Second major release", createdAt = "2023-02-01T10:00:00Z", diff --git a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/unit/base/BaseUnitTest.kt b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/unit/base/BaseUnitTest.kt index c85b1e459..131bdf419 100644 --- a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/unit/base/BaseUnitTest.kt +++ b/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/unit/base/BaseUnitTest.kt @@ -45,15 +45,6 @@ abstract class BaseUnitTest { const val TEST_TAG_NAME = "v1.0.0" } - /** - * Setup method that runs before each test. - * Override this method in test classes to add custom setup. - */ - @BeforeEach - open fun setUp() { - // Common setup code can be added here - } - /** * Utility method to create a test exception with a message. */ diff --git a/binocular-backend-new/cli/src/test/resources/fixtures/advanced.sh b/binocular-backend-new/core/src/test/resources/fixtures/advanced.sh similarity index 100% rename from binocular-backend-new/cli/src/test/resources/fixtures/advanced.sh rename to binocular-backend-new/core/src/test/resources/fixtures/advanced.sh diff --git a/binocular-backend-new/cli/src/test/resources/fixtures/octo.sh b/binocular-backend-new/core/src/test/resources/fixtures/octo.sh similarity index 100% rename from binocular-backend-new/cli/src/test/resources/fixtures/octo.sh rename to binocular-backend-new/core/src/test/resources/fixtures/octo.sh diff --git a/binocular-backend-new/cli/src/test/resources/fixtures/simple.sh b/binocular-backend-new/core/src/test/resources/fixtures/simple.sh similarity index 100% rename from binocular-backend-new/cli/src/test/resources/fixtures/simple.sh rename to binocular-backend-new/core/src/test/resources/fixtures/simple.sh diff --git a/binocular-backend-new/core/src/test/resources/fixtures/simple_add_commit.sh b/binocular-backend-new/core/src/test/resources/fixtures/simple_add_commit.sh new file mode 100755 index 000000000..2822f7b8f --- /dev/null +++ b/binocular-backend-new/core/src/test/resources/fixtures/simple_add_commit.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Force Git to use UTC for all timestamps +export TZ=UTC + +# Usage check +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +REPO_DIR="$(pwd)/$1" +REMOTE_DIR="${REPO_DIR}_remote.git" + +cd "$REPO_DIR" + +############################################################################### +# Helper to commit with fixed author/committer data +############################################################################### +git_commit() { + local msg="$1"; shift + local date="$1"; shift + local name="$1"; shift + local email="$1" + GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ + GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" \ + GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" \ + git commit -m "$msg" -q +} + +# 14: Re-add file2.txt with new content by Bob +echo "Added file xyz" > xyz.txt +git add xyz.txt +GIT_AUTHOR_DATE="2024-01-02T00:30:00+00:00" \ +git_commit "Added file.xyz" \ + "2024-01-02T01:00:00+00:00" \ + "Dave" "dave@example.com" + +exit 0 diff --git a/binocular-backend-new/domain/README.md b/binocular-backend-new/domain/README.md new file mode 100644 index 000000000..c54e787ec --- /dev/null +++ b/binocular-backend-new/domain/README.md @@ -0,0 +1,609 @@ +# Domain Module + +**Pure domain logic for the Binocular Git repository analysis system.** + +This module contains the core domain models and business logic for Binocular. It is completely framework-agnostic, has +no external infrastructure dependencies, and follows Domain-Driven Design (DDD) principles. + +## Table of Contents + +- [Overview](#overview) +- [Architecture & Design Principles](#architecture--design-principles) +- [Core Domain Models](#core-domain-models) +- [Key Concepts](#key-concepts) +- [Domain Model Relationships](#domain-model-relationships) +- [Validation](#validation) +- [Testing](#testing) +- [Usage Examples](#usage-examples) +- [Building](#building) + +--- + +## Overview + +The domain module provides: + +- **Pure domain models** representing Git repositories, commits, branches, files, users, and projects +- **Business logic** encapsulated within domain entities +- **Validation rules** enforcing domain invariants +- **Collection semantics** for managing entity relationships with consistency guarantees +- **Identity management** via dual identity system (technical ID + business key) + +**Key characteristics:** + +- Zero framework dependencies (no Spring, no Hibernate, no database concerns) +- Immutable identifiers with value-based business keys +- Add-only collections with repository consistency checks +- Comprehensive KDoc documentation +- Extensive unit test coverage (80%+ with mutation testing) + +--- + +## Architecture & Design Principles + +### Hexagonal Architecture + +The domain module sits at the **core** of a hexagonal (ports & adapters) architecture: + +``` +┌─────────────────────────────────────────────┐ +│ Application Layer (cli, web) │ +│ - Spring Boot entry points │ +│ - GraphQL API, Shell commands │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ Core Module │ +│ - Application services │ +│ - Port interfaces (InfrastructurePorts) │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ Domain Module ← YOU ARE HERE │ +│ - Pure domain models │ +│ - Business logic │ +│ - Domain validation │ +└─────────────────────────────────────────────┘ + ↑ +┌─────────────────────────────────────────────┐ +│ Infrastructure Adapters │ +│ - infrastructure-sql (PostgreSQL/Hibernate)│ +│ - infrastructure-arangodb (ArangoDB) │ +└─────────────────────────────────────────────┘ +``` + +### Design Principles + +1. **Framework Independence**: No external dependencies except Kotlin stdlib, Jakarta validation, and SLF4J +2. **Immutable Identifiers**: Technical IDs (`iid`) are immutable and stable +3. **Business Keys**: Natural keys (`uniqueKey`) enable domain-driven deduplication +4. **Set-Once Relationships**: Parent references cannot be reassigned once set +5. **Add-Only Collections**: History-preserving collections prevent data loss +6. **Repository Consistency**: Cross-repository relationships are prevented via runtime checks +7. **Comprehensive Documentation**: Every public API has detailed KDoc with semantics, invariants, and examples + +--- + +## Core Domain Models + +### Primary Entities + +These entities are to root aggregates which in turn own secondary entities. +Although `Repository` is owned by the `Project` it serves as a root aggregate for all Source Code Management (SCM) +related data. +`Project` on the other hand is the root aggregate for everything. +Especially for the ITS/CI data, since it is not _directly_ related to the `Repository`, just indirectly based on the +platform. + +| Entity | Description | Identity | Business Key | +|----------------|---------------------------------|------------------------|----------------------------| +| **Project** | Top-level organizational unit | `Project.Id` (UUID) | `name` | +| **Repository** | Git repository within a project | `Repository.Id` (UUID) | `(project.iid, localPath)` | + +### Secondary Entities + +Effectively all others. + +| Entity | Description | Identity | Business Key | +|------------|-----------------------------|-----------------------|--------------------------| +| **Commit** | Git commit snapshot | `Commit.Id` (UUID) | `sha` (40-char hex) | +| **Branch** | Named Git reference | `Reference.Id` (UUID) | `(repository.iid, name)` | +| **User** | Git commit author/committer | `User.Id` (UUID) | `(repository.iid, name)` | +| **File** | File tracked in repository | `File.Id` (UUID) | `(repository.iid, path)` | + +### Supporting Entities + +- **CommitDiff**: Represents diff between two commits +- **FileDiff**: Represents changes to a file within a commit +- **FileState**: Snapshot of a file at a specific commit +- **Issue**, **MergeRequest**, **Build**, **Job**: CI/CD and issue tracking entities (future/legacy) + +### Collections + +- **NonRemovingMutableSet**: Add-only set backed by `ConcurrentHashMap`, keyed by `uniqueKey` + +--- + +## Key Concepts + +### 1. Dual Identity System + +Every domain entity inherits from `AbstractDomainObject` and exposes two forms of identity: + +#### Technical Identity (`iid`) + +- Immutable, stable identifier generated at construction time +- Typically a UUID wrapped in a value class (e.g., `Commit.Id`, `Repository.Id`) +- Used for `hashCode()` calculation and entity identity in collections + +#### Business Key (`uniqueKey`) + +- Domain-level natural key that's unique within its scope +- Used for deduplication in `NonRemovingMutableSet` +- Examples: + - `Commit.uniqueKey = sha` + - `Branch.uniqueKey = Key(repository.iid, name)` + - `User.uniqueKey = Key(repository.iid, name)` + +**Equality Semantics:** + +```kotlin +override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AbstractDomainObject<*, *> + + if (iid != other.iid) return false + if (uniqueKey != other.uniqueKey) return false + + return true +} + +override fun hashCode(): Int = iid.hashCode() +``` + +Two entities are equal **only if** they have the same runtime class **and** both `iid` and `uniqueKey` match. + +### 2. Add-Only Collections + +Domain entities use **`NonRemovingMutableSet`** for relationship collections: + +**Characteristics:** + +- ✅ **Add operations**: `add()`, `addAll()`, `contains()`, `containsAll()` +- ❌ **Remove operations**: `remove()`, `removeAll()`, `retainAll()`, `clear()`, iterator `remove()` → throw + `UnsupportedOperationException` +- 🔑 **Deduplication**: By `uniqueKey`, not object identity +- 🔒 **Canonical instance**: First instance added for a given `uniqueKey` is retained +- ⚡ **Thread-safe**: Backed by `ConcurrentHashMap` for concurrent reads/writes +- 🔄 **Weakly consistent iteration**: Iterator may not reflect concurrent modifications + +**Why add-only?** + +1. **History preservation**: Git data is immutable and shouldn't be deleted +2. **Consistency**: Prevents accidentally breaking the commit graph or relationships +3. **Audit trail**: Domain maintains complete record of all added entities + +**Example:** + +```kotlin +val repository = Repository(localPath = "/path/to/repo", project = myProject) +val commit1 = Commit(sha = "a".repeat(40), ..., repository = repository) +val commit2 = Commit(sha = "a".repeat(40), ..., repository = repository) // Same SHA + +repository.commits.add(commit1) // true - added +repository.commits.add(commit2) // false - duplicate uniqueKey, commit1 is canonical + +repository.commits.contains(commit1) // true +repository.commits.contains(commit2) // true (checks uniqueKey, not identity) +repository.commits.size // 1 +``` + +### 3. Repository Consistency + +Entities belonging to different repositories **cannot** be linked: + +```kotlin +val repoA = Repository(localPath = "/repoA", project = projectA) +val repoB = Repository(localPath = "/repoB", project = projectB) + +val commitA = Commit(sha = "abc...", repository = repoA) + +// ❌ This will throw IllegalArgumentException: +repoB.commits.add(commitA) + +// ❌ This will also throw: +val branchB = Branch(name = "main", repository = repoB, head = commitA) +``` + +All add operations validate `element.repository == this@Repository`. + +### 4. Set-Once Relationships + +Parent references use **set-once** semantics: + +```kotlin +var repo: Repository? = null +set(value) { + requireNotNull(value) { "Cannot set repo to null" } + if (value == this.repo) return // Idempotent: same value is no-op + if (this.repo != null) { + throw IllegalArgumentException("Repository already set") + } + field = value +} +``` + +**Rules:** + +- Cannot be set to `null` +- Cannot be reassigned to a different instance +- Re-assigning the same instance is a no-op + +This prevents: + +- Breaking relationships after initialization +- Accidentally moving entities between aggregates +- Null reference errors + +--- + +## Domain Model Relationships + +### Project ↔ Repository (1:1) + +```kotlin +val project = Project(name = "MyProject") +val repository = Repository(localPath = "/path", project = project) + +// Bidirectional link established in Repository.init: +assert(repository.project === project) +assert(project.repo === repository) +``` + +### Repository → Commits/Branches/Users (1:N) + +```kotlin +val repository = Repository(localPath = "/path", project = project) + +// Commits auto-register on construction: +val commit = Commit(sha = "abc...", repository = repository) +assert(commit in repository.commits) + +// Branches auto-register on construction: +val branch = Branch(name = "main", repository = repository, head = commit) +assert(branch in repository.branches) + +// Users are explicitly added: +val user = User(name = "Alice", email = "alice@example.com", repository = repository) +assert(user in repository.user) +``` + +### Commit ↔ Author/Committer (N:1) + +```kotlin +val commit = Commit(sha = "abc...", repository = repository) +val author = User(name = "Alice", email = "alice@example.com", repository = repository) + +commit.author = author // Set-once, bidirectional +assert(commit in author.authoredCommits) + +commit.committer = author +assert(commit in author.committedCommits) +``` + +### Commit → Parents/Children (DAG) + +```kotlin +val parent = Commit(sha = "aaa...", repository = repository) +val child = Commit(sha = "bbb...", repository = repository) + +// Bidirectional link: +child.parents.add(parent) +assert(parent in child.parents) +assert(child in parent.children) +``` + +### Branch → Commits (1:N) + +```kotlin +val branch = Branch(name = "main", repository = repository, head = headCommit) + +// Access complete commit history via lazy property: +val allCommits: List = branch.commits +// Returns [head, parent1, parent2, ..., root] in topological order +``` + +--- + +## Validation + +### Jakarta Validation Annotations + +Domain models use Jakarta Validation (JSR-380) annotations: + +```kotlin +@field:NotBlank +val name: String + +@field:Size(min = 40, max = 40) +val sha: String + +@field:PastOrPresent +val commitDateTime: LocalDateTime +``` + +### Runtime Validation + +In addition to annotations, constructors enforce invariants with `require()`: + +```kotlin +init { + require(name.isNotBlank()) { "name must not be blank" } + require(sha.length == 40) { "SHA must be 40 hex chars" } + require(sha.all { it.isHex() }) { "SHA-1 must be hex [0-9a-fA-F]" } + require(commitDateTime.isBefore(LocalDateTime.now())) { + "commitDateTime must be past or present" + } +} +``` + +### Repository Consistency Checks + +Collections validate repository ownership: + +```kotlin +override fun add(element: Commit): Boolean { + require(element.repository == this@Repository) { + "$element cannot be added to a different repository." + } + return super.add(element) +} +``` + +--- + +## Testing + +### Test Organization + +``` +domain/src/test/kotlin/ +├── com/inso_world/binocular/ +│ ├── data/ +│ │ ├── MockTestDataProvider.kt # Test fixtures for integration tests +│ │ └── DummyTestData.kt # Parameterized test data +│ └── model/ +│ ├── *ModelTest.kt # Unit tests for domain models +│ ├── NonRemovingMutableSetTest.kt # Unit tests for collection +│ ├── validation/ # Jakarta Validation tests +│ │ ├── *ValidationTest.kt +│ │ └── base/ValidationTest.kt +│ └── utils/ +│ └── ReflectionUtils.kt # Test utilities +``` + +### Running Tests + +```bash +# Run all unit tests +mvn test -Dgroups=unit + +# Run specific test class +mvn test -Dtest=CommitModelTest -Dgroups=unit + +# Run with coverage +mvn verify jacoco:report -Dgroups=unit +open target/site/jacoco/index.html +``` + +### Mutation Testing + +The domain module uses [PIT (pitest)](https://pitest.org/) for mutation testing to ensure test quality: + +```bash +# Run mutation tests +mvn org.pitest:pitest-maven:mutationCoverage + +# View report +open target/pit-reports/*/index.html +``` + +**Coverage Goals:** + +- Line coverage: **80%+** +- Mutation coverage: **80%+** +- C3/C4 (branch/path) coverage for complex logic + +### Test Fixtures + +The domain module exports a **test-jar** for reuse in other modules: + +```xml + + + com.inso-world.binocular + domain + 0.0.1-SNAPSHOT + tests + test-jar + test + +``` + +**Available fixtures:** + +- `MockTestDataProvider`: Creates pre-wired graphs of commits, branches, users +- `DummyTestData`: Provides parameterized test data (blank strings, valid strings, etc.) + +--- + +## Usage Examples + +### Creating a Project and Repository + +```kotlin +@OptIn(ExperimentalUuidApi::class) +val project = Project(name = "Binocular") +val repository = Repository( + localPath = "/path/to/binocular/.git", + project = project +) + +// Bidirectional link is established automatically +assert(project.repo === repository) +``` + +### Creating Commits and Building History + +```kotlin +val user = User( + name = "Alice", + email = "alice@example.com", + repository = repository +) + +val commit1 = Commit( + sha = "a".repeat(40), + message = "Initial commit", + commitDateTime = LocalDateTime.now().minusDays(2), + authorDateTime = LocalDateTime.now().minusDays(2), + repository = repository +) +commit1.author = user +commit1.committer = user + +val commit2 = Commit( + sha = "b".repeat(40), + message = "Second commit", + commitDateTime = LocalDateTime.now().minusDays(1), + authorDateTime = LocalDateTime.now().minusDays(1), + repository = repository +) +commit2.author = user +commit2.committer = user +commit2.parents.add(commit1) // Establishes parent-child relationship + +assert(commit1 in commit2.parents) +assert(commit2 in commit1.children) +``` + +### Creating Branches + +```kotlin +val main = Branch( + name = "main", + repository = repository, + head = commit2 // Points to latest commit +) + +// Branch automatically registers with repository +assert(main in repository.branches) + +// Access complete commit history +val history: List = main.commits +assert(history == listOf(commit2, commit1)) +``` + +### Working with Collections + +```kotlin +// Add commits (idempotent, deduplicates by uniqueKey) +repository.commits.add(commit1) // true - added +repository.commits.add(commit1) // false - already present + +// Check membership (by uniqueKey) +val probe = Commit(sha = "a".repeat(40), ..., repository = repository) +assert(repository.commits.contains(probe)) // true - same sha + +// Removal is not allowed +repository.commits.remove(commit1) // Throws UnsupportedOperationException +``` + +### Handling Validation Errors + +```kotlin +// ❌ Blank project name +assertThrows { + Project(name = " ") +} + +// ❌ Invalid SHA +assertThrows { + Commit(sha = "invalid", ..., repository = repository) +} + +// ❌ Cross-repository reference +val repoA = Repository(localPath = "/repoA", project = projectA) +val repoB = Repository(localPath = "/repoB", project = projectB) +val commitA = Commit(sha = "abc...", repository = repoA) + +assertThrows { + repoB.commits.add(commitA) +} +``` + +--- + +## Best Practices + +### When Adding New Domain Models + +1. **Extend `AbstractDomainObject`** + ```kotlin + data class MyEntity(val name: String) + : AbstractDomainObject(Id(Uuid.random())) { + + @JvmInline + value class Id(val value: Uuid) + data class Key(val name: String) + + override val uniqueKey: Key get() = Key(name) + + // Override equals/hashCode to use parent implementation + override fun equals(other: Any?) = super.equals(other) + override fun hashCode(): Int = super.hashCode() + } + ``` + +2. **Add comprehensive KDoc** + - Short description + - Identity & equality semantics + - Construction & validation rules + - Relationships & mutability + - Thread-safety guarantees + - Examples + +3. **Write comprehensive tests** + - Unit tests covering all public methods + - Validation tests for all constraints + - Edge cases and error conditions + - Aim for 80%+ line and mutation coverage + +4. **Use add-only collections for relationships** + ```kotlin + val children: MutableSet = + object : NonRemovingMutableSet() { + override fun add(element: MyChild): Boolean { + require(element.parent == this@MyEntity) + return super.add(element) + } + } + ``` + +5. **Validate at construction time** + ```kotlin + init { + require(name.isNotBlank()) { "name must not be blank" } + require(value >= 0) { "value must be non-negative" } + } + ``` + +### When Modifying Existing Models + +1. **Never change `iid` after construction** (breaks hashCode contract) +2. **Keep `uniqueKey` stable** (used for deduplication) +3. **Add tests for new behavior** (maintain >80% coverage) +4. **Update KDoc** to reflect changes +5. **Run mutation tests** to verify test quality diff --git a/binocular-backend-new/domain/pom.xml b/binocular-backend-new/domain/pom.xml index 1c225f28a..acfe63707 100644 --- a/binocular-backend-new/domain/pom.xml +++ b/binocular-backend-new/domain/pom.xml @@ -1,5 +1,5 @@ - 4.0.0 @@ -63,7 +63,7 @@ jakarta.validation jakarta.validation-api - + 3.1.1 @@ -107,34 +107,51 @@ - ${project.basedir}/src/main/kotlin - ${project.basedir}/src/test/kotlin + org.jetbrains.kotlin kotlin-maven-plugin ${kotlin.version} - true - - - -Xjsr305=strict - - - all-open - - - - - org.jetbrains.kotlin - kotlin-maven-allopen - ${kotlin.version} - - - org.jetbrains.kotlin - kotlin-maven-noarg - ${kotlin.version} - - + + + compile + compile + + compile + + + + ${project.basedir}/src/main/kotlin + + + + + test-compile + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + test-jar + + + diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/AbstractDomainObject.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/AbstractDomainObject.kt index 77a8a9953..8535829ff 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/AbstractDomainObject.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/AbstractDomainObject.kt @@ -1,5 +1,106 @@ package com.inso_world.binocular.model -abstract class AbstractDomainObject { - abstract fun uniqueKey(): String +/** + * Base type for domain entities within Binocular that expose two notions of identity: + * + * 1. **`iid` (internal/technical id):** A stable, immutable identifier for this + * instance (e.g., aggregate-local id or value uniquely derived + * at creation time). Used here to derive `hashCode()`. + * 2. **`uniqueKey` (business key):** A domain/natural key that should be unique + * within the relevant boundary (e.g., within a repository or project). + * Used by collection helpers (e.g., `NonRemovingMutableSet`) to enforce + * de-duplication based on business semantics. + * + * ### Equality & hashing + * - `equals` returns `true` **only if**: + * 1. same reference, or + * 2. exact same runtime class **and** both `iid` **and** `uniqueKey` are equal. + * - `hashCode` is derived **only** from `iid`. + * + * This satisfies the contract: if two objects are equal, they have the same `iid` + * and therefore the same `hashCode`. Non-equal objects may share the same hash + * (e.g., equal `iid` but different `uniqueKey`), which is allowed and at worst + * increases collisions. + * + * ### Contracts & invariants + * - `iid` MUST be effectively immutable for the lifetime of the object; do not + * mutate it after construction (would corrupt hash-based collections). + * - `uniqueKey` MUST be stable and **domain-unique** within its scope; if it is + * composite, model it as a value type with proper `equals/hashCode`. + * - If a subclass overrides `equals`, it MUST ensure that equal objects produce + * the same `hashCode`. In practice, that means either: + * - base `equals` on the same `iid`, or + * - also override `hashCode` consistently (not recommended to diverge here). + * + * ### Usage guidance + * - Prefer **reference equality** for entity identity checks at runtime unless + * your domain explicitly requires value equality. + * - For de-duplicating by business semantics (e.g., commits by `sha`, + * branches by `repo+name`), use `uniqueKey` and key-aware collections like + * `NonRemovingMutableSet`. + * - Sub-classed `data class`es **must** override `equals/hashCode` to avoid kotlin's auto-generated + * `equals/hashCode`. Simply use: + * ```kotlin + * override fun equals(other: Any?) = super.equals(other) + * override fun hashCode(): Int = super.hashCode() + * ``` + * + * ### Example + * ```kotlin + * // required for kotlin UUID: + * // https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.uuid/-experimental-uuid-api/ + * @OptIn(ExperimentalUuidApi::class) + * Commit( + * [...] + * ) : AbstractDomainObject( + * Commit.Id(Uuid.random()) + * ) { + * @JvmInline + * value class Id(val value: Uuid) + * [...] + * } + * ``` + */ +abstract class AbstractDomainObject( + /** + * Technical/aggregate identifier (stable, immutable). + * Typical sources: aggregate-generated id or deterministic value id. + */ + val iid: Iid +) { + + /** + * Business/natural key that uniquely identifies this object **in domain terms**. + * + * - Scope of uniqueness must be defined by the domain (e.g., “unique within a repository”). + * - Should be stable and immutable for reliable collection semantics. + * - Often a value type (e.g., data class or tuple) capturing the natural key. + */ + abstract val uniqueKey: Key + + /** + * Hash code derived solely from [iid]. + * + * Rationale: keeps hash buckets stable regardless of other field changes and + * aligns with common entity identity semantics. If a subclass overrides `equals`, + * it must ensure that equal objects share the same [hashCode]. + */ + override fun hashCode(): Int = iid.hashCode() + + /** + * Equality requires the same runtime class and both `iid` **and** `uniqueKey` to match. + * Note: if the persistence layer uses proxies, consider relaxing the class check. + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AbstractDomainObject<*, *> + + if (iid != other.iid) return false + if (uniqueKey != other.uniqueKey) return false + + return true + } + } diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Account.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Account.kt index 964641336..210ab95fb 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Account.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Account.kt @@ -1,9 +1,14 @@ package com.inso_world.binocular.model +import com.inso_world.binocular.model.Commit.Id +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + /** * Domain model for an Account, representing a user account from a platform like GitHub or GitLab. * This class is database-agnostic and contains no persistence-specific annotations. */ +@OptIn(ExperimentalUuidApi::class) data class Account( var id: String? = null, var platform: Platform? = null, @@ -15,4 +20,15 @@ data class Account( var issues: List = emptyList(), var mergeRequests: List = emptyList(), var notes: List = emptyList(), -) +) : AbstractDomainObject( + Id(Uuid.random()) +) { + @JvmInline + value class Id(val value: Uuid) + + // TODO work in progress, just for compatibility + data class Key(val login: String) // value object for lookups + + override val uniqueKey: Key + get() = TODO("Not yet implemented") +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Branch.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Branch.kt index 33c361061..f82e94279 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Branch.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Branch.kt @@ -1,92 +1,182 @@ package com.inso_world.binocular.model +import com.inso_world.binocular.model.vcs.ReferenceCategory import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.NotNull -import java.util.Objects +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** - * Domain model for a Branch, representing a branch in a Git repository. - * This class is database-agnostic and contains no persistence-specific annotations. + * Branch — a named pointer within a Git [Repository]. + * + * ### Identity & equality + * - Technical identity: immutable [iid] of type [Id] (assigned at construction). + * - Business key: [uniqueKey] == [Key]([repository].iid, [name]). + * - Equality delegates to [AbstractDomainObject] (identity-based); `hashCode()` derives from [iid]. + * + * ### Construction & validation + * - Requires a non-blank [name] (`@field:NotBlank` + runtime `require`). + * - On initialization the instance registers itself in `repository.branches` + * (idempotent, add-only set). + * + * ### Relationships & collections + * - [commits]: complete history of all commits reachable from the [head] element on this [branch]. + * - [files]: add-only collection keyed by business keys of [File]; exposed as `Set` for read-only use. + * + * ### Thread-safety + * - The entity is mutable and not thread-safe. Collection fields use concurrent maps internally, + * but multi-step workflows are **not** atomic; coordinate externally. + * + * @property name Branch name used for domain identity (shortened ref). + * @property fullName Fully-qualified Git reference name (e.g., `refs/heads/main`). + * @property category Category/type of the reference as reported by gix. + * @property active Whether this branch is currently the active/checked-out branch. + * @property tracksFileRenames Whether file rename tracking is enabled when analyzing history. + * @property latestCommit Optional last known commit SHA associated with this branch. + * @property head Last known commit SHA associated with this branch. + * @property repository Owning repository; this branch registers itself to `repository.branches` in `init`. */ -data class Branch( - val id: String? = null, - @field:NotBlank - val name: String, - val active: Boolean = false, - val tracksFileRenames: Boolean = false, - val latestCommit: String? = null, - // Relationships - val files: List = emptyList(), +@OptIn(ExperimentalUuidApi::class) +class Branch( + @field:NotBlank val name: String, + @field:NotBlank val fullName: String, + override val category: ReferenceCategory, @field:NotNull - var repository: Repository? = null, -) { + override val repository: Repository, + head: Commit, +) : Reference(category, repository), Cloneable { + @JvmInline + value class Id(val value: Uuid) + + data class Key(val repositoryId: Repository.Id, val name: String) + + @Deprecated("Avoid using database specific id, use business key", ReplaceWith("iid")) + var id: String? = null + + @Deprecated("old") + var active: Boolean = false + + @Deprecated("old") + var tracksFileRenames: Boolean = false + + @Deprecated("", ReplaceWith("head.sha")) + val latestCommit: String + get() = head.sha + @Deprecated("legacy, use name property instead", replaceWith = ReplaceWith("name")) val branch: String = name - @field:NotEmpty - private val _commits: MutableSet = mutableSetOf() - - @get:NotEmpty - val commits: MutableSet = - object : MutableSet by _commits { - override fun add(element: Commit): Boolean { - // add to this commit’s parents… - val added = _commits.add(element) - if (added) { - // …and back-link to this as a child - element.branches.add(this@Branch) - } - val parentsAdded = _commits.addAll(element.parents) - return added || parentsAdded + var head: Commit = head + set(@NotNull value) { + require(value.repository == this@Branch.repository) { + "Head is from different repository (${value.repository}) than branch (${this@Branch.repository})" } - override fun addAll(elements: Collection): Boolean { - // for bulk-adds make sure each one gets the same treatment - var anyAdded = false - for (e in elements) { - if (add(e)) anyAdded = true - } - return anyAdded - } + field = value } - fun uniqueKey(): String { - val repo = - requireNotNull(repository) { - "Cannot generate unique key for $javaClass when repository is null" - } - return "${repo.localPath},$name" + val files: Set = object : NonRemovingMutableSet() {} + + init { + require(name.isNotBlank()) { "name must not be blank" } + require(fullName.isNotBlank()) { "fullName must not be blank" } + // workaround since `this.head = head` does not call the setter, avoid duplicate logic + head.also { this.head = it } + repository.branches.add(this) } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as Branch + /** + * All commits reachable from this branch’s `head`, in **Git `--topo-order`**. + * + * ### Semantics + * - Produces a **children-before-parents** topological order: **no commit is shown before any of its descendants** + * reachable from `head` (i.e., merges appear *before* their parents; `head` appears *first*). + * - When multiple parent branches are available, the traversal **prefers more recent parents first** + * (by `commitDateTime`, tie-broken by `sha`) to emulate Git’s visual “non-crossing” branch flow. + * - The returned set preserves iteration order (via `LinkedHashSet`) and is recomputed on each access. + * + * ### Validation + * - Annotated with `@get:NotEmpty`: validation frameworks will reject an instance where this getter yields + * an empty set. Inclusion of `head` normally guarantees non-emptiness. + * + * ### Invariants enforced on get + * - Result contains only commits reachable via `parents` starting at `head`. + * + * ### Bulk adds + * - N/A — this is a derived snapshot; the underlying model is not mutated. + * + * ### Idempotency & recursion safety + * - Uses **iterative** DFS with visited-tracking, then a **postorder-reverse** to achieve `--topo-order` + * without recursion or stack overflows. + * + * ### Exceptions + * - None intentionally; cycles (which should not exist for commits) are tolerated by visited-tracking. + * + * ### Thread-safety + * - Builds an ephemeral snapshot while iterating over mutable, add-only sets (`parents`). + * Concurrent mutations may yield a **weakly consistent** snapshot; coordinate externally if needed. + * + * ### Complexity + * - Reachability + ordering is **O(V+E)** time and **O(V)** space over the reachable subgraph. + */ + @get:NotEmpty + val commits: Set + get() { + // 1) Collect reachable nodes from head (iterative DFS on parents) + val reachable = LinkedHashSet() + val stack = ArrayDeque() + stack.addLast(head) - if (active != other.active) return false - if (tracksFileRenames != other.tracksFileRenames) return false - if (id != other.id) return false - if (name != other.name) return false - if (latestCommit != other.latestCommit) return false + // prefer newer parents first to mimic git’s visual flow when branches diverge + fun parentOrder(c: Commit): List = + c.parents.toList() + .sortedWith(compareByDescending { it.commitDateTime }.thenBy { it.sha }) - return true - } + while (stack.isNotEmpty()) { + val c = stack.removeLast() + if (reachable.add(c)) { + val parents = parentOrder(c) + for (i in parents.lastIndex downTo 0) { + val p = parents[i] + if (p !in reachable) stack.addLast(p) + } + } + } - override fun hashCode(): Int { - var result = Objects.hashCode(active) - result = 31 * result + Objects.hashCode(tracksFileRenames) - result = 31 * result + Objects.hashCode(id) - result = 31 * result + Objects.hashCode(name) - result = 31 * result + Objects.hashCode(latestCommit) - return result - } + // 2) Postorder over parents, then reverse → children-before-parents (git --topo-order) + val seen = HashSet(reachable.size) + val out = ArrayList(reachable.size) + val work = ArrayDeque>() // (node, expanded?) + work.addLast(head to false) - override fun toString(): String = - "Branch(id=$id, name='$name', active=$active, tracksFileRenames=$tracksFileRenames, latestCommit=$latestCommit, commitShas=${ - commits.map { - it.sha + while (work.isNotEmpty()) { + val (node, expanded) = work.removeLast() + if (!expanded) { + if (!seen.add(node)) continue + work.addLast(node to true) // post-visit marker + val parents = parentOrder(node).filter { it in reachable } + for (i in parents.lastIndex downTo 0) { + val p = parents[i] + if (p !in seen) work.addLast(p to false) + } + } else { + out.add(node) // postorder append + } } - }, repositoryId=${repository?.id})" + + out.reverse() // reverse postorder ⇒ children before parents (git topo-order) + return LinkedHashSet(out) + } + + override val uniqueKey: Key + get() = Key(repository.iid, this.name) + + // Entities compare by immutable identity only + override fun equals(other: Any?) = super.equals(other) + override fun hashCode(): Int = super.hashCode() + + override fun toString(): String = + "Branch(id=$id, iid=$iid, name='$name', fullName='$fullName', category=$category, active=$active, tracksFileRenames=$tracksFileRenames, latestCommit=$latestCommit, head=${head.sha}, repositoryId=${repository.id})" } diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Build.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Build.kt index fc27ecee4..490aaf5ad 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Build.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Build.kt @@ -1,11 +1,14 @@ package com.inso_world.binocular.model import java.time.LocalDateTime +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Domain model for a Build, representing a CI/CD build. * This class is database-agnostic and contains no persistence-specific annotations. */ +@OptIn(ExperimentalUuidApi::class) data class Build( var id: String? = null, var sha: String? = null, @@ -24,4 +27,15 @@ data class Build( var webUrl: String? = null, // Relationships var commits: List = emptyList(), -) +) : AbstractDomainObject( + Id(Uuid.random()) +) { + @JvmInline + value class Id(val value: Uuid) + + // TODO work in progress, just for compatibility + data class Key(val key: String) // value object for lookups + + override val uniqueKey: Key + get() = TODO("Not yet implemented") +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Commit.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Commit.kt index 18b0499bb..92ee84490 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Commit.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Commit.kt @@ -1,86 +1,169 @@ package com.inso_world.binocular.model -import com.inso_world.binocular.model.validation.NoCommitCycle +import com.inso_world.binocular.model.validation.Hexadecimal +import com.inso_world.binocular.model.validation.isHex +import jakarta.validation.Valid import jakarta.validation.constraints.NotNull -import jakarta.validation.constraints.PastOrPresent import jakarta.validation.constraints.Size import java.time.LocalDateTime -import java.util.Objects -import java.util.concurrent.ConcurrentHashMap +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** - * Domain model for a Commit, representing a commit in a Git repository. - * This class is database-agnostic and contains no persistence-specific annotations. + * Commit — a Git snapshot that belongs to a [Repository]. + * + * ## Identity & Equality + * - Technical identity: immutable [iid] of type [Id] (generated at construction). + * - Business key: [uniqueKey] == [sha]. + * - Equality follows [AbstractDomainObject]: same runtime class **and** equal [iid] and [uniqueKey]; + * `hashCode()` derives from [iid]. + * + * ## Construction & Validation + * - [sha] must be exactly 40 hexadecimal characters (`[0-9a-fA-F]`). + * - [authorSignature] is **required** and contains the author [Developer] and timestamp. + * - [committerSignature] is **optional**; if not provided, defaults to [authorSignature]. + * - Both signatures' timestamps must be past-or-present. + * - Both signatures' developers must belong to the same repository as this commit. + * - On initialization the instance registers itself in `repository.commits`, + * `author.authoredCommits`, and `committer.committedCommits`. + * + * ## Git Semantics + * - **Author**: The person who originally wrote the code (captured in [authorSignature]). + * - **Committer**: The person who committed the code (captured in [committerSignature]). + * - These can differ when patches are applied, commits are cherry-picked, or rebased. + * - When the same person authors and commits, [committerSignature] can be omitted (defaults to author). + * + * ## Relationships + * - [author]: Derived from [authorSignature.developer][Signature.developer]. + * - [committer]: Derived from [committerSignature.developer][Signature.developer]. + * - [parents], [children]: Add-only, repository-consistent, bidirectionally maintained. + * + * ## Thread-safety + * - The entity is mutable and not thread-safe. Collection fields use concurrent maps internally, + * but multi-step workflows are **not** atomic; coordinate externally. + * + * @property sha 40-character hex SHA-1 identifying the commit; forms the business key. + * @property authorSignature The signature of the commit's author (required). + * @property committerSignature The signature of the committer (optional, defaults to author). + * @property message Optional commit message summary/body. + * @property repository Owning repository; the commit registers itself to `repository.commits` in `init`. + * @see Signature + * @see Developer */ -@NoCommitCycle +@OptIn(ExperimentalUuidApi::class) data class Commit( - val id: String? = null, @field:Size(min = 40, max = 40) + @field:Hexadecimal val sha: String, - @field:PastOrPresent - val authorDateTime: LocalDateTime? = null, - @field:PastOrPresent @field:NotNull - val commitDateTime: LocalDateTime? = null, -// @field:NotBlank // commits may have not message + @field:Valid + val authorSignature: Signature, + @field:Valid + @field:NotNull + val committerSignature: Signature = authorSignature, val message: String? = null, @field:NotNull - var repository: Repository? = null, - val webUrl: String? = null, + val repository: Repository, +) : AbstractDomainObject( + Id(Uuid.random()) +) { + @JvmInline + value class Id(val value: Uuid) + + data class Key(val sha: String) + + @Deprecated("Avoid using database specific id, use business key", ReplaceWith("iid")) + var id: String? = null + + var webUrl: String? = null + @Deprecated("do not use") - val branch: String? = null, - val stats: Stats? = null, - // Relationships -// old stuff - val builds: List = emptyList(), - val files: List = emptyList(), - val modules: List = emptyList(), - val issues: List = emptyList(), -) : AbstractDomainObject(), - Cloneable { - // 1) private backing set - private val _parents = ConcurrentHashMap.newKeySet() - private val _children = ConcurrentHashMap.newKeySet() - private val _branches = ConcurrentHashMap.newKeySet() - - var committer: User? = null - set(value) { - if (value == this.committer) { - return - } - if (this.committer != null) { - throw IllegalArgumentException("Committer already set for Commit $sha: $committer") - } - field = value - this.committer!!.committedCommits.add(this) + var branch: String? = null + var stats: Stats? = null + val builds: List = emptyList() + val files: List = emptyList() + val modules: List = emptyList() + val issues: List = emptyList() + + /** + * The author [Developer] of this commit. + * Derived from [authorSignature]. + */ + val author: Developer + get() = authorSignature.developer + + /** + * The committer [Developer] of this commit. + * Derived from [committerSignature], defaults to [author] if not explicitly set. + */ + val committer: Developer + get() = committerSignature.developer + + /** + * The author timestamp. + * Derived from [authorSignature.timestamp][Signature.timestamp]. + */ + val authorDateTime: LocalDateTime + get() = authorSignature.timestamp + + /** + * The commit timestamp. + * Derived from [committerSignature.timestamp][Signature.timestamp], + * defaults to [authorSignature.timestamp] if [committerSignature] was not explicitly set. + */ + val commitDateTime: LocalDateTime + get() = committerSignature.timestamp + + init { + require(sha.length == 40) { "SHA must be 40 hex chars, got ${sha.length}" } + require(sha.all { it.isHex() }) { "SHA-1 must be hex [0-9a-fA-F]" } + require(authorSignature.developer.repository == this.repository) { + "Repository between author ${authorSignature.developer} and Commit do not match: ${this.repository}" } - - var author: User? = null - set(value) { - if (value == this.author) { - return - } - if (this.author != null) { - throw IllegalArgumentException("Author already set for Commit $sha: $author") - } - field = value - this.author!!.authoredCommits.add(this) + require(committerSignature.developer.repository == this.repository) { + "Repository between committer ${committerSignature.developer} and Commit do not match: ${this.repository}" } + // Register commit to repository and developers + this.repository.commits.add(this) + author.authoredCommits.add(this) + committer.committedCommits.add(this) + } + + /** + * Direct parent commits of this [Commit]. + * + * ### Semantics + * - **Non-removable set:** Backed by [NonRemovingMutableSet] — removals are disallowed. + * - **Repository consistency:** Every parent must belong to the same `repository`. + * - **Bidirectional link:** On successful `add`, this commit is added to the parent's `children`. + * - **Set semantics:** Duplicates are ignored; re-adding is a no-op. + * + * ### Exceptions + * - Throws [IllegalArgumentException] if a parent from a different repository is added. + * - Throws [IllegalArgumentException] if adding self as parent. + * - Throws [IllegalArgumentException] if the element is already in children. + */ val parents: MutableSet = - object : MutableSet by _parents { + object : NonRemovingMutableSet() { override fun add(element: Commit): Boolean { - // add to this commit’s parents… - val added = _parents.add(element) + require(element.repository == this@Commit.repository) { + "Repository between $element and Commit do not match: ${this@Commit.repository}" + } + require(element != this@Commit) { + "Commit cannot be its own parent" + } + require(!this@Commit.children.contains(element)) { + "${element.sha} is already present in '${this@Commit.sha}' children collection. Cannot be added as parent too." + } + val added = super.add(element) if (added) { - // …and back-link to this as a child - element._children.add(this@Commit) + element.children.add(this@Commit) } return added } override fun addAll(elements: Collection): Boolean { - // for bulk-adds make sure each one gets the same treatment var anyAdded = false for (e in elements) { if (add(e)) anyAdded = true @@ -89,42 +172,40 @@ data class Commit( } } + /** + * Direct child commits of this [Commit]. + * + * ### Semantics + * - **Non-removable set:** Backed by [NonRemovingMutableSet] — removals are disallowed. + * - **Repository consistency:** Every child must belong to the same `repository`. + * - **Bidirectional link:** On successful `add`, this commit is added to the child's `parents`. + * - **Set semantics:** Duplicates are ignored; re-adding is a no-op. + * + * ### Exceptions + * - Throws [IllegalArgumentException] if a child from a different repository is added. + * - Throws [IllegalArgumentException] if adding self as child. + * - Throws [IllegalArgumentException] if the element is already in parents. + */ val children: MutableSet = - object : MutableSet by _children { + object : NonRemovingMutableSet() { override fun add(element: Commit): Boolean { - // add to this commit’s parents… - val added = _children.add(element) - if (added) { - // …and back-link to this as a child - element._parents.add(this@Commit) + require(element.repository == this@Commit.repository) { + "Repository between $element and Commit do not match: ${this@Commit.repository}" } - return added - } - - override fun addAll(elements: Collection): Boolean { - // for bulk-adds make sure each one gets the same treatment - var anyAdded = false - for (e in elements) { - if (add(e)) anyAdded = true + require(element != this@Commit) { + "Commit cannot be its own child" } - return anyAdded - } - } - - val branches: MutableSet = - object : MutableSet by _branches { - override fun add(element: Branch): Boolean { - // add to this commit’s parents… - val added = _branches.add(element) + require(!this@Commit.parents.contains(element)) { + "${element.sha} is already present in '${this@Commit.sha}' parent collection. Cannot be added as child too." + } + val added = super.add(element) if (added) { - // …and back-link to this as a child - element.commits.add(this@Commit) + element.parents.add(this@Commit) } return added } - override fun addAll(elements: Collection): Boolean { - // for bulk-adds make sure each one gets the same treatment + override fun addAll(elements: Collection): Boolean { var anyAdded = false for (e in elements) { if (add(e)) anyAdded = true @@ -134,43 +215,16 @@ data class Commit( } @Deprecated("Do not use") - val users: List - get() = - mutableListOf() - .let { lst -> - author?.let { lst.add(it) } - committer?.let { lst.add(it) } - lst - } + val users: List + get() = listOfNotNull(author, committer.takeIf { it != author }) - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false + override val uniqueKey: Key + get() = Key(sha) - other as Commit - -// if (id != other.id) return false - if (sha != other.sha) return false -// if (authorDateTime != other.authorDateTime) return false -// if (commitDateTime != other.commitDateTime) return false -// if (message != other.message) return false -// if (webUrl != other.webUrl) return false - - return true - } - - override fun hashCode(): Int { -// var result = Objects.hashCode(id) -// result += 31 * Objects.hashCode(sha) -// result += 31 * Objects.hashCode(authorDateTime) -// result += 31 * Objects.hashCode(commitDateTime) -// result += 31 * Objects.hashCode(message) -// result += 31 * Objects.hashCode(webUrl) - return Objects.hashCode(sha) - } + // Entities compare by immutable identity only + override fun equals(other: Any?) = super.equals(other) + override fun hashCode(): Int = super.hashCode() override fun toString(): String = - "Commit(id=$id, sha='$sha', authorDateTime=$authorDateTime, commitDateTime=$commitDateTime, message=$message, webUrl=$webUrl, stats=$stats, author=${author?.name}, committer=${committer?.name}, repositoryId=${repository?.id})" - - override fun uniqueKey(): String = sha + "Commit(id=$id, sha='$sha', authorDateTime=$authorDateTime, commitDateTime=$commitDateTime, message=$message, webUrl=$webUrl, stats=$stats, author=$author, committer=$committer, repositoryId=${repository.id}, children=${children.map { it.sha }}, parents=${parents.map { it.sha }})" } diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/CommitDiff.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/CommitDiff.kt deleted file mode 100644 index cfad0c208..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/CommitDiff.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.inso_world.binocular.model - -import jakarta.validation.constraints.NotNull - -class CommitDiff( - val source: Commit, - val target: Commit?, - var files: Set, - @field:NotNull - var repository: Repository? = null, -) : AbstractDomainObject() { - override fun uniqueKey(): String = "${source.sha},${target?.sha}" - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as CommitDiff - - if (source != other.source) return false - if (target != other.target) return false -// if (stats != other.stats) return false - - return true - } - - override fun hashCode(): Int { - var result = source.hashCode() - result = 31 * result + target.hashCode() - return result - } - - override fun toString(): String = "CommitDiff(source=${source.sha}, target=${target?.sha})" - - data class Stats( - var additions: Long, - var deletions: Long, - var kind: StatsKind? = null, - ) - - enum class StatsKind( - label: String, - ) { - // todo check in rust if this can simply be an enum at first, not string - ADDITION("Addition"), - DELETION("Deletion"), - MODIFICATION("Modification"), - ; - - companion object { - fun fromString(value: String): StatsKind = - // erst nach label, dann nach NAME (z.B. "ADDITION") - entries.firstOrNull { it.name.equals(value, ignoreCase = true) } - ?: valueOf(value.uppercase()) - } - } -} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Developer.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Developer.kt new file mode 100644 index 000000000..8cf9eb9b7 --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Developer.kt @@ -0,0 +1,183 @@ +package com.inso_world.binocular.model + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Domain entity representing a Git developer scoped to a [Repository]. + * + * ## Identity & Equality + * - Inherits entity identity from [Stakeholder] → [AbstractDomainObject]. + * - Technical id: [iid] of type [Id], generated at construction. + * - Business key: [uniqueKey] = [Key]([repository].iid, [gitSignature]). + * - Although this is a `data class`, `equals`/`hashCode` delegate to + * [AbstractDomainObject] (no value-based equality on properties). + * + * ## Construction + * - Validates that [name] is non-blank. + * - Validates that [email] is non-blank (required, unlike the old User model). + * - Registers itself with `repository.developers` during `init` (idempotent, add-only collection). + * + * ## Relationships + * - [committedCommits] and [authoredCommits] are add-only, bidirectionally maintained sets. + * - [files] and [issues] are add-only collections keyed by domain/business keys. + * + * ## Migration from User + * This class replaces the former `User` class with key differences: + * - `email` is now **required** (was optional). + * - Extends [Stakeholder] for better type hierarchy. + * - Repository collection renamed from `user` to `developers`. + * + * ## Threading + * - Instances are mutable and not thread-safe; coordinate external synchronization for multi-step updates. + * + * @property name Display name as used in Git signatures; must be non-blank. + * @property email Email address as used in Git signatures; must be non-blank. + * @property repository Owning repository; participates in the [uniqueKey] and scopes this developer. + * @see Stakeholder + * @see committedCommits + * @see authoredCommits + */ +@OptIn(ExperimentalUuidApi::class) +data class Developer( + @field:NotBlank + override val name: String, + @field:NotBlank + override val email: String, + @field:NotNull + val repository: Repository, +) : Stakeholder( + Id(Uuid.random()), +) { + /** + * Business key for developer lookups within a repository. + * Combines repository identity with git signature for uniqueness. + */ + data class Key(val repositoryId: Repository.Id, val gitSignature: String) + + /** + * Technical identifier for the developer entity. + */ + @JvmInline + value class Id(val value: Uuid) + + @Deprecated("Avoid using database specific id, use business key", ReplaceWith("iid")) + var id: String? = null + + /** + * Issues associated with this developer. + */ + val issues: MutableSet = mutableSetOf() + + /** + * Files associated with this developer. + */ + val files: MutableSet = object : NonRemovingMutableSet() {} + + init { + require(name.trim().isNotBlank()) { "name cannot be blank." } + require(email.trim().isNotBlank()) { "email cannot be blank." } + repository.developers.add(this) + } + + /** + * Commits committed by this [Developer]. + * + * # Semantics + * - **Add-only collection:** Backed by `NonRemovingMutableSet` — removals are not supported. + * - **Repository consistency:** Each added [Commit] must belong to the **same** `repository` as this developer. + * - **Set semantics / de-duplication:** Membership is keyed by each commit's `uniqueKey` (business key). + * Re-adding an existing commit is a no-op (`false`). + * + * # Invariants enforced on insert + * - Precondition: `element.repository == this@Developer.repository`. + * - Precondition: `element.committer == this@Developer`. + * + * # Exceptions + * - Throws [IllegalArgumentException] when the commit's repository differs from this developer's repository. + * - Throws [IllegalArgumentException] when this developer is not the committer of the commit. + * - Any attempt to remove elements from this collection throws [UnsupportedOperationException]. + * + * # Thread-safety + * - Internally backed by a concurrent map; individual `add`/`contains` operations are safe for concurrent use. + */ + val committedCommits: MutableSet = object : NonRemovingMutableSet() { + override fun add(element: Commit): Boolean { + require(element.repository == this@Developer.repository) { + "Commit.repository (${element.repository}) doesn't match developer.repository (${this@Developer.repository})" + } + require(element.committer == this@Developer) { + "Cannot add Commit $element to committedCommits since developer is not committer of Commit." + } + return super.add(element) + } + + override fun addAll(elements: Collection): Boolean { + var anyAdded = false + for (e in elements) { + if (add(e)) anyAdded = true + } + return anyAdded + } + } + + /** + * Commits authored by this [Developer]. + * + * # Semantics + * - **Add-only collection:** Backed by `NonRemovingMutableSet` — removals are not supported. + * - **Repository consistency:** Each added [Commit] must belong to the **same** `repository` as this developer. + * - **Set semantics / de-duplication:** Membership is keyed by each commit's `uniqueKey` (business key). + * Re-adding an existing commit is a no-op (`false`). + * + * # Invariants enforced on insert + * - Precondition: `element.repository == this@Developer.repository`. + * - Precondition: `element.author == this@Developer`. + * + * # Exceptions + * - Throws [IllegalArgumentException] when the commit's repository differs from this developer's repository. + * - Throws [IllegalArgumentException] when this developer is not the author of the commit. + * - Any attempt to remove elements from this collection throws [UnsupportedOperationException]. + * + * # Thread-safety + * - Internally backed by a concurrent map; individual `add`/`contains` operations are safe for concurrent use. + */ + val authoredCommits: MutableSet = object : NonRemovingMutableSet() { + override fun add(element: Commit): Boolean { + require(element.repository == this@Developer.repository) { + "Commit.repository (${element.repository}) doesn't match developer.repository (${this@Developer.repository})" + } + require(element.author == this@Developer) { + "Cannot add Commit $element to authoredCommits since developer is not author of Commit." + } + return super.add(element) + } + + override fun addAll(elements: Collection): Boolean { + var anyAdded = false + for (e in elements) { + if (add(e)) anyAdded = true + } + return anyAdded + } + } + + /** + * Git signature format combining name and email. + * Format: "Name " + */ + val gitSignature: String + get() = "${name.trim()} <${email.trim()}>" + + override val uniqueKey: Key + get() = Key(repository.iid, gitSignature) + + // Entities compare by immutable identity only + override fun equals(other: Any?) = super.equals(other) + override fun hashCode(): Int = super.hashCode() + + override fun toString(): String = + "Developer(id=$id, iid=$iid, name=$name, email=$email, gitSignature=$gitSignature, repositoryId=${repository.id})" +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/File.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/File.kt index 88dbfb178..10d27c65d 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/File.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/File.kt @@ -1,21 +1,35 @@ package com.inso_world.binocular.model +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + /** * Domain model for a File, representing a file in a Git repository. * This class is database-agnostic and contains no persistence-specific annotations. */ +@OptIn(ExperimentalUuidApi::class) data class File( - var id: String? = null, var path: String, - val states: MutableSet = mutableSetOf(), -) : AbstractDomainObject() { + val revisions: MutableSet = mutableSetOf(), +) : AbstractDomainObject( + Id(Uuid.random()) +) { + @JvmInline + value class Id(val value: Uuid) + + data class Key(val path: String) // value object for lookups + + // some database dependent id + @Deprecated("Avoid using database specific id, use business key .iid", ReplaceWith("iid")) + var id: String? = null + @Deprecated("legacy") lateinit var webUrl: String @Deprecated("legacy") val maxLength: Int get() = - states + revisions .mapNotNull { it.content?.length } .takeIf { it.isNotEmpty() } ?.reduce { acc, n -> if (n > acc) n else acc } ?: Int.MIN_VALUE @@ -23,11 +37,10 @@ data class File( // Relationships @Deprecated("legacy") val commits: List - get() = states.map { it.commit } + get() = revisions.map { it.commit } @Deprecated("legacy") - val branches: List - get() = states.map { it.commit }.flatMap { it.branches } + val branches: List = emptyList() @Deprecated("legacy") var modules: List = emptyList() @@ -36,10 +49,11 @@ data class File( val relatedFiles: List = emptyList() @Deprecated("legacy") - val users: List - get() = states.map { it.commit }.flatMap { it.users } + val users: List + get() = revisions.map { it.commit }.flatMap { it.users } - override fun uniqueKey(): String = path + override val uniqueKey: Key + get() = Key(this.path) - override fun toString(): String = "File(states=$states, path='$path', id=$id)" + override fun toString(): String = "File(states=$revisions, path='$path', id=$id)" } diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/FileDiff.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/FileDiff.kt deleted file mode 100644 index b4a10b1f9..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/FileDiff.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.inso_world.binocular.model - -data class FileDiff( - val pathBefore: String? = null, // null bei ADD - val pathAfter: String? = null, // null bei DELETE - val change: ChangeType, - val stats: Stats, - var oldFileState: FileState?, - var newFileState: FileState?, -// val hunks: List, // optional, nur wenn du Textdiffs speicherst -) : AbstractDomainObject() { - override fun uniqueKey(): String = "$pathBefore,$pathAfter,$change" - - init { - when (change) { - ChangeType.ADDITION -> - require(oldFileState == null) { - "File change type must be ADDITION when oldFileState is NULL" - } - ChangeType.DELETION -> - require(newFileState == null) { - "File change type DELETION requires newFileState to be NULL" - } - ChangeType.MODIFICATION -> { - require(oldFileState != null && newFileState != null) { - "File change type MODIFICATION requires newFileState and oldFileState not to be NULL" - } - require(pathBefore == pathAfter) { - "File change type MODIFICATION requires pathBefore == pathAfter" - } - } - ChangeType.REWRITE -> { - require(oldFileState != null && newFileState != null) { - "File change type REWRITE requires newFileState and oldFileState not to be NULL" - } - require(pathBefore != pathAfter) { - "File change type REWRITE requires pathBefore != pathAfter" - } - } - } - } - - enum class ChangeType( - label: String, - ) { - // todo check in rust if this can simply be an enum at first, not string - ADDITION("Addition"), - DELETION("Deletion"), - MODIFICATION("Modification"), - REWRITE("Rewrite"), - ; - - companion object { - fun fromString(value: String): ChangeType = - // erst nach label, dann nach NAME (z.B. "ADDITION") - entries.firstOrNull { it.name.equals(value, ignoreCase = true) } - ?: valueOf(value.uppercase()) - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as FileDiff - - if (pathBefore != other.pathBefore) return false - if (pathAfter != other.pathAfter) return false - if (change != other.change) return false - if (stats != other.stats) return false - - return true - } - - override fun hashCode(): Int { - var result = pathBefore?.hashCode() ?: 0 - result = 31 * result + (pathAfter?.hashCode() ?: 0) - result = 31 * result + change.hashCode() - result = 31 * result + stats.hashCode() - return result - } - - override fun toString(): String = - "FileDiff(pathBefore=$pathBefore, pathAfter=$pathAfter, change=$change, stats=$stats, oldFile=${oldFileState?.file?.path}, newFile=${newFileState?.file?.path})" -} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/FileState.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/FileState.kt deleted file mode 100644 index 688a3e67b..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/FileState.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.inso_world.binocular.model - -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -data class FileState( - var id: String? = null, - val content: String? = null, - val commit: Commit, - val file: File, -) : AbstractDomainObject() { - override fun uniqueKey(): String = "${commit.sha},${file.path}" - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as FileState - - if (id != other.id) return false - if (content != other.content) return false - - return true - } - - override fun hashCode(): Int { - var result = id?.hashCode() ?: 0 - result = 31 * result + (content?.hashCode() ?: 0) - return result - } - - @OptIn(ExperimentalEncodingApi::class) - override fun toString(): String = - "FileState(id=$id, content=${Base64.Default.encode( - content?.trim()?.encodeToByteArray() ?: ByteArray(0), - )}, commit=${commit.sha}, file=${file.path})" -} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Issue.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Issue.kt index 1bc36c248..44b6a9ffb 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Issue.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Issue.kt @@ -1,14 +1,17 @@ package com.inso_world.binocular.model import java.time.LocalDateTime +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Domain model for an Issue, representing an issue in a Git repository. * This class is database-agnostic and contains no persistence-specific annotations. */ +@OptIn(ExperimentalUuidApi::class) data class Issue( var id: String? = null, - var iid: Int? = null, + var platformIid: Int? = null, var title: String? = null, var description: String? = null, var createdAt: LocalDateTime? = null, @@ -24,4 +27,15 @@ data class Issue( var milestones: List = emptyList(), var notes: List = emptyList(), var users: List = emptyList(), -) +): AbstractDomainObject( + Id(Uuid.random()) +){ + @JvmInline + value class Id(val value: Uuid) + + // TODO work in progress, just for compatibility + data class Key(val key: String) // value object for lookups + + override val uniqueKey: Key + get() = TODO("Not yet implemented") +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/MergeRequest.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/MergeRequest.kt index 1f21ad642..3e94e2448 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/MergeRequest.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/MergeRequest.kt @@ -1,12 +1,16 @@ package com.inso_world.binocular.model +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + /** * Domain model for a MergeRequest, representing a merge/pull request in a Git repository. * This class is database-agnostic and contains no persistence-specific annotations. */ +@OptIn(ExperimentalUuidApi::class) data class MergeRequest( var id: String? = null, - var iid: Int? = null, + var platformIid: Int? = null, var title: String? = null, var description: String? = null, var createdAt: String? = null, @@ -20,4 +24,15 @@ data class MergeRequest( var accounts: List = emptyList(), var milestones: List = emptyList(), var notes: List = emptyList(), -) +): AbstractDomainObject( + Id(Uuid.random()) +){ + @JvmInline + value class Id(val value: Uuid) + + // TODO work in progress, just for compatibility + data class Key(val key: String) // value object for lookups + + override val uniqueKey: Key + get() = TODO("Not yet implemented") +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Milestone.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Milestone.kt index f24d7e7ea..3eabf9447 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Milestone.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Milestone.kt @@ -1,12 +1,16 @@ package com.inso_world.binocular.model +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + /** * Domain model for a Milestone, representing a milestone in a Git repository. * This class is database-agnostic and contains no persistence-specific annotations. */ +@OptIn(ExperimentalUuidApi::class) data class Milestone( var id: String? = null, - var iid: Int? = null, + var platformIid: Int? = null, var title: String? = null, var description: String? = null, var createdAt: String? = null, @@ -19,4 +23,15 @@ data class Milestone( // Relationships var issues: List = emptyList(), var mergeRequests: List = emptyList(), -) +) : AbstractDomainObject( + Id(Uuid.random()) +) { + @JvmInline + value class Id(val value: Uuid) + + // TODO work in progress, just for compatibility + data class Key(val key: String) // value object for lookups + + override val uniqueKey: Key + get() = TODO("Not yet implemented") +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Module.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Module.kt index 263b3090f..b43886e7f 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Module.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Module.kt @@ -1,9 +1,13 @@ package com.inso_world.binocular.model +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + /** * Domain model for a Module, representing a code module or package in the codebase. * This class is database-agnostic and contains no persistence-specific annotations. */ +@OptIn(ExperimentalUuidApi::class) data class Module( var id: String? = null, var path: String, @@ -12,4 +16,15 @@ data class Module( var files: List = emptyList(), var childModules: List = emptyList(), var parentModules: List = emptyList(), -) +): AbstractDomainObject( + Id(Uuid.random()) +){ + @JvmInline + value class Id(val value: Uuid) + + // TODO work in progress, just for compatibility + data class Key(val key: String) // value object for lookups + + override val uniqueKey: Key + get() = TODO("Not yet implemented") +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/NonRemovingMutableSet.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/NonRemovingMutableSet.kt new file mode 100644 index 000000000..4f2a07167 --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/NonRemovingMutableSet.kt @@ -0,0 +1,95 @@ +package com.inso_world.binocular.model + +import java.util.concurrent.ConcurrentHashMap + +/** + * A mutable **add-only** `Set` for domain objects whose membership is keyed by a + * stable `uniqueKey` (from [AbstractDomainObject]). + * + * ### What it does + * - **Add-only:** Any form of removal (`remove`, `removeAll`, `retainAll`, `clear`, iterator `remove`) + * throws [UnsupportedOperationException]. Use this when edges/links must not be deleted + * via the collection API (e.g., history graphs). + * - **Uniqueness by `uniqueKey`:** Two different instances with the same `uniqueKey` + * are treated as the *same* element. The first inserted instance becomes the + * **canonical** element stored in the set; later inserts with the same `uniqueKey` + * return `false` and do not replace the canonical instance. + * - **Concurrent-friendly reads/writes:** Backed by a [ConcurrentHashMap]; `add` and + * `contains` are safe for concurrent use. The iterator is **weakly consistent**: + * it may reflect some—but not necessarily all—concurrent changes. + * + * ### Semantics & invariants + * - **Membership test:** `contains(e)` checks presence by `e.uniqueKey`. + * - **Set equality/hash semantics:** Inherits default [AbstractMutableSet] behavior. + * Equality compares element presence; iteration and `hashCode` derive from the + * **stored canonical instances**, not from the keys. + * - **Iteration order:** Implementation-defined (map value iteration); do not rely on order. + * + * ### Performance + * - `add` / `contains` / `containsAll` / `size` are expected **O(1)** average time. + * - Memory overhead is one entry per distinct `uniqueKey`. + * + * ### Requirements / pitfalls + * - `uniqueKey` **must be immutable and stable** for the lifetime of the element in the set. + * Mutating it after insertion breaks membership guarantees. + * - If two different objects share a `uniqueKey`, only the *first* one added will be retained. + * The set will **not** swap the stored instance if a later, “newer” instance with the same key is added. + * - This class does not synchronize multi-step operations (e.g., “check then add” outside `add`); + * coordinate externally if you need atomic higher-level workflows. + * + * ### Examples + * ```kotlin + * val set = NonRemovingMutableSet() + * val a1 = MyEntity(id = "A") // uniqueKey == "A" + * val a2 = MyEntity(id = "A") // different instance, same key + * + * check(set.add(a1)) // true — inserted, a1 is canonical + * check(!set.add(a2)) // false — same key, ignored; a1 stays canonical + * check(a1 in set) // true + * // set.remove(a1) // throws UnsupportedOperationException + * ``` + */ +open class NonRemovingMutableSet>( + protected val backing: ConcurrentHashMap> = ConcurrentHashMap() +) : AbstractMutableSet() { + + override val size: Int get() = backing.size + + // allow reads and adds + override fun contains(element: T): Boolean = backing.keys.contains(element.uniqueKey) + override fun containsAll(elements: Collection): Boolean = backing.keys.containsAll(elements.map { it.uniqueKey }) + override fun isEmpty(): Boolean = backing.isEmpty() + override fun add(element: T): Boolean { + val key = element.uniqueKey as Any + if (backing.containsKey(key)) { + return false + } else { + backing.computeIfAbsent(key) { element } + return true + } + } + + // block any form of removal + override fun clear(): Unit = fail() + override fun remove(element: T): Boolean = fail() + override fun removeAll(elements: Collection): Boolean = fail() + override fun retainAll(elements: Collection): Boolean = fail() + + // iterator that cannot remove + override fun iterator(): MutableIterator { + val it = backing.values.iterator() + return object : MutableIterator { + override fun hasNext(): Boolean = it.hasNext() + + @Suppress("UNCHECKED_CAST") + override fun next(): T = it.next() as T + override fun remove(): Unit = fail() + } + } + + // nice string; equals/hashCode come from AbstractMutableSet (set semantics) + override fun toString(): String = backing.values.toString() + + private fun fail(): T = throw UnsupportedOperationException("Removing objects is not allowed.") +} + diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Note.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Note.kt index e766fdfd3..1f65d1fde 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Note.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Note.kt @@ -1,9 +1,13 @@ package com.inso_world.binocular.model +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + /** * Domain model for a Note, representing a comment or note in a Git repository. * This class is database-agnostic and contains no persistence-specific annotations. */ +@OptIn(ExperimentalUuidApi::class) data class Note( var id: String? = null, var body: String, @@ -19,4 +23,15 @@ data class Note( var accounts: List = emptyList(), var issues: List = emptyList(), var mergeRequests: List = emptyList(), -) +): AbstractDomainObject( + Id(Uuid.random()) +){ + @JvmInline + value class Id(val value: Uuid) + + // TODO work in progress, just for compatibility + data class Key(val key: String) // value object for lookups + + override val uniqueKey: Key + get() = TODO("Not yet implemented") +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Project.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Project.kt index 7d97f7885..99f8e7cd5 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Project.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Project.kt @@ -1,20 +1,94 @@ package com.inso_world.binocular.model -import com.inso_world.binocular.model.validation.ProjectValidation -import com.inso_world.binocular.model.validation.RepositoryValidation import jakarta.validation.constraints.NotBlank +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid -@ProjectValidation -@RepositoryValidation -class Project( - var id: String? = null, +/** + * Project — a named, top-level domain entity that may be associated with a [Repository]. + * + * ### Identity & equality + * - Technical identity: immutable [iid] of type [Id] (generated at construction). + * - Business key: [uniqueKey] == validated [name]. + * - Equality is **identity-based** (same [iid]); `hashCode()` derives from [iid]. This intentionally + * overrides the default value-based semantics of a Kotlin `data class`. + * + * ### Construction & validation + * - Requires a non-blank [name] (`@field:NotBlank` + runtime `require`). + * - The constructor does **not** auto-wire repository relations; associate a repository via [repo] if needed. + * + * ### Relationships & mutability + * - [repo] is optional and **set-once** (cannot be reassigned to a different repository; cannot be set to `null`). + * + * ### Thread-safety + * - Instances are mutable and not thread-safe. Coordinate external synchronization for multi-step updates. + * + * @property name Human-readable project name; must be non-blank and forms the [uniqueKey]. + */ +@OptIn(ExperimentalUuidApi::class) +data class Project( @field:NotBlank - val name: String, - val issues: MutableSet = mutableSetOf(), - val description: String? = null, - var repo: Repository? = null, -) : AbstractDomainObject() { - override fun toString(): String = "Project(id=$id, name='$name', description=$description)" - - override fun uniqueKey(): String = name + val name: String +) : AbstractDomainObject( + Id(Uuid.random()) +) { + @JvmInline + value class Id(val value: Uuid) + + data class Key(val name: String) // value object for lookups + + val issues: MutableSet = mutableSetOf() +// object : NonRemovingMutableSetSet() {} + + var description: String? = null + + /** + * Optional owning [Repository]. + * + * #### Semantics + * - A project can exist without a repository. + * - Assignment is **set-once** and **non-null**: + * - Reassigning the **same** instance is a no-op. + * - Reassigning to a **different** repository throws. + * + * #### Invariants enforced on set + * - Precondition: + * - `value != null` + * - `this.repo == null || this.repo === value` + * + * #### Exceptions + * - [IllegalArgumentException] if `value` is `null`. + * - [IllegalArgumentException] if a different repository is assigned after one was already set. + * + * #### Thread-safety + * - No internal synchronization; coordinate externally if multiple threads may mutate this property. + */ + var repo: Repository? = null + set(value) { + requireNotNull(value) { "Cannot set repo to null" } + if (value == this.repo) { + return + } + if (this.repo != null) { + throw IllegalArgumentException("Repository already set for Project $name: $repo") + } + field = value + } + + // some database dependent id + @Deprecated("Avoid using database specific id, use business key .iid", ReplaceWith("iid")) + var id: String? = null + + init { + require(name.isNotBlank()) + } + + override fun toString(): String = "Project(id=$id, iid=$iid, name='$name', description=$description)" + + override val uniqueKey: Project.Key + get() = Project.Key(this.name) + + // Entities compare by immutable identity only + override fun equals(other: Any?) = super.equals(other) + override fun hashCode(): Int = super.hashCode() } diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Reference.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Reference.kt new file mode 100644 index 000000000..507b0cadf --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Reference.kt @@ -0,0 +1,23 @@ +package com.inso_world.binocular.model + +import com.inso_world.binocular.model.vcs.ReferenceCategory +import jakarta.validation.constraints.NotNull +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +abstract class Reference( + @field:NotNull + open val category: ReferenceCategory, + @field:NotNull + open val repository: Repository +) : AbstractDomainObject( + Id(Uuid.random()) +) { + @JvmInline + value class Id(val value: Uuid) + + override fun equals(other: Any?): Boolean = super.equals(other) + + override fun hashCode(): Int = super.hashCode() +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Repository.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Repository.kt index 6735716b7..5c639c9dc 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Repository.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Repository.kt @@ -1,38 +1,116 @@ package com.inso_world.binocular.model -import com.inso_world.binocular.model.validation.CommitValidation +import com.inso_world.binocular.model.vcs.Remote import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size import org.slf4j.Logger import org.slf4j.LoggerFactory +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid -@CommitValidation +/** + * Repository — domain entity representing a local Git repository scoped to a [Project]. + * + * ### Identity & equality + * - Technical identity: immutable [iid] of type [Id] (generated at construction). + * - Business key: [uniqueKey] == [Key]([project].iid, [localPath].trim()). + * - Equality is identity-based (same [iid]); `hashCode()` derives from [iid]. + * + * ### Construction & validation + * - Requires a non-blank [localPath] (`@field:NotBlank` + runtime `require`). + * - On construction, the repository **links itself** to the owning [project] via `project.repo = this`. + * + * ### Relationships & collections + * - [commits], [branches], [user], and [remotes] are add-only, repository-consistent, de-duplicated sets backed by + * `NonRemovingMutableSet`. See their KDoc for invariants and exceptions. + * + * ### Thread-safety + * - Instances are mutable and not thread-safe. Collections use concurrent maps for element-level ops, + * but multi-step workflows are **not atomic**; coordinate externally. + * + * @property localPath Absolute or workspace-relative path to the repository; must be non-blank. + * Participates in [uniqueKey] as `localPath.trim()`. + * @property project Owning [Project]; establishes the [Repository]↔[Project] association during `init`. + */ +@OptIn(ExperimentalUuidApi::class) data class Repository( - val id: String? = null, @field:NotBlank + @field:Size(max = 255) val localPath: String, - @field:NotNull // TODO conditional validation, only when coming out of infra - var project: Project? = null, - // TODO add remotes -) : AbstractDomainObject() { - private val _commits: MutableSet = mutableSetOf() - private val _branches: MutableSet = mutableSetOf() - private val _user: MutableSet = mutableSetOf() + val project: Project, +) : AbstractDomainObject( + Id(Uuid.random()) +) { + @JvmInline + value class Id(val value: Uuid) + + data class Key(val projectId: Project.Id, val localPath: String) // value object for lookups + + // some database dependent id + @Deprecated("Avoid using database specific id, use business key .iid", ReplaceWith("iid")) + var id: String? = null + + init { + require(localPath.trim().isNotBlank()) { "localPath cannot be blank." } + project.repo = this + } companion object { private val logger: Logger = LoggerFactory.getLogger(Repository::class.java) } - @get:Valid + /** + * Commits that belong to this [Repository]. + * + * ### Semantics + * - **Add-only collection:** Backed by `NonRemovingMutableSet` — removal operations + * (`remove`, `retainAll`, `clear`, iterator `remove`) are unsupported. + * - **Repository consistency:** A commit can be added only if `commit.repository == this@Repository`. + * - **No implicit graph wiring:** Adding a commit here **does not** establish parent/child/branch + * relations; those must be handled explicitly elsewhere. + * - **Set semantics / de-duplication:** Membership is keyed by each commit’s `uniqueKey` + * (business key). Re-adding an existing commit is a no-op (`false`). The first instance + * for a given key becomes the canonical stored instance. + * + * ### Invariants enforced on insert + * - Precondition: `element.repository == this@Repository`. + * - Postcondition (on success / no exception): + * - `element in commits` + * + * ### Bulk adds + * - `addAll` applies the same checks per element as `add`. + * - Returns `true` if at least one new commit was added. + * - **Not transactional:** If inserting one element fails, earlier successful inserts remain. + * + * ### Idempotency & recursion safety + * - Re-adding an already-present commit (same `uniqueKey`) is a no-op and does not trigger + * any additional side effects. No mutual/back-linking occurs here, so there is no risk of + * infinite recursion. + * + * ### Exceptions + * - Throws [IllegalArgumentException] if a commit from a different repository is added. + * - Any attempt to remove elements from this collection throws [UnsupportedOperationException]. + * + * ### Thread-safety + * - Internally backed by a concurrent map; individual `add`/`contains` operations are safe + * for concurrent use. Iteration is **weakly consistent**. Multi-step workflows are **not atomic**; + * coordinate externally if you need stronger guarantees. + */ + @field:Valid val commits: MutableSet = - object : MutableSet by _commits { + object : NonRemovingMutableSet() { + /** + * Adds a Commit to the repository. Relatives (parents, children) have to be added manually! + * @return true if the set was modified + */ override fun add(element: Commit): Boolean { - val added = _commits.add(element) - if (added) { - element.repository = this@Repository + // check if commit.repository is same as this + require(element.repository == this@Repository) { + "$element cannot be added to a different repository." } + + val added = super.add(element) return added } @@ -46,14 +124,55 @@ data class Repository( } } - @get:Valid + /** + * Branches that belong to this [Repository]. + * + * # Semantics + * - **Add-only collection:** Backed by `NonRemovingMutableSet` — removal operations + * (`remove`, `retainAll`, `clear`, iterator `remove`) are not supported. + * - **Repository consistency:** A branch can be added only if `branch.repository == this@Repository`. + * This method **does not** mutate `branch.repository`; callers must ensure the branch is created + * for this repository. + * - **No implicit graph wiring:** Adding a branch here does **not** touch its commits or any other + * relations; those must be managed elsewhere. + * - **Set semantics / de-duplication:** Membership is keyed by each branch’s `uniqueKey` + * (business key). Re-adding an existing branch is a no-op (`false`). The first instance for a + * given key becomes the canonical stored element. + * + * # Invariants enforced on insert + * - Precondition: `element.repository == this@Repository`. + * - Postcondition (on success / no exception): + * - `element in branches` + * - **No back-links:** This operation does not modify `element.commits` or other associations. + * + * # Bulk adds + * - `addAll` applies the same checks as `add`, element by element. + * - Returns `true` if at least one new branch was added. + * - **Not transactional:** If a later element fails (e.g., repo mismatch), earlier successful inserts remain. + * + * # Idempotency & recursion safety + * - Re-adding a branch already present (same `uniqueKey`) returns `false` and has no side effects. + * - No mutual/back-linking is performed here, so there is no risk of recursive `add` loops. + * + * # Exceptions + * - Throws [IllegalArgumentException] if a branch from a different repository is added. + * - Any attempt to remove elements throws [UnsupportedOperationException]. + * + * # Thread-safety + * - Internally backed by a concurrent map; individual `add`/`contains` calls are safe for concurrent use. + * Iteration is **weakly consistent**. Multi-step workflows are **not atomic**; coordinate externally if needed. + */ + @field:Valid val branches: MutableSet = - object : MutableSet by _branches { + object : NonRemovingMutableSet() { override fun add(element: Branch): Boolean { - val added = _branches.add(element) - if (added) { - element.repository = this@Repository + // check if branch has no repository set + require(element.repository == this@Repository) { + "$element cannot be added to a different repository." } + + // Add to this repository + val added = super.add(element) return added } @@ -67,19 +186,25 @@ data class Repository( } } - @get:Valid - val user: MutableSet = - object : MutableSet by _user { + /** + * Users that belong to this [Repository]. + * + * @deprecated Use [developers] instead. This property is maintained for backwards compatibility. + */ + @Deprecated("Use developers instead", ReplaceWith("developers")) + val user: MutableSet + get() = _legacyUsers + + private val _legacyUsers: MutableSet = + object : NonRemovingMutableSet() { override fun add(element: User): Boolean { - val added = _user.add(element) - if (added) { - element.repository = this@Repository + require(element.repository == this@Repository) { + "$element cannot be added to a different repository." } - return added + return super.add(element) } override fun addAll(elements: Collection): Boolean { - // for bulk-adds make sure each one gets the same treatment var anyAdded = false for (e in elements) { if (add(e)) anyAdded = true @@ -88,34 +213,128 @@ data class Repository( } } - override fun toString(): String = "Repository(id=$id, localPath='$localPath')" - - fun removeCommitBySha( - @Size(min = 40, max = 40) - sha: String, - ) { - // Remove the commit from the repository's commits set - require(commits.removeIf { it.sha == sha }) - // Remove the commit sha from all branches' commitShas sets - val affectedBranchNames = mutableListOf() - branches.forEach { branch -> - if (branch.commits.removeIf { it.sha == sha }) { - affectedBranchNames.add(branch.name) + /** + * Developers that belong to this [Repository]. + * + * # Semantics + * - **Add-only collection:** Backed by `NonRemovingMutableSet` — removal operations + * (`remove`, `retainAll`, `clear`, iterator `remove`) are not supported. + * - **Repository consistency:** A developer can be added only if `developer.repository == this@Repository`. + * This method **does not** mutate `developer.repository`; callers must ensure the developer + * is created for this repository. + * - **No implicit graph wiring:** Adding a developer here does **not** touch the developer's + * authored/committed commits or any other relations. + * - **Set semantics / de-duplication:** Membership is keyed by each developer's `uniqueKey` + * (business key). Re-adding an existing developer is a no-op (`false`). The first instance + * for a given key becomes the canonical stored element. + * + * # Invariants enforced on insert + * - Precondition: `element.repository == this@Repository`. + * - Postcondition (on success / no exception): + * - `element in developers`. + * - **No back-links:** This operation does not modify other associations on the developer. + * + * # Bulk adds + * - `addAll` applies the same checks as `add`, element by element. + * - Returns `true` if at least one new developer was added. + * - **Not transactional:** If a later element fails (e.g., repo mismatch), earlier successful inserts remain. + * + * # Idempotency & recursion safety + * - Re-adding a developer already present (same `uniqueKey`) returns `false` and has no side effects. + * - No mutual/back-linking is performed here, so there is no risk of recursive `add` loops. + * + * # Exceptions + * - Throws [IllegalArgumentException] if a developer from a different repository is added. + * - Any attempt to remove elements throws [UnsupportedOperationException]. + * + * # Thread-safety + * - Internally backed by a concurrent map; individual `add`/`contains` calls are safe for concurrent use. + * Iteration is **weakly consistent**. Multi-step workflows are **not atomic**; coordinate externally if needed. + */ + @field:Valid + val developers: MutableSet = + object : NonRemovingMutableSet() { + override fun add(element: Developer): Boolean { + require(element.repository == this@Repository) { + "$element cannot be added to a different repository." + } + return super.add(element) + } + + override fun addAll(elements: Collection): Boolean { + var anyAdded = false + for (e in elements) { + if (add(e)) anyAdded = true + } + return anyAdded } } - logger.trace( - "Removed commit sha '{}' from {} branches: {}", - sha, - affectedBranchNames.size, - affectedBranchNames.joinToString(", "), - ) - } - override fun uniqueKey(): String { - val project = - requireNotNull(this.project) { - "Repository project must not be null" + /** + * Remotes that belong to this [Repository]. + * + * # Semantics + * - **Add-only collection:** Backed by `NonRemovingMutableSet` — removal operations + * (`remove`, `retainAll`, `clear`, iterator `remove`) are not supported. + * - **Repository consistency:** A remote can be added only if `remote.repository == this@Repository`. + * This method **does not** mutate `remote.repository`; callers must ensure the remote is created + * for this repository. + * - **No implicit graph wiring:** Adding a remote here does **not** touch any other relations. + * - **Set semantics / de-duplication:** Membership is keyed by each remote's `uniqueKey` + * (business key). Re-adding an existing remote is a no-op (`false`). The first instance for a + * given key becomes the canonical stored element. + * + * # Invariants enforced on insert + * - Precondition: `element.repository == this@Repository`. + * - Postcondition (on success / no exception): + * - `element in remotes` + * - **No back-links:** This operation does not modify other associations on the remote. + * + * # Bulk adds + * - `addAll` applies the same checks as `add`, element by element. + * - Returns `true` if at least one new remote was added. + * - **Not transactional:** If a later element fails (e.g., repo mismatch), earlier successful inserts remain. + * + * # Idempotency & recursion safety + * - Re-adding a remote already present (same `uniqueKey`) returns `false` and has no side effects. + * - No mutual/back-linking is performed here, so there is no risk of recursive `add` loops. + * + * # Exceptions + * - Throws [IllegalArgumentException] if a remote from a different repository is added. + * - Any attempt to remove elements throws [UnsupportedOperationException]. + * + * # Thread-safety + * - Internally backed by a concurrent map; individual `add`/`contains` calls are safe for concurrent use. + * Iteration is **weakly consistent**. Multi-step workflows are **not atomic**; coordinate externally if needed. + */ + val remotes: MutableSet = + object : NonRemovingMutableSet() { + override fun add(element: Remote): Boolean { + // check if remote has no repository set + require(element.repository == this@Repository) { + "$element cannot be added to a different repository." + } + + val added = super.add(element) + return added } - return "${project.uniqueKey()},$localPath" - } + + override fun addAll(elements: Collection): Boolean { + // for bulk-adds make sure each one gets the same treatment + var anyAdded = false + for (e in elements) { + if (add(e)) anyAdded = true + } + return anyAdded + } + } + + override fun toString(): String = "Repository(id=$id, iid=$iid, localPath='$localPath', project=$project)" + + override val uniqueKey: Key + get() = Key(project.iid, localPath.trim()) + + // Entities compare by immutable identity only + override fun equals(other: Any?) = super.equals(other) + override fun hashCode(): Int = super.hashCode() } diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Revision.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Revision.kt new file mode 100644 index 000000000..3b5184cfd --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Revision.kt @@ -0,0 +1,37 @@ +package com.inso_world.binocular.model + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +data class Revision( + val content: String? = null, + val commit: Commit, + val file: File, +) : AbstractDomainObject( + Id(Uuid.random()) +) { + @JvmInline + value class Id(val value: Uuid) + + // some database dependent id + @Deprecated("Avoid using database specific id, use business key .iid", ReplaceWith("iid")) + var id: String? = null + + override val uniqueKey: String + get() = "${commit.sha},${file.path}" + + // Entities compare by immutable identity only + override fun equals(other: Any?) = other is Revision && other.iid == iid + override fun hashCode(): Int = iid.hashCode() + + @OptIn(ExperimentalEncodingApi::class) + override fun toString(): String = + "FileState(iid=$iid, id=$id, content=${ + Base64.Default.encode( + content?.trim()?.encodeToByteArray() ?: ByteArray(0), + ) + }, commit=${commit.sha}, file=${file.path})" +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Signature.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Signature.kt new file mode 100644 index 000000000..efa069269 --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Signature.kt @@ -0,0 +1,69 @@ +package com.inso_world.binocular.model + +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.PastOrPresent +import java.time.LocalDateTime + +/** + * Value object representing a Git signature for commit authorship or committing. + * + * ## Purpose + * A Signature captures the identity ([developer]) and timestamp ([timestamp]) of a Git + * action (authoring or committing). This is analogous to Git's internal signature format + * which stores name, email, and timestamp together. + * + * ## Git Semantics + * In Git, every commit has two signatures: + * - **Author signature**: Who wrote the code (author name, email, and timestamp). + * - **Committer signature**: Who committed the code (committer name, email, and timestamp). + * These can differ when patches are applied, commits are cherry-picked, or rebased. + * + * ## Value Semantics + * - This is a **data class** with value-based equality. + * - Two signatures are equal if they have the same [developer] and [timestamp]. + * - Immutable after construction. + * + * ## Validation + * - [timestamp] must be past or present (no future timestamps allowed). + * - [developer] must not be null. + * + * ## Usage + * ```kotlin + * val author = Developer(name = "John", email = "john@example.com", repository = repo) + * val authorSignature = Signature(developer = author, timestamp = LocalDateTime.now()) + * + * val commit = Commit( + * sha = "abc123...", + * authorSignature = authorSignature, + * repository = repo + * ) + * ``` + * + * @property developer The [Developer] who performed the action. + * @property timestamp When the action occurred; must be past or present. + * @see Developer + * @see Commit + */ +data class Signature( + @field:NotNull + val developer: Developer, + @field:PastOrPresent + @field:NotNull + val timestamp: LocalDateTime +) { + init { + val now = LocalDateTime.now().plusNanos(1) + require(timestamp.isBefore(now)) { + "timestamp ($timestamp) must be past or present ($now)" + } + } + + /** + * Git signature string format. + * Delegates to the developer's [Developer.gitSignature]. + * + * @return Formatted string like "Name " + */ + val gitSignature: String + get() = developer.gitSignature +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Stakeholder.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Stakeholder.kt new file mode 100644 index 000000000..726da9124 --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/Stakeholder.kt @@ -0,0 +1,48 @@ +package com.inso_world.binocular.model + +/** + * Abstract base class representing any person involved in a project. + * + * ## Purpose + * Stakeholder serves as the root type in the person hierarchy, abstracting + * common identity attributes ([name], [email]) that all project participants share. + * Concrete subtypes include [Developer] (Git users with repository-scoped identity). + * + * ## Identity & Equality + * - Inherits entity identity from [AbstractDomainObject]. + * - Technical id: [iid] of generic type [Iid], generated at construction. + * - Business key: [uniqueKey] of generic type [Key], defined by subclasses. + * - Equality follows [AbstractDomainObject]: same runtime class **and** equal [iid] and [uniqueKey]. + * + * ## Design Rationale + * - Separates identity concerns (name/email) from role-specific behavior (e.g., commit authorship). + * - Enables future extension for non-developer stakeholders (reviewers, project managers, etc.). + * - Provides type-safe base for collections that may contain mixed stakeholder types. + * + * ## Subclassing Contract + * - Subclasses must provide [name] and [email] implementations (non-null, typically non-blank). + * - Subclasses should override `equals`/`hashCode` to delegate to super (avoid data class auto-generation). + * + * @param Iid The type of the technical identifier (e.g., `Developer.Id`). + * @param Key The type of the business key (e.g., `Developer.Key`). + * @property name Display name of the stakeholder; must be non-blank in concrete implementations. + * @property email Email address of the stakeholder; must be non-blank in concrete implementations. + * @see Developer + * @see AbstractDomainObject + */ +abstract class Stakeholder( + iid: Iid +) : AbstractDomainObject(iid) { + + /** + * Display name of the stakeholder. + * Concrete implementations should validate non-blank values. + */ + abstract val name: String + + /** + * Email address of the stakeholder. + * Concrete implementations should validate non-blank values. + */ + abstract val email: String +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/User.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/User.kt index bce338456..f25503650 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/User.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/User.kt @@ -2,108 +2,162 @@ package com.inso_world.binocular.model import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull -import java.util.Objects -import java.util.concurrent.ConcurrentHashMap +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** - * Domain model for a User, representing a Git user. - * This class is database-agnostic and contains no persistence-specific annotations. + * Domain entity representing a Git user scoped to a [Repository]. + * + * @deprecated Use [Developer] instead. This class is maintained for backwards compatibility only. + * The new [Developer] class provides better semantics with required email and proper [Signature] integration. + * + * ## Migration Guide + * - Replace `User` with `Developer` + * - `email` is now required in `Developer` (was optional in `User`) + * - Use [Signature] for commit author/committer timestamps + * - Use `repository.developers` instead of `repository.user` + * + * ## Identity & equality + * - Inherits entity identity from [AbstractDomainObject]. + * - Technical id: [iid] of type [Id], generated at construction. + * - Business key: [uniqueKey] = [User.Key]([repository].iid, [name].trim()). + * - Although this is a `data class`, `equals`/`hashCode` delegate to + * [AbstractDomainObject] (no value-based equality on properties). + * + * @property name Display name as used in Git signatures; must be non-blank. + * @property repository Owning repository; participates in the [uniqueKey] and scopes this user. + * @see Developer + * @see Signature */ +@Deprecated("Use Developer instead", ReplaceWith("Developer")) +@OptIn(ExperimentalUuidApi::class) data class User( - var id: String? = null, - @field:NotBlank val name: String? = null, - var email: String? = null, - @field:NotNull - var repository: Repository? = null, + @field:NotBlank val name: String, + @field:NotNull val repository: Repository, +) : AbstractDomainObject( + Id(Uuid.random()), +) { + data class Key(val repositoryId: Repository.Id, val gitSignature: String) // value object for lookups + + @JvmInline + value class Id(val value: Uuid) + + @Deprecated("Avoid using database specific id, use business key", ReplaceWith("iid")) + var id: String? = null + + var email: String? = null + set(value) { + require(value?.trim()?.isNotBlank() == true) { "Email must not be empty" } + field = value + } + // Relationships - val issues: List = emptyList(), - val files: List = emptyList(), -) : AbstractDomainObject() { - private val _committedCommits = ConcurrentHashMap.newKeySet() - private val _authoredCommits = ConcurrentHashMap.newKeySet() + val issues: MutableSet = mutableSetOf() + + // object : NonRemovingMutableSet() {} + val files: MutableSet = object : NonRemovingMutableSet() {} init { - repository?.user?.add(this) + require(name.trim().isNotBlank()) { "name cannot be blank." } + repository.user.add(this) } - val committedCommits: MutableSet = - object : MutableSet by _committedCommits { - override fun add(element: Commit): Boolean { - // add to this commit’s parents… - val added = _committedCommits.add(element) - if (added) { - // …and back-link to this as a child - element.committer = this@User - } - return added + /** + * Commits committed by this [User]. + * + * # Semantics + * - **Add-only collection:** Backed by `NonRemovingMutableSet` — removals (`remove`, `retainAll`, `clear`, + * iterator `remove`) are not supported. + * - **Repository consistency:** Each added [Commit] must belong to the **same** `repository` as this user. + * - **Bidirectional link:** On successful insert, this user is assigned as the commit’s `committer` + * (`element.committer = this@User`), keeping both sides in sync. + * - **Set semantics / de-duplication:** Membership is keyed by each commit’s `uniqueKey` (business key). + * Re-adding an existing commit is a no-op (`false`). + * + * # Invariants enforced on insert + * - Precondition: `element.repository == this@User.repository`. + * - Postcondition (on success / no exception): + * - `element in committedCommits` + * - `element.committer == this@User` + * + * # Bulk adds + * - `addAll` applies the same checks and back-linking as `add`, per element. + * - Returns `true` iff at least one new commit was added. + * - **Not transactional:** if a later element fails (e.g., repository mismatch or `author` already set + * to a different user), earlier successful inserts remain. Callers should handle rollback if needed. + * + * # Idempotency & recursion safety + * - The back-link (`element.committer = this@User`) runs **only** when the commit is newly added + * (`added == true`), preventing infinite mutual updates. + * - Re-adding the same commit (by `uniqueKey`) is a no-op and does not re-trigger the back-link. + * + * # Exceptions + * - Throws [IllegalArgumentException] when the commit’s repository differs from this user’s repository. + * - May throw [IllegalArgumentException] from `Commit.committer`’s setter if the commit already has a + * different committer assigned (set-once constraint). + * - Any attempt to remove elements from this collection throws [UnsupportedOperationException]. + * + * # Thread-safety + * - Internally backed by a concurrent map; individual `add`/`contains` operations are safe for concurrent use. + * However, multi-step workflows (e.g., “add then do X”) are **not atomic**; coordinate externally to + * avoid torn updates between set membership and the `author` back-link. + */ + val committedCommits: MutableSet = object : NonRemovingMutableSet() { + override fun add(element: Commit): Boolean { + require(element.repository == this@User.repository) { + "Commit.repository (${element.repository}) doesn't match user.repository (${this@User.repository})" } - - override fun addAll(elements: Collection): Boolean { - // for bulk-adds make sure each one gets the same treatment - var anyAdded = false - for (e in elements) { - if (add(e)) anyAdded = true - } - return anyAdded + require(element.committer == this@User) { + "Cannot add Commit $element to committedCommits since user is not committer of Commit." } + val added = super.add(element) + return added } - val authoredCommits: MutableSet = - object : MutableSet by _authoredCommits { - override fun add(element: Commit): Boolean { - // add to this commit’s parents… - val added = _authoredCommits.add(element) - if (added) { - // …and back-link to this as a child - element.author = this@User - } - return added + override fun addAll(elements: Collection): Boolean { + // for bulk-adds make sure each one gets the same treatment + var anyAdded = false + for (e in elements) { + if (add(e)) anyAdded = true } + return anyAdded + } + } - override fun addAll(elements: Collection): Boolean { - // for bulk-adds make sure each one gets the same treatment - var anyAdded = false - for (e in elements) { - if (add(e)) anyAdded = true - } - return anyAdded + /** + * Commits authored by this [User]. + * + * @deprecated This collection is deprecated along with [User]. Use [Developer.authoredCommits] instead. + * Note: This collection no longer back-links to commits as the new [Commit] model uses [Signature]. + */ + @Deprecated("Use Developer.authoredCommits instead") + val authoredCommits: MutableSet = object : NonRemovingMutableSet() { + override fun add(element: Commit): Boolean { + require(element.repository == this@User.repository) { + "Commit.repository (${element.repository}) doesn't match user.repository (${this@User.repository})" } + return super.add(element) } - @Deprecated("do not use, just for compatibility") - val gitSignature: String - get() = "$name <$email>" - - override fun uniqueKey(): String { - requireNotNull(repository) { - throw IllegalStateException("Cannot generate unique key for $javaClass when repository is null") + override fun addAll(elements: Collection): Boolean { + var anyAdded = false + for (e in elements) { + if (add(e)) anyAdded = true + } + return anyAdded } - return "${repository?.localPath},$email" } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as User - -// if (id != other.id) return false - if (name != other.name) return false - if (repository?.localPath != other.repository?.localPath) return false -// if (gitSignature != other.gitSignature) return false -// if (authoredCommits != other.authoredCommits) return false -// if (committedCommits != other.committedCommits) return false -// if (issues != other.issues) return false -// if (files != other.files) return false + val gitSignature: String + get() = "${name.trim()} <${email?.trim()}>" - return true - } + override val uniqueKey: Key + get() = Key(repository.iid, gitSignature) - override fun hashCode(): Int { - var result = Objects.hashCode(name) - result += 31 * Objects.hashCode(repository?.localPath) - return result - } + // Entities compare by immutable identity only + override fun equals(other: Any?) = super.equals(other) + override fun hashCode(): Int = super.hashCode() - override fun toString(): String = "User(id=$id, name=$name, gitSignature=$gitSignature, repositoryId=${repository?.id})" + override fun toString(): String = + "User(id=$id, iid=$iid, name=$name, gitSignature=$gitSignature, repositoryId=${repository.id}, committedCommits=${committedCommits.map { it.sha }}, authoredCommits=${authoredCommits.map { it.sha }})" } diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/CommitValidation.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/CommitValidation.kt deleted file mode 100644 index 95bd7aeb4..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/CommitValidation.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.inso_world.binocular.model.validation - -import jakarta.validation.Constraint -import jakarta.validation.Payload -import kotlin.annotation.AnnotationRetention.RUNTIME -import kotlin.annotation.AnnotationTarget.CLASS -import kotlin.reflect.KClass - -@Target(CLASS) -@Retention(RUNTIME) -@Constraint(validatedBy = [CommitValidator::class]) -annotation class CommitValidation( - val message: String = "Repository ID must be null if repository has null ID, or must match repository ID if repository has non-null ID", - val groups: Array> = [], - val payload: Array> = [], -) diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/CommitValidator.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/CommitValidator.kt deleted file mode 100644 index 8c9d4d454..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/CommitValidator.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.inso_world.binocular.model.validation - -import com.inso_world.binocular.model.Repository -import jakarta.validation.ConstraintValidator -import jakarta.validation.ConstraintValidatorContext - -class CommitValidator : ConstraintValidator { - override fun isValid( - repository: Repository, - context: ConstraintValidatorContext, - ): Boolean { - val checks = - repository.commits - .mapIndexed { index, commit -> - val repositoryId = commit.repository?.id - val repositoryActualId = repository.id - - when { - repositoryActualId == null -> { - if (repositoryId != null) { - context.disableDefaultConstraintViolation() - context - .buildConstraintViolationWithTemplate( - "Repository ID of Commit=${commit.sha} is null, but commit has a repositoryId=$repositoryActualId.", - ).addPropertyNode("commits") - .addPropertyNode("repositoryId") - .inIterable() - .addConstraintViolation() - return@mapIndexed false - } - } - - repositoryId != repositoryActualId -> { - context.disableDefaultConstraintViolation() - context - .buildConstraintViolationWithTemplate( - "Commit repository.id=$repositoryId does not match repository.id=$repositoryActualId.", - ).addPropertyNode("commits") - .addPropertyNode("repositoryId") - .inIterable() - .addConstraintViolation() - return@mapIndexed false - } - } - true - } - return checks.all { it } - } -} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/GitUrl.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/GitUrl.kt new file mode 100644 index 000000000..bb4047a10 --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/GitUrl.kt @@ -0,0 +1,30 @@ +package com.inso_world.binocular.model.validation + +import jakarta.validation.Constraint +import jakarta.validation.Payload +import kotlin.reflect.KClass + +/** + * Validates that a string is a valid Git repository URL. + * + * Supports all common Git URL formats: + * - HTTPS: `https://github.com/user/repo.git` + * - HTTP: `http://github.com/user/repo.git` + * - SSH: `ssh://git@github.com/user/repo.git` + * - Git protocol: `git://github.com/user/repo.git` + * - SCP-like SSH: `git@github.com:user/repo.git` + * - File: `file:///path/to/repo.git` + * - Absolute paths: `/path/to/repo` + * - Relative paths: `../relative/path` + * + * @see GitUrlValidator + */ +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [GitUrlValidator::class]) +@MustBeDocumented +annotation class GitUrl( + val message: String = "must be a valid Git repository URL", + val groups: Array> = [], + val payload: Array> = [] +) \ No newline at end of file diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/GitUrlValidator.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/GitUrlValidator.kt new file mode 100644 index 000000000..830a35d44 --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/GitUrlValidator.kt @@ -0,0 +1,145 @@ +package com.inso_world.binocular.model.validation + +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import java.net.URI + +/** + * Validator for [GitUrl] annotation. + * + * Validates Git repository URLs by checking against common Git URL patterns: + * + * 1. **HTTP/HTTPS URLs:** Standard web URLs (e.g., `https://github.com/user/repo.git`) + * 2. **SSH URLs:** SSH protocol URLs (e.g., `ssh://git@github.com/user/repo.git`) + * 3. **Git protocol URLs:** Git-specific protocol (e.g., `git://github.com/user/repo.git`) + * 4. **SCP-like syntax:** SSH shorthand (e.g., `git@github.com:user/repo.git`) + * 5. **File URLs:** Local file paths (e.g., `file:///path/to/repo.git`) + * 6. **Absolute paths:** Unix/Windows absolute paths (e.g., `/path/to/repo`, `C:\path\to\repo`) + * 7. **Relative paths:** Relative paths (e.g., `../repo`, `./repo`) + * + * ### Validation logic + * - Null or blank values are considered invalid (use `@NotBlank` separately if needed) + * - Attempts to parse as URI/URL for standard protocols + * - Falls back to regex pattern matching for SCP-like syntax and paths + * + * ### Implementation notes + * - Thread-safe and stateless + * - Lenient parsing to accommodate various Git configurations + * - Does not perform network validation (URL existence/accessibility) + * + * @see GitUrl + */ +class GitUrlValidator : ConstraintValidator { + + companion object { + /** + * Regex pattern for SCP-like SSH URLs (e.g., `git@github.com:user/repo.git`). + * + * Format: `[user@]host:path` + * - Optional user (e.g., `git@`) + * - Required host (domain or IP) + * - Colon separator + * - Path to repository + */ + private val SCP_PATTERN = Regex( + """^(?:[\w.-]+@)?[\w.-]+:[\w./_-]+$""" + ) + + /** + * Regex pattern for local file paths (absolute and relative). + * + * Covers: + * - Unix absolute: `/path/to/repo` + * - Unix relative: `../repo`, `./repo`, `relative/path` + * - Windows absolute: `C:\path\to\repo` + * - Windows UNC: `\\server\share\repo` + */ + private val PATH_PATTERN = Regex( + """^(?:[a-zA-Z]:[\\/]|/|\\\\|\.\.?/|[\w-]+/).*$""" + ) + + /** + * Supported Git URL schemes. + */ + private val VALID_SCHEMES = setOf( + "http", "https", "ssh", "git", "file", "ftp", "ftps" + ) + } + + override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean { + // Null or blank is invalid (use @NotBlank separately) + if (value.isNullOrBlank()) { + return false + } + + val trimmedValue = value.trim() + + // URLs should not contain spaces + if (trimmedValue.contains(' ')) { + return false + } + + // If value looks like a URL with scheme (contains "://"), it must be valid + // Reject malformed URLs that have scheme-like patterns but are invalid + if (trimmedValue.contains("://")) { + try { + val uri = URI(trimmedValue) + val scheme = uri.scheme?.lowercase() + + // If has a scheme, validate it's a Git-compatible protocol + if (scheme != null) { + if (!VALID_SCHEMES.contains(scheme)) { + return false + } + + // Validate scheme-specific part is not blank + if (uri.schemeSpecificPart.isBlank()) { + return false + } + + // For network protocols (http, https, ssh, git, ftp, ftps), require a valid host + if (scheme in setOf("http", "https", "ssh", "git", "ftp", "ftps")) { + val host = uri.host + // Host must exist and not be blank + if (host.isNullOrBlank()) { + return false + } + } + + return true + } + // Has "://" but no valid scheme - invalid + return false + } catch (e: Exception) { + // Looks like a URL but failed to parse - invalid + return false + } + } + + // Try parsing as URI without scheme (for Windows paths like C:\path) + try { + val uri = URI(trimmedValue) + val scheme = uri.scheme?.lowercase() + + if (scheme != null && VALID_SCHEMES.contains(scheme)) { + // Already handled above + return true + } + } catch (e: Exception) { + // Not a valid URI, continue to pattern matching + } + + // Check for SCP-like SSH syntax (git@host:path) + if (SCP_PATTERN.matches(trimmedValue)) { + return true + } + + // Check for local paths (absolute or relative) + if (PATH_PATTERN.matches(trimmedValue)) { + return true + } + + // If none of the above match, it's invalid + return false + } +} \ No newline at end of file diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/NoCommitCycle.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/Hexadecimal.kt similarity index 51% rename from binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/NoCommitCycle.kt rename to binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/Hexadecimal.kt index 3b88f8cbb..07ca13b4b 100644 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/NoCommitCycle.kt +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/Hexadecimal.kt @@ -4,11 +4,12 @@ import jakarta.validation.Constraint import jakarta.validation.Payload import kotlin.reflect.KClass -@Target(AnnotationTarget.CLASS) +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) -@Constraint(validatedBy = [NoCommitCycleValidator::class]) -annotation class NoCommitCycle( - val message: String = "Commit and its parents must not form a cycle", +@Constraint(validatedBy = [HexadecimalValidator::class]) +@MustBeDocumented +annotation class Hexadecimal( + val message: String = "must be a valid hexadecimal string", val groups: Array> = [], val payload: Array> = [] -) \ No newline at end of file +) diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/HexadecimalValidator.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/HexadecimalValidator.kt new file mode 100644 index 000000000..cdd7220d3 --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/HexadecimalValidator.kt @@ -0,0 +1,17 @@ +package com.inso_world.binocular.model.validation + +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext + +internal class HexadecimalValidator : ConstraintValidator { + override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean { + // Null or blank is invalid (use @NotBlank separately) + if (value.isNullOrBlank()) { + return false + } + + val trimmedValue = value.trim() + + return trimmedValue.all { it.isHex() } + } +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/NoCommitCycleValidator.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/NoCommitCycleValidator.kt deleted file mode 100644 index b8a88833e..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/NoCommitCycleValidator.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.inso_world.binocular.model.validation - -import com.inso_world.binocular.model.Commit -import jakarta.validation.ConstraintValidator -import jakarta.validation.ConstraintValidatorContext - -class NoCommitCycleValidator : ConstraintValidator { - override fun isValid( - commit: Commit, - context: ConstraintValidatorContext, - ): Boolean { - if (commit.children.contains(commit)) { - return false - } - if (commit.parents.contains(commit)) { - return false - } - - return true - } -} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/ProjectValidation.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/ProjectValidation.kt deleted file mode 100644 index ed6370593..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/ProjectValidation.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.inso_world.binocular.model.validation - -import jakarta.validation.Constraint -import jakarta.validation.Payload -import kotlin.annotation.AnnotationRetention.RUNTIME -import kotlin.annotation.AnnotationTarget.CLASS -import kotlin.reflect.KClass - -@Target(CLASS) -@Retention(RUNTIME) -@Constraint(validatedBy = [ProjectValidator::class]) -annotation class ProjectValidation( - val message: String = "Repository must reference back to this project if it exists", - val groups: Array> = [], - val payload: Array> = [], -) diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/ProjectValidator.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/ProjectValidator.kt deleted file mode 100644 index c28cf3521..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/ProjectValidator.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.inso_world.binocular.model.validation - -import com.inso_world.binocular.model.Project -import jakarta.validation.ConstraintValidator -import jakarta.validation.ConstraintValidatorContext - -class ProjectValidator : ConstraintValidator { - override fun isValid( - project: Project?, - context: ConstraintValidatorContext, - ): Boolean { - if (project == null) return true - - val repository = project.repo - - // If repository is null, the relationship is valid - if (repository == null) return true - - // Validate that the repository's projectId matches this project's id - val repositoryProjectId = repository.project?.id - val projectId = project.id - - return when { - projectId == null -> repositoryProjectId == null - else -> repositoryProjectId == projectId - } - } -} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/RepositoryValidation.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/RepositoryValidation.kt deleted file mode 100644 index 55213d894..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/RepositoryValidation.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.inso_world.binocular.model.validation - -import jakarta.validation.Constraint -import jakarta.validation.Payload -import kotlin.annotation.AnnotationRetention.RUNTIME -import kotlin.annotation.AnnotationTarget.CLASS -import kotlin.reflect.KClass - -@Target(CLASS) -@Retention(RUNTIME) -@Constraint(validatedBy = [RepositoryValidator::class]) -annotation class RepositoryValidation( - val message: String = "Project ID must be null if project has null ID, or must match project ID if project has non-null ID", - val groups: Array> = [], - val payload: Array> = [], -) diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/RepositoryValidator.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/RepositoryValidator.kt deleted file mode 100644 index 693668291..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/RepositoryValidator.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.inso_world.binocular.model.validation - -import com.inso_world.binocular.model.Project -import jakarta.validation.ConstraintValidator -import jakarta.validation.ConstraintValidatorContext - -class RepositoryValidator : ConstraintValidator { - override fun isValid( - project: Project?, - context: ConstraintValidatorContext, - ): Boolean { - if (project == null) return true - - val projectId = project.id - val repository = project.repo - - // If project is null, we can't validate the relationship - // In this case, we'll assume it's valid (project might be set later) - if (repository == null) return true - - val projectActualId = repository.project?.id - - return when { - projectActualId == null -> projectId == null - else -> projectId == projectActualId - } - } -} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/ValidationGroups.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/ValidationGroups.kt deleted file mode 100644 index 21fda5fea..000000000 --- a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/ValidationGroups.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.inso_world.binocular.model.validation - -/** - * Validation group for constraints that should be applied when sending data into the infrastructure layer. - * Use this for properties that should only be validated before persistence or external system interaction. - */ -interface ToInfrastructure - -/** - * Validation group for constraints that should be applied when receiving data from the infrastructure layer. - * Use this for properties that should only be validated after loading from persistence or external systems. - */ -interface FromInfrastructure - -// The Default group is provided by javax/jakarta.validation and does not need to be redefined here. \ No newline at end of file diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/isHex.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/isHex.kt new file mode 100644 index 000000000..85fddd926 --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/validation/isHex.kt @@ -0,0 +1,4 @@ +package com.inso_world.binocular.model.validation + +fun Char.isHex(): Boolean = + this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/vcs/ReferenceCategory.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/vcs/ReferenceCategory.kt new file mode 100644 index 000000000..563dc17dc --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/vcs/ReferenceCategory.kt @@ -0,0 +1,17 @@ +package com.inso_world.binocular.model.vcs + +enum class ReferenceCategory { + LOCAL_BRANCH, + REMOTE_BRANCH, + TAG, + NOTE, + PSEUDO_REF, + UNKNOWN, + MAIN_PSEUDO_REF, + MAIN_REF, + LINKED_PSEUDO_REF, + LINKED_REF, + BISECT, + REWRITTEN, + WORKTREE_PRIVATE, +} diff --git a/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/vcs/Remote.kt b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/vcs/Remote.kt new file mode 100644 index 000000000..bfe1600f5 --- /dev/null +++ b/binocular-backend-new/domain/src/main/kotlin/com/inso_world/binocular/model/vcs/Remote.kt @@ -0,0 +1,125 @@ +package com.inso_world.binocular.model.vcs + +import com.inso_world.binocular.model.AbstractDomainObject +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.validation.GitUrl +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Remote — a named URL endpoint referencing an external Git repository location. + * + * In Git terminology, a **remote** is a bookmark for a URL that points to another copy + * of the repository (typically on a server like GitHub, GitLab, or Bitbucket). The most + * common remote name is `origin`, pointing to the repository from which the local copy + * was cloned. + * + * ### SEON (Software Engineering ONtology) compliance + * - **Remote** is a first-class entity in the Git domain model representing external repository endpoints. + * - A remote has a **name** (e.g., "origin", "upstream") and a **URL** (fetch/push location). + * - Remotes enable distributed version control by linking local repositories to external ones. + * + * ### Identity & equality + * - Technical identity: immutable [iid] of type [Id] (generated at construction). + * - Business key: [uniqueKey] == [Key]([repository].iid, [name]). + * - Equality is identity-based (same [iid]); `hashCode()` derives from [iid]. + * + * ### Construction & validation + * - Requires a non-blank [name] (`@field:NotBlank` + runtime `require`). + * - Requires a non-blank [url] (`@field:NotBlank` + `@field:GitUrl` + runtime `require`). + * - The [url] must be a valid Git repository URL (supports HTTP/HTTPS, SSH, Git protocol, file paths, etc.). + * - On construction, the remote **registers itself** to the owning [repository] via `repository.remotes.add(this)`. + * + * ### Relationships + * - **Many-to-one with [Repository]:** Each remote belongs to exactly one repository. + * A repository may have multiple remotes (e.g., "origin", "upstream", "fork"). + * - **Naming convention:** The default remote created by `git clone` is named "origin". + * + * ### Git remote operations + * - **fetch:** Downloads objects and refs from the remote URL. + * - **push:** Uploads local refs and objects to the remote URL. + * - **pull:** Combines fetch + merge from the remote. + * + * ### Thread-safety + * - Instances are mutable (e.g., [url] can be updated) and not thread-safe. + * Coordinate externally if concurrent access is required. + * + * @property name The short, human-readable name for this remote (e.g., "origin", "upstream"). + * Must be non-blank and unique within the [repository]. Participates in [uniqueKey]. + * @property url The complete URL to the remote repository (e.g., "https://github.com/user/repo.git"). + * Must be non-blank. Can be HTTP(S), SSH, or Git protocol. + * @property repository The local [Repository] that references this remote. + * + * ### Example + * ```kotlin + * val repo = Repository(localPath = "/path/to/repo", project = myProject) + * val origin = Remote( + * name = "origin", + * url = "https://github.com/user/repo.git", + * repository = repo + * ) + * + * // The remote is automatically added to repo.remotes during construction + * check(origin in repo.remotes) + * ``` + * + * ### Git documentation reference + * - [git-remote](https://git-scm.com/docs/git-remote) + * - [Working with Remotes](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes) + */ +@OptIn(ExperimentalUuidApi::class) +data class Remote( + @field:NotBlank + @field:Pattern( + regexp = "^[a-zA-Z0-9._/-]+$", + message = "Remote name must contain only alphanumeric characters, dots, underscores, slashes, or hyphens" + ) + val name: String, + + @field:NotBlank + @field:GitUrl + var url: String, + + val repository: Repository, +) : AbstractDomainObject( + Id(Uuid.random()) +) { + /** + * Type-safe wrapper for the technical identity of a [Remote]. + */ + @JvmInline + value class Id(val value: Uuid) + + /** + * Business key for a [Remote]: unique combination of repository ID and remote name. + * + * Within a single repository, remote names must be unique (Git enforces this). + * Across repositories, the same name (e.g., "origin") can exist independently. + */ + data class Key(val repositoryId: Repository.Id, val name: String) + + /** + * Optional database-specific identifier. + * @deprecated Prefer using [iid] for identity; this field is infrastructure-specific. + */ + @Deprecated("Avoid using database specific id, use business key .iid", ReplaceWith("iid")) + var id: String? = null + + init { + require(name.trim().isNotBlank()) { "Remote name cannot be blank." } + require(url.trim().isNotBlank()) { "Remote URL cannot be blank." } + repository.remotes.add(this) + } + + override val uniqueKey: Key + get() = Key(repository.iid, name.trim()) + + // Entities compare by immutable identity only + override fun equals(other: Any?) = super.equals(other) + override fun hashCode(): Int = super.hashCode() + + override fun toString(): String = + "Remote(id=$id, iid=$iid, name='$name', url='$url', repository=${repository.uniqueKey})" +} diff --git a/binocular-backend-new/domain/src/test/java/com/inso_world/binocular/model/NullKeyAdo.java b/binocular-backend-new/domain/src/test/java/com/inso_world/binocular/model/NullKeyAdo.java new file mode 100644 index 000000000..6a5978cf4 --- /dev/null +++ b/binocular-backend-new/domain/src/test/java/com/inso_world/binocular/model/NullKeyAdo.java @@ -0,0 +1,16 @@ +package com.inso_world.binocular.model; + +public class NullKeyAdo extends AbstractDomainObject { + + private final String key; // Intentionally nullable to trigger Kotlin null-check + + public NullKeyAdo(Integer iid, String key) { + super(iid); + this.key = key; // may be null + } + + @Override + public String getUniqueKey() { + return key; // returning null is allowed from Java; Kotlin will assert non-null at callsite + } +} diff --git a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/data/DummyTestData.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/domain/data/DummyTestData.kt similarity index 97% rename from binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/data/DummyTestData.kt rename to binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/domain/data/DummyTestData.kt index 4970d94aa..564344b6d 100644 --- a/binocular-backend-new/core/src/test/kotlin/com/inso_world/binocular/core/data/DummyTestData.kt +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/domain/data/DummyTestData.kt @@ -1,4 +1,4 @@ -package com.inso_world.binocular.core.data +package com.inso_world.binocular.domain.data import org.junit.jupiter.params.provider.Arguments import java.time.LocalDateTime diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/domain/data/MockTestDataProvider.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/domain/data/MockTestDataProvider.kt new file mode 100644 index 000000000..f95673e77 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/domain/data/MockTestDataProvider.kt @@ -0,0 +1,144 @@ +package com.inso_world.binocular.domain.data + +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.User +import com.inso_world.binocular.model.vcs.ReferenceCategory +import java.time.LocalDateTime + +class MockTestDataProvider( + val repository: Repository +) { + val testProjects = listOf( + Project(name = "proj-pg-0"), + Project(name = "proj-pg-1"), + Project(name = "proj-pg-2"), + Project(name = "proj-pg-3"), + Project(name = "proj-pg-4"), + Project(name = "proj-pg-5"), + Project(name = "proj-for-repos"), + ) + val projectsByName = testProjects.associateBy { requireNotNull(it.name) } + + val testRepositories: List = listOf( + run { + val project = projectsByName.getValue("proj-pg-0") + Repository(localPath = "repo-pg-0", project = project) + }, + run { + val project = projectsByName.getValue("proj-pg-1") + Repository(localPath = "repo-pg-1", project = project) + }, + run { + val project = projectsByName.getValue("proj-pg-2") + Repository(localPath = "repo-pg-2", project = project) + }, + run { + val project = projectsByName.getValue("proj-pg-3") + Repository(localPath = "repo-pg-3", project = project) + }, + run { + val project = projectsByName.getValue("proj-pg-4") + Repository(localPath = "repo-pg-4", project = project) + }, + run { + val project = projectsByName.getValue("proj-for-repos") + Repository(localPath = "repo-pg-5", project = project) + }, + run { + val project = projectsByName.getValue("proj-pg-5") + Repository(localPath = "repo-empty", project = project) + } + ) + val repositoriesByPath = testRepositories.associateBy { requireNotNull(it.localPath) } + + // Legacy User support (deprecated) + @Deprecated("Use developers instead") + val users: List = listOf( + User(name = "User A", repository = repository).apply { this.email = "a@test.com" }, + User(name = "User B", repository = repository).apply { this.email = "b@test.com" }, + User(name = "User C", repository = repository).apply { this.email = "c@test.com" }, + User(name = "User D", repository = repository).apply { this.email = "d@test.com" }, + User(name = "Author Only", repository = repository).apply { this.email = "author@test.com" } + ) + + @Deprecated("Use developerByEmail instead") + val userByEmail = users.associateBy { requireNotNull(it.email) } + + // New Developer-based test data + val developers: List = listOf( + Developer(name = "User A", email = "a@test.com", repository = repository), + Developer(name = "User B", email = "b@test.com", repository = repository), + Developer(name = "User C", email = "c@test.com", repository = repository), + Developer(name = "User D", email = "d@test.com", repository = repository), + Developer(name = "Author Only", email = "author@test.com", repository = repository) + ) + val developerByEmail = developers.associateBy { it.email } + + val commits: List = listOf( + run { + val dev = developerByEmail.getValue("a@test.com") + val timestamp = LocalDateTime.now().minusSeconds(1) + Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = Signature(developer = dev, timestamp = timestamp), + repository = repository, + ) + }, + run { + val dev = developerByEmail.getValue("b@test.com") + val timestamp = LocalDateTime.now().minusSeconds(1) + Commit( + sha = "b".repeat(40), + message = "msg2", + authorSignature = Signature(developer = dev, timestamp = timestamp), + repository = repository, + ) + }, + run { + val dev = developerByEmail.getValue("c@test.com") + val timestamp = LocalDateTime.now().minusSeconds(1) + Commit( + sha = "c".repeat(40), + message = "msg1", + authorSignature = Signature(developer = dev, timestamp = timestamp), + repository = repository, + ) + }, + run { + val dev = developerByEmail.getValue("d@test.com") + val timestamp = LocalDateTime.now().minusSeconds(1) + Commit( + sha = "d".repeat(40), + message = "msg-d", + authorSignature = Signature(developer = dev, timestamp = timestamp), + repository = repository, + ) + } + ) + val commitBySha = commits.associateBy(Commit::sha) + + val branches = listOf( + Branch( + fullName = "refs/remotes/origin/feature/test", + name = "origin/feature/test", + repository = repository, + head = commitBySha.getValue("a".repeat(40)), + category = ReferenceCategory.REMOTE_BRANCH + ), + Branch( + fullName = "refs/remotes/origin/fixme/123", + name = "origin/fixme/123", + repository = repository, + head = commitBySha.getValue("a".repeat(40)), + category = ReferenceCategory.REMOTE_BRANCH + ) + ) + + val branchByName = branches.associateBy(Branch::name) +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/AbstractDomainObjectEqualsTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/AbstractDomainObjectEqualsTest.kt new file mode 100644 index 000000000..1ce8b4a11 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/AbstractDomainObjectEqualsTest.kt @@ -0,0 +1,83 @@ +import com.inso_world.binocular.model.AbstractDomainObject +import com.inso_world.binocular.model.NullKeyAdo +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll + + +// Simple concrete Kotlin subclass for normal cases +private class KObj( + iid: Int, + override val uniqueKey: String +) : AbstractDomainObject(iid) { + override fun hashCode(): Int { + return super.hashCode() + } + + override fun equals(other: Any?): Boolean { + return super.equals(other) + } +} + +internal class AbstractDomainObjectEqualsTest { + + @Test + fun `equals false when same class & same iid but different uniqueKey (covers uniqueKey branch)`() { + val a = KObj(iid = 1, uniqueKey = "A") + val b = KObj(iid = 1, uniqueKey = "B") + + // Hits: javaClass check passes -> cast executes -> iid equal -> uniqueKey differs -> return false + assertAll( + { assertFalse(a == b) }, + { assertFalse(b == a) } + ) + } + + @Test + fun `equals true when same class & same iid & same uniqueKey`() { + val a = KObj(iid = 42, uniqueKey = "X") + val b = KObj(iid = 42, uniqueKey = "X") + + // Hits: javaClass check passes -> cast executes -> iid equal -> uniqueKey equal -> return true + assertAll( + { assertTrue(a == b) }, + { assertTrue(b == a) } + ) + } + + @Test + fun `equals false when same class but different iid`() { + val a = KObj(iid = 1, uniqueKey = "A") + val b = KObj(iid = 2, uniqueKey = "A") + + // Hits: javaClass check passes -> cast executes -> iid differs -> return false (doesn't reach uniqueKey) + assertAll( + { assertFalse(a == b) }, + { assertFalse(b == a) } + ) + } + + @Test + fun `equals short-circuits to false on different runtime class`() { + val a = KObj(iid = 1, uniqueKey = "A") + val other = "not an AbstractDomainObject" + + // Hits: javaClass != other?.javaClass -> return false (cast not executed) + assertFalse(a.equals(other)) + } + + @Test + fun `equals triggers Kotlin null-assertion on other_uniqueKey (kills removed Intrinsics_checkNotNull)`() { + val a = NullKeyAdo(1, "A") // non-null uniqueKey + val b = NullKeyAdo(1, null) // null uniqueKey to trip Kotlin callsite null-check + + // Path: same concrete class -> cast executes -> iid equal -> accessing other.uniqueKey + // Kotlin inserts Intrinsics.checkNotNull on the property read; expect NPE. + assertAll( + { assertFalse(a == b) }, + { assertFalse(b == a) } + ) + } +} + diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/BranchModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/BranchModelTest.kt new file mode 100644 index 000000000..0167f5504 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/BranchModelTest.kt @@ -0,0 +1,342 @@ +package com.inso_world.binocular.model + +import com.inso_world.binocular.domain.data.MockTestDataProvider +import com.inso_world.binocular.model.utils.ReflectionUtils.Companion.setField +import com.inso_world.binocular.model.vcs.ReferenceCategory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.time.LocalDateTime +import kotlin.uuid.ExperimentalUuidApi + +class BranchModelTest { + private lateinit var repository: Repository + private lateinit var head: Commit + + @BeforeEach + fun setup() { + repository = Repository(localPath = "test repo", project = Project(name = "test project")) + val developer = Developer(name = "Test Developer", email = "dev@test.com", repository = repository) + val signature = Signature(developer = developer, timestamp = LocalDateTime.now().minusSeconds(1)) + head = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + } + + @Test + fun `add branch to other repository too, should fail`() { + val mockCommit = MockTestDataProvider(this@BranchModelTest.repository).commitBySha.getValue("a".repeat(40)) + val dummyBranch = + branch( + name = "branch", + // other repo on purpose + repository = this@BranchModelTest.repository, + head = mockCommit, + ) + assertThrows { + Repository( + localPath = "test repo", + project = Project(name = "test project") + ).branches.add(dummyBranch) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideAllowedStrings") + fun `create branch with allowed names, should succeed`( + name: String, + ) { + assertDoesNotThrow { + branch( + name = name, + fullName = name + ) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `create branch with blank name, should fail`( + name: String, + ) { + assertThrows { + branch( + name = name, + fullName = name + ) + } + } + + @Test + fun `create branch, check iid is set automatically`() { + val branch = branch() + + assertThat(branch.iid).isNotNull() + } + + @Test + fun `create branch, stores provided metadata`() { + val branch = branch( + name = "main", + fullName = "refs/heads/main", + category = ReferenceCategory.LOCAL_BRANCH + ) + + assertThat(branch.fullName).isEqualTo("refs/heads/main") + assertThat(branch.category).isEqualTo(ReferenceCategory.LOCAL_BRANCH) + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `create branch with blank fullName should fail`( + fullName: String, + ) { + assertThrows { + branch(name = "branch", fullName = fullName) + } + } + + @Test + fun `create branch, validate uniqueKey`() { + val branch = branch() + + @OptIn(ExperimentalUuidApi::class) + assertAll( + { assertThat(branch.uniqueKey).isEqualTo(Branch.Key(repository.iid, "branch")) }, + { assertThat(branch.uniqueKey.repositoryId).isEqualTo(repository.iid) }, + // compare .value here + // Because inline classes may be represented both as the underlying value and as a wrapper, referential equality is pointless for them and is therefore prohibited. + // https://kotlinlang.org/docs/inline-classes.html#representation + { assertThat(branch.uniqueKey.repositoryId.value).isSameAs(repository.iid.value) }, + { assertThat(branch.uniqueKey.name).isSameAs(branch.name) }, + ) + } + + @Test + fun `create branch, check that hashCode is based on iid`() { + val branch = branch() + + assertThat(branch.hashCode()).isEqualTo(branch.iid.hashCode()) + } + + @Test + fun `create branch, assert that id is null`() { + val branch = branch() + + assertThat(branch.id).isNull() + } + + @Test + @Disabled + fun `create branch, then copy, check that they are not equal`() { +// val mockCommit = MockTestDataProvider(this@BranchModelTest.repository).commitBySha.getValue("a".repeat(40)) +// val branch = Branch( +// name = "branch", +// repository = Repository( +// localPath = "test repo", +// project = Project(name = "test project") +// ), +// head = mockCommit, +// ) +// val branchCopy = branch.clone() +// +// assertThat(branch).isNotSameAs(branchCopy) +// assertThat(branch).isNotEqualTo(branchCopy) +// assertThat(branch.iid).isNotEqualTo(branchCopy.iid) + } + + @Test + @Disabled + fun `create branch, then copy, edit iid, check that they are equal`() { +// val branch = Branch( +// name = "branch", +// repository, +// ) +// val originIid = branch.iid +// val branchCopy = branch.copy() +// setField( +// branchCopy.javaClass.superclass.superclass.getDeclaredField("iid"), +// branchCopy, +// originIid +// ) +// +// assertThat(branch).isNotSameAs(branchCopy) +// assertThat(branch.iid).isEqualTo(originIid) +// assertThat(branch.iid).isEqualTo(branchCopy.iid) +// assertThat(branch).isEqualTo(branchCopy) + } + + @Test + fun `create branch, check link to repository`() { + val branch = branch() + + assertThat(branch.repository).isSameAs(repository) + assertThat(branch.repository.branches).hasSize(1) + assertThat(branch.repository.branches).containsOnly(branch) + } + + @Nested + inner class CommitRelation { + @BeforeEach + fun setup() { + this@BranchModelTest.setup() + } + + @Test + fun `create branch, add commit from different repository, should fail`() { + val head = MockTestDataProvider(this@BranchModelTest.repository).commitBySha.getValue("a".repeat(40)) + + val differentRepository = Repository( + localPath = "different-repository", + project = Project(name = "different-project"), + ) + + val branch = branch( + repository = this@BranchModelTest.repository, + head = head + ) + + setField( + head.javaClass.getDeclaredField("repository"), + head, + differentRepository + ) + assertAll( + { assertThat(branch.repository).isNotEqualTo(head.repository) }, + { + assertThrows { + branch.head = head + } + } + ) + } + + @Test + fun `create branch, with commit, get head, should succeed`() { + val mockCommit = MockTestDataProvider(this@BranchModelTest.repository).commitBySha.getValue("a".repeat(40)) + + val branch = branch( + repository = this@BranchModelTest.repository, + head = mockCommit + ) + + assertThat(branch.head).isSameAs(mockCommit) + } + + @Test + fun `create branch, with commit, get commits, should succeed`() { + val mockCommits = MockTestDataProvider(this@BranchModelTest.repository).commitBySha.getValue("a".repeat(40)) + + val branch = branch( + repository = this@BranchModelTest.repository, + head = mockCommits + ) + + assertAll( + { assertThat(branch.commits).hasSize(1) }, + { assertThat(branch.commits).containsOnly(mockCommits) }, + { assertThat(branch.commits.first()).isSameAs(mockCommits) } + ) + } + + @Test + fun `create branch, commit history of 2, get commits, should succeed`() { + var head: Commit + var mockCommitB: Commit + with(MockTestDataProvider(this@BranchModelTest.repository)) { + head = this.commitBySha.getValue("a".repeat(40)) + mockCommitB = this.commitBySha.getValue("b".repeat(40)) + + head.parents.add(mockCommitB) + } + + val branch = branch( + repository = this@BranchModelTest.repository, + head = head + ) + + with(branch.commits) { + assertAll( + { assertThat(this).hasSize(2) }, + { assertThat(this).containsOnly(head, mockCommitB) }, + { assertThat(this.first()).isSameAs(head) }, + { assertThat(this.last()).isSameAs(mockCommitB) } + ) + } + } + + @Test + fun `create branch, commit history of 3, get commits, should succeed`() { + var head: Commit + var mockCommitB: Commit + var mockCommitC: Commit + with(MockTestDataProvider(this@BranchModelTest.repository)) { + head = this.commitBySha.getValue("a".repeat(40)) + mockCommitB = this.commitBySha.getValue("b".repeat(40)) + mockCommitC = this.commitBySha.getValue("c".repeat(40)) + + head.parents.add(mockCommitB) + mockCommitB.parents.add(mockCommitC) + } + + val branch = branch( + repository = this@BranchModelTest.repository, + head = head + ) + + with(branch.commits) { + assertAll( + { assertThat(this).hasSize(3) }, + { assertThat(this).containsOnly(head, mockCommitB, mockCommitC) }, + { assertThat(this.first()).isSameAs(head) }, + { assertThat(this.last()).isSameAs(mockCommitC) } + ) + } + } + + } + + @Nested + inner class FileRelation { + @BeforeEach + fun setup() { + this@BranchModelTest.setup() + } + + @Test + fun `create branch, check that file relation is empty`() { + val mockCommit = MockTestDataProvider(this@BranchModelTest.repository).commitBySha.getValue("a".repeat(40)) + val branch = branch( + repository = repository, + head = mockCommit + ) + + assertThat(branch.files).isEmpty() + } + } + + private fun branch( + name: String = "branch", + fullName: String = name, + category: ReferenceCategory = ReferenceCategory.LOCAL_BRANCH, + repository: Repository = this.repository, + head: Commit = this.head + ): Branch = + Branch( + name = name, + fullName = fullName, + category = category, + repository = repository, + head = head + ) +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/CommitModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/CommitModelTest.kt new file mode 100644 index 000000000..f2a4ffd15 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/CommitModelTest.kt @@ -0,0 +1,551 @@ +package com.inso_world.binocular.model + +import com.inso_world.binocular.domain.data.MockTestDataProvider +import com.inso_world.binocular.model.utils.ReflectionUtils.Companion.setField +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.time.LocalDateTime + +class CommitModelTest { + private lateinit var repository: Repository + private lateinit var mockTestDataProvider: MockTestDataProvider + + @BeforeEach + fun setUp() { + val project = Project(name = "test-project") + repository = Repository( + localPath = "test", + project = project, + ) + mockTestDataProvider = MockTestDataProvider(repository) + } + + private fun createDeveloper(name: String = "Test Developer", email: String = "dev@test.com") = + Developer(name = name, email = email, repository = repository) + + private fun createSignature(developer: Developer, timestamp: LocalDateTime = LocalDateTime.now().minusSeconds(1)) = + Signature(developer = developer, timestamp = timestamp) + + @Test + fun `create commit, check that iid is created automatically`() { + val developer = createDeveloper() + val signature = createSignature(developer) + val commit = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + + assertThat(commit.iid).isNotNull() + } + + @Test + fun `create commit, check that hashCode is based on iid`() { + val developer = createDeveloper() + val signature = createSignature(developer) + val commit = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + + assertThat(commit.hashCode()).isEqualTo(commit.iid.hashCode()) + } + + @Test + fun `create commit, validate uniqueKey`() { + val developer = createDeveloper() + val signature = createSignature(developer) + val commit = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + + assertAll( + { assertThat(commit.uniqueKey).isEqualTo(Commit.Key("a".repeat(40))) }, + { assertThat(commit.uniqueKey.sha).isSameAs(commit.sha) } + ) + } + + @Test + fun `create commit, validate repository relation`() { + val repository = Repository( + localPath = "test-2", + project = Project(name = "test-2"), + ) + val developer = Developer(name = "Test", email = "test@example.com", repository = repository) + val signature = Signature(developer = developer, timestamp = LocalDateTime.now().minusSeconds(1)) + val commit = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + + assertThat(commit.repository).isSameAs(repository) + assertAll( + { assertThat(repository.commits).hasSize(1) }, + { assertThat(repository.commits).containsOnly(commit) }, + { assertThat(repository.commits.first()).isSameAs(commit) }) + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideInvalidPastOrPresentDateTime") + fun `create commit, invalid timestamp in signature`( + timestamp: LocalDateTime, + ) { + val developer = createDeveloper() + assertThrows { + Signature(developer = developer, timestamp = timestamp) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideAllowedPastOrPresentDateTime") + fun `create commit, valid timestamp in signature`( + timestamp: LocalDateTime, + ) { + val developer = createDeveloper() + assertDoesNotThrow { + val signature = Signature(developer = developer, timestamp = timestamp) + Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + } + } + + @Test + fun `create two commits, same sha, should not be equal`() { + val developer = createDeveloper() + val signature = createSignature(developer) + val commitA = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + val commitB = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + + assertAll( + { assertThat(commitA.iid).isNotEqualTo(commitB.iid) }, + { assertThat(commitA.uniqueKey).isEqualTo(commitB.uniqueKey) }, + { assertThat(commitA).isNotEqualTo(commitB) }) + } + + @Test + fun `create commit, then copy, should not be equal`() { + val developer = createDeveloper() + val signature = createSignature(developer) + val commitA = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + val commitB = commitA.copy() + + assertAll( + { assertThat(commitA.iid).isNotEqualTo(commitB.iid) }, + { assertThat(commitA.uniqueKey).isEqualTo(commitB.uniqueKey) }, + { assertThat(commitA).isNotEqualTo(commitB) }) + } + + @Test + fun `create commit, then copy, edit iid, should equal`() { + val developer = createDeveloper() + val signature = createSignature(developer) + val commitA = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + val commitB = commitA.copy() + setField( + commitB.javaClass.superclass.getDeclaredField("iid"), commitB, commitA.iid + ) + + assertThat(commitA.iid).isEqualTo(commitB.iid) + + assertAll( + { assertThat(commitA.uniqueKey).isEqualTo(commitB.uniqueKey) }, + { assertThat(commitA).isEqualTo(commitB) }) + } + + @Nested + inner class AuthorAndCommitterValidation { + @BeforeEach + fun setUp() { + this@CommitModelTest.setUp() + } + + @Test + fun `create commit with authorSignature only, author and committer should be same`() { + val developer = createDeveloper() + val signature = createSignature(developer) + + val commit = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + + assertAll( + { assertThat(commit.author).isSameAs(developer) }, + { assertThat(commit.committer).isSameAs(developer) }, + { assertThat(commit.author).isSameAs(commit.committer) }, + { assertThat(developer.authoredCommits).contains(commit) }, + { assertThat(developer.committedCommits).contains(commit) } + ) + } + + @Test + fun `create commit with separate committerSignature, should have different author and committer`() { + val author = createDeveloper(name = "Author", email = "author@test.com") + val committer = createDeveloper(name = "Committer", email = "committer@test.com") + val authorSig = createSignature(author) + val committerSig = createSignature(committer) + + val commit = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = authorSig, + committerSignature = committerSig, + repository = repository, + ) + + assertAll( + { assertThat(commit.author).isSameAs(author) }, + { assertThat(commit.committer).isSameAs(committer) }, + { assertThat(commit.author).isNotSameAs(commit.committer) }, + { assertThat(author.authoredCommits).contains(commit) }, + { assertThat(committer.committedCommits).contains(commit) } + ) + } + + @Test + fun `create commit with author from different repository, should fail`() { + val differentRepository = Repository( + localPath = "test-2", + project = Project(name = "test-2"), + ) + val developer = Developer(name = "Test", email = "test@example.com", repository = differentRepository) + val signature = Signature(developer = developer, timestamp = LocalDateTime.now().minusSeconds(1)) + + assertThrows { + Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = signature, + repository = repository, + ) + } + } + + @Test + fun `create commit with committer from different repository, should fail`() { + val differentRepository = Repository( + localPath = "test-2", + project = Project(name = "test-2"), + ) + val author = createDeveloper() + val committer = Developer(name = "Committer", email = "committer@example.com", repository = differentRepository) + val authorSig = createSignature(author) + val committerSig = Signature(developer = committer, timestamp = LocalDateTime.now().minusSeconds(1)) + + assertThrows { + Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = authorSig, + committerSignature = committerSig, + repository = repository, + ) + } + } + + @Test + fun `commit timestamps come from signatures`() { + val author = createDeveloper(name = "Author", email = "author@test.com") + val committer = createDeveloper(name = "Committer", email = "committer@test.com") + val authorTime = LocalDateTime.of(2024, 1, 1, 10, 0) + val committerTime = LocalDateTime.of(2024, 1, 1, 11, 0) + val authorSig = Signature(developer = author, timestamp = authorTime) + val committerSig = Signature(developer = committer, timestamp = committerTime) + + val commit = Commit( + sha = "a".repeat(40), + message = "msg1", + authorSignature = authorSig, + committerSignature = committerSig, + repository = repository, + ) + + assertAll( + { assertThat(commit.authorDateTime).isEqualTo(authorTime) }, + { assertThat(commit.commitDateTime).isEqualTo(committerTime) } + ) + } + } + + @Nested + inner class ParentsRelation { + @BeforeEach + fun setUp() { + this@CommitModelTest.setUp() + } + + @Test + fun `create commit, add parent, should succeed`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val parent = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + assertTrue(commit.parents.add(parent)) + + assertAll( + "parent relation", + { assertThat(commit.parents).hasSize(1) }, + { assertThat(commit.parents).containsOnly(parent) }, + ) + assertAll( + "child relation", + { assertThat(parent.children).hasSize(1) }, + { assertThat(parent.children).containsOnly(commit) }, + ) + } + + @Test + fun `create commit, addAll single parent, should succeed`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val parent = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + assertTrue(commit.parents.addAll(listOf(parent))) + + assertAll( + "commit->parent relation", + { assertThat(commit.parents).hasSize(1) }, + { assertThat(commit.parents).containsOnly(parent) }, + ) + assertAll( + "parent->commit relation", + { assertThat(parent.children).hasSize(1) }, + { assertThat(parent.children).containsOnly(commit) }, + ) + } + + @Test + fun `create commit, add same parent twice, should only be added once`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val parent = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + assertTrue(commit.parents.add(parent)) + assertFalse(commit.parents.add(parent)) + } + + @Test + fun `create commit, addAll same parent twice, should only be added once`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val parent = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + assertTrue(commit.parents.addAll(listOf(parent))) + assertFalse(commit.parents.addAll(listOf(parent))) + } + + @Test + fun `create commit, add parent with different repository, should fail`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val parent = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + val differentRepository = Repository( + localPath = "test-2", + project = Project(name = "test-2"), + ) + setField( + parent.javaClass.getDeclaredField("repository"), parent, differentRepository + ) + + assertThrows { + commit.parents.add(parent) + } + } + + @Test + fun `create commit, add same commit to parents, should fail`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + + val ex = assertThrows { + commit.parents.add(commit) + } + + assertThat(ex.message).isEqualTo("Commit cannot be its own parent") + } + + @Test + fun `create commit, add same commit to children, should fail`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + + val ex = assertThrows { + commit.children.add(commit) + } + + assertThat(ex.message).isEqualTo("Commit cannot be its own child") + } + + @Test + fun `create commit, add other commit to parents and children, should fail`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val parent = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + assertDoesNotThrow { + commit.parents.add(parent) + } + val ex = assertThrows { + commit.children.add(parent) + } + + assertThat(ex.message).isEqualTo( + "${parent.sha} is already present in '${commit.sha}' parent collection. Cannot be added as child too." + ) + } + } + + @Nested + inner class ChildrenRelation { + @BeforeEach + fun setUp() { + this@CommitModelTest.setUp() + } + + @Test + fun `create commit, add child, should succeed`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val child = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + assertTrue(commit.children.add(child)) + + assertAll( + "child relation", + { assertThat(commit.children).hasSize(1) }, + { assertThat(commit.children).containsOnly(child) }, + ) + assertAll( + "parent relation", + { assertThat(child.parents).hasSize(1) }, + { assertThat(child.parents).containsOnly(commit) }, + ) + } + + @Test + fun `create commit, addAll single child, should succeed`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val child = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + assertTrue(commit.children.addAll(listOf(child))) + + assertAll( + "child relation", + { assertThat(commit.children).hasSize(1) }, + { assertThat(commit.children).containsOnly(child) }, + ) + assertAll( + "parent relation", + { assertThat(child.parents).hasSize(1) }, + { assertThat(child.parents).containsOnly(commit) }, + ) + } + + @Test + fun `create commit, add same children twice, should only be added once`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val child = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + assertTrue(commit.children.add(child)) + assertFalse(commit.children.add(child)) + } + + @Test + fun `create commit, add child with different repository, should fail`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val child = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + val differentRepository = Repository( + localPath = "test-2", + project = Project(name = "test-2"), + ) + setField( + child.javaClass.getDeclaredField("repository"), child, differentRepository + ) + + assertThrows { + commit.children.add(child) + } + } + } + + @Nested + inner class BranchRelation { + @BeforeEach + fun setUp() { + this@CommitModelTest.setUp() + } + + @Test + fun `create commit, add to branch, should succeed`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = mockTestDataProvider.branchByName.getValue("origin/feature/test") + + branch.head = commit + + assertAll( + "check branch relation", + { assertThat(branch.commits).hasSize(1) }, + { assertThat(branch.commits).containsOnly(commit) }, + { assertThat(branch.commits.first()).isSameAs(commit) }, + ) + } + + @Test + fun `create commit, add to branch from different repository, should fail`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = mockTestDataProvider.branchByName.getValue("origin/feature/test") + + val differentRepository = Repository( + localPath = "test-2", + project = Project(name = "test-2"), + ) + setField( + commit.javaClass.getDeclaredField("repository"), + commit, + differentRepository, + ) + + assertAll({ assertThat(commit.repository).isNotSameAs(branch.repository) }, { + assertThrows { + branch.head = commit + } + }) + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/CommitSignatureModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/CommitSignatureModelTest.kt new file mode 100644 index 000000000..f4af249b6 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/CommitSignatureModelTest.kt @@ -0,0 +1,321 @@ +package com.inso_world.binocular.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows +import java.time.LocalDateTime +import kotlin.uuid.ExperimentalUuidApi + +/** + * BDD tests for Commit model with new Signature-based author/committer semantics. + * + * Key changes from previous model: + * - author is now required (via authorSignature) + * - committer is optional (via committerSignature), defaults to author if not provided + * - Both use Signature value objects containing Developer + timestamp + */ +@OptIn(ExperimentalUuidApi::class) +class CommitSignatureModelTest { + + private lateinit var repository: Repository + private lateinit var author: Developer + private lateinit var committer: Developer + + @BeforeEach + fun setUp() { + val project = Project(name = "test-project") + repository = Repository(localPath = "test-repo", project = project) + author = Developer(name = "Author Name", email = "author@example.com", repository = repository) + committer = Developer(name = "Committer Name", email = "committer@example.com", repository = repository) + } + + @Nested + inner class AuthorSignature { + + @Test + fun `given valid authorSignature, when creating commit, then author should be set`() { + // Given + val authorTimestamp = LocalDateTime.now().minusSeconds(10) + val authorSignature = Signature(developer = author, timestamp = authorTimestamp) + + // When + val commit = Commit( + sha = "a".repeat(40), + authorSignature = authorSignature, + repository = repository + ) + + // Then + assertAll( + { assertThat(commit.authorSignature).isEqualTo(authorSignature) }, + { assertThat(commit.authorSignature.developer).isSameAs(author) }, + { assertThat(commit.authorSignature.timestamp).isEqualTo(authorTimestamp) } + ) + } + + @Test + fun `given commit with authorSignature, when author is accessed via convenience property, then it should return the developer`() { + // Given + val authorSignature = Signature(developer = author, timestamp = LocalDateTime.now().minusSeconds(1)) + val commit = Commit( + sha = "b".repeat(40), + authorSignature = authorSignature, + repository = repository + ) + + // When + val commitAuthor = commit.author + + // Then + assertThat(commitAuthor).isSameAs(author) + } + + @Test + fun `given commit creation, when checking author's authoredCommits, then commit should be added`() { + // Given + val authorSignature = Signature(developer = author, timestamp = LocalDateTime.now().minusSeconds(1)) + + // When + val commit = Commit( + sha = "c".repeat(40), + authorSignature = authorSignature, + repository = repository + ) + + // Then + assertThat(author.authoredCommits).contains(commit) + } + } + + @Nested + inner class CommitterSignature { + + @Test + fun `given no committerSignature, when creating commit, then committer should default to author`() { + // Given + val authorSignature = Signature(developer = author, timestamp = LocalDateTime.now().minusSeconds(1)) + + // When + val commit = Commit( + sha = "d".repeat(40), + authorSignature = authorSignature, + repository = repository + // committerSignature not provided + ) + + // Then + assertAll( + { assertThat(commit.committerSignature).isEqualTo(authorSignature) }, + { assertThat(commit.committer).isSameAs(author) } + ) + } + + @Test + fun `given explicit committerSignature, when creating commit, then committer should be different from author`() { + // Given + val authorTimestamp = LocalDateTime.now().minusSeconds(10) + val committerTimestamp = LocalDateTime.now().minusSeconds(5) + val authorSignature = Signature(developer = author, timestamp = authorTimestamp) + val committerSignature = Signature(developer = committer, timestamp = committerTimestamp) + + // When + val commit = Commit( + sha = "e".repeat(40), + authorSignature = authorSignature, + committerSignature = committerSignature, + repository = repository + ) + + // Then + assertAll( + { assertThat(commit.authorSignature).isEqualTo(authorSignature) }, + { assertThat(commit.committerSignature).isEqualTo(committerSignature) }, + { assertThat(commit.author).isSameAs(author) }, + { assertThat(commit.committer).isSameAs(committer) }, + { assertThat(commit.author).isNotSameAs(commit.committer) } + ) + } + + @Test + fun `given commit with explicit committerSignature, when checking committer's committedCommits, then commit should be added`() { + // Given + val authorSignature = Signature(developer = author, timestamp = LocalDateTime.now().minusSeconds(10)) + val committerSignature = Signature(developer = committer, timestamp = LocalDateTime.now().minusSeconds(5)) + + // When + val commit = Commit( + sha = "f".repeat(40), + authorSignature = authorSignature, + committerSignature = committerSignature, + repository = repository + ) + + // Then + assertThat(committer.committedCommits).contains(commit) + } + + @Test + fun `given same person as author and committer, when creating commit, then both should reference same developer`() { + // Given + val authorTimestamp = LocalDateTime.now().minusSeconds(10) + val committerTimestamp = LocalDateTime.now().minusSeconds(5) + val authorSignature = Signature(developer = author, timestamp = authorTimestamp) + val committerSignature = Signature(developer = author, timestamp = committerTimestamp) // same developer + + // When + val commit = Commit( + sha = "1".repeat(40), + authorSignature = authorSignature, + committerSignature = committerSignature, + repository = repository + ) + + // Then + assertAll( + { assertThat(commit.author).isSameAs(commit.committer) }, + { assertThat(commit.authorSignature.timestamp).isNotEqualTo(commit.committerSignature!!.timestamp) } + ) + } + } + + @Nested + inner class RepositoryConsistency { + + @Test + fun `given authorSignature with developer from different repository, when creating commit, then it should throw`() { + // Given + val otherProject = Project(name = "other-project") + val otherRepository = Repository(localPath = "other-repo", project = otherProject) + val otherDeveloper = Developer(name = "Other", email = "other@example.com", repository = otherRepository) + val authorSignature = Signature(developer = otherDeveloper, timestamp = LocalDateTime.now().minusSeconds(1)) + + // When & Then + assertThrows { + Commit( + sha = "2".repeat(40), + authorSignature = authorSignature, + repository = repository + ) + } + } + + @Test + fun `given committerSignature with developer from different repository, when creating commit, then it should throw`() { + // Given + val otherProject = Project(name = "other-project") + val otherRepository = Repository(localPath = "other-repo", project = otherProject) + val otherDeveloper = Developer(name = "Other", email = "other@example.com", repository = otherRepository) + val authorSignature = Signature(developer = author, timestamp = LocalDateTime.now().minusSeconds(10)) + val committerSignature = Signature(developer = otherDeveloper, timestamp = LocalDateTime.now().minusSeconds(5)) + + // When & Then + assertThrows { + Commit( + sha = "3".repeat(40), + authorSignature = authorSignature, + committerSignature = committerSignature, + repository = repository + ) + } + } + } + + @Nested + inner class CommitDateTime { + + @Test + fun `given commit without explicit commitDateTime, when accessing commitDateTime, then it should use committerSignature timestamp`() { + // Given + val authorTimestamp = LocalDateTime.now().minusSeconds(10) + val committerTimestamp = LocalDateTime.now().minusSeconds(5) + val authorSignature = Signature(developer = author, timestamp = authorTimestamp) + val committerSignature = Signature(developer = committer, timestamp = committerTimestamp) + + // When + val commit = Commit( + sha = "4".repeat(40), + authorSignature = authorSignature, + committerSignature = committerSignature, + repository = repository + ) + + // Then + assertThat(commit.commitDateTime).isEqualTo(committerTimestamp) + } + + @Test + fun `given commit without committerSignature, when accessing commitDateTime, then it should use authorSignature timestamp`() { + // Given + val authorTimestamp = LocalDateTime.now().minusSeconds(5) + val authorSignature = Signature(developer = author, timestamp = authorTimestamp) + + // When + val commit = Commit( + sha = "5".repeat(40), + authorSignature = authorSignature, + repository = repository + ) + + // Then + assertThat(commit.commitDateTime).isEqualTo(authorTimestamp) + } + } + + @Nested + inner class AuthorDateTime { + + @Test + fun `given commit, when accessing authorDateTime, then it should return authorSignature timestamp`() { + // Given + val authorTimestamp = LocalDateTime.now().minusSeconds(10) + val authorSignature = Signature(developer = author, timestamp = authorTimestamp) + + // When + val commit = Commit( + sha = "6".repeat(40), + authorSignature = authorSignature, + repository = repository + ) + + // Then + assertThat(commit.authorDateTime).isEqualTo(authorTimestamp) + } + } + + @Nested + inner class BackwardsCompatibility { + + @Test + fun `given commit, when accessing deprecated author property, then it should return authorSignature developer`() { + // Given + val authorSignature = Signature(developer = author, timestamp = LocalDateTime.now().minusSeconds(1)) + val commit = Commit( + sha = "7".repeat(40), + authorSignature = authorSignature, + repository = repository + ) + + // When & Then + assertThat(commit.author).isSameAs(author) + } + + @Test + fun `given commit, when accessing deprecated committer property, then it should return committerSignature developer`() { + // Given + val authorSignature = Signature(developer = author, timestamp = LocalDateTime.now().minusSeconds(10)) + val committerSignature = Signature(developer = committer, timestamp = LocalDateTime.now().minusSeconds(5)) + val commit = Commit( + sha = "8".repeat(40), + authorSignature = authorSignature, + committerSignature = committerSignature, + repository = repository + ) + + // When & Then + assertThat(commit.committer).isSameAs(committer) + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/DeveloperModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/DeveloperModelTest.kt new file mode 100644 index 000000000..dbdc0d777 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/DeveloperModelTest.kt @@ -0,0 +1,220 @@ +package com.inso_world.binocular.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import kotlin.uuid.ExperimentalUuidApi + +/** + * BDD tests for Developer domain model. + * Developer represents a git user scoped to a Repository, extending Stakeholder. + */ +@OptIn(ExperimentalUuidApi::class) +class DeveloperModelTest { + + private lateinit var repository: Repository + + @BeforeEach + fun setUp() { + val project = Project(name = "test-project") + repository = Repository(localPath = "test-repo", project = project) + } + + @Nested + inner class Construction { + + @Test + fun `given valid name and email, when creating developer, then it should be created with iid`() { + // Given + val name = "John Doe" + val email = "john@example.com" + + // When + val developer = Developer(name = name, email = email, repository = repository) + + // Then + assertAll( + { assertThat(developer.name).isEqualTo(name) }, + { assertThat(developer.email).isEqualTo(email) }, + { assertThat(developer.repository).isSameAs(repository) }, + { assertThat(developer.iid).isNotNull() } + ) + } + + @Test + fun `given developer creation, when checking repository link, then developer should be in repository developers`() { + // Given & When + val developer = Developer(name = "Jane", email = "jane@example.com", repository = repository) + + // Then + assertThat(repository.developers).contains(developer) + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `given blank name, when creating developer, then it should throw IllegalArgumentException`(name: String) { + // When & Then + assertThrows { + Developer(name = name, email = "test@example.com", repository = repository) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `given blank email, when creating developer, then it should throw IllegalArgumentException`(email: String) { + // When & Then + assertThrows { + Developer(name = "Test", email = email, repository = repository) + } + } + } + + @Nested + inner class UniqueKey { + + @Test + fun `given developer, when accessing uniqueKey, then it should contain repositoryId and gitSignature`() { + // Given + val developer = Developer(name = "Test User", email = "test@example.com", repository = repository) + + // When + val key = developer.uniqueKey + + // Then + assertAll( + { assertThat(key.repositoryId).isEqualTo(repository.iid) }, + { assertThat(key.gitSignature).isEqualTo("Test User ") } + ) + } + } + + @Nested + inner class GitSignature { + + @Test + fun `given developer with name and email, when getting gitSignature, then it should return formatted signature`() { + // Given + val developer = Developer(name = "John Doe", email = "john@example.com", repository = repository) + + // When + val signature = developer.gitSignature + + // Then + assertThat(signature).isEqualTo("John Doe ") + } + + @Test + fun `given developer with whitespace in name, when getting gitSignature, then it should trim the name`() { + // Given + val developer = Developer(name = " John Doe ", email = "john@example.com", repository = repository) + + // When + val signature = developer.gitSignature + + // Then + assertThat(signature).isEqualTo("John Doe ") + } + } + + @Nested + inner class Equality { + + @Test + fun `given same developer instance, when comparing with equals, then it should be equal`() { + // Given + val developer = Developer(name = "Test", email = "test@example.com", repository = repository) + + // Then + assertThat(developer).isEqualTo(developer) + } + + @Test + fun `given two different developers, when comparing, then they should not be equal`() { + // Given + val developer1 = Developer(name = "Test1", email = "test1@example.com", repository = repository) + val developer2 = Developer(name = "Test2", email = "test2@example.com", repository = repository) + + // Then + assertThat(developer1).isNotEqualTo(developer2) + } + + @Test + fun `given developer, when getting hashCode, then it should be based on iid`() { + // Given + val developer = Developer(name = "Test", email = "test@example.com", repository = repository) + + // Then + assertThat(developer.hashCode()).isEqualTo(developer.iid.hashCode()) + } + } + + @Nested + inner class InheritanceFromStakeholder { + + @Test + fun `given developer, when checking inheritance, then it should be instance of Stakeholder`() { + // Given + val developer = Developer(name = "Test", email = "test@example.com", repository = repository) + + // Then + assertThat(developer).isInstanceOf(Stakeholder::class.java) + } + } + + @Nested + inner class CommitRelations { + + @Nested + inner class AuthoredCommits { + + @Test + fun `given new developer, when checking authoredCommits, then it should be empty`() { + // Given + val developer = Developer(name = "Test", email = "test@example.com", repository = repository) + + // Then + assertThat(developer.authoredCommits).isEmpty() + } + } + + @Nested + inner class CommittedCommits { + + @Test + fun `given new developer, when checking committedCommits, then it should be empty`() { + // Given + val developer = Developer(name = "Test", email = "test@example.com", repository = repository) + + // Then + assertThat(developer.committedCommits).isEmpty() + } + } + } + + @Nested + inner class FileAndIssueRelations { + + @Test + fun `given new developer, when checking files, then it should be empty`() { + // Given + val developer = Developer(name = "Test", email = "test@example.com", repository = repository) + + // Then + assertThat(developer.files).isEmpty() + } + + @Test + fun `given new developer, when checking issues, then it should be empty`() { + // Given + val developer = Developer(name = "Test", email = "test@example.com", repository = repository) + + // Then + assertThat(developer.issues).isEmpty() + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/NonRemovingMutableSetTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/NonRemovingMutableSetTest.kt new file mode 100644 index 000000000..3a770feb3 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/NonRemovingMutableSetTest.kt @@ -0,0 +1,674 @@ +package com.inso_world.binocular.model + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Comprehensive unit tests for [NonRemovingMutableSet]. + * + * Tests cover: + * - Add operations and canonical instance preservation + * - Contains operations with uniqueKey-based membership + * - Size and empty state operations + * - Removal operations (all should fail) + * - Iterator operations and removal restrictions + * - Concurrent operations and thread safety + * - String representation and set equality semantics + * - Edge cases and boundary conditions + */ +@Tag("unit") +class NonRemovingMutableSetTest { + + /** + * Simple test entity for testing [NonRemovingMutableSet]. + * Uses `name` as the uniqueKey for deduplication. + */ + @OptIn(ExperimentalUuidApi::class) + private data class TestEntity( + val id: String?, + val name: String, + val data: String = "test" + ) : AbstractDomainObject(Uuid.random()) { + override val uniqueKey: String get() = name + } + + @Nested + @DisplayName("Add operations") + inner class AddOperations { + + @Test + fun `add should return true for new element`() { + val set = NonRemovingMutableSet() + val entity = TestEntity(null, "A") + + assertThat(set.add(entity)).isTrue() + } + + @Test + fun `add should return false for duplicate uniqueKey`() { + val set = NonRemovingMutableSet() + val entity1 = TestEntity(null, "A", "first") + val entity2 = TestEntity(null, "A", "second") + + set.add(entity1) + + assertThat(set.add(entity2)).isFalse() + } + + @Test + fun `add should preserve canonical instance when duplicate key is added`() { + val set = NonRemovingMutableSet() + val entity1 = TestEntity(null, "A", "first") + val entity2 = TestEntity(null, "A", "second") + + set.add(entity1) + set.add(entity2) + + val stored = set.first { it.name == "A" } + assertThat(stored.data).isEqualTo("first") + assertThat(stored).isSameAs(entity1) + assertThat(stored).isNotSameAs(entity2) + } + + @Test + fun `add should handle multiple distinct elements`() { + val set = NonRemovingMutableSet() + val entities = listOf( + TestEntity(null, "A"), + TestEntity(null, "B"), + TestEntity(null, "C") + ) + + val results = entities.map { set.add(it) } + + assertThat(results).allMatch { it } + assertThat(set).hasSize(3) + } + + @Test + fun `add should handle adding same instance multiple times`() { + val set = NonRemovingMutableSet() + val entity = TestEntity(null, "A") + + val firstAdd = set.add(entity) + val secondAdd = set.add(entity) + + assertThat(firstAdd).isTrue() + assertThat(secondAdd).isFalse() + assertThat(set).hasSize(1) + } + + @Test + fun `add should handle elements with different data but same uniqueKey`() { + val set = NonRemovingMutableSet() + + repeat(10) { i -> + set.add(TestEntity(null, "shared-key", "data-$i")) + } + + assertThat(set).hasSize(1) + assertThat(set.first().data).isEqualTo("data-0") // First one wins + } + } + + @Nested + @DisplayName("Contains operations") + inner class ContainsOperations { + + @Test + fun `contains should return true for added element`() { + val set = NonRemovingMutableSet() + val entity = TestEntity(null, "A") + + set.add(entity) + + assertThat(set.contains(entity)).isTrue() + } + + @Test + fun `contains should return true for different instance with same uniqueKey`() { + val set = NonRemovingMutableSet() + val entity1 = TestEntity(null, "A", "first") + val entity2 = TestEntity(null, "A", "second") + + set.add(entity1) + + assertThat(set.contains(entity2)).isTrue() + } + + @Test + fun `contains should return false for non-existent element`() { + val set = NonRemovingMutableSet() + val entity = TestEntity(null, "A") + + assertThat(set.contains(entity)).isFalse() + } + + @Test + fun `containsAll should return true when all elements present`() { + val set = NonRemovingMutableSet() + val entities = listOf( + TestEntity(null, "A"), + TestEntity(null, "B"), + TestEntity(null, "C") + ) + entities.forEach { set.add(it) } + + assertThat(set.containsAll(entities)).isTrue() + } + + @Test + fun `containsAll should return false when some elements missing`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + + val entities = listOf( + TestEntity(null, "A"), + TestEntity(null, "B") + ) + + assertThat(set.containsAll(entities)).isFalse() + } + + @Test + fun `containsAll should work with different instances having same uniqueKeys`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A", "original-A")) + set.add(TestEntity(null, "B", "original-B")) + + val probes = listOf( + TestEntity(null, "A", "probe-A"), + TestEntity(null, "B", "probe-B") + ) + + assertThat(set.containsAll(probes)).isTrue() + } + + @Test + fun `containsAll should return true for empty collection`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + + assertThat(set.containsAll(emptyList())).isTrue() + } + } + + @Nested + @DisplayName("Size and empty operations") + inner class SizeOperations { + + @Test + fun `isEmpty should return true for new set`() { + val set = NonRemovingMutableSet() + + assertThat(set.isEmpty()).isTrue() + } + + @Test + fun `isEmpty should return false after adding elements`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + + assertThat(set.isEmpty()).isFalse() + } + + @Test + fun `size should return 0 for empty set`() { + val set = NonRemovingMutableSet() + + assertThat(set.size).isEqualTo(0) + } + + @ParameterizedTest + @ValueSource(ints = [1, 5, 10, 50, 100]) + fun `size should return correct count after adding elements`(count: Int) { + val set = NonRemovingMutableSet() + repeat(count) { set.add(TestEntity(null, "entity-$it")) } + + assertThat(set.size).isEqualTo(count) + } + + @Test + fun `size should not increase when adding duplicate keys`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A", "first")) + set.add(TestEntity(null, "A", "second")) + set.add(TestEntity(null, "A", "third")) + + assertThat(set.size).isEqualTo(1) + } + + @Test + fun `size should handle mix of unique and duplicate keys`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A", "v1")) + set.add(TestEntity(null, "B", "v1")) + set.add(TestEntity(null, "A", "v2")) // Duplicate + set.add(TestEntity(null, "C", "v1")) + set.add(TestEntity(null, "B", "v2")) // Duplicate + + assertThat(set.size).isEqualTo(3) // Only A, B, C + } + } + + @Nested + @DisplayName("Removal operations (should all fail)") + inner class RemovalOperations { + + @Test + fun `remove should throw UnsupportedOperationException`() { + val set = NonRemovingMutableSet() + val entity = TestEntity(null, "A") + set.add(entity) + + val ex = assertThrows { set.remove(entity) } + assertThat(ex.message).isEqualTo("Removing objects is not allowed.") + } + + @Test + fun `removeAll should throw UnsupportedOperationException`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + + val ex = assertThrows { set.removeAll(listOf(TestEntity(null, "A"))) } + assertThat(ex.message).isEqualTo("Removing objects is not allowed.") + } + + @Test + fun `retainAll should throw UnsupportedOperationException`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + + val ex = assertThrows { set.retainAll(listOf(TestEntity(null, "A"))) } + assertThat(ex.message).isEqualTo("Removing objects is not allowed.") + } + + @Test + fun `clear should throw UnsupportedOperationException`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + + val ex = assertThrows { set.clear() } + assertThat(ex.message).isEqualTo("Removing objects is not allowed.") + } + + @Test + fun `removal operations should not modify set`() { + val set = NonRemovingMutableSet() + val entity = TestEntity(null, "A") + set.add(entity) + + // Attempt removal (will throw) + assertAll( + { assertThrows { set.remove(entity) } }, + { assertThrows { set.clear() } }, + { assertThrows { set.removeAll(listOf(entity)) } }, + { assertThrows { set.retainAll(emptyList()) } }, + ) + + // Verify set unchanged + assertAll( + { assertThat(set.size).isEqualTo(1) }, + { assertThat(set.contains(entity)).isTrue() } + ) + } + + @Test + fun `clear should throw even on empty set`() { + val set = NonRemovingMutableSet() + + val ex = assertThrows { set.clear() } + assertThat(ex.message).isEqualTo("Removing objects is not allowed.") + } + } + + @Nested + @DisplayName("Iterator operations") + inner class IteratorOperations { + + @Test + fun `iterator should iterate over all elements`() { + val set = NonRemovingMutableSet() + val entities = listOf( + TestEntity(null, "A"), + TestEntity(null, "B"), + TestEntity(null, "C") + ) + entities.forEach { set.add(it) } + + val iterated = set.iterator().asSequence().toList() + + assertThat(iterated).hasSize(3) + assertThat(iterated).containsExactlyInAnyOrderElementsOf(entities) + } + + @Test + fun `iterator hasNext should return false for empty set`() { + val set = NonRemovingMutableSet() + + assertThat(set.iterator().hasNext()).isFalse() + } + + @Test + fun `iterator next should return elements`() { + val set = NonRemovingMutableSet() + val entity = TestEntity(null, "A") + set.add(entity) + + val iterator = set.iterator() + + assertThat(iterator.hasNext()).isTrue() + assertThat(iterator.next()).isEqualTo(entity) + assertThat(iterator.hasNext()).isFalse() + } + + @Test + fun `iterator remove should throw UnsupportedOperationException`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + val iterator = set.iterator() + iterator.next() + + assertThatThrownBy { iterator.remove() } + .isInstanceOf(UnsupportedOperationException::class.java) + .hasMessageContaining("Removing objects is not allowed") + } + + @Test + fun `iterator should support multiple concurrent iterations`() { + val set = NonRemovingMutableSet() + repeat(5) { set.add(TestEntity(null, "entity-$it")) } + + val iter1 = set.iterator() + val iter2 = set.iterator() + + assertThat(iter1.asSequence().toList()).hasSize(5) + assertThat(iter2.asSequence().toList()).hasSize(5) + } + + @Test + fun `iterator should iterate only over canonical instances`() { + val set = NonRemovingMutableSet() + val first = TestEntity(null, "A", "first") + val second = TestEntity(null, "A", "second") + + set.add(first) + set.add(second) // Not added due to duplicate key + + val iterated = set.iterator().asSequence().toList() + + assertThat(iterated).hasSize(1) + assertThat(iterated.first()).isSameAs(first) + } + + @Test + fun `iterator remove should throw even before calling next`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + val iterator = set.iterator() + + assertThatThrownBy { iterator.remove() } + .isInstanceOf(UnsupportedOperationException::class.java) + } + } + + @Nested + @DisplayName("Concurrent operations") + inner class ConcurrentOperations { + + @Test + fun `concurrent adds should be thread-safe`() { + val set = NonRemovingMutableSet() + val threadCount = 10 + val elementsPerThread = 100 + val latch = CountDownLatch(threadCount) + val executor = Executors.newFixedThreadPool(threadCount) + + repeat(threadCount) { threadId -> + executor.submit { + repeat(elementsPerThread) { i -> + set.add(TestEntity(null, "thread-$threadId-item-$i")) + } + latch.countDown() + } + } + + latch.await(10, TimeUnit.SECONDS) + executor.shutdown() + + assertThat(set.size).isEqualTo(threadCount * elementsPerThread) + } + + @Test + fun `concurrent adds with same key should preserve first canonical instance`() { + val set = NonRemovingMutableSet() + val threadCount = 100 + val latch = CountDownLatch(threadCount) + val executor = Executors.newFixedThreadPool(threadCount) + + repeat(threadCount) { threadId -> + executor.submit { + set.add(TestEntity(null, "shared-key", "thread-$threadId")) + latch.countDown() + } + } + + latch.await(10, TimeUnit.SECONDS) + executor.shutdown() + + assertThat(set.size).isEqualTo(1) + assertThat(set.any { it.uniqueKey == "shared-key" }).isTrue() + } + + @Test + fun `concurrent reads and writes should not cause errors`() { + val set = NonRemovingMutableSet() + val threadCount = 20 + val latch = CountDownLatch(threadCount) + val executor = Executors.newFixedThreadPool(threadCount) + + // Pre-populate + repeat(50) { set.add(TestEntity(null, "pre-$it")) } + + repeat(threadCount) { threadId -> + executor.submit { + // Mix reads and writes + if (threadId % 2 == 0) { + repeat(50) { set.add(TestEntity(null, "writer-$threadId-$it")) } + } else { + repeat(100) { + set.contains(TestEntity(null, "pre-${it % 50}")) + set.iterator().hasNext() + } + } + latch.countDown() + } + } + + latch.await(10, TimeUnit.SECONDS) + executor.shutdown() + + // Verify no corruption occurred + assertThat(set.size).isGreaterThanOrEqualTo(50) // At least pre-populated items + } + + @Test + fun `concurrent adds with partial key overlap should deduplicate correctly`() { + val set = NonRemovingMutableSet() + val pool = Executors.newFixedThreadPool(8) + val start = CountDownLatch(1) + val done = CountDownLatch(100) + + // Names with duplicates + val names = listOf("a", "b", "c", "d", "e") + repeat(100) { i -> + pool.submit { + try { + start.await() + val n = names[i % names.size] + set.add(TestEntity(null, n, "data-$i")) + } finally { + done.countDown() + } + } + } + + start.countDown() + done.await(10, TimeUnit.SECONDS) + pool.shutdown() + + assertThat(set.map { it.name }.toSet()) + .containsExactlyInAnyOrderElementsOf(names) + assertThat(set).hasSize(names.size) + } + } + + @Nested + @DisplayName("String representation and equality") + inner class StringAndEquality { + + @Test + fun `toString should return string representation of values`() { + val set = NonRemovingMutableSet() + val entity = TestEntity(null, "A", "test-data") + set.add(entity) + + val result = set.toString() + + assertThat(result).contains("A") + } + + @Test + fun `toString should return empty collection format for empty set`() { + val set = NonRemovingMutableSet() + + assertThat(set.toString()).isEqualTo("[]") + } + + @Test + fun `should maintain set equality semantics`() { + val set1 = NonRemovingMutableSet() + val set2 = NonRemovingMutableSet() + val entity = TestEntity(null, "A") + + set1.add(entity) + set2.add(entity) + + assertThat(set1).isEqualTo(set2) + } + + @Test + fun `sets with different elements should not be equal`() { + val set1 = NonRemovingMutableSet() + val set2 = NonRemovingMutableSet() + + set1.add(TestEntity(null, "A")) + set2.add(TestEntity(null, "B")) + + assertThat(set1).isNotEqualTo(set2) + } + + @Test + fun `empty sets should be equal`() { + val set1 = NonRemovingMutableSet() + val set2 = NonRemovingMutableSet() + + assertThat(set1).isEqualTo(set2) + } + } + + @Nested + @DisplayName("Edge cases and boundary conditions") + inner class EdgeCases { + + @Test + fun `should handle large number of elements`() { + val set = NonRemovingMutableSet() + val count = 10_000 + + repeat(count) { set.add(TestEntity(null, "entity-$it")) } + + assertThat(set.size).isEqualTo(count) + } + + @Test + fun `should handle elements with null id`() { + val set = NonRemovingMutableSet() + val entity = TestEntity(null, "A") + + set.add(entity) + + assertThat(set.contains(entity)).isTrue() + } + + @Test + fun `should handle elements with non-null id`() { + val set = NonRemovingMutableSet() + val entity = TestEntity("id-123", "A") + + set.add(entity) + + assertThat(set.contains(entity)).isTrue() + } + + @Test + fun `should work with for-each loop`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + set.add(TestEntity(null, "B")) + + val collected = mutableListOf() + for (entity in set) { + collected.add(entity.name) + } + + assertThat(collected).containsExactlyInAnyOrder("A", "B") + } + + @Test + fun `should work with collection operations`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + set.add(TestEntity(null, "B")) + + val filtered = set.filter { it.name == "A" } + val mapped = set.map { it.name } + + assertThat(filtered).hasSize(1) + assertThat(mapped).containsExactlyInAnyOrder("A", "B") + } + + @Test + fun `should handle add during iteration`() { + val set = NonRemovingMutableSet() + set.add(TestEntity(null, "A")) + set.add(TestEntity(null, "B")) + + // Iterator is weakly consistent - may or may not see new additions + val iterator = set.iterator() + set.add(TestEntity(null, "C")) + + // Should not throw exception + var count = 0 + while (iterator.hasNext()) { + iterator.next() + count++ + } + + assertThat(count).isGreaterThanOrEqualTo(2) + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/ProjectModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/ProjectModelTest.kt new file mode 100644 index 000000000..74f2a5278 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/ProjectModelTest.kt @@ -0,0 +1,111 @@ +package com.inso_world.binocular.model + +import com.inso_world.binocular.model.utils.ReflectionUtils.Companion.setField +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +class ProjectModelTest { + @Test + fun `create empty project, checks that iid is created automatically`() { + val project = Project(name = "test-project") + + assertThat(project.iid).isNotNull() + assertThat(project.repo).isNull() + } + + @Test + fun `create user, validate hashCode is same based on iid`() { + val project = Project(name = "test-project") + + assertThat(project.hashCode()).isEqualTo(project.iid.hashCode()) + } + + @Test + fun `create user, validate uniqueKey`() { + val project = Project(name = "test-project") + + assertAll( + { assertThat(project.uniqueKey).isEqualTo(Project.Key("test-project")) }, + { assertThat(project.uniqueKey.name).isSameAs(project.name) } + ) + } + + @Test + fun `create projects, check that equals uses iid only`() { + val projectA = Project(name = "test-project") + val projectB = Project(name = "test-project") // same name + + assertThat(projectA).isNotEqualTo(projectB) + } + + @Test + fun `create projects via copy, check that equals they are equal`() { + val projectA = Project(name = "test-project") + val originIid = projectA.iid + val projectB = projectA.copy() + setField( + projectB.javaClass.superclass.getDeclaredField("iid").apply { isAccessible = true }, + projectB, + originIid + ) + + assertThat(projectA).isNotSameAs(projectB) + assertThat(projectA.iid).isEqualTo(originIid) + assertThat(projectA.iid).isEqualTo(projectB.iid) + assertThat(projectA).isEqualTo(projectB) + } + + @Test + fun `create project with repository, should link correctly`() { + val project = Project(name = "test-project").apply { + this.repo = Repository( + localPath = "test", + project = this, + ) + } + + // check reference + assertThat(project.repo).isNotNull() + assertThat(requireNotNull(project.repo).project).isSameAs(project) + assertThat(requireNotNull(project.repo).project.repo).isSameAs(project.repo) + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `create project with blank name, should fail`( + name: String, + ) { + assertThrows { Project(name) } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideAllowedStrings") + fun `create project with allowed names, should pass`( + name: String, + ) { + assertDoesNotThrow { Project(name) } + } + + @Test + fun `create project with description`() { + val project = Project(name = "test-project").apply { + description = "test-description" + } + + assertThat(project.description).isEqualTo("test-description") + } + + @Test + fun `create project with explicit null repo`() { + assertThrows { + Project(name = "test-project").apply { + this.repo = null + } + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/RemoteModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/RemoteModelTest.kt new file mode 100644 index 000000000..eaabd9b72 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/RemoteModelTest.kt @@ -0,0 +1,737 @@ +package com.inso_world.binocular.model + +import com.inso_world.binocular.domain.data.MockTestDataProvider +import com.inso_world.binocular.model.utils.ReflectionUtils.Companion.setField +import com.inso_world.binocular.model.vcs.Remote +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import java.util.concurrent.ConcurrentHashMap +import kotlin.uuid.ExperimentalUuidApi + +/** + * Comprehensive test suite for the [Remote] domain model. + * + * Tests cover: + * - Construction and validation + * - Identity and equality semantics + * - Repository relationships + * - Business key uniqueness + * - Add-only collection semantics + * - Edge cases and error conditions + */ +class RemoteModelTest { + + private lateinit var mockTestDataProvider: MockTestDataProvider + private lateinit var repository: Repository + + @BeforeEach + fun setup() { + val project = Project(name = "proj-remote-model-test") + repository = Repository( + localPath = "repo-remote-model-test", + project = project, + ) + mockTestDataProvider = MockTestDataProvider(repository) + + // Clear remotes collection via reflection + val base = NonRemovingMutableSet::class.java + val field = repository.javaClass.getDeclaredField("remotes") + .apply { this.isAccessible = true } + val obj = field.get(repository) ?: return + + val backingField = base.getDeclaredField("backing").apply { isAccessible = true } + val backing = (backingField.get(obj) as ConcurrentHashMap<*, *>) + backing.clear() + } + + @Nested + inner class Construction { + @Test + fun `create remote with valid name and url, should succeed`() { + assertDoesNotThrow { + Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + } + } + + @Test + fun `create remote, check iid is set automatically`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertThat(remote.iid).isNotNull() + } + + @Test + fun `create remote, check it is automatically added to repository`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertAll( + { assertThat(repository.remotes).hasSize(1) }, + { assertThat(repository.remotes).contains(remote) }, + { assertThat(remote.repository).isSameAs(repository) } + ) + } + + @Test + fun `create remote, check id is null by default`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertThat(remote.id).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["origin", "upstream", "fork", "origin-backup", "my_remote", "remote.name", "remote/path"]) + fun `create remote with valid names, should succeed`(name: String) { + assertDoesNotThrow { + Remote( + name = name, + url = "https://github.com/user/repo.git", + repository = repository + ) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `create remote with blank name, should fail`(name: String) { + assertThrows { + Remote( + name = name, + url = "https://github.com/user/repo.git", + repository = repository + ) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `create remote with blank url, should fail`(url: String) { + assertThrows { + Remote( + name = "origin", + url = url, + repository = repository + ) + } + } + + @ParameterizedTest + @ValueSource( + strings = [ + "https://github.com/user/repo.git", + "git@github.com:user/repo.git", + "ssh://git@github.com/user/repo.git", + "git://github.com/user/repo.git", + "https://gitlab.com/group/subgroup/project.git", + "file:///path/to/repo.git", + "/absolute/path/to/repo", + "../relative/path/to/repo" + ] + ) + fun `create remote with various valid URLs, should succeed`(url: String) { + assertDoesNotThrow { + Remote( + name = "origin", + url = url, + repository = repository + ) + } + } + } + + @Nested + inner class IdentityAndEquality { + @Test + fun `create remote, validate uniqueKey`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + @OptIn(ExperimentalUuidApi::class) + assertAll( + { assertThat(remote.uniqueKey).isEqualTo(Remote.Key(repository.iid, "origin")) }, + { assertThat(remote.uniqueKey.repositoryId).isEqualTo(repository.iid) }, + // Compare .value here because inline classes may be represented both as the underlying value and as a wrapper + // https://kotlinlang.org/docs/inline-classes.html#representation + { assertThat(remote.uniqueKey.repositoryId.value).isSameAs(repository.iid.value) }, + { assertThat(remote.uniqueKey.name).isEqualTo("origin") } + ) + } + + @Test + fun `create remote with name containing whitespace, uniqueKey should trim`() { + val remote = Remote( + name = " origin ", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertThat(remote.uniqueKey.name).isEqualTo("origin") + } + + @Test + fun `create remote, validate hashCode is based on iid`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertThat(remote.hashCode()).isEqualTo(remote.iid.hashCode()) + } + + @Test + fun `create two remotes, check they are not equal`() { + val remoteA = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val remoteB = Remote( + name = "upstream", + url = "https://github.com/other/repo.git", + repository = repository + ) + + assertAll( + { assertThat(remoteA).isNotSameAs(remoteB) }, + { assertThat(remoteA).isNotEqualTo(remoteB) }, + { assertThat(remoteA.iid).isNotEqualTo(remoteB.iid) } + ) + } + + @Test + fun `create remote, copy and edit iid via reflection, should be equal`() { + val remoteA = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val originIid = remoteA.iid + val originUniqueKey = remoteA.uniqueKey + + val remoteB = remoteA.copy() + + // Edit iid and repository to match remoteA + setField( + remoteB.javaClass.superclass.getDeclaredField("iid"), + remoteB, + originIid + ) + setField( + remoteB.javaClass.getDeclaredField("repository"), + remoteB, + remoteA.repository + ) + + assertAll( + { assertThat(remoteA).isNotSameAs(remoteB) }, + { assertThat(remoteA).isEqualTo(remoteB) }, + { assertThat(remoteA.iid).isEqualTo(originIid) }, + { assertThat(remoteA.uniqueKey).isEqualTo(originUniqueKey) }, + { assertThat(remoteA.iid).isEqualTo(remoteB.iid) } + ) + } + + @Test + fun `create remote, copy and edit iid via reflection, change name, should not be equal`() { + val remoteA = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val originIid = remoteA.iid + val originUniqueKey = remoteA.uniqueKey + + val remoteB = remoteA.copy(name = "upstream") + + // Edit iid and repository to match remoteA + setField( + remoteB.javaClass.superclass.getDeclaredField("iid"), + remoteB, + originIid + ) + setField( + remoteB.javaClass.getDeclaredField("repository"), + remoteB, + remoteA.repository + ) + + assertAll( + { assertThat(remoteA).isNotSameAs(remoteB) }, + { assertThat(remoteA).isNotEqualTo(remoteB) }, + { assertThat(remoteA.iid).isEqualTo(originIid) }, + { assertThat(remoteA.uniqueKey).isEqualTo(originUniqueKey) }, + { assertThat(remoteA.iid).isEqualTo(remoteB.iid) } + ) + } + } + + @Nested + inner class RepositoryRelation { + @BeforeEach + fun setup() { + this@RemoteModelTest.setup() + } + + @Test + fun `add remote to repository once, should be added`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + // Remote is already added via constructor + assertAll( + { assertThat(repository.remotes).hasSize(1) }, + { assertThat(repository.remotes).contains(remote) }, + { assertThat(remote.repository).isSameAs(repository) } + ) + } + + @Test + fun `add same remote to repository twice, should only be added once`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertAll( + // Already added via constructor + { assertFalse(repository.remotes.add(remote)) }, + { assertThat(repository.remotes).hasSize(1) } + ) + } + + @Test + fun `add multiple remotes with different names, expect all to be added`() { + val origin = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val upstream = Remote( + name = "upstream", + url = "https://github.com/upstream/repo.git", + repository = repository + ) + val fork = Remote( + name = "fork", + url = "https://github.com/fork/repo.git", + repository = repository + ) + + assertAll( + { assertThat(repository.remotes).hasSize(3) }, + { assertThat(repository.remotes).contains(origin, upstream, fork) } + ) + } + + @Test + fun `add remote with same name twice, expect only first to be added`() { + val remoteA = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val remoteB = Remote( + name = "origin", + url = "https://github.com/other/repo.git", + repository = repository + ) + + assertAll( + // First one added via constructor + { assertThat(repository.remotes).hasSize(1) }, + { assertThat(repository.remotes.first()).isSameAs(remoteA) }, + { assertThat(repository.remotes.first().url).isEqualTo("https://github.com/user/repo.git") } + ) + } + + @Test + fun `add remote via addAll, expect to be added`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + // Already added via constructor + assertFalse(repository.remotes.addAll(listOf(remote))) + assertThat(repository.remotes).hasSize(1) + } + + @Test + fun `add multiple remotes via addAll, expect all to be added`() { + val origin = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val upstream = Remote( + name = "upstream", + url = "https://github.com/upstream/repo.git", + repository = repository + ) + + // Already added via constructor + assertFalse(repository.remotes.addAll(listOf(origin, upstream))) + assertThat(repository.remotes).hasSize(2) + } + + @Test + fun `add same remote twice via addAll, expect only one to be added`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertAll( + // Already added via constructor + { assertFalse(repository.remotes.addAll(listOf(remote))) }, + { assertFalse(repository.remotes.addAll(listOf(remote))) }, + { assertThat(repository.remotes).hasSize(1) } + ) + } + + @Test + fun `add duplicate remotes via addAll, expect unique to be added`() { + val remoteA = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val remoteB = Remote( + name = "upstream", + url = "https://github.com/upstream/repo.git", + repository = repository + ) + val remoteC = Remote( + name = "origin", // Same name as remoteA + url = "https://github.com/other/repo.git", + repository = repository + ) + + assertAll( + // All added via constructor, but remoteC has duplicate name + { assertThat(repository.remotes).hasSize(2) }, + { assertThat(repository.remotes).contains(remoteA, remoteB) }, + { assertThat(repository.remotes).doesNotContain(remoteC) } + ) + } + + @Test + fun `add remote from different repository, should fail`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val otherRepo = mockTestDataProvider.repositoriesByPath.getValue("repo-pg-1") + + assertThat(remote.repository).isNotSameAs(otherRepo) + + assertThrows { + otherRepo.remotes.add(remote) + } + } + + @Test + fun `add empty collection of remotes, expect no remotes added`() { + val emptyList = emptyList() + + assertFalse(repository.remotes.addAll(emptyList)) + assertThat(repository.remotes).hasSize(0) + } + } + + @Nested + inner class RemovalOperations { + @BeforeEach + fun setup() { + this@RemoteModelTest.setup() + } + + @Test + fun `remove remote from repository should throw UnsupportedOperationException`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + assertThat(repository.remotes).hasSize(1) + + assertThrows { + repository.remotes.remove(remote) + } + assertThat(repository.remotes).hasSize(1) // Should still be there + } + + @Test + fun `clear all remotes should throw UnsupportedOperationException`() { + Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + Remote( + name = "upstream", + url = "https://github.com/upstream/repo.git", + repository = repository + ) + assertThat(repository.remotes).hasSize(2) + + assertThrows { + repository.remotes.clear() + } + assertThat(repository.remotes).hasSize(2) // Should still be there + } + + @Test + fun `remove remote by predicate should throw UnsupportedOperationException`() { + Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + Remote( + name = "upstream", + url = "https://github.com/upstream/repo.git", + repository = repository + ) + assertThat(repository.remotes).hasSize(2) + + assertThrows { + repository.remotes.removeIf { it.name == "origin" } + } + assertThat(repository.remotes).hasSize(2) // Should still be there + } + + @Test + fun `retain only specific remotes should throw UnsupportedOperationException`() { + val origin = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val upstream = Remote( + name = "upstream", + url = "https://github.com/upstream/repo.git", + repository = repository + ) + val fork = Remote( + name = "fork", + url = "https://github.com/fork/repo.git", + repository = repository + ) + assertThat(repository.remotes).hasSize(3) + + assertThrows { + repository.remotes.retainAll(setOf(origin, fork)) + } + assertThat(repository.remotes).hasSize(3) // Should still be there + } + + @Test + fun `remove via iterator should throw UnsupportedOperationException`() { + Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + assertThat(repository.remotes).hasSize(1) + + val iterator = repository.remotes.iterator() + assertTrue(iterator.hasNext()) + iterator.next() + + assertThrows { + iterator.remove() + } + assertThat(repository.remotes).hasSize(1) // Should still be there + } + } + + @Nested + inner class MutationOperations { + @BeforeEach + fun setup() { + this@RemoteModelTest.setup() + } + + @Test + fun `create remote then modify url, expect changes to persist`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + assertThat(repository.remotes).hasSize(1) + + // Modify URL + remote.url = "git@github.com:user/repo.git" + + assertAll( + { assertThat(repository.remotes).hasSize(1) }, + { assertThat(repository.remotes.first().url).isEqualTo("git@github.com:user/repo.git") } + ) + } + + @Test + fun `create remote then modify database id, expect changes to persist`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + assertThat(remote.id).isNull() + + // Modify database ID (as infrastructure layers would do) + remote.id = "db-id-123" + + assertThat(remote.id).isEqualTo("db-id-123") + } + } + + @Nested + inner class EdgeCases { + @BeforeEach + fun setup() { + this@RemoteModelTest.setup() + } + + @Test + fun `create remote with very long name, should be added`() { + val longName = "remote-" + "a".repeat(1000) + val remote = Remote( + name = longName, + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertAll( + { assertThat(repository.remotes).hasSize(1) }, + { assertThat(remote.name).isEqualTo(longName) } + ) + } + + @Test + fun `create remote with very long url, should be added`() { + val longUrl = "https://github.com/" + "a".repeat(1000) + "/repo.git" + val remote = Remote( + name = "origin", + url = longUrl, + repository = repository + ) + + assertAll( + { assertThat(repository.remotes).hasSize(1) }, + { assertThat(remote.url).isEqualTo(longUrl) } + ) + } + + @Test + fun `create remote with special characters in URL, should succeed`() { + val specialUrl = "https://user:password@github.com:8080/path/to/repo.git?param=value#fragment" + val remote = Remote( + name = "origin", + url = specialUrl, + repository = repository + ) + + assertAll( + { assertThat(repository.remotes).hasSize(1) }, + { assertThat(remote.url).isEqualTo(specialUrl) } + ) + } + + @Test + fun `create remotes with same url but different names, should all be added`() { + val sameUrl = "https://github.com/user/repo.git" + val origin = Remote( + name = "origin", + url = sameUrl, + repository = repository + ) + val backup = Remote( + name = "backup", + url = sameUrl, + repository = repository + ) + + assertAll( + { assertThat(repository.remotes).hasSize(2) }, + { assertThat(repository.remotes).contains(origin, backup) } + ) + } + + @Test + fun `create remote with name trimming, check uniqueKey uses trimmed value`() { + val remote = Remote( + name = " origin ", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertAll( + { assertThat(remote.name).isEqualTo(" origin ") }, + { assertThat(remote.uniqueKey.name).isEqualTo("origin") } + ) + } + + @Test + fun `create multiple repositories with same remote name, should all work independently`() { + val repo1 = repository + val repo2 = mockTestDataProvider.repositoriesByPath.getValue("repo-pg-1") + + val remote1 = Remote( + name = "origin", + url = "https://github.com/user/repo1.git", + repository = repo1 + ) + val remote2 = Remote( + name = "origin", + url = "https://github.com/user/repo2.git", + repository = repo2 + ) + + assertAll( + { assertThat(repo1.remotes).hasSize(1) }, + { assertThat(repo2.remotes).hasSize(1) }, + { assertThat(remote1.name).isEqualTo("origin") }, + { assertThat(remote2.name).isEqualTo("origin") }, + { assertThat(remote1.uniqueKey).isNotEqualTo(remote2.uniqueKey) } + ) + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/RemoveOperation.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/RemoveOperation.kt new file mode 100644 index 000000000..2142d5fb3 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/RemoveOperation.kt @@ -0,0 +1,89 @@ +package com.inso_world.binocular.model + +import com.inso_world.binocular.domain.data.MockTestDataProvider +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +class RemoveOperation { + companion object { + private val mockTestDataProvider = MockTestDataProvider( + Repository( + localPath = "mockTestDataProvider repo", + project = Project(name = "mockTestDataProvider project") + ) + ) + + @JvmStatic + fun provideModelCollections(): Stream = Stream.of( + *run { + val repo = mockTestDataProvider.repository + + return@run listOf( + repo.commits, + repo.user, + repo.branches, + ).map { Arguments.of(it) }.toTypedArray() + }, + *run { + val cmt = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + + return@run listOf( + cmt.children, + cmt.parents, +// cmt.issues, +// cmt.files, +// cmt.builds, +// cmt.modules, + ).map { Arguments.of(it) }.toTypedArray() + }, + *run { + val branch = mockTestDataProvider.branchByName.getValue("origin/feature/test") + + return@run listOf( + branch.files, + ).map { Arguments.of(it) }.toTypedArray() + }, + *run { + val user = mockTestDataProvider.userByEmail.getValue("a@test.com") + + return@run listOf( + user.committedCommits, + user.authoredCommits, + user.files, +// user.issues, + ).map { Arguments.of(it) }.toTypedArray() + } + ) + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.model.RemoveOperation#provideModelCollections") + fun `try removeAll, should fail`( + obj: NonRemovingMutableSet<*> + ) { + assertThrows { + obj.removeAll(setOf()) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.model.RemoveOperation#provideModelCollections") + fun `try retainAll branch, should fail`( + obj: NonRemovingMutableSet<*> + ) { + assertThrows { + obj.retainAll(setOf()) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.model.RemoveOperation#provideModelCollections") + fun `try remove via iterator branch, should fail`(obj: NonRemovingMutableSet<*>) { + assertThrows { + obj.iterator().remove() + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/RepositoryModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/RepositoryModelTest.kt new file mode 100644 index 000000000..80a4e8cf0 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/RepositoryModelTest.kt @@ -0,0 +1,1044 @@ +package com.inso_world.binocular.model + +import com.inso_world.binocular.domain.data.MockTestDataProvider +import com.inso_world.binocular.model.utils.ReflectionUtils.Companion.setField +import com.inso_world.binocular.model.vcs.ReferenceCategory +import com.inso_world.binocular.model.vcs.Remote +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.util.concurrent.ConcurrentHashMap +import kotlin.uuid.ExperimentalUuidApi + +class RepositoryModelTest { + + private lateinit var mockTestDataProvider: MockTestDataProvider + + private lateinit var repository: Repository + + @BeforeEach + fun setup() { + val project = Project(name = "proj-repository-model-test") + repository = Repository( + localPath = "repo-repository-model-test", + project = project, + ) + mockTestDataProvider = MockTestDataProvider(repository) + + // clear field via reflection + for (fieldName in listOf("_legacyUsers", "developers", "branches", "commits", "remotes")) { + val base = NonRemovingMutableSet::class.java + + val field = repository.javaClass.getDeclaredField(fieldName) + .apply { this.isAccessible = true } + val obj = field.get(repository) ?: return + + val backingField = base.getDeclaredField("backing").apply { isAccessible = true } + val backing = (backingField.get(obj) as ConcurrentHashMap<*, *>) + backing.clear() + } + } + + @Test + fun `create empty repository, checks that iid is created automatically`() { + val project = Project(name = "test-project") + val repo = Repository( + localPath = "test", + project = project, + ) + + assertThat(repo.iid).isNotNull() + // check reference + assertThat(repo.project).isSameAs(project) + assertThat(repo.project.repo).isSameAs(repo) + } + + @Test + fun `create repository, validate uniqueKey`() { + val project = Project(name = "test-project") + val repo = Repository( + localPath = "test", + project = project, + ) + + @OptIn(ExperimentalUuidApi::class) + assertAll( + { assertThat(repo.uniqueKey).isEqualTo(Repository.Key(project.iid, "test")) }, + { assertThat(repo.uniqueKey.projectId).isEqualTo(project.iid) }, + // compare .value here + // Because inline classes may be represented both as the underlying value and as a wrapper, referential equality is pointless for them and is therefore prohibited. + // https://kotlinlang.org/docs/inline-classes.html#representation + { assertThat(repo.uniqueKey.projectId.value).isSameAs(project.iid.value) }, + { assertThat(repo.uniqueKey.localPath).isSameAs(repo.localPath) }, + ) + } + + @Test + fun `create repository, validate hashCode is same based on iid`() { + val repo = Repository( + localPath = "test", + project = Project(name = "test-project"), + ) + + assertThat(repo.hashCode()).isEqualTo(repo.iid.hashCode()) + } + + @Test + fun `create repository, copy, check that equals uses iid only`() { + val repoA = Repository( + localPath = "test a", + project = Project(name = "test-project"), + ) + val repoB = repoA.copy(project = Project(name = "test-project-2")) + + assertThat(repoA).isNotSameAs(repoB) + assertThat(repoA).isNotEqualTo(repoB) + assertThat(repoA.iid).isNotEqualTo(repoB.iid) + } + + @Test + fun `create repository, edit iid, check that both are equal`() { + val repoA = Repository( + localPath = "test a", + project = Project(name = "test-project"), + ) + val originIid = repoA.iid + val originUniqueKey = repoA.uniqueKey + val repoB = repoA.copy(project = Project(name = "test-project-2")) + + setField( + repoB.javaClass.superclass.getDeclaredField("iid"), + repoB, + originIid + ) + // edit project as required for equals + setField( + repoB.javaClass.getDeclaredField("project"), + repoB, + repoA.project + ) + + assertThat(repoA).isNotSameAs(repoB) + assertThat(repoA).isEqualTo(repoB) + assertThat(repoA.iid).isEqualTo(originIid) + assertThat(repoA.uniqueKey).isEqualTo(originUniqueKey) + assertThat(repoA.iid).isEqualTo(repoB.iid) + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `create repository with blank paths, should fail`( + path: String, + ) { + assertThrows { + Repository( + localPath = path, + project = Project(name = "test-project"), + ) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideAllowedStrings") + fun `create repository with allowed paths, should not fail`( + path: String, + ) { + assertDoesNotThrow { + Repository( + localPath = path, + project = Project(name = "test-project"), + ) + } + } + + + @Nested + inner class CommitsRelation { + @BeforeEach + fun setup() { + this@RepositoryModelTest.setup() + } + + @Test + fun `add commit without parent, expect to be added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + + assertTrue(repository.commits.add(commit)) + assertThat(repository.commits).hasSize(1) + assertThat(repository.commits.toList()[0].repository).isSameAs(repository) + } + + @Test + fun `add same commit without parent twice, expect to be added once`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + + assertAll( + { assertTrue(repository.commits.add(commit)) }, + { assertFalse(repository.commits.add(commit)) } + ) + assertAll( + { assertThat(repository.commits).hasSize(1) }, + { assertThat(repository.commits.toList()[0].repository).isSameAs(repository) } + ) + } + + @Test + fun `add commit without parent twice, expect only once be added`() { + val commitA = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val commitB = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) // same on purpose + + assertAll( + { assertTrue(repository.commits.add(commitA)) }, + { assertFalse(repository.commits.add(commitB)) } + ) + assertThat(repository.commits).hasSize(1) + } + + @Test + fun `add two commits without parent via add(), expect both to be added`() { + val commitA = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val commitB = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + assertTrue(repository.commits.add(commitA)) + assertTrue(repository.commits.add(commitB)) + assertThat(repository.commits).hasSize(2) + } + + @Test + fun `add two commits without parent via addAll(), expect both to be added`() { + val commitA = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val commitB = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + + val list = listOf(commitA, commitB) + assertThat(list).hasSize(2) + + assertTrue(repository.commits.addAll(list)) + assertThat(repository.commits).hasSize(2) + } + + @Test + fun `add same commit twice via addAll(), expect to be added once`() { + val commitA = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + + assertTrue(repository.commits.addAll(listOf(commitA))) + assertFalse(repository.commits.addAll(listOf(commitA))) + assertThat(repository.commits).hasSize(1) + } + + @Test + fun `add one commits with parent, expect only child to be added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)).apply { + this.parents.add(mockTestDataProvider.commitBySha.getValue("b".repeat(40))) + } + + assertTrue(repository.commits.add(commit)) + assertThat(repository.commits).hasSize(1) + } + + @Test + fun `add one commits with children, expect only parent to be added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)).apply { + this.children.add(mockTestDataProvider.commitBySha.getValue("b".repeat(40))) + } + + assertTrue(repository.commits.add(commit)) + assertThat(repository.commits).hasSize(1) + } + + @Test + fun `add duplicate commits without parent via addAll(), expect only one to be added`() { + val commitA = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val commitB = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) + val commitC = mockTestDataProvider.commitBySha.getValue("b".repeat(40)) // same on purpose as commitB + + val list = listOf(commitA, commitB, commitC) + assertThat(list).hasSize(3) + + assertTrue(repository.commits.addAll(list)) + assertThat(repository.commits).hasSize(2) + } + + @Test + fun `add commit which belongs to a different repository`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val probeB = mockTestDataProvider.repositoriesByPath.getValue("repo-pg-1") + + assertThat(commit.repository).isNotSameAs(probeB) + + assertThrows { probeB.commits.add(commit) } + } + } + + @Nested + inner class BranchesRelation { + @BeforeEach + fun setup() { + this@RepositoryModelTest.setup() + } + + @Test + fun `add branch to repository once, should be added once`() { + val branch = mockTestDataProvider.branchByName.getValue("origin/feature/test") + + assertTrue(repository.branches.add(branch)) + assertThat(repository.branches).hasSize(1) + // check if reference is set correctly + assertThat(repository).isSameAs(branch.repository) + } + + @Test + fun `add same branch to repository twice, should only be added once`() { + val branch = mockTestDataProvider.branchByName.getValue("origin/feature/test") + + assertAll( + { assertTrue(repository.branches.add(branch)) }, + { assertFalse(repository.branches.add(branch)) } + ) + assertThat(repository.branches).hasSize(1) + } + + @Test + fun `add same branch to repository twice via addAll, should only be added once`() { + val branch = mockTestDataProvider.branchByName.getValue("origin/feature/test") + + assertAll( + { assertTrue(repository.branches.addAll(listOf(branch))) }, + { assertFalse(repository.branches.addAll(listOf(branch))) } + ) + assertThat(repository.branches).hasSize(1) + } + + @Test + fun `add multiple branches at once, expect all to be added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branchA = branch(name = "feature/branch-a", head = commit) + val branchB = branch(name = "feature/branch-b", head = commit) + + assertFalse(repository.branches.addAll(listOf(branchA, branchB))) // already added via constructor + assertThat(repository.branches).hasSize(2) + // check if references are set correctly + assertAll( + { assertThat(repository).isSameAs(branchA.repository) }, + { assertThat(repository).isSameAs(branchB.repository) } + ) + } + + @Test + fun `add multiple branches at once with duplicates, expect unique to be added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branchA = branch(name = "feature/branch-a", head = commit) + val branchB = branch(name = "feature/branch-b", head = commit) + val branchC = + branch(name = "feature/branch-a", head = commit) // same name as branchA + + val list = listOf(branchA, branchB, branchC) + assertThat(list).hasSize(3) + + assertThat(repository.branches).hasSize(2) + } + + @Test + fun `add branch with different properties, expect to be added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = branch( + name = "feature/test-branch", + head = commit + ).apply { + active = true + tracksFileRenames = true + } + + // assertFalse since branch is already added via constructor + assertFalse(repository.branches.add(branch)) + assertThat(repository.branches).hasSize(1) + assertThat(repository).isSameAs(branch.repository) + } + + @Test + fun `add branch with commits, expect only branch to be added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = branch(name = "feature/with-commits", head = commit) + + assertFalse(repository.branches.add(branch)) + assertThat(repository.branches).hasSize(1) + assertThat(repository).isSameAs(branch.repository) + } + + @Test + fun `add empty collection of branches, expect no branches added`() { + val emptyList = emptyList() + + assertFalse(repository.branches.addAll(emptyList)) + assertThat(repository.branches).hasSize(0) + } + + @Test + fun `add null branch should throw exception`() { + assertThrows(NullPointerException::class.java) { + repository.branches.add(null as Branch) + } + } + + @Test + fun `add branch with empty name should throw IllegalArgumentException`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + assertThrows { + branch(name = "", fullName = "", head = commit) + } + } + + @Test + fun `add branch with very long name should be added`() { + val longName = "feature/" + "a".repeat(1000) + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = branch(name = longName, head = commit) + + assertFalse(repository.branches.add(branch)) + assertThat(repository.branches).hasSize(1) + assertThat(repository).isSameAs(branch.repository) + } + + @Test + fun `add branch with special characters in name should be added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = branch(name = "feature/test-branch@#$%^&*()", head = commit) + + assertFalse(repository.branches.add(branch)) + assertThat(repository.branches).hasSize(1) + assertThat(repository).isSameAs(branch.repository) + } + + @Test + fun `add branch that already exists in different repository should be added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val otherRepo = mockTestDataProvider.repositoriesByPath.getValue("repo-pg-1") + val branch = branch(name = "feature/shared-branch", head = commit) + + // Add to first repository + assertFalse(repository.branches.add(branch)) + assertThat(repository.branches).hasSize(1) + + // Add same branch to different repository + assertThrows { + otherRepo.branches.add(branch) + } + assertThat(otherRepo.branches).hasSize(0) + + // Branch should be unchanged + assertThat(repository).isSameAs(branch.repository) + } + + @Test + fun `remove branch from repository should throw UnsupportedOperationException`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = branch(name = "feature/to-remove", head = commit) + assertThat(repository.branches).hasSize(1) + + assertThrows { + repository.branches.remove(branch) + } + assertThat(repository.branches).hasSize(1) // Should still be there + } + + @Test + fun `clear all branches should throw UnsupportedOperationException`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branchA = branch(name = "feature/branch-a", head = commit) + val branchB = branch(name = "feature/branch-b", head = commit) + assertThat(repository.branches).hasSize(2) + + assertThrows { + repository.branches.clear() + } + assertThat(repository.branches).hasSize(2) // Should still be there + assertThat(branchA.repository).isSameAs(repository) + assertThat(branchB.repository).isSameAs(repository) + } + + @Test + fun `remove branch by predicate should throw UnsupportedOperationException`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branchA = branch(name = "feature/branch-a", head = commit) + val branchB = branch(name = "feature/branch-b", head = commit) + assertThat(repository.branches).hasSize(2) + + assertThrows { + repository.branches.removeIf { it.name == "feature/branch-a" } + } + assertThat(repository.branches).hasSize(2) // Should still be there + } + + @Test + fun `retain only specific branches should throw UnsupportedOperationException`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branchA = branch(name = "feature/branch-a", head = commit) + val branchB = branch(name = "feature/branch-b", head = commit) + val branchC = branch(name = "feature/branch-c", head = commit) + assertThat(repository.branches).hasSize(3) + + assertThrows { + repository.branches.retainAll(setOf(branchA, branchC)) + } + assertThat(repository.branches).hasSize(3) // Should still be there + } + + @Test + fun `add branch then modify its properties, expect changes to persist`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = branch(name = "feature/mutable-branch", head = commit) + assertThat(repository.branches).hasSize(1) + + // Modify branch properties + branch.head = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + + // Verify the branch still exists and changes are preserved + assertThat(repository.branches).hasSize(1) + assertThat(repository.branches.first().commits).hasSize(1) + } + + @Test + fun `add branch with same name but different properties, expect only one added`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branchA = branch(name = "feature/same-name", head = commit).apply { + active = true + } + val branchB = branch(name = "feature/same-name", head = commit).apply { + active = false + } + + assertThat(repository.branches).hasSize(1) + assertThat(repository.branches.first().active).isTrue() + } + + @Test + fun `add branch then try to remove and add again, expect removal to fail`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = branch(name = "feature/re-add", head = commit) + + // Add branch + assertFalse(repository.branches.add(branch)) + assertThat(repository.branches).hasSize(1) + + // Try to remove branch - should throw exception + assertThrows { + repository.branches.remove(branch) + } + assertThat(repository.branches).hasSize(1) // Should still be there + + // Try to add same branch again - should return false (already exists) + assertFalse(repository.branches.add(branch)) + assertThat(repository.branches).hasSize(1) + assertThat(repository).isSameAs(branch.repository) + } + + @Test + fun `add branch with commits then try to remove, expect removal to fail`() { + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + val branch = branch(name = "feature/with-commits", head = commit) + + repository.branches.add(branch) + assertThat(repository.branches).hasSize(1) + assertThat(branch.commits).hasSize(1) + + // Try to remove branch - should throw exception + assertThrows { + repository.branches.remove(branch) + } + assertThat(repository.branches).hasSize(1) // Should still be there + // Commits should still exist in the branch + assertThat(branch.commits).hasSize(1) + } + + @Test + fun `add branch to multiple repositories, expect only first repository to work`() { + val otherRepo = mockTestDataProvider.repositoriesByPath.getValue("repo-pg-1") + assertThat(otherRepo).isNotSameAs(repository) + + val commit = mockTestDataProvider.commitBySha.getValue("a".repeat(40)) + + // Create branch without repository first + val branch = branch(name = "feature/multi-repo", head = commit) + + // Add to first repository + assertThat(branch.repository).isSameAs(repository) + assertThat(repository.branches).hasSize(1) + + // Try to add to second repository - should not work since it's a different repository + assertThrows { + otherRepo.branches.add(branch) + } + assertThat(branch.repository).isSameAs(repository) // Should still reference the original repository + assertThat(repository.branches).hasSize(1) // Should be removed from first repo + assertThat(otherRepo.branches).hasSize(0) + } + } + + @Nested + inner class UserRelation { + @BeforeEach + fun setup() { + this@RepositoryModelTest.setup() + } + + @Test + fun `add user to repository once, should be added once`() { + val user = mockTestDataProvider.userByEmail.getValue("a@test.com") + + assertTrue(repository.user.add(user)) + assertThat(repository.user).hasSize(1) + // check if reference is set correctly + assertThat(repository).isSameAs(user.repository) + } + + @Test + fun `add same user to repository twice, should only be added once`() { + val user = mockTestDataProvider.userByEmail.getValue("a@test.com") + + assertAll( + { assertTrue(repository.user.add(user)) }, + { assertFalse(repository.user.add(user)) } + ) + assertThat(repository.user).hasSize(1) + } + + @Test + fun `add multiple users at once, expect all to be added`() { + val userA = mockTestDataProvider.userByEmail.getValue("a@test.com") + val userB = mockTestDataProvider.userByEmail.getValue("b@test.com") + + assertTrue(repository.user.addAll(listOf(userA, userB))) + assertThat(repository.user).hasSize(2) + } + + @Test + fun `add same user twice via addAll, expect only one to be added`() { + val userA = mockTestDataProvider.userByEmail.getValue("a@test.com") + + assertAll( + { assertTrue(repository.user.addAll(listOf(userA))) }, + { assertFalse(repository.user.addAll(listOf(userA))) }, + ) + assertThat(repository.user).hasSize(1) + } + + @Test + fun `add multiple users at once with duplicates, expect unique to be added`() { + val userA = mockTestDataProvider.userByEmail.getValue("a@test.com") + val userB = mockTestDataProvider.userByEmail.getValue("b@test.com") + val userC = mockTestDataProvider.userByEmail.getValue("a@test.com") // same as userA + + val list = listOf(userA, userB, userC) + assertThat(list).hasSize(3) + + assertTrue(repository.user.addAll(list)) + assertThat(repository.user).hasSize(2) + } + + @Test + fun `add user from another repository, should fail`() { + val userA = mockTestDataProvider.userByEmail.getValue("a@test.com") + val probeB = mockTestDataProvider.repositoriesByPath.getValue("repo-pg-1") + + assertThat(userA.repository).isNotSameAs(probeB) + + assertThrows { probeB.user.add(userA) } + } + } + + @Nested + inner class RemotesRelation { + @BeforeEach + fun setup() { + this@RepositoryModelTest.setup() + } + + @Test + fun `add remote to repository once, should be added once`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertFalse(repository.remotes.add(remote)) // already added via constructor + assertThat(repository.remotes).hasSize(1) + // check if reference is set correctly + assertThat(repository).isSameAs(remote.repository) + } + + @Test + fun `add same remote to repository twice, should only be added once`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertAll( + { assertFalse(repository.remotes.add(remote)) }, // already added via constructor + { assertFalse(repository.remotes.add(remote)) } + ) + assertThat(repository.remotes).hasSize(1) + } + + @Test + fun `add same remote to repository twice via addAll, should only be added once`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + assertAll( + { assertFalse(repository.remotes.addAll(listOf(remote))) }, // already added via constructor + { assertFalse(repository.remotes.addAll(listOf(remote))) } + ) + assertThat(repository.remotes).hasSize(1) + } + + @Test + fun `add multiple remotes at once, expect all to be added`() { + val remoteA = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + val remoteB = Remote(name = "upstream", url = "https://github.com/upstream/repo.git", repository = repository) + + assertFalse(repository.remotes.addAll(listOf(remoteA, remoteB))) // already added via constructor + assertThat(repository.remotes).hasSize(2) + // check if references are set correctly + assertAll( + { assertThat(repository).isSameAs(remoteA.repository) }, + { assertThat(repository).isSameAs(remoteB.repository) } + ) + } + + @Test + fun `add multiple remotes at once with duplicates, expect unique to be added`() { + val remoteA = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + val remoteB = Remote(name = "upstream", url = "https://github.com/upstream/repo.git", repository = repository) + val remoteC = Remote(name = "origin", url = "https://different.com/repo.git", repository = repository) // same name as remoteA + + val list = listOf(remoteA, remoteB, remoteC) + assertThat(list).hasSize(3) + + assertThat(repository.remotes).hasSize(2) // remoteC not added due to same business key as remoteA + } + + @Test + fun `add remote with different URL, expect to be added`() { + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + // assertFalse since remote is already added via constructor + assertFalse(repository.remotes.add(remote)) + assertThat(repository.remotes).hasSize(1) + assertThat(repository).isSameAs(remote.repository) + } + + @Test + fun `add empty collection of remotes, expect no remotes added`() { + val emptyList = emptyList() + + assertFalse(repository.remotes.addAll(emptyList)) + assertThat(repository.remotes).hasSize(0) + } + + @Test + fun `add null remote should throw exception`() { + assertThrows(NullPointerException::class.java) { + repository.remotes.add(null as Remote) + } + } + + @Test + fun `add remote with empty name should throw IllegalArgumentException`() { + assertThrows { + Remote(name = "", url = "https://github.com/user/repo.git", repository = repository) + } + } + + @Test + fun `add remote with blank name should throw IllegalArgumentException`() { + assertThrows { + Remote(name = " ", url = "https://github.com/user/repo.git", repository = repository) + } + } + + @Test + fun `add remote with empty url should throw IllegalArgumentException`() { + assertThrows { + Remote(name = "origin", url = "", repository = repository) + } + } + + @Test + fun `add remote with blank url should throw IllegalArgumentException`() { + assertThrows { + Remote(name = "origin", url = " ", repository = repository) + } + } + + @Test + fun `add remote with very long name should be added`() { + val longName = "remote-" + "a".repeat(1000) + val remote = Remote(name = longName, url = "https://github.com/user/repo.git", repository = repository) + + assertFalse(repository.remotes.add(remote)) + assertThat(repository.remotes).hasSize(1) + assertThat(repository).isSameAs(remote.repository) + } + + @Test + fun `add remote with very long url should be added`() { + val longPath = "path/".repeat(100) + val longUrl = "https://github.com/user/$longPath/repo.git" + val remote = Remote(name = "origin", url = longUrl, repository = repository) + + assertFalse(repository.remotes.add(remote)) + assertThat(repository.remotes).hasSize(1) + assertThat(repository).isSameAs(remote.repository) + } + + @Test + fun `add remote with special characters in url should be added`() { + val remote = Remote( + name = "origin", + url = "https://user:password@github.com:443/user/repo-name_123.git?query=value#fragment", + repository = repository + ) + + assertFalse(repository.remotes.add(remote)) + assertThat(repository.remotes).hasSize(1) + assertThat(repository).isSameAs(remote.repository) + } + + @Test + fun `add remote with different protocols, expect all to be added`() { + val httpsRemote = Remote(name = "https", url = "https://github.com/user/repo.git", repository = repository) + val sshRemote = Remote(name = "ssh", url = "ssh://git@github.com/user/repo.git", repository = repository) + val gitRemote = Remote(name = "git", url = "git://github.com/user/repo.git", repository = repository) + val fileRemote = Remote(name = "file", url = "file:///path/to/repo.git", repository = repository) + + assertThat(repository.remotes).hasSize(4) + assertAll( + { assertThat(repository).isSameAs(httpsRemote.repository) }, + { assertThat(repository).isSameAs(sshRemote.repository) }, + { assertThat(repository).isSameAs(gitRemote.repository) }, + { assertThat(repository).isSameAs(fileRemote.repository) } + ) + } + + @Test + fun `add remote that already exists in different repository should fail`() { + val otherRepo = mockTestDataProvider.repositoriesByPath.getValue("repo-pg-1") + val remote = Remote(name = "origin", url = "https://github.com/shared/repo.git", repository = repository) + + // Add to first repository + assertFalse(repository.remotes.add(remote)) + assertThat(repository.remotes).hasSize(1) + + // Try to add same remote to different repository + assertThrows { + otherRepo.remotes.add(remote) + } + assertThat(otherRepo.remotes).hasSize(0) + + // Remote should be unchanged + assertThat(repository).isSameAs(remote.repository) + } + + @Test + fun `remove remote from repository should throw UnsupportedOperationException`() { + val remote = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + assertThat(repository.remotes).hasSize(1) + + assertThrows { + repository.remotes.remove(remote) + } + assertThat(repository.remotes).hasSize(1) // Should still be there + } + + @Test + fun `clear all remotes should throw UnsupportedOperationException`() { + val remoteA = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + val remoteB = Remote(name = "upstream", url = "https://github.com/upstream/repo.git", repository = repository) + assertThat(repository.remotes).hasSize(2) + + assertThrows { + repository.remotes.clear() + } + assertThat(repository.remotes).hasSize(2) // Should still be there + assertThat(remoteA.repository).isSameAs(repository) + assertThat(remoteB.repository).isSameAs(repository) + } + + @Test + fun `remove remote by predicate should throw UnsupportedOperationException`() { + val remoteA = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + val remoteB = Remote(name = "upstream", url = "https://github.com/upstream/repo.git", repository = repository) + assertThat(repository.remotes).hasSize(2) + + assertThrows { + repository.remotes.removeIf { it.name == "origin" } + } + assertThat(repository.remotes).hasSize(2) // Should still be there + } + + @Test + fun `retain only specific remotes should throw UnsupportedOperationException`() { + val remoteA = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + val remoteB = Remote(name = "upstream", url = "https://github.com/upstream/repo.git", repository = repository) + val remoteC = Remote(name = "fork", url = "https://github.com/fork/repo.git", repository = repository) + assertThat(repository.remotes).hasSize(3) + + assertThrows { + repository.remotes.retainAll(setOf(remoteA, remoteC)) + } + assertThat(repository.remotes).hasSize(3) // Should still be there + } + + @Test + fun `add remote then modify its url, expect changes to persist`() { + val remote = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + assertThat(repository.remotes).hasSize(1) + + // Modify remote URL + remote.url = "https://gitlab.com/user/repo.git" + + // Verify the remote still exists and changes are preserved + assertThat(repository.remotes).hasSize(1) + assertThat(repository.remotes.first().url).isEqualTo("https://gitlab.com/user/repo.git") + } + + @Test + fun `add remote with same name but different url, expect only one added`() { + val remoteA = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + val remoteB = Remote( + name = "origin", + url = "https://gitlab.com/user/repo.git", + repository = repository + ) + + assertThat(repository.remotes).hasSize(1) + assertThat(repository.remotes.first().url).isEqualTo("https://github.com/user/repo.git") + } + + @Test + fun `add remote then try to remove and add again, expect removal to fail`() { + val remote = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + + // Add remote + assertFalse(repository.remotes.add(remote)) + assertThat(repository.remotes).hasSize(1) + + // Try to remove remote - should throw exception + assertThrows { + repository.remotes.remove(remote) + } + assertThat(repository.remotes).hasSize(1) // Should still be there + + // Try to add same remote again - should return false (already exists) + assertFalse(repository.remotes.add(remote)) + assertThat(repository.remotes).hasSize(1) + assertThat(repository).isSameAs(remote.repository) + } + + @Test + fun `add remote to multiple repositories, expect only first repository to work`() { + val otherRepo = mockTestDataProvider.repositoriesByPath.getValue("repo-pg-1") + assertThat(otherRepo).isNotSameAs(repository) + + // Create remote with repository + val remote = Remote(name = "origin", url = "https://github.com/multi/repo.git", repository = repository) + + // Add to first repository + assertThat(remote.repository).isSameAs(repository) + assertThat(repository.remotes).hasSize(1) + + // Try to add to second repository - should not work since it's a different repository + assertThrows { + otherRepo.remotes.add(remote) + } + assertThat(remote.repository).isSameAs(repository) // Should still reference the original repository + assertThat(repository.remotes).hasSize(1) + assertThat(otherRepo.remotes).hasSize(0) + } + + @Test + fun `add multiple standard Git remotes, expect all to be added`() { + val origin = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + val upstream = Remote(name = "upstream", url = "https://github.com/upstream/repo.git", repository = repository) + val fork = Remote(name = "fork", url = "https://github.com/fork/repo.git", repository = repository) + + assertThat(repository.remotes).hasSize(3) + assertThat(repository.remotes).containsExactlyInAnyOrder(origin, upstream, fork) + } + + @Test + fun `add remote with hyphen and underscore in name, should be added`() { + val remoteA = Remote(name = "origin-https", url = "https://github.com/user/repo.git", repository = repository) + val remoteB = Remote(name = "origin_ssh", url = "ssh://git@github.com/user/repo.git", repository = repository) + + assertThat(repository.remotes).hasSize(2) + assertAll( + { assertThat(repository).isSameAs(remoteA.repository) }, + { assertThat(repository).isSameAs(remoteB.repository) } + ) + } + + @Test + fun `contains check for existing remote should return true`() { + val remote = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + + assertThat(repository.remotes.contains(remote)).isTrue() + } + + @Test + fun `contains check for non-existing remote should return false`() { + val otherRepo = mockTestDataProvider.repositoriesByPath.getValue("repo-pg-1") + val remoteInOtherRepo = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = otherRepo) + + assertThat(repository.remotes.contains(remoteInOtherRepo)).isFalse() + } + + @Test + fun `iterate over remotes collection, expect all remotes returned`() { + val origin = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + val upstream = Remote(name = "upstream", url = "https://github.com/upstream/repo.git", repository = repository) + val fork = Remote(name = "fork", url = "https://github.com/fork/repo.git", repository = repository) + + val remoteNames = repository.remotes.map { it.name }.toSet() + + assertThat(remoteNames).containsExactlyInAnyOrder("origin", "upstream", "fork") + } + } + + private fun branch( + name: String, + head: Commit, + repository: Repository = this.repository, + fullName: String = name, + category: ReferenceCategory = ReferenceCategory.LOCAL_BRANCH + ): Branch = + Branch( + name = name, + fullName = fullName, + category = category, + repository = repository, + head = head + ) +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/SignatureModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/SignatureModelTest.kt new file mode 100644 index 000000000..5fa6b4080 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/SignatureModelTest.kt @@ -0,0 +1,149 @@ +package com.inso_world.binocular.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.time.LocalDateTime +import kotlin.uuid.ExperimentalUuidApi + +/** + * BDD tests for Signature value class. + * Signature represents a Git signature consisting of a Developer and a timestamp, + * used for author and committer information in commits. + */ +@OptIn(ExperimentalUuidApi::class) +class SignatureModelTest { + + private lateinit var repository: Repository + private lateinit var developer: Developer + + @BeforeEach + fun setUp() { + val project = Project(name = "test-project") + repository = Repository(localPath = "test-repo", project = project) + developer = Developer(name = "John Doe", email = "john@example.com", repository = repository) + } + + @Nested + inner class Construction { + + @Test + fun `given valid developer and timestamp, when creating signature, then it should be created successfully`() { + // Given + val timestamp = LocalDateTime.now().minusSeconds(1) + + // When + val signature = Signature(developer = developer, timestamp = timestamp) + + // Then + assertAll( + { assertThat(signature.developer).isSameAs(developer) }, + { assertThat(signature.timestamp).isEqualTo(timestamp) } + ) + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideAllowedPastOrPresentDateTime") + fun `given valid past or present timestamp, when creating signature, then it should succeed`(timestamp: LocalDateTime) { + // When & Then + val signature = Signature(developer = developer, timestamp = timestamp) + assertThat(signature.timestamp).isEqualTo(timestamp) + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideInvalidPastOrPresentDateTime") + fun `given future timestamp, when creating signature, then it should throw IllegalArgumentException`(timestamp: LocalDateTime) { + // When & Then + assertThrows { + Signature(developer = developer, timestamp = timestamp) + } + } + } + + @Nested + inner class ValueSemantics { + + @Test + fun `given two signatures with same developer and timestamp, when comparing, then they should be equal`() { + // Given + val timestamp = LocalDateTime.of(2024, 1, 1, 12, 0, 0) + val signature1 = Signature(developer = developer, timestamp = timestamp) + val signature2 = Signature(developer = developer, timestamp = timestamp) + + // Then + assertThat(signature1).isEqualTo(signature2) + } + + @Test + fun `given two signatures with different timestamps, when comparing, then they should not be equal`() { + // Given + val signature1 = Signature(developer = developer, timestamp = LocalDateTime.of(2024, 1, 1, 12, 0, 0)) + val signature2 = Signature(developer = developer, timestamp = LocalDateTime.of(2024, 1, 1, 13, 0, 0)) + + // Then + assertThat(signature1).isNotEqualTo(signature2) + } + + @Test + fun `given two signatures with different developers, when comparing, then they should not be equal`() { + // Given + val developer2 = Developer(name = "Jane", email = "jane@example.com", repository = repository) + val timestamp = LocalDateTime.of(2024, 1, 1, 12, 0, 0) + val signature1 = Signature(developer = developer, timestamp = timestamp) + val signature2 = Signature(developer = developer2, timestamp = timestamp) + + // Then + assertThat(signature1).isNotEqualTo(signature2) + } + + @Test + fun `given same signature values, when getting hashCode, then they should be equal`() { + // Given + val timestamp = LocalDateTime.of(2024, 1, 1, 12, 0, 0) + val signature1 = Signature(developer = developer, timestamp = timestamp) + val signature2 = Signature(developer = developer, timestamp = timestamp) + + // Then + assertThat(signature1.hashCode()).isEqualTo(signature2.hashCode()) + } + } + + @Nested + inner class GitSignatureFormat { + + @Test + fun `given signature, when getting gitSignature, then it should return formatted string`() { + // Given + val timestamp = LocalDateTime.of(2024, 6, 15, 14, 30, 0) + val signature = Signature(developer = developer, timestamp = timestamp) + + // When + val gitSig = signature.gitSignature + + // Then - should contain developer's name and email + assertThat(gitSig).isEqualTo("John Doe ") + } + } + + @Nested + inner class Immutability { + + @Test + fun `given signature, when accessing properties, then they should be immutable`() { + // Given + val timestamp = LocalDateTime.of(2024, 1, 1, 12, 0, 0) + val signature = Signature(developer = developer, timestamp = timestamp) + + // Then - Signature is a data class with val properties, proving immutability + assertAll( + { assertThat(signature.developer).isSameAs(developer) }, + { assertThat(signature.timestamp).isEqualTo(timestamp) } + ) + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/StakeholderModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/StakeholderModelTest.kt new file mode 100644 index 000000000..b7a00e434 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/StakeholderModelTest.kt @@ -0,0 +1,91 @@ +package com.inso_world.binocular.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * BDD tests for Stakeholder abstract base class. + * Stakeholder represents any person involved in a project (developer, reviewer, etc.) + */ +@OptIn(ExperimentalUuidApi::class) +class StakeholderModelTest { + + @Nested + inner class Construction { + + @Test + fun `given valid name and email, when creating a stakeholder subclass, then it should be created successfully`() { + // Given + val name = "John Doe" + val email = "john@example.com" + + // When - using a concrete test subclass + val stakeholder = TestStakeholder(name = name, email = email) + + // Then + assertThat(stakeholder.name).isEqualTo(name) + assertThat(stakeholder.email).isEqualTo(email) + assertThat(stakeholder.iid).isNotNull() + } + + @Test + fun `given a stakeholder, when accessing uniqueKey, then it should return the expected key`() { + // Given + val stakeholder = TestStakeholder(name = "Jane Doe", email = "jane@example.com") + + // When + val key = stakeholder.uniqueKey + + // Then + assertThat(key).isNotNull() + } + } + + @Nested + inner class Equality { + + @Test + fun `given two stakeholders with same iid and uniqueKey, when comparing, then they should be equal`() { + // Given + val stakeholder1 = TestStakeholder(name = "Test", email = "test@example.com") + val stakeholder2 = stakeholder1 // same reference + + // Then + assertThat(stakeholder1).isEqualTo(stakeholder2) + } + + @Test + fun `given two different stakeholders, when comparing, then they should not be equal`() { + // Given + val stakeholder1 = TestStakeholder(name = "Test1", email = "test1@example.com") + val stakeholder2 = TestStakeholder(name = "Test2", email = "test2@example.com") + + // Then + assertThat(stakeholder1).isNotEqualTo(stakeholder2) + } + } + + /** + * Concrete test implementation of Stakeholder for testing purposes. + */ + private class TestStakeholder( + override val name: String, + override val email: String + ) : Stakeholder(Id(Uuid.random())) { + + @JvmInline + value class Id(val value: Uuid) + + data class Key(val email: String) + + override val uniqueKey: Key + get() = Key(email) + + override fun equals(other: Any?) = super.equals(other) + override fun hashCode(): Int = super.hashCode() + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/UserModelTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/UserModelTest.kt new file mode 100644 index 000000000..921249574 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/UserModelTest.kt @@ -0,0 +1,268 @@ +package com.inso_world.binocular.model + +import com.inso_world.binocular.domain.data.MockTestDataProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import kotlin.uuid.ExperimentalUuidApi + +class UserModelTest { + + private lateinit var repository: Repository + + @BeforeEach + fun setUp() { + val project = Project(name = "test-project") + repository = Repository( + localPath = "test", + project = project, + ) + } + + @Test + fun `create user, check that iid is created automatically`() { + val user = User(name = "test-user", repository) + + assertThat(user.iid).isNotNull() + } + + @Test + fun `create user, validate uniqueKey`() { + val user = User(name = "test-user", repository) + + @OptIn(ExperimentalUuidApi::class) + assertAll( + { assertThat(user.uniqueKey).isEqualTo(User.Key(repository.iid, "test-user ")) }, + { assertThat(user.uniqueKey.repositoryId).isEqualTo(repository.iid) }, + // compare .value here + // Because inline classes may be represented both as the underlying value and as a wrapper, referential equality is pointless for them and is therefore prohibited. + // https://kotlinlang.org/docs/inline-classes.html#representation + { assertThat(user.uniqueKey.repositoryId.value).isSameAs(repository.iid.value) }, + ) + } + + @Test + fun `create user, validate hashCode is same based on iid`() { + val user = User(name = "test-user", repository) + + assertThat(user.hashCode()).isEqualTo(user.iid.hashCode()) + } + + @Test + fun `create user, is is null`() { + val user = User(name = "test-user", repository) + + assertThat(user.id).isNull() + } + + @Test + fun `create user, check reference to owning repository`() { + val user = User(name = "test-user", repository) + + assertThat(user.repository).isSameAs(repository) + assertThat(user.repository.user).containsOnly(user) + assertThat(repository.user).containsOnly(user) + } + + @Test + fun `create user, update email`() { + val user = User(name = "test-user", repository) + + assertDoesNotThrow { + user.email = "test-email" + } + assertThat(user.email).isEqualTo("test-email") + } + + @Test + fun `create user, update email, check gitSignature`() { + val user = User(name = "test-user", repository) + + assertDoesNotThrow { + user.email = "test-email" + } + assertThat(user.gitSignature).isEqualTo("test-user ") + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `create user, update email with invalid strings, should fail`( + email: String, + ) { + val user = User(name = "test-user", repository) + + assertThrows { + user.email = email + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `create user with invalid name, should fail`( + name: String, + ) { + assertThrows { + User(name, repository) + } + } + + @Nested + inner class CommitRelations { + @BeforeEach + fun setUp() { + this@UserModelTest.setUp() + } + + @Nested + inner class CommittedCommits { + @BeforeEach + fun setUp() { + this@CommitRelations.setUp() + } + + @Test + fun `create user, validate committedCommits relation is empty`() { + val user = User(name = "test-user", repository) + assertThat(user.committedCommits).isEmpty() + } + + @Test + fun `create user, add committedCommit with different repository, should fail`() { + val mockCommit = + MockTestDataProvider(this@UserModelTest.repository).commitBySha.getValue("a".repeat(40)) + + val differentRepository = Repository( + localPath = "different-repository", + project = Project(name = "different-project"), + ) + + val user = User(name = "test-user", differentRepository) + + assertAll( + { assertThat(user.repository).isNotEqualTo(mockCommit.repository) }, + { + assertThrows { + user.committedCommits.add(mockCommit) + } + } + ) + } + + @Test + fun `create user, add committedCommit from same repository but developer committer, should fail`() { + val mockCommit = + MockTestDataProvider(this@UserModelTest.repository).commitBySha.getValue("a".repeat(40)) + + val user = User(name = "test-user", repository) + + assertThrows { + user.committedCommits.add(mockCommit) + } + } + } + + @Nested + inner class AuthoredCommits { + + @BeforeEach + fun setUp() { + this@CommitRelations.setUp() + } + + @Test + fun `create user, validate authoredCommits relation is empty`() { + val user = User(name = "test-user", repository) + assertThat(user.authoredCommits).isEmpty() + } + + @Test + fun `create user, add authoredCommits with different repository, should fail`() { + val mockCommit = + MockTestDataProvider(this@UserModelTest.repository).commitBySha.getValue("a".repeat(40)) + + val differentRepository = Repository( + localPath = "different-repository", + project = Project(name = "different-project"), + ) + + val user = User(name = "test-user", differentRepository) + + assertAll( + { assertThat(user.repository).isNotEqualTo(mockCommit.repository) }, + { + assertThrows { + user.authoredCommits.add(mockCommit) + } + } + ) + } + + @Test + fun `create user, add authoredCommit from different repository, should fail`() { + val mockCommit = + MockTestDataProvider(this@UserModelTest.repository).commitBySha.getValue("a".repeat(40)) + + val differentRepository = Repository( + localPath = "different-repository", + project = Project(name = "different-project"), + ) + + val user = User(name = "test-user", differentRepository) + + assertThrows { + user.authoredCommits.add(mockCommit) + } + } + + @Test + fun `create user, add authoredCommit from same repository, should add without mutating commit author`() { + val mockCommit = + MockTestDataProvider(this@UserModelTest.repository).commitBySha.getValue("a".repeat(40)) + + val user = User(name = "test-user", repository) + + assertTrue(user.authoredCommits.add(mockCommit)) + assertAll( + { assertThat(user.authoredCommits).containsOnly(mockCommit) }, + { assertThat(mockCommit.author).isNotEqualTo(user) } + ) + } + } + } + + @Nested + inner class IssueRelation { + @BeforeEach + fun setUp() { + this@UserModelTest.setUp() + } + + @Test + fun `create user, validate issue relation is empty`() { + val user = User(name = "test-user", repository) + assertThat(user.issues).isEmpty() + } + } + + @Nested + inner class FileRelation { + @BeforeEach + fun setUp() { + this@UserModelTest.setUp() + } + + @Test + fun `create user, validate files relation is empty`() { + val user = User(name = "test-user", repository) + assertThat(user.files).isEmpty() + } + } + +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/utils/ReflectionUtils.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/utils/ReflectionUtils.kt new file mode 100644 index 000000000..c893d76a5 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/utils/ReflectionUtils.kt @@ -0,0 +1,15 @@ +package com.inso_world.binocular.model.utils + +import java.lang.reflect.Field + +internal class ReflectionUtils { + companion object { + /** + * Mimics [org.springframework.util.ReflectionUtils.setField] + */ + fun setField(field: Field, target: Any, value: Any?) { + field.isAccessible = true + field.set(target, value) + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/CommitCycleValidationTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/CommitCycleValidationTest.kt deleted file mode 100644 index a4a6c3e54..000000000 --- a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/CommitCycleValidationTest.kt +++ /dev/null @@ -1,310 +0,0 @@ -package com.inso_world.binocular.model.validation - -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import jakarta.validation.Validation -import jakarta.validation.Validator -import org.assertj.core.api.Assertions.assertThat -import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertAll -import java.time.LocalDateTime - -@Disabled -class CommitCycleValidationTest { - private lateinit var validator: Validator - - @BeforeEach - fun setUp() { - val validatorFactory = - Validation - .byDefaultProvider() - .configure() - .messageInterpolator(ParameterMessageInterpolator()) - .buildValidatorFactory() - validator = validatorFactory.validator - } - - @Test - fun `should pass validation for commit with no parents`() { - val branch = Branch( - name = "b", - ) - val commit = - Commit( - sha = "a".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Initial commit", - ) - branch.commits.add(commit) - val violations = validator.validate(commit) - assertAll( - { assertThat(violations).isEmpty() }, - ) - } - - @Test - fun `should pass validation for commit with linear ancestry`() { - val parent = - Commit( - sha = "b".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Parent commit", - ) - val branch = Branch( - name = "b", - ) - val commit = - Commit( - sha = "a".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Child commit", - ) - branch.commits.add(commit) - commit.parents.add(parent) - val violations = validator.validate(commit) - assertAll( - { assertThat(violations).isEmpty() }, - ) - } - - @Test - fun `should pass validation for commit with merge ancestry`() { - val parent1 = - Commit( - sha = "b".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Parent1 commit", - ) - val parent2 = - Commit( - sha = "c".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Parent2 commit", - ) - val branch = Branch( - name = "b" - ) - val commit = - Commit( - sha = "a".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Merge commit", - ) - branch.commits.addAll(listOf(parent1, parent2)) - commit.parents.add(parent1) - commit.parents.add(parent2) - val violations = validator.validate(commit) - assertAll( - { assertThat(violations).isEmpty() }, - ) - } - - @Test - fun `should fail validation for direct cycle`() { - val branch = Branch( - name = "b", - ) - val commit = - Commit( - sha = "a".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Cyclic commit", - ) - branch.commits.add(commit) - // Direct cycle: commit is its own parent - commit.parents.add(commit) - val violation = - run { - val violations = validator.validate(commit) - assertThat(violations).hasSize(1) - violations.toList()[0] - } - assertThat(violation.message).contains("${"a".repeat(40)} -> ${"a".repeat(40)}") - } - - @Test - fun `should fail validation for indirect cycle`() { - val branch = Branch(name = "b") - val commitA = - Commit( - sha = "a".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "A", - ) - val commitB = - Commit( - sha = "b".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "B", - ) - val commitC = - Commit( - sha = "c".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "C", - ) - branch.commits.add(commitA) - branch.commits.add(commitB) - branch.commits.add(commitC) - // A -> B -> C -> A - commitA.parents.add(commitB) - commitB.parents.add(commitC) - commitC.parents.add(commitA) - val violation = - run { - val violations = validator.validate(commitA) - assertThat(violations).hasSize(1) - violations.toList()[0] - } - assertThat(violation.message).contains( - "${"a".repeat(40)} -> ${"b".repeat(40)} -> ${"c".repeat(40)} -> ${ - "a".repeat( - 40 - ) - }" - ) - } - - @Test - fun `should fail validation for deep nested cycle at level 3`() { - val repository = Repository( - localPath = "test repo", - project = Project(name = "test project") - ) - val branch = Branch(name = "b") - repository.branches.add(branch) - val commitA = - Commit( - sha = "a".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "A", - ) - branch.commits.add(commitA) - repository.commits.add(commitA) - val commitB = - Commit( - sha = "b".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "B", - ) - branch.commits.add(commitB) - repository.commits.add(commitB) - val commitC = - Commit( - sha = "c".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "C", - ) - branch.commits.add(commitC) - repository.commits.add(commitC) - val commitD = - Commit( - sha = "d".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "D", - ) - branch.commits.add(commitD) - repository.commits.add(commitD) - // A -> B -> C -> D -> B (cycle at level 3) - commitA.parents.add(commitB) - commitB.parents.add(commitC) - commitC.parents.add(commitD) - commitD.parents.add(commitB) - branch.commits.add(commitA) - branch.commits.add(commitB) - branch.commits.add(commitC) - branch.commits.add(commitD) - val violation = - run { - val violations = validator.validate(repository) - assertThat(violations).hasSize(1) - violations.toList()[0] - } - assertAll( - { - assertThat( - violation.message, - ).contains("${"b".repeat(40)} -> ${"c".repeat(40)} -> ${"d".repeat(40)} -> ${"b".repeat(40)}") - }, - ) - } - - @Test - fun `should pass validation for commit with multiple parents and no cycles`() { - val parent1 = - Commit( - sha = "b".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Parent1 commit", - ) - val parent2 = - Commit( - sha = "c".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Parent2 commit", - ) - val branch = Branch( - name = "b", - ) - val commit = - Commit( - sha = "a".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "Merge commit", - ) - branch.commits.addAll(mutableSetOf( - Commit(sha = "b".repeat(40)), - Commit(sha = "c".repeat(40)), - Commit(sha = "a".repeat(40)) - )) - commit.parents.addAll(listOf(parent1, parent2)) - val violations = validator.validate(commit) - assertAll( - { assertThat(violations).isEmpty() }, - ) - } - - @Test - fun `should fail validation for cycles in multiple parents`() { - val branch = Branch(name = "b") - val commitA = - Commit( - sha = "a".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "A", - ) - val commitB = - Commit( - sha = "b".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "B", - ) - val commitC = - Commit( - sha = "c".repeat(40), - commitDateTime = LocalDateTime.now(), - message = "C", - ) - branch.commits.add(commitA) - branch.commits.add(commitB) - branch.commits.add(commitC) - // A -> B, A -> C, B -> C, C -> A (cycle through both parents) - commitA.parents.addAll(listOf(commitB,commitC)) - commitB.parents.add(commitC) - commitC.parents.add(commitA) - val violation = - run { - val violations = validator.validate(commitA) - assertThat(violations).hasSize(1) - violations.toList()[0] - } - assertThat( - violation.message, - ).contains("${"a".repeat(40)} -> ${"b".repeat(40)} -> ${"c".repeat(40)} -> ${"a".repeat(40)}") - } -} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/CommitValidationTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/CommitValidationTest.kt index b611912c3..ab6af2502 100644 --- a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/CommitValidationTest.kt +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/CommitValidationTest.kt @@ -2,58 +2,28 @@ package com.inso_world.binocular.model.validation import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import jakarta.validation.Validation -import jakarta.validation.Validator +import com.inso_world.binocular.model.validation.base.ValidationTest +import com.inso_world.binocular.model.vcs.ReferenceCategory import org.assertj.core.api.Assertions.assertThat -import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertAll import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource -import java.time.LocalDateTime -import java.util.stream.Stream - -class CommitValidationTest { - private lateinit var validator: Validator - private lateinit var repository: Repository - - @BeforeEach - fun setUp() { - validator = - Validation - .byDefaultProvider() - .configure() - .messageInterpolator(ParameterMessageInterpolator()) - .buildValidatorFactory() - .validator - repository = Repository(localPath = "test repo", project = Project(name = "test project")) - } - - companion object { - @JvmStatic - fun invalidCommitsModels(): Stream = ValidationTestData.invalidCommitsModels() - } +internal class CommitValidationTest : ValidationTest() { @ParameterizedTest - @MethodSource("invalidCommitsModels") + @MethodSource("com.inso_world.binocular.model.validation.ValidationTestData#invalidCommitsModels") fun `invalid property with valid branch`( invalidCommit: Commit, propertyPath: String, ) { + val repository = invalidCommit.repository val dummyBranch = Branch( + fullName = "refs/heads/branch", name = "branch", - repository = Repository( - localPath = "test repo" - ) + repository = repository, + head = invalidCommit, + category = ReferenceCategory.LOCAL_BRANCH ) - invalidCommit.branches.add(dummyBranch) - dummyBranch.commits.add(invalidCommit) repository.branches.add(dummyBranch) repository.commits.add(invalidCommit) @@ -61,119 +31,11 @@ class CommitValidationTest { val violation = run { val violations = validator.validate(invalidCommit) - assertThat(violations).hasSize(1) + // when null both `Hexadecimal` and `Size` are violated + assertThat(violations).hasSizeBetween(1, 2) violations.toList()[0] } assertThat(violation.propertyPath.toString()).isEqualTo(propertyPath) } - @Test - fun `should pass validation when repository id is null and repositoryId is null`() { - // Given - val repository = Repository(id = null, localPath = "test-repo") - val branch = Branch( - name = "b", -// commits = mutableSetOf(Commit(sha="a".repeat(40)) - ) - val commit = - Commit( - sha = "a".repeat(40), - message = "message", - commitDateTime = LocalDateTime.now(), - ) - repository.commits.add(commit) - branch.commits.add(commit) - - // When - val violations = validator.validate(commit) - - // Then - assertThat(violations).isEmpty() - } - - @Test - fun `should pass validation when repository id is not null and repositoryId matches`() { - // Given - val repository = Repository(id = "repo-123", localPath = "test-repo") - val branch = Branch(name = "b") - val commit = - Commit( - sha = "a".repeat(40), - message = "message", - commitDateTime = LocalDateTime.now(), - ) - repository.commits.add(commit) - branch.commits.add(commit) - - // When - val violations = validator.validate(commit) - - // Then - assertThat(violations).isEmpty() - } - - @Test - fun `should fail validation when repository id is not null but repositoryId does not match`() { - // Given - val branch = Branch(name = "b") - val commit = run { - val repository = Repository(id = "different-id", localPath = "test-repo") - val cmt = Commit( - sha = "a".repeat(40), - message = "message", - commitDateTime = LocalDateTime.now(), - ) - repository.commits.add(cmt) - cmt - } - branch.commits.add(commit) - val repository = Repository( - id = "repo-123", - localPath = "test-repo", - project = Project(name = "test") - ) - val repository2 = Repository( - id = "different-id", - localPath = "test-repo", - project = Project(name = "test 2") - ) - repository.commits.add(commit) - repository2.commits.add(commit) - - // When - val violations = validator.validate(repository) - - // Then - assertAll( - { assertFalse(violations.isEmpty(), "Should have validation violations") }, - { assertThat(violations).hasSize(1) }, - { - assertThat( - violations.map { it.message }[0], - ).contains("Commit repository.id=different-id does not match repository.id=repo-123") - }, - ) - } - - @Test - fun `should fail validation when repository is null`() { - // Given - val branch = Branch(name = "b") - val commit = - Commit( - message = "test", - sha = "a".repeat(40), - commitDateTime = LocalDateTime.now(), - repository = null, - ) - branch.commits.add(commit) - - // When - val violations = validator.validate(commit) - - // Then - assertThat(violations).hasSize(1) - val message = violations.toList()[0].propertyPath.toString() - assertThat(message).isEqualTo("repository") - } } diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/ProjectValidationTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/ProjectValidationTest.kt index 27ebd0643..01b03d9ba 100644 --- a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/ProjectValidationTest.kt +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/ProjectValidationTest.kt @@ -1,115 +1,34 @@ package com.inso_world.binocular.model.validation import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import jakarta.validation.Validation -import jakarta.validation.Validator +import com.inso_world.binocular.model.utils.ReflectionUtils.Companion.setField +import com.inso_world.binocular.model.validation.base.ValidationTest +import jakarta.validation.ConstraintViolation import org.assertj.core.api.Assertions.assertThat -import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource -class ProjectValidationTest { - private lateinit var validator: Validator +internal class ProjectValidationTest : ValidationTest() { - @BeforeEach - fun setUp() { - val validatorFactory = - Validation - .byDefaultProvider() - .configure() - .messageInterpolator(ParameterMessageInterpolator()) - .buildValidatorFactory() - validator = validatorFactory.validator - } - - @Test - fun `should pass validation when project id is null and repository projectId is null`() { + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `should fail when localPath is blank`( + name: String, + ) { // Given - val project = Project(id = null, name = "test-project") - val repository = - Repository( - id = null, - localPath = "test-repo", - project = project, - ) - project.repo = repository + val project = Project(name = "test-project") + // change field via reflection, otherwise constructor check fails + setField(project.javaClass.getDeclaredField("name").apply { isAccessible = true }, project, name) + assertThat(project.name).isEqualTo(name) // When val violations = validator.validate(project) // Then - assertThat(violations).isEmpty() + assertThat(violations).hasSize(1) + val violation = violations.first() + assertThat(violation).isInstanceOf(ConstraintViolation::class.java) + assertThat(violation.propertyPath.toString()).isEqualTo("name") } - @Test - fun `should fail validation when project id is null but repository projectId is not null`() { - // Given - val project = Project(id = null, name = "test-project") - val repository = - Repository( - id = null, - localPath = "test-repo", - project = Project(id = "some-id", name = "test-project") - ) - project.repo = repository - - // When - val violations = validator.validate(project) - - // Then - assertThat(violations).isNotEmpty() - assertThat(violations.any { it.message.contains("Repository must reference back") }).isTrue() - } - - @Test - fun `should pass validation when project id is not null and repository projectId matches`() { - // Given - val project = Project(id = "123", name = "test-project") - val repository = - Repository( - id = null, - localPath = "test-repo", - project = project, - ) - project.repo = repository - - // When - val violations = validator.validate(project) - - // Then - assertThat(violations).isEmpty() - } - - @Test - fun `should fail validation when project id is not null but repository projectId does not match`() { - // Given - val project = Project(id = "project-123", name = "test-project") - val repository = - Repository( - id = null, - localPath = "test-repo", - project = Project(id = "different-id", name = "test-project"), - ) - project.repo = repository - - // When - val violations = validator.validate(project) - - // Then - assertThat(violations).isNotEmpty() - assertThat(violations.any { it.message.contains("Repository must reference back") }).isTrue() - } - - @Test - fun `should pass validation when repository is null`() { - // Given - val project = Project(id = "project-123", name = "test-project", repo = null) - - // When - val violations = validator.validate(project) - - // Then - assertThat(violations).isEmpty() - } } diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/RemoteValidationTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/RemoteValidationTest.kt new file mode 100644 index 000000000..aab589c76 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/RemoteValidationTest.kt @@ -0,0 +1,533 @@ +package com.inso_world.binocular.model.validation + +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.utils.ReflectionUtils.Companion.setField +import com.inso_world.binocular.model.validation.base.ValidationTest +import com.inso_world.binocular.model.vcs.Remote +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource + +/** + * Comprehensive validation tests for [Remote] domain model ensuring SEON compliance. + * + * # SEON Compliance Requirements + * - **Entity Identity**: Remote must have stable technical identity (iid) and business key (repository.iid + name) + * - **Domain Validation**: All properties must satisfy domain constraints (non-blank, format patterns) + * - **Relationship Integrity**: Remote must maintain valid relationship with owning Repository + * - **Immutability Constraints**: Core identifying properties must be immutable + * + * # Validation Coverage + * - Property-level Jakarta validation annotations (@NotBlank, @Pattern, @GitUrl) + * - Business rule validation (uniqueness within repository) + * - Relationship consistency (repository reference integrity) + * - Edge cases (whitespace, special characters, invalid formats) + */ +internal class RemoteValidationTest : ValidationTest() { + private lateinit var repository: Repository + + @BeforeEach + fun setup() { + val project = Project(name = "test-project") + repository = Repository(localPath = "/path/to/repo", project = project) + } + + @Nested + inner class NameValidation { + /** + * Tests validation of blank remote names. + * + * # SEON Requirement + * - Remote name is a mandatory identifying property in Git domain model + * - Must be non-blank per @NotBlank constraint + * + * # Validation + * - Blank strings (empty, whitespace-only) must fail validation + * - Violation must target the 'name' property + */ + @ParameterizedTest + @MethodSource("com.inso_world.binocular.model.validation.ValidationTestData#provideBlankStrings") + fun `should fail when name is blank`(name: String) { + // Given + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + // Change field via reflection, otherwise constructor check fails + setField( + remote.javaClass.getDeclaredField("name").apply { isAccessible = true }, + remote, + name + ) + assertThat(remote.name).isEqualTo(name) + + // When + val violations = validator.validate(remote) + + // Then + assertThat(violations).hasSizeGreaterThanOrEqualTo(1) + val nameViolations = violations.filter { it.propertyPath.toString() == "name" } + assertThat(nameViolations).isNotEmpty + } + + /** + * Tests validation of invalid remote name patterns. + * + * # SEON Requirement + * - Remote names must follow Git naming conventions + * - Only alphanumeric, dots, underscores, slashes, hyphens allowed + * + * # Validation + * - Names with invalid characters must fail @Pattern constraint + */ + @ParameterizedTest + @ValueSource( + strings = [ + "origin@upstream", // @ not allowed + "remote name", // space not allowed + "remote:name", // : not allowed + "remote*name", // * not allowed + "remote&name", // & not allowed + "remote#name", // # not allowed + "remote\$name", // $ not allowed + "remote%name", // % not allowed + "remote^name", // ^ not allowed + "remote(name)", // parentheses not allowed + "remote[name]", // brackets not allowed + "remote{name}", // braces not allowed + "remote|name", // pipe not allowed + "remote\\name", // backslash not allowed + "remote;name", // semicolon not allowed + "remote'name", // single quote not allowed + "remote\"name", // double quote not allowed + "remote", // angle brackets not allowed + "remote,name", // comma not allowed + "remote?name", // question mark not allowed + "remote!name", // exclamation not allowed + "remote~name", // tilde not allowed + "remote`name", // backtick not allowed + ] + ) + fun `should fail when name contains invalid characters`(invalidName: String) { + // Given + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + setField( + remote.javaClass.getDeclaredField("name").apply { isAccessible = true }, + remote, + invalidName + ) + + // When + val violations = validator.validate(remote) + + // Then + assertThat(violations).hasSizeGreaterThanOrEqualTo(1) + val nameViolations = violations.filter { + it.propertyPath.toString() == "name" && + it.message.contains("alphanumeric") + } + assertThat(nameViolations).isNotEmpty + } + + /** + * Tests validation of valid remote name patterns. + * + * # SEON Requirement + * - Common Git remote names must pass validation + * - Supports standard naming conventions + */ + @ParameterizedTest + @ValueSource( + strings = [ + "origin", // Most common + "upstream", // Fork workflow + "fork", // Another fork name + "production", // Deployment remote + "staging", // Another deployment remote + "backup", // Backup remote + "origin-https", // With hyphen + "origin_ssh", // With underscore + "remote.name", // With dot + "remote/name", // With slash + "remote-name_123", // Mixed alphanumeric + "Remote123", // Mixed case + "ORIGIN", // Uppercase + "123remote", // Starting with number + "r", // Single character + "remote-name-with-many-hyphens", // Long with hyphens + "remote_name_with_many_underscores", // Long with underscores + "remote.name.with.dots", // With multiple dots + ] + ) + fun `should pass when name contains only valid characters`(validName: String) { + // Given + val remote = Remote( + name = validName, + url = "https://github.com/user/repo.git", + repository = repository + ) + + // When + val violations = validator.validate(remote) + + // Then + val nameViolations = violations.filter { it.propertyPath.toString() == "name" } + assertThat(nameViolations).isEmpty() + } + } + + @Nested + inner class UrlValidation { + /** + * Tests validation of blank remote URLs. + * + * # SEON Requirement + * - Remote URL is mandatory for identifying the external repository location + * - Must be non-blank per @NotBlank constraint + * + * # Validation + * - Blank URLs must fail validation + */ + @ParameterizedTest + @MethodSource("com.inso_world.binocular.model.validation.ValidationTestData#provideBlankStrings") + fun `should fail when url is blank`(url: String) { + // Given + val remote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + setField( + remote.javaClass.getDeclaredField("url").apply { isAccessible = true }, + remote, + url + ) + assertThat(remote.url).isEqualTo(url) + + // When + val violations = validator.validate(remote) + + // Then + assertThat(violations).hasSizeGreaterThanOrEqualTo(1) + val urlViolations = violations.filter { it.propertyPath.toString() == "url" } + assertThat(urlViolations).isNotEmpty + } + + /** + * Tests validation of invalid URL formats. + * + * # SEON Requirement + * - Remote URL must be a valid Git URL per @GitUrl constraint + * - Supports HTTP(S), SSH, Git protocols, and local paths + * + * # Validation + * - Invalid URL formats must fail validation + */ + @ParameterizedTest + @ValueSource( + strings = [ + "not-a-url", // Plain text + "github.com/user/repo", // Missing protocol + "htp://invalid.com", // Invalid protocol + "://no-protocol.com", // Missing protocol name + "http://", // Incomplete URL + "http:///path", // Missing host + "http:// space.com", // Space in URL + ] + ) + fun `should fail when url format is invalid`(invalidUrl: String) { + // Given + val remote = Remote( + name = "origin", + url = "https://example.com/user/repo.git", + repository = repository + ) + // required, otherwise constructor will reject + setField( + remote.javaClass.getDeclaredField("url").apply { isAccessible = true }, + remote, + invalidUrl + ) + + // When + val violations = validator.validate(remote) + + // Then + assertThat(violations).hasSizeGreaterThanOrEqualTo(1) + val urlViolations = violations.filter { it.propertyPath.toString() == "url" } + assertThat(urlViolations).isNotEmpty() + } + + /** + * Tests validation of valid URL formats for Git remotes. + * + * # SEON Requirement + * - Remote URLs must support all standard Git protocols + * - HTTPS, HTTP, Git, SSH, file protocols, and local paths are valid + * + * # Supported formats + * - HTTP/HTTPS URLs (e.g., https://github.com/user/repo.git) + * - SSH URLs (e.g., ssh://git@github.com/user/repo.git) + * - Git protocol (e.g., git://github.com/user/repo.git) + * - SCP-like SSH (e.g., git@github.com:user/repo.git) + * - File URLs (e.g., file:///path/to/repo.git) + * - Absolute paths (e.g., /path/to/repo) + * - Relative paths (e.g., ../repo) + */ + @ParameterizedTest + @ValueSource( + strings = [ + // HTTPS URLs + "https://example.com/user/repo.git", + "https://github.com/user/repo.git", + "https://gitlab.com/group/project.git", + "https://bitbucket.org/user/repo.git", + "https://example.com:8080/repo.git", + "https://user@example.com/repo.git", + "https://user:token@example.com/repo.git", + "https://example.com/user/repo-name.git", + "https://example.com/user/repo_name.git", + "https://git.example.com/user/repo.git", + // HTTP URLs + "http://example.com/user/repo.git", + "http://github.com/user/repo.git", + // Git protocol + "git://example.com/user/repo.git", + "git://github.com/user/repo.git", + // SSH URLs + "ssh://git@example.com/user/repo.git", + "ssh://git@github.com/user/repo.git", + "ssh://git@github.com:22/user/repo.git", + // SCP-like SSH syntax + "git@github.com:user/repo.git", + "git@gitlab.com:group/project.git", + "user@example.com:path/to/repo.git", + // File URLs + "file:///path/to/repo.git", + "file:///absolute/path/repo", + // Absolute paths + "/path/to/repo", + "/absolute/path/to/repository.git", + // Relative paths + "../relative/path/repo", + "./current/dir/repo", + "relative/repo", + ] + ) + fun `should pass when url format is valid`(validUrl: String) { + // Given + val remote = Remote( + name = "origin", + url = validUrl, + repository = repository + ) + + // When + val violations = validator.validate(remote) + + // Then + val urlViolations = violations.filter { it.propertyPath.toString() == "url" } + assertThat(urlViolations).isEmpty() + } + } + + @Nested + inner class RepositoryRelationshipValidation { + /** + * Tests that remote properly maintains relationship with repository. + * + * # SEON Requirement + * - Remote is a dependent entity requiring an owning Repository + * - Relationship integrity is critical for domain consistency + * + * # Validation + * - Repository reference must not be null + * - Remote must be registered in repository's remotes collection + */ + @Test + fun `should maintain valid repository relationship`() { + // Given + val remote = Remote( + name = "origin", + url = "https://example.com/user/repo.git", + repository = repository + ) + + // When + val violations = validator.validate(remote) + + // Then + assertThat(violations).isEmpty() + assertThat(remote.repository).isNotNull + assertThat(remote.repository).isSameAs(repository) + assertThat(repository.remotes).contains(remote) + } + } + + @Nested + inner class BusinessKeyValidation { + /** + * Tests uniqueness constraint within repository scope. + * + * # SEON Requirement + * - Remote names must be unique within a single repository + * - Business key is (repository.iid, name) + * - Same name can exist across different repositories + * + * # Validation + * - Duplicate names in same repository should result in same business key + * - Different repositories can have same remote name + */ + @Test + fun `should generate unique business key per repository`() { + // Given + val remote1 = Remote( + name = "origin", + url = "https://example.com/user/repo1.git", + repository = repository + ) + + val anotherProject = Project(name = "another-project") + val anotherRepository = Repository(localPath = "/path/to/another", project = anotherProject) + val remote2 = Remote( + name = "origin", + url = "https://example.com/user/repo2.git", + repository = anotherRepository + ) + + // When + val key1 = remote1.uniqueKey + val key2 = remote2.uniqueKey + + // Then - Same name, different repositories → different keys + assertThat(key1).isNotEqualTo(key2) + assertThat(key1.repositoryId).isEqualTo(repository.iid) + assertThat(key2.repositoryId).isEqualTo(anotherRepository.iid) + assertThat(key1.name).isEqualTo("origin") + assertThat(key2.name).isEqualTo("origin") + } + + @Test + fun `should generate same business key for same repository and name`() { + // Given + val remote1 = Remote( + name = "origin", + url = "https://example.com/user/repo1.git", + repository = repository + ) + + val remote2 = Remote( + name = "upstream", + url = "https://example.com/user/repo2.git", + repository = repository + ) + // Change name to match remote1 + setField( + remote2.javaClass.getDeclaredField("name").apply { isAccessible = true }, + remote2, + "origin" + ) + + // When + val key1 = remote1.uniqueKey + val key2 = remote2.uniqueKey + + // Then - Same repository, same name → same business key + assertThat(key1).isEqualTo(key2) + assertThat(key1.repositoryId).isEqualTo(repository.iid) + assertThat(key2.repositoryId).isEqualTo(repository.iid) + } + } + + @Nested + inner class EdgeCases { + /** + * Tests handling of whitespace in name property. + * + * # SEON Requirement + * - Business key uses trimmed name for consistency + * - Validation should detect effectively blank names + */ + @Test + fun `should trim whitespace in business key name`() { + // Given + val remote = Remote( + name = "origin", + url = "https://example.com/user/repo.git", + repository = repository + ) + setField( + remote.javaClass.getDeclaredField("name").apply { isAccessible = true }, + remote, + " origin " + ) + + // When + val key = remote.uniqueKey + + // Then - Business key should use trimmed name + assertThat(key.name).isEqualTo("origin") + } + + /** + * Tests handling of very long remote names. + * + * # SEON Requirement + * - Remote names should support reasonable lengths + * - No artificial length restrictions beyond pattern validation + */ + @Test + fun `should handle very long remote names`() { + // Given + val longName = "remote-" + "name".repeat(50) // 255 characters + val remote = Remote( + name = longName, + url = "https://example.com/user/repo.git", + repository = repository + ) + + // When + val violations = validator.validate(remote) + + // Then - Should pass pattern validation + val nameViolations = violations.filter { it.propertyPath.toString() == "name" } + assertThat(nameViolations).isEmpty() + } + + /** + * Tests handling of very long URLs. + * + * # SEON Requirement + * - URLs can be arbitrarily long (deep paths, query params) + * - Should support complex repository URLs + */ + @Test + fun `should handle very long urls`() { + // Given + val longPath = "path/".repeat(50) + val longUrl = "https://example.com/user/$longPath/repo.git" + val remote = Remote( + name = "origin", + url = longUrl, + repository = repository + ) + + // When + val violations = validator.validate(remote) + + // Then + val urlViolations = violations.filter { it.propertyPath.toString() == "url" } + assertThat(urlViolations).isEmpty() + } + } +} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/RepositoryValidationTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/RepositoryValidationTest.kt index 126562846..d631d8e57 100644 --- a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/RepositoryValidationTest.kt +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/RepositoryValidationTest.kt @@ -2,117 +2,41 @@ package com.inso_world.binocular.model.validation import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository -import jakarta.validation.Validation -import jakarta.validation.Validator +import com.inso_world.binocular.model.utils.ReflectionUtils.Companion.setField +import com.inso_world.binocular.model.validation.base.ValidationTest +import jakarta.validation.ConstraintViolation import org.assertj.core.api.Assertions.assertThat -import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class RepositoryValidationTest { - private lateinit var validator: Validator - - @BeforeEach - fun setUp() { - val validatorFactory = - Validation - .byDefaultProvider() - .configure() - .messageInterpolator(ParameterMessageInterpolator()) - .buildValidatorFactory() - validator = validatorFactory.validator - } - - @Test - fun `should pass validation when project id is null and projectId is null`() { - // Given - val project = Project(id = null, name = "test-project") - val repository = - Repository( - id = null, - localPath = "test-repo", - project = Project(id = null, name = "test-project-2"), - ) - - // When - val violations = validator.validate(repository) - - // Then - assertThat(violations).isEmpty() - } - - @Test - fun `should fail validation when project id is null but projectId is not null`() { - // Given - val project = Project(id = null, name = "test-project") - val repository = - Repository( - id = null, - localPath = "test-repo", - project = Project(id = "some-id", name = "test-project"), - ) - project.repo = repository - - // When - val violations = validator.validate(project) - - // Then - assertThat(violations).isNotEmpty() - assertThat(violations.any { it.message.contains("Project ID must be null") }).isTrue() - } - - @Test - fun `should pass validation when project id is not null and projectId matches`() { - // Given - val project = Project(id = "project-123", name = "test-project") - val repository = - Repository( - id = null, - localPath = "test-repo", - project = Project(id = "project-123", name = "test-project"), - ) - - // When - val violations = validator.validate(repository) - - // Then - assertThat(violations).isEmpty() - } - - @Test - fun `should fail validation when project id is not null but projectId does not match`() { - // Given - val project = Project(id = "project-123", name = "test-project") - val repository = - Repository( - id = null, - localPath = "test-repo", - project = Project(id = "different-id", name = "test-project"), - ) - project.repo = repository - - // When - val violations = validator.validate(project) - - // Then - assertThat(violations).isNotEmpty() - assertThat(violations.any { it.message.contains("must match project ID") }).isTrue() - } - - @Test - fun `should pass validation when project is null`() { +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +internal class RepositoryValidationTest : ValidationTest() { + @ParameterizedTest + @MethodSource("com.inso_world.binocular.domain.data.DummyTestData#provideBlankStrings") + fun `should fail when localPath is blank`( + localPath: String, + ) { // Given + val project = Project(name = "test-project") val repository = Repository( - id = null, - localPath = "test-repo", - project = Project(id = null, name = "test-project"), + localPath = "localPath", + project = project ) + // change field via reflection, otherwise constructor check fails + setField( + repository.javaClass.getDeclaredField("localPath").apply { isAccessible = true }, + repository, + localPath + ) + assertThat(repository.localPath).isEqualTo(localPath) // When val violations = validator.validate(repository) // Then - assertThat(violations).isEmpty() + assertThat(violations).hasSize(1) + val violation = violations.first() + assertThat(violation).isInstanceOf(ConstraintViolation::class.java) + assertThat(violation.propertyPath.toString()).isEqualTo("localPath") } } diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/UserValidationTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/UserValidationTest.kt deleted file mode 100644 index f70703d56..000000000 --- a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/UserValidationTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.inso_world.binocular.model.validation - -import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User -import jakarta.validation.Validation -import jakarta.validation.Validator -import org.assertj.core.api.Assertions.assertThat -import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource - -class UserValidationTest { - private lateinit var validator: Validator - - private lateinit var repository: Repository - - @BeforeEach - fun setUp() { - val validatorFactory = - Validation - .byDefaultProvider() - .configure() - .messageInterpolator(ParameterMessageInterpolator()) - .buildValidatorFactory() - validator = validatorFactory.validator - - repository = Repository(localPath = "test repo", project = Project(name = "test project")) - } - - @ParameterizedTest - @CsvSource( - "gewerbe@brexner.com", - "noreply@github.com", - "johann.grabner@inso.tuwien.ac.at", - "e1633058@student.tuwien.ac.at", - "mail@matthiasweiss.at", - "e1226762@student.tuwien.ac.at", - "49699333+dependabot[bot]@users.noreply.github.com", - "Michael.Thurner@dr-thurner.org", - "1226762@student.tuwien.ac.at", - "code@rala.io", - "alexander.nemetz-fiedler@outlook.com", - "me@juliankotrba.xyz", - "roman.decker@gmail.com", - ) - fun `test valid email`(mail: String) { - val user = User( - name = "test user", - email = mail - ) - repository.user.add(user) - - // When - val violations = validator.validate(repository) - - // Then - assertThat(violations).isEmpty() - } -} diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/ValidationTestData.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/ValidationTestData.kt index fb813b4d4..bae9e07a9 100644 --- a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/ValidationTestData.kt +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/ValidationTestData.kt @@ -1,161 +1,120 @@ package com.inso_world.binocular.model.validation +import com.inso_world.binocular.domain.data.DummyTestData +import com.inso_world.binocular.domain.data.MockTestDataProvider import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.utils.ReflectionUtils.Companion.setField import org.junit.jupiter.params.provider.Arguments import java.time.LocalDateTime import java.util.stream.Stream +import kotlin.streams.asStream internal object ValidationTestData { @JvmStatic - fun provideBlankStrings(): Stream = - Stream.of( - Arguments.of(""), // Empty string - Arguments.of(" "), // Spaces only - Arguments.of("\t"), // Tab only - Arguments.of("\n"), // Newline only - Arguments.of(" \t\n "), // Mixed whitespace - Arguments.of("\r\n"), // Carriage return + newline - ) + fun provideBlankStrings(): Stream = DummyTestData.provideBlankStrings() @JvmStatic - fun provideInvalidPastOrPresentDateTime(): Stream = - Stream.of( - Arguments.of(LocalDateTime.now().plusSeconds(10)), - Arguments.of(LocalDateTime.now().plusDays(1)), - Arguments.of(LocalDateTime.now().plusWeeks(1)), - Arguments.of(LocalDateTime.now().plusMonths(1)), - Arguments.of(LocalDateTime.now().plusYears(1)), + fun provideInvalidPastOrPresentDateTime(): Stream = DummyTestData.provideInvalidPastOrPresentDateTime() + + @JvmStatic + fun provideInvalidShaHex(): Stream = Stream.of( + Arguments.of("a".repeat(38)), + Arguments.of("a".repeat(39)), + Arguments.of("a".repeat(41)), + *(('g'..'z') + ('G'..'Z')).map { + Arguments.of("$it".repeat(40)) + }.toTypedArray(), + *(('g'..'z') + ('G'..'Z')).map { + Arguments.of(it + "0".repeat(39)) + }.toTypedArray(), ) + private fun createDeveloper(repository: Repository, email: String = "test@example.com"): Developer = + Developer(name = "Test Developer", email = email, repository = repository) + + private fun createSignature(developer: Developer, timestamp: LocalDateTime = LocalDateTime.now().minusSeconds(1)): Signature = + Signature(developer = developer, timestamp = timestamp) + @JvmStatic fun invalidCommitsModels(): Stream = Stream.of( Arguments.of( run { - val repository = Repository(id = "1", localPath = "test repo") + val repository = Repository(localPath = "test repo", project = Project(name = "test project")) + val developer = createDeveloper(repository) + val signature = createSignature(developer) val cmt = Commit( - id = null, - sha = "", // invalid: should be 40 chars - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), + sha = "a".repeat(40), + authorSignature = signature, message = "Valid message", + repository = repository, ) repository.commits.add(cmt) + // change field via reflection, otherwise constructor check fails + setField(cmt.javaClass.getDeclaredField("sha").apply { isAccessible = true }, cmt, "") + cmt }, "sha", ), Arguments.of( run { - val repository = Repository(id="1",localPath = "2222222") + val repository = Repository(localPath = "2222222", project = Project(name = "test project")) + val developer = createDeveloper(repository, "test2@example.com") + val signature = createSignature(developer) val cmt = Commit( - id = null, - sha = "a".repeat(39), // invalid: should be 40 chars - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), + sha = "a".repeat(40), + authorSignature = signature, message = "Valid message", + repository = repository, ) repository.commits.add(cmt) + // invalid: should be 40 chars + // change field via reflection, otherwise constructor check fails + setField(cmt.javaClass.getDeclaredField("sha").apply { isAccessible = true }, cmt, "a".repeat(39)) + cmt }, "sha", ), Arguments.of( run { - val repository = Repository(id="1",localPath = "33333") + val repository = Repository(localPath = "33333", project = Project(name = "test project")) + val developer = createDeveloper(repository, "test3@example.com") + val signature = createSignature(developer) val cmt = Commit( - id = null, - sha = "b".repeat(41), // invalid: should be 40 chars - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), + sha = "a".repeat(40), + authorSignature = signature, message = "Valid message", + repository = repository, ) repository.commits.add(cmt) + + // invalid: should be 40 chars + // change field via reflection, otherwise constructor check fails + setField(cmt.javaClass.getDeclaredField("sha").apply { isAccessible = true }, cmt, "b".repeat(41)) cmt }, "sha", ), - Arguments.of( - run { - val repository = Repository(id="1",localPath = "44444") - val cmt = Commit( - id = null, - sha = "c".repeat(40), - authorDateTime = LocalDateTime.now(), - commitDateTime = null, // invalid: NotNull - message = "Valid message", - ) - repository.commits.add(cmt) - cmt - }, - "commitDateTime", - ), - *provideInvalidPastOrPresentDateTime() - .map { - Arguments.of( - run { - val repository = Repository(id="1",localPath = "5555") - val cmt = Commit( - id = null, - sha = "c".repeat(40), - authorDateTime = LocalDateTime.now(), - commitDateTime = it.get()[0] as LocalDateTime, // invalid: Future - message = "Valid message", - ) - repository.commits.add(cmt) - cmt}, - "commitDateTime", - ) - }.toList() - .toTypedArray(), - *provideInvalidPastOrPresentDateTime() - .map { - Arguments.of( - run { - val repository = Repository(id="1",localPath = "6666") - val cmt = Commit( - id = null, - sha = "c".repeat(40), - authorDateTime = it.get()[0] as LocalDateTime, // invalid: Future - commitDateTime = LocalDateTime.now(), - message = "Valid message", - ) - repository.commits.add(cmt) - cmt}, - "authorDateTime", - ) - }.toList() - .toTypedArray(), - // Add all blank string cases from provideBlankStrings() -// *provideBlankStrings() -// .map { -// Arguments.of( -// Commit( -// id = null, -// sha = "d".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = it.get()[0] as String, // extract the blank string -// repositoryId = "1", -// ), -// "message", -// ) -// }.toList() -// .toTypedArray(), -// run { -// } -// Arguments.of( -// Commit( -// id = null, -// sha = "e".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = "Valid message", -// repositoryId = null, // invalid: NotNull, TODO only invalid if coming out of mapper, going in is ok e.g. on create -// ), -// "repositoryId", -// ), ) + + @JvmStatic + fun mockCommitModels(): Stream { + return run { + val project = Project(name = "test-project") + val repository = Repository( + localPath = "test", + project = project, + ) + return@run MockTestDataProvider(repository).commits.map { + Arguments.of(it) + }.asSequence().asStream() + } + } } diff --git a/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/base/ValidationTest.kt b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/base/ValidationTest.kt new file mode 100644 index 000000000..f9b7116f2 --- /dev/null +++ b/binocular-backend-new/domain/src/test/kotlin/com/inso_world/binocular/model/validation/base/ValidationTest.kt @@ -0,0 +1,21 @@ +package com.inso_world.binocular.model.validation.base + +import jakarta.validation.Validation +import jakarta.validation.Validator +import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator +import org.junit.jupiter.api.BeforeEach + +internal open class ValidationTest { + lateinit var validator: Validator + + @BeforeEach + fun setUp() { + validator = + Validation + .byDefaultProvider() + .configure() + .messageInterpolator(ParameterMessageInterpolator()) + .buildValidatorFactory() + .validator + } +} diff --git a/binocular-backend-new/ffi/lib/Cargo.lock b/binocular-backend-new/ffi/lib/Cargo.lock index fc49d01ab..d162c86b7 100644 --- a/binocular-backend-new/ffi/lib/Cargo.lock +++ b/binocular-backend-new/ffi/lib/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.3" @@ -174,11 +168,10 @@ name = "binocular-blame" version = "0.0.1" dependencies = [ "anyhow", + "commits", "crossbeam-channel", "gix", "log", - "serde", - "serde_json", "shared", ] @@ -188,6 +181,7 @@ version = "0.0.1" dependencies = [ "anyhow", "assertables", + "commits", "crossbeam-channel", "crossbeam-queue", "gix", @@ -367,8 +361,22 @@ dependencies = [ "anyhow", "base64", "gix", + "gix-object", "log", "shared", + "thiserror", +] + +[[package]] +name = "commits-test" +version = "0.0.0" +dependencies = [ + "commits", + "gix", + "itertools", + "parameterized", + "pretty_assertions", + "shared", ] [[package]] @@ -478,17 +486,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "dunce" version = "1.0.5" @@ -583,17 +580,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" -[[package]] -name = "flate2" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" -dependencies = [ - "crc32fast", - "libz-rs-sys", - "miniz_oxide", -] - [[package]] name = "fnv" version = "1.0.7" @@ -607,13 +593,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "fs-err" @@ -648,9 +631,9 @@ dependencies = [ [[package]] name = "gix" -version = "0.73.0" +version = "0.75.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514c29cc879bdc0286b0cbc205585a49b252809eb86c69df4ce4f855ee75f635" +checksum = "60beff35667fb0ac935c4c45941868d9cf5025e4b85c58deb3c5a65113e22ce4" dependencies = [ "gix-actor", "gix-archive", @@ -699,7 +682,6 @@ dependencies = [ "gix-worktree", "gix-worktree-state", "gix-worktree-stream", - "once_cell", "parking_lot", "regex", "signal-hook", @@ -709,9 +691,9 @@ dependencies = [ [[package]] name = "gix-actor" -version = "0.35.4" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d36dcf9efe32b51b12dfa33cedff8414926124e760a32f9e7a6b5580d280967" +checksum = "694f6c16eb88b16b00b1d811e4e4bda6f79e9eb467a1b04fd5b848da677baa81" dependencies = [ "bstr", "gix-date", @@ -723,9 +705,9 @@ dependencies = [ [[package]] name = "gix-archive" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be088a0e1b30abe15572ffafb3409172a3d88148e13959734f24f52112a19d6" +checksum = "1573842ddcd6debcca7c19158ba473dfb5c096a280d3275f6050795528edd348" dependencies = [ "bstr", "gix-date", @@ -737,9 +719,9 @@ dependencies = [ [[package]] name = "gix-attributes" -version = "0.27.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45442188216d08a5959af195f659cb1f244a50d7d2d0c3873633b1cd7135f638" +checksum = "cc6591add69314fc43db078076a8da6f07957c65abb0b21c3e1b6a3cf50aa18d" dependencies = [ "bstr", "gix-glob", @@ -769,18 +751,18 @@ dependencies = [ [[package]] name = "gix-bitmap" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1db9765c69502650da68f0804e3dc2b5f8ccc6a2d104ca6c85bc40700d37540" +checksum = "5e150161b8a75b5860521cb876b506879a3376d3adc857ec7a9d35e7c6a5e531" dependencies = [ "thiserror", ] [[package]] name = "gix-blame" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f33ae48a0d557084199b25441745d44a3f8d0bdaa0e60f534aec305abd3b63dc" +checksum = "5d7c62ee6ebdfe8a21d23609d7e73e45f13a0ec9308aec7d7303640d7bf80fbc" dependencies = [ "gix-commitgraph", "gix-date", @@ -797,18 +779,18 @@ dependencies = [ [[package]] name = "gix-chunk" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1f1d8764958699dc764e3f727cef280ff4d1bd92c107bbf8acd85b30c1bd6f" +checksum = "5c356b3825677cb6ff579551bb8311a81821e184453cbd105e2fc5311b288eeb" dependencies = [ "thiserror", ] [[package]] name = "gix-command" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b31b65ca48a352ae86312b27a514a0c661935f96b481ac8b4371f65815eb196" +checksum = "095c8367c9dc4872a7706fbc39c7f34271b88b541120a4365ff0e36366f66e62" dependencies = [ "bstr", "gix-path", @@ -819,9 +801,9 @@ dependencies = [ [[package]] name = "gix-commitgraph" -version = "0.29.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb23121e952f43a5b07e3e80890336cb847297467a410475036242732980d06" +checksum = "826994ff6c01f1ff00d6a1844d7506717810a91ffed143da71e3bf39369751ef" dependencies = [ "bstr", "gix-chunk", @@ -832,9 +814,9 @@ dependencies = [ [[package]] name = "gix-config" -version = "0.46.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfb898c5b695fd4acfc3c0ab638525a65545d47706064dcf7b5ead6cdb136c0" +checksum = "9419284839421488b5ab9b9b88386bdc1e159a986c08e17ffa3e9a5cd2b139f5" dependencies = [ "bstr", "gix-config-value", @@ -844,7 +826,6 @@ dependencies = [ "gix-ref", "gix-sec", "memchr", - "once_cell", "smallvec", "thiserror", "unicode-bom", @@ -853,9 +834,9 @@ dependencies = [ [[package]] name = "gix-config-value" -version = "0.15.1" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f012703eb67e263c6c1fc96649fec47694dd3e5d2a91abfc65e4a6a6dc85309" +checksum = "2c489abb061c74b0c3ad790e24a606ef968cebab48ec673d6a891ece7d5aef64" dependencies = [ "bitflags", "bstr", @@ -866,9 +847,9 @@ dependencies = [ [[package]] name = "gix-credentials" -version = "0.30.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0039dd3ac606dd80b16353a41b61fc237ca5cb8b612f67a9f880adfad4be4e05" +checksum = "3c5576b03b6396d2df102c98a4bd639797f1922dd06599c92830dfc68fcff287" dependencies = [ "bstr", "gix-command", @@ -884,9 +865,9 @@ dependencies = [ [[package]] name = "gix-date" -version = "0.10.5" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "996b6b90bafb287330af92b274c3e64309dc78359221d8612d11cd10c8b9fe1c" +checksum = "9f94626a5bc591a57025361a3a890092469e47c7667e59fc143439cd6eaf47fe" dependencies = [ "bstr", "itoa", @@ -897,9 +878,9 @@ dependencies = [ [[package]] name = "gix-diff" -version = "0.53.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de854852010d44a317f30c92d67a983e691c9478c8a3fb4117c1f48626bcdea8" +checksum = "cfc7735ca267da78c37e916e9b32d67b0b0e3fc9401378920e9469b5d497dccf" dependencies = [ "bstr", "gix-attributes", @@ -921,9 +902,9 @@ dependencies = [ [[package]] name = "gix-dir" -version = "0.15.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad34e4f373f94902df1ba1d2a1df3a1b29eacd15e316ac5972d842e31422dd7" +checksum = "cb9a55642e31c81d235e6ab2a7f00343c0f79e70973245a8a1e1d16c498e3e86" dependencies = [ "bstr", "gix-discover", @@ -941,9 +922,9 @@ dependencies = [ [[package]] name = "gix-discover" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb180c91ca1a2cf53e828bb63d8d8f8fa7526f49b83b33d7f46cbeb5d79d30a" +checksum = "809f8dba9fbd7a054894ec222815742b96def1ca08e18c38b1dbc1f737dd213d" dependencies = [ "bstr", "dunce", @@ -957,19 +938,19 @@ dependencies = [ [[package]] name = "gix-features" -version = "0.43.1" +version = "0.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1543cd9b8abcbcebaa1a666a5c168ee2cda4dea50d3961ee0e6d1c42f81e5b" +checksum = "dfa64593d1586135102307fb57fb3a9d3868b6b1f45a4da1352cce5070f8916a" dependencies = [ "bytes", "bytesize", "crc32fast", "crossbeam-channel", - "flate2", "gix-path", "gix-trace", "gix-utils", "libc", + "libz-rs-sys", "once_cell", "parking_lot", "prodash", @@ -979,9 +960,9 @@ dependencies = [ [[package]] name = "gix-filter" -version = "0.20.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa6571a3927e7ab10f64279a088e0dae08e8da05547771796d7389bbe28ad9ff" +checksum = "9e137e7df1ae40fe2b49dcb2845c6bf7ac04cd53a320d72e761c598a6fd452ed" dependencies = [ "bstr", "encoding_rs", @@ -989,7 +970,7 @@ dependencies = [ "gix-command", "gix-hash", "gix-object", - "gix-packetline-blocking", + "gix-packetline", "gix-path", "gix-quote", "gix-trace", @@ -1000,9 +981,9 @@ dependencies = [ [[package]] name = "gix-fs" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a4d90307d064fa7230e0f87b03231be28f8ba63b913fc15346f489519d0c304" +checksum = "3f1ecd896258cdc5ccd94d18386d17906b8de265ad2ecf68e3bea6b007f6a28f" dependencies = [ "bstr", "fastrand", @@ -1014,9 +995,9 @@ dependencies = [ [[package]] name = "gix-glob" -version = "0.21.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b947db8366823e7a750c254f6bb29e27e17f27e457bf336ba79b32423db62cd5" +checksum = "74254992150b0a88fdb3ad47635ab649512dff2cbbefca7916bb459894fc9d56" dependencies = [ "bitflags", "bstr", @@ -1026,9 +1007,9 @@ dependencies = [ [[package]] name = "gix-hash" -version = "0.19.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251fad79796a731a2a7664d9ea95ee29a9e99474de2769e152238d4fdb69d50e" +checksum = "826036a9bee95945b0be1e2394c64cd4289916c34a639818f8fd5153906985c1" dependencies = [ "faster-hex", "gix-features", @@ -1038,20 +1019,20 @@ dependencies = [ [[package]] name = "gix-hashtable" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35300b54896153e55d53f4180460931ccd69b7e8d2f6b9d6401122cdedc4f07" +checksum = "a27d4a3ea9640da504a2657fef3419c517fd71f1767ad8935298bcc805edd195" dependencies = [ "gix-hash", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "parking_lot", ] [[package]] name = "gix-ignore" -version = "0.16.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "564d6fddf46e2c981f571b23d6ad40cb08bddcaf6fc7458b1d49727ad23c2870" +checksum = "93b6a9679a1488123b7f2929684bacfd9cd2a24f286b52203b8752cbb8d7fc49" dependencies = [ "bstr", "gix-glob", @@ -1062,9 +1043,9 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af39fde3ce4ce11371d9ce826f2936ec347318f2d1972fe98c2e7134e267e25" +checksum = "eab6410318b98750883eb3e35eb999abfb155b407eb0580726d4d868b60cde04" dependencies = [ "bitflags", "bstr", @@ -1079,7 +1060,7 @@ dependencies = [ "gix-traverse", "gix-utils", "gix-validate", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "itoa", "libc", "memmap2", @@ -1090,9 +1071,9 @@ dependencies = [ [[package]] name = "gix-lock" -version = "18.0.0" +version = "19.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9fa71da90365668a621e184eb5b979904471af1b3b09b943a84bc50e8ad42ed" +checksum = "729d7857429a66023bc0c29d60fa21d0d6ae8862f33c1937ba89e0f74dd5c67f" dependencies = [ "gix-tempfile", "gix-utils", @@ -1101,9 +1082,9 @@ dependencies = [ [[package]] name = "gix-mailmap" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8982e1874a2034d7dd481bcdd6a05579ba444bcda748511eb0f8e50eb10487" +checksum = "2a97041c66c8b6c2f34cf6b8585a36e28a07401a611a69d8a5d2cee0eea2aa72" dependencies = [ "bstr", "gix-actor", @@ -1113,9 +1094,9 @@ dependencies = [ [[package]] name = "gix-negotiate" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d58d4c9118885233be971e0d7a589f5cfb1a8bd6cb6e2ecfb0fc6b1b293c83b" +checksum = "1d7ecfa02c9bddd371ec2cf938ee207fe242616386578f2bfc09d1f8f81d25f9" dependencies = [ "bitflags", "gix-commitgraph", @@ -1129,9 +1110,9 @@ dependencies = [ [[package]] name = "gix-object" -version = "0.50.2" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69ce108ab67b65fbd4fb7e1331502429d78baeb2eee10008bdef55765397c07" +checksum = "84743d1091c501a56f00d7f4c595cb30f20fcef6503b32ac0a1ff3817efd7b5d" dependencies = [ "bstr", "gix-actor", @@ -1150,9 +1131,9 @@ dependencies = [ [[package]] name = "gix-odb" -version = "0.70.0" +version = "0.72.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9d7af10fda9df0bb4f7f9bd507963560b3c66cb15a5b825caf752e0eb109ac" +checksum = "5f81b480252f3a4d55f87e6e358c4c6f7615f98b1742e1e70118c57282a92e82" dependencies = [ "arc-swap", "gix-date", @@ -1171,9 +1152,9 @@ dependencies = [ [[package]] name = "gix-pack" -version = "0.60.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8571df89bfca5abb49c3e3372393f7af7e6f8b8dbe2b96303593cef5b263019" +checksum = "38e868463538731a0fd99f3950637957413bbfbe69143520c0b5c1e163303577" dependencies = [ "clru", "gix-chunk", @@ -1190,21 +1171,9 @@ dependencies = [ [[package]] name = "gix-packetline" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2592fbd36249a2fea11056f7055cc376301ef38d903d157de41998335bbf1f93" -dependencies = [ - "bstr", - "faster-hex", - "gix-trace", - "thiserror", -] - -[[package]] -name = "gix-packetline-blocking" -version = "0.19.1" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc4e706f328cd494cc8f932172e123a72b9a4711b0db5e411681432a89bd4c94" +checksum = "fad0ffb982a289888087a165d3e849cbac724f2aa5431236b050dd2cb9c7de31" dependencies = [ "bstr", "faster-hex", @@ -1214,23 +1183,21 @@ dependencies = [ [[package]] name = "gix-path" -version = "0.10.20" +version = "0.10.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06d37034a4c67bbdda76f7bcd037b2f7bc0fba0c09a6662b19697a5716e7b2fd" +checksum = "7cb06c3e4f8eed6e24fd915fa93145e28a511f4ea0e768bae16673e05ed3f366" dependencies = [ "bstr", "gix-trace", "gix-validate", - "home", - "once_cell", "thiserror", ] [[package]] name = "gix-pathspec" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daedead611c9bd1f3640dc90a9012b45f790201788af4d659f28d94071da7fba" +checksum = "d05e28457dca7c65a2dbe118869aab922a5bd382b7bb10cff5354f366845c128" dependencies = [ "bitflags", "bstr", @@ -1243,9 +1210,9 @@ dependencies = [ [[package]] name = "gix-prompt" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ffa1a7a34c81710aaa666a428c142b6c5d640492fcd41267db0740d923c7906" +checksum = "868e6516dfa16fdcbc5f8c935167d085f2ae65ccd4c9476a4319579d12a69d8d" dependencies = [ "gix-command", "gix-config-value", @@ -1256,9 +1223,9 @@ dependencies = [ [[package]] name = "gix-protocol" -version = "0.51.0" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12b4b807c47ffcf7c1e5b8119585368a56449f3493da93b931e1d4239364e922" +checksum = "6947d3b919ec8d10738f4251905a8485366ffdd24942cdbe9c6b69376bf57d64" dependencies = [ "bstr", "gix-date", @@ -1275,9 +1242,9 @@ dependencies = [ [[package]] name = "gix-quote" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a375a75b4d663e8bafe3bf4940a18a23755644c13582fa326e99f8f987d83fd" +checksum = "e912ec04b7b1566a85ad486db0cab6b9955e3e32bcd3c3a734542ab3af084c5b" dependencies = [ "bstr", "gix-utils", @@ -1286,9 +1253,9 @@ dependencies = [ [[package]] name = "gix-ref" -version = "0.53.1" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b966f578079a42f4a51413b17bce476544cca1cf605753466669082f94721758" +checksum = "e51330a32f173c8e831731dfef8e93a748c23c057f4b028841f222564cad84cb" dependencies = [ "gix-actor", "gix-features", @@ -1307,11 +1274,12 @@ dependencies = [ [[package]] name = "gix-refspec" -version = "0.31.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d29cae1ae31108826e7156a5e60bffacab405f4413f5bc0375e19772cce0055" +checksum = "7f88233214a302d61e60bb9d1387043c1759b761dba4a8704b341fecbf6b1266" dependencies = [ "bstr", + "gix-glob", "gix-hash", "gix-revision", "gix-validate", @@ -1321,9 +1289,9 @@ dependencies = [ [[package]] name = "gix-revision" -version = "0.35.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f651f2b1742f760bb8161d6743229206e962b73d9c33c41f4e4aefa6586cbd3d" +checksum = "ffe7f489bd27e7e388885210bc189088012db6062ccc75d713d1cef8eff56883" dependencies = [ "bitflags", "bstr", @@ -1339,9 +1307,9 @@ dependencies = [ [[package]] name = "gix-revwalk" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e74f91709729e099af6721bd0fa7d62f243f2005085152301ca5cdd86ec02c" +checksum = "dd2fae8449d97fb92078c46cb63544e0024955f43738a610d24277a3b01d5a00" dependencies = [ "gix-commitgraph", "gix-date", @@ -1354,21 +1322,21 @@ dependencies = [ [[package]] name = "gix-sec" -version = "0.12.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f7053ed7c66633b56c57bc6ed3377be3166eaf3dc2df9f1c5ec446df6fdf2c" +checksum = "ea9962ed6d9114f7f100efe038752f41283c225bb507a2888903ac593dffa6be" dependencies = [ "bitflags", "gix-path", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "gix-shallow" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d936745103243ae4c510f19e0760ce73fb0f08096588fdbe0f0d7fb7ce8944b7" +checksum = "e2374692db1ee1ffa0eddcb9e86ec218f7c4cdceda800ebc5a9fdf73a8c08223" dependencies = [ "bstr", "gix-hash", @@ -1378,9 +1346,9 @@ dependencies = [ [[package]] name = "gix-status" -version = "0.20.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4afff9b34eeececa8bdc32b42fb318434b6b1391d9f8d45fe455af08dc2d35" +checksum = "53c9ad16b4d9da73d527eb6d1be05de9e0641855b8084b362dd657255684f81f" dependencies = [ "bstr", "filetime", @@ -1401,9 +1369,9 @@ dependencies = [ [[package]] name = "gix-submodule" -version = "0.20.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "657cc5dd43cbc7a14d9c5aaf02cfbe9c2a15d077cded3f304adb30ef78852d3e" +checksum = "2b79f64c669d8578f45046b3ffb8d4d9cc4beb798871ff638a7b5c1f59dbd2fc" dependencies = [ "bstr", "gix-config", @@ -1416,14 +1384,13 @@ dependencies = [ [[package]] name = "gix-tempfile" -version = "18.0.0" +version = "19.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666c0041bcdedf5fa05e9bef663c897debab24b7dc1741605742412d1d47da57" +checksum = "e265fc6b54e57693232a79d84038381ebfda7b1a3b1b8a9320d4d5fe6e820086" dependencies = [ "dashmap", "gix-fs", "libc", - "once_cell", "parking_lot", "signal-hook", "signal-hook-registry", @@ -1432,15 +1399,15 @@ dependencies = [ [[package]] name = "gix-trace" -version = "0.1.13" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ccaf54b0b1743a695b482ca0ab9d7603744d8d10b2e5d1a332fef337bee658" +checksum = "1d3f59a8de2934f6391b6b3a1a7654eae18961fcb9f9c843533fed34ad0f3457" [[package]] name = "gix-transport" -version = "0.48.0" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f7cc0179fc89d53c54e1f9ce51229494864ab4bf136132d69db1b011741ca3" +checksum = "e058d6667165dba7642b3c293d7c355e2a964acef9bc9408604547d952943a8f" dependencies = [ "bstr", "gix-command", @@ -1454,9 +1421,9 @@ dependencies = [ [[package]] name = "gix-traverse" -version = "0.47.0" +version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7cdc82509d792ba0ad815f86f6b469c7afe10f94362e96c4494525a6601bdd5" +checksum = "054c79f4c3f87e794ff7dc1fec8306a2bb563cfb38f6be2dc0e4c0fa82f74d59" dependencies = [ "bitflags", "gix-commitgraph", @@ -1471,23 +1438,22 @@ dependencies = [ [[package]] name = "gix-url" -version = "0.32.0" +version = "0.33.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b76a9d266254ad287ffd44467cd88e7868799b08f4d52e02d942b93e514d16f" +checksum = "d995249a1cf1ad79ba10af6499d4bf37cb78035c0983eaa09ec5910da694957c" dependencies = [ "bstr", "gix-features", "gix-path", "percent-encoding", "thiserror", - "url", ] [[package]] name = "gix-utils" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5351af2b172caf41a3728eb4455326d84e0d70fe26fc4de74ab0bd37df4191c5" +checksum = "befcdbdfb1238d2854591f760a48711bed85e72d80a10e8f2f93f656746ef7c5" dependencies = [ "bstr", "fastrand", @@ -1496,9 +1462,9 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77b9e00cacde5b51388d28ed746c493b18a6add1f19b5e01d686b3b9ece66d4d" +checksum = "5b1e63a5b516e970a594f870ed4571a8fdcb8a344e7bd407a20db8bd61dbfde4" dependencies = [ "bstr", "thiserror", @@ -1506,9 +1472,9 @@ dependencies = [ [[package]] name = "gix-worktree" -version = "0.42.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f625ac9126c19bef06dbc6d2703cdd7987e21e35b497bb265ac37d383877b1" +checksum = "428e8928e0e27341b58aa89e20adaf643efd6a8f863bc9cdf3ec6199c2110c96" dependencies = [ "bstr", "gix-attributes", @@ -1525,16 +1491,14 @@ dependencies = [ [[package]] name = "gix-worktree-state" -version = "0.20.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ba9b17cbacc02b25801197b20100f7f9bd621db1e7fce9d3c8ab3175207bf8" +checksum = "9e12c7c67138e02717dd87d3cd63065cdd1b6abf8e2aca46f575dc6a99def48c" dependencies = [ "bstr", "gix-features", "gix-filter", "gix-fs", - "gix-glob", - "gix-hash", "gix-index", "gix-object", "gix-path", @@ -1545,9 +1509,9 @@ dependencies = [ [[package]] name = "gix-worktree-stream" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f56a737cefbcd90b573cb5393d636f6dc5e0d08a8086356d8c4fcc623b49a0e8" +checksum = "ed2ccc885b308d918b7de0d7273377990f191706b5716eabb730baeea4d883c6" dependencies = [ "gix-attributes", "gix-features", @@ -1599,9 +1563,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1609,6 +1571,11 @@ name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heapless" @@ -1626,15 +1593,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "home" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "human_format" version = "1.1.0" @@ -1665,113 +1623,6 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" -dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "imara-diff" version = "0.1.8" @@ -1789,6 +1640,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.16.0", + "serde", + "serde_core", ] [[package]] @@ -1807,6 +1660,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1905,12 +1767,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" - [[package]] name = "lock_api" version = "0.4.14" @@ -1958,16 +1814,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "nom" version = "7.1.3" @@ -1999,6 +1845,28 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "parameterized" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502433373749b48ce909bc70124593d055347640417cf88518bac794171bcef8" +dependencies = [ + "parameterized-macro", +] + +[[package]] +name = "parameterized-macro" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6510c182b2c8ab147c6ee557784009379f07dc1c388f2731603730a727c9b665" +dependencies = [ + "fnv", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2049,15 +1917,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "potential_utf" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" -dependencies = [ - "zerovec", -] - [[package]] name = "pretty_assertions" version = "1.4.1" @@ -2280,6 +2139,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_test" version = "1.0.177" @@ -2353,12 +2221,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" - [[package]] name = "siphasher" version = "0.3.11" @@ -2406,17 +2268,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tempfile" version = "3.23.0" @@ -2459,16 +2310,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tinyvec" version = "1.10.0" @@ -2486,13 +2327,43 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "toml" -version = "0.5.11" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "serde", + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", ] +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + [[package]] name = "typenum" version = "1.19.0" @@ -2531,9 +2402,9 @@ dependencies = [ [[package]] name = "uniffi" -version = "0.29.4" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4" +checksum = "c866f627c3f04c3df068b68bb2d725492caaa539dd313e2a9d26bb85b1a32f4e" dependencies = [ "anyhow", "camino", @@ -2548,9 +2419,9 @@ dependencies = [ [[package]] name = "uniffi_bindgen" -version = "0.29.4" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b39ef1acbe1467d5d210f274fae344cb6f8766339330cb4c9688752899bf6b" +checksum = "7c8ca600167641ebe7c8ba9254af40492dda3397c528cc3b2f511bd23e8541a5" dependencies = [ "anyhow", "askama", @@ -2574,9 +2445,9 @@ dependencies = [ [[package]] name = "uniffi_build" -version = "0.29.4" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6683e6b665423cddeacd89a3f97312cf400b2fb245a26f197adaf65c45d505b2" +checksum = "3e55c05228f4858bb258f651d21d743fcc1fe5a2ec20d3c0f9daefddb105ee4d" dependencies = [ "anyhow", "camino", @@ -2585,9 +2456,9 @@ dependencies = [ [[package]] name = "uniffi_core" -version = "0.29.4" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d990b553d6b9a7ee9c3ae71134674739913d52350b56152b0e613595bb5a6f" +checksum = "7e7a5a038ebffe8f4cf91416b154ef3c2468b18e828b7009e01b1b99938089f9" dependencies = [ "anyhow", "bytes", @@ -2597,9 +2468,9 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.29.4" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f4f224becf14885c10e6e400b95cc4d1985738140cb194ccc2044563f8a56b" +checksum = "e3c2a6f93e7b73726e2015696ece25ca0ac5a5f1cf8d6a7ab5214dd0a01d2edf" dependencies = [ "anyhow", "indexmap", @@ -2610,9 +2481,9 @@ dependencies = [ [[package]] name = "uniffi_macros" -version = "0.29.4" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b481d385af334871d70904e6a5f129be7cd38c18fcf8dd8fd1f646b426a56d58" +checksum = "64c6309fc36c7992afc03bc0c5b059c656bccbef3f2a4bc362980017f8936141" dependencies = [ "camino", "fs-err", @@ -2627,9 +2498,9 @@ dependencies = [ [[package]] name = "uniffi_meta" -version = "0.29.4" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10f817868a3b171bb7bf259e882138d104deafde65684689b4694c846d322491" +checksum = "0a138823392dba19b0aa494872689f97d0ee157de5852e2bec157ce6de9cdc22" dependencies = [ "anyhow", "siphasher", @@ -2639,9 +2510,9 @@ dependencies = [ [[package]] name = "uniffi_pipeline" -version = "0.29.4" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b147e133ad7824e32426b90bc41fda584363563f2ba747f590eca1fd6fd14e6" +checksum = "8c27c4b515d25f8e53cc918e238c39a79c3144a40eaf2e51c4a7958973422c29" dependencies = [ "anyhow", "heck", @@ -2652,9 +2523,9 @@ dependencies = [ [[package]] name = "uniffi_udl" -version = "0.29.4" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caed654fb73da5abbc7a7e9c741532284532ba4762d6fe5071372df22a41730a" +checksum = "d0adacdd848aeed7af4f5af7d2f621d5e82531325d405e29463482becfdeafca" dependencies = [ "anyhow", "textwrap", @@ -2662,24 +2533,6 @@ dependencies = [ "weedle2", ] -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.2" @@ -3049,96 +2902,12 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zlib-rs" version = "0.5.2" diff --git a/binocular-backend-new/ffi/lib/Cargo.toml b/binocular-backend-new/ffi/lib/Cargo.toml index 564604a85..0fd32445e 100644 --- a/binocular-backend-new/ffi/lib/Cargo.toml +++ b/binocular-backend-new/ffi/lib/Cargo.toml @@ -21,13 +21,14 @@ crate-type = ["lib", "cdylib"] members = [ "crates/diff", "crates/commits", + "crates/commits/tests", "crates/blame", "crates/shared", ] [workspace.dependencies] -gix = { version = "=0.73.0", default-features = true, features = ["blame"] } +gix = { version = "0.75.0", default-features = true, features = ["blame"] } log = "0.4.28" env_logger = "0.11.8" anyhow = "1.0.100" @@ -35,7 +36,7 @@ chrono = { version = "0.4.42" } thiserror = { version = "2.0.17", default-features = false } [dependencies] -uniffi = { version = "0.29.4", features = ["cli"] } +uniffi = { version = "0.30", features = ["cli"] } gix = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } @@ -46,4 +47,4 @@ binocular-diff = { path = "./crates/diff" } binocular-blame = { path = "./crates/blame" } [build-dependencies] -uniffi = { version = "0.29.2", features = ["build"] } +uniffi = { version = "0.30.0", features = ["build"] } diff --git a/binocular-backend-new/ffi/lib/crates/blame/Cargo.toml b/binocular-backend-new/ffi/lib/crates/blame/Cargo.toml index 66f136191..caac8b321 100644 --- a/binocular-backend-new/ffi/lib/crates/blame/Cargo.toml +++ b/binocular-backend-new/ffi/lib/crates/blame/Cargo.toml @@ -20,6 +20,5 @@ shared = { path = "../shared" } gix = { workspace = true, features = ["max-performance-safe", "revision", "blame"] } log = { workspace = true } anyhow = { workspace = true } -serde = { version = "1.0.218", features = ["derive"] } -serde_json = { version = "1.0.138" } +commits = {path = "../commits"} crossbeam-channel = { version = "0.5.14" } \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/crates/blame/src/git/blame.rs b/binocular-backend-new/ffi/lib/crates/blame/src/git/blame.rs index f7257a03a..d9b093471 100644 --- a/binocular-backend-new/ffi/lib/crates/blame/src/git/blame.rs +++ b/binocular-backend-new/ffi/lib/crates/blame/src/git/blame.rs @@ -29,7 +29,7 @@ where BStr::new(file_path.as_bytes()), gix::blame::Options { diff_algorithm: gix::diff::blob::Algorithm::Histogram, - range: gix::blame::BlameRanges::default(), + ranges: gix::blame::BlameRanges::default(), since: None, rewrites: None, debug_track_path: true, diff --git a/binocular-backend-new/ffi/lib/crates/blame/src/objects/blame_result.rs b/binocular-backend-new/ffi/lib/crates/blame/src/objects/blame_result.rs index afd49ed75..a23c907d5 100644 --- a/binocular-backend-new/ffi/lib/crates/blame/src/objects/blame_result.rs +++ b/binocular-backend-new/ffi/lib/crates/blame/src/objects/blame_result.rs @@ -3,7 +3,7 @@ use crate::git::objects::BlameOutcome; #[derive(Debug, Clone)] pub struct BlameResult { pub blames: Vec, - pub commit_oid: gix::ObjectId, + pub commit_oid: gix::ObjectId, // TODO change to GitCommitMetric } pub(crate) struct BlameResultVec(pub(crate) Vec); diff --git a/binocular-backend-new/ffi/lib/crates/commits/Cargo.toml b/binocular-backend-new/ffi/lib/crates/commits/Cargo.toml index 160e61476..e704b1825 100644 --- a/binocular-backend-new/ffi/lib/crates/commits/Cargo.toml +++ b/binocular-backend-new/ffi/lib/crates/commits/Cargo.toml @@ -18,6 +18,8 @@ doctest = false [dependencies] base64 = "0.22.1" gix = { workspace = true, default-features = true, features = ["mailmap", "max-performance"] } +gix-object = "0.52.0" log = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } shared = { path = "../shared" } diff --git a/binocular-backend-new/ffi/lib/crates/commits/src/git/error.rs b/binocular-backend-new/ffi/lib/crates/commits/src/git/error.rs new file mode 100644 index 000000000..6170ccd82 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/commits/src/git/error.rs @@ -0,0 +1,72 @@ +use thiserror::Error; + +/// Error types for commit lookup operations. +/// +/// Provides detailed error information for different failure scenarios +/// when looking up commits in a Git repository. +#[derive(Debug, Error)] +pub enum CommitLookupError { + /// The provided commit hash could not be parsed as a valid revision. + /// + /// This can happen when: + /// - The hash is malformed (not a valid hex string) + /// - The hash length is incorrect + /// - The revision spec syntax is invalid + #[error("Failed to parse revision '{hash}': {source}")] + RevisionParseError { + hash: String, + #[source] + source: gix::revision::spec::parse::single::Error, + }, + + /// The parsed revision could not be resolved to an object in the repository. + /// + /// This typically occurs when the hash is syntactically valid but + /// doesn't correspond to any object in the repository's object database. + #[error("Object not found for revision '{hash}': {source}")] + ObjectNotFound { + hash: String, + #[source] + source: gix::object::find::existing::Error, + }, + + /// The object exists but could not be converted to a commit. + /// + /// This happens when the object ID points to a non-commit object + /// (e.g., a tree, blob, or tag that doesn't dereference to a commit). + #[error("Object '{hash}' is not a commit: {source}")] + NotACommit { + hash: String, + #[source] + source: gix::object::try_into::Error, + }, + + /// Failed to read commit author information. + #[error("Failed to read author information from commit '{hash}': {message}")] + AuthorReadError { hash: String, message: String }, + + /// Failed to read commit committer information. + #[error("Failed to read committer information from commit '{hash}': {message}")] + CommitterReadError { hash: String, message: String }, +} + +impl CommitLookupError { + /// Returns the hash that caused this error, if available. + pub fn hash(&self) -> &str { + match self { + CommitLookupError::RevisionParseError { hash, .. } => hash, + CommitLookupError::ObjectNotFound { hash, .. } => hash, + CommitLookupError::NotACommit { hash, .. } => hash, + CommitLookupError::AuthorReadError { hash, .. } => hash, + CommitLookupError::CommitterReadError { hash, .. } => hash, + } + } + + /// Returns true if this error indicates the commit does not exist. + pub fn is_not_found(&self) -> bool { + matches!( + self, + CommitLookupError::RevisionParseError { .. } | CommitLookupError::ObjectNotFound { .. } + ) + } +} diff --git a/binocular-backend-new/ffi/lib/crates/commits/src/git/lookup.rs b/binocular-backend-new/ffi/lib/crates/commits/src/git/lookup.rs new file mode 100644 index 000000000..8ebc47828 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/commits/src/git/lookup.rs @@ -0,0 +1,79 @@ +use crate::git::error::CommitLookupError; +use crate::git::utils::apply_mailmap; +use crate::GitCommitMetric; +use std::ops::Deref; + +/// Finds a commit by its hash and returns its metadata. +/// +/// # Arguments +/// * `repo` - The Git repository to search in +/// * `hash` - The commit hash (full or abbreviated) or any valid revision spec +/// * `use_mailmap` - Whether to apply mailmap transformations to author/committer info +/// +/// # Returns +/// A `GitCommitMetric` containing the commit's metadata on success. +/// +/// # Errors +/// Returns `CommitLookupError` in the following cases: +/// - `RevisionParseError`: The hash/revision spec is invalid or malformed +/// - `ObjectNotFound`: No object exists with the given hash +/// - `NotACommit`: The object exists but is not a commit +/// +/// # Example +/// ```ignore +/// let repo = gix::discover(".")?; +/// let commit = find_commit(&repo, "HEAD".to_string(), true)?; +/// println!("Author: {}", commit.author.name); +/// ``` +pub fn find_commit( + repo: &gix::Repository, + hash: String, + use_mailmap: bool, +) -> Result { + log::debug!("find_commit(..., {:?})", hash); + + let mailmap = if use_mailmap { + Some(repo.open_mailmap()) + } else { + None + }; + + // Parse the revision spec + let object_id = repo + .rev_parse_single(hash.deref()) + .map_err(|e| CommitLookupError::RevisionParseError { + hash: hash.clone(), + source: e, + })?; + + // Get the object from the repository + let object = object_id + .object() + .map_err(|e| CommitLookupError::ObjectNotFound { + hash: hash.clone(), + source: e, + })?; + + // Convert to a commit + let commit = object + .try_into_commit() + .map_err(|e| CommitLookupError::NotACommit { + hash: hash.clone(), + source: e, + })?; + + let mut gcm = GitCommitMetric::from(commit.clone()); + + // Apply mailmap transformations if enabled + if let Some(ref mailmap) = mailmap { + if let Some(mailmap_committer) = apply_mailmap(commit.committer(), mailmap) { + gcm.committer = mailmap_committer; + } + + if let Some(mailmap_author) = apply_mailmap(commit.author(), mailmap) { + gcm.author = mailmap_author; + } + } + + Ok(gcm) +} \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/crates/commits/src/git/metrics.rs b/binocular-backend-new/ffi/lib/crates/commits/src/git/metrics.rs index 080a15bdf..ca4675868 100644 --- a/binocular-backend-new/ffi/lib/crates/commits/src/git/metrics.rs +++ b/binocular-backend-new/ffi/lib/crates/commits/src/git/metrics.rs @@ -1,20 +1,22 @@ use base64::prelude::*; use gix::bstr::BString; +use gix::Commit; use shared::signature::Sig; #[derive(Debug, Clone)] pub struct GitCommitMetric { pub commit: gix::ObjectId, pub message: String, - pub committer: Option, - pub author: Option, + /// The committer is the person who last applied the work + pub committer: Sig, + /// The author is the person who originally wrote the work + pub author: Sig, pub branch: Option, pub parents: Vec, pub file_tree: Vec, } -impl From> for GitCommitMetric { - fn from(info: gix::revision::walk::Info) -> Self { - let commit = info.object().unwrap(); +impl From> for GitCommitMetric { + fn from(commit: Commit<'_>) -> Self { let commit_ref = commit.decode().unwrap(); let parents = commit .parent_ids() @@ -36,11 +38,18 @@ impl From> for GitCommitMetric { commit: commit.id, //message: commit_ref.message.to_string().trim().to_string(), message: BASE64_STANDARD.encode(commit_ref.message.to_string().trim()), - author: Some(Sig::from(commit_ref.author)), - committer: Some(Sig::from(commit_ref.committer)), + author: Sig::from(commit_ref.author()), + committer: Sig::from(commit_ref.committer()), branch: None, parents, file_tree, } } } + +impl From> for GitCommitMetric { + fn from(info: gix::revision::walk::Info) -> Self { + let commit = info.object().unwrap(); + GitCommitMetric::from(commit) + } +} diff --git a/binocular-backend-new/ffi/lib/crates/commits/src/git/traverse.rs b/binocular-backend-new/ffi/lib/crates/commits/src/git/traverse.rs index ed27990af..cec0237a3 100644 --- a/binocular-backend-new/ffi/lib/crates/commits/src/git/traverse.rs +++ b/binocular-backend-new/ffi/lib/crates/commits/src/git/traverse.rs @@ -1,17 +1,24 @@ use crate::git::metrics::GitCommitMetric; -use gix::actor::SignatureRef; +use crate::git::utils::apply_mailmap; +use gix::mailmap::Snapshot; +use gix::refs::Reference; +use gix::revision::walk::Info; use gix::traverse::commit::topo::Sorting; use gix::traverse::commit::Parents; -use gix::{Commit, Reference}; +use gix::Commit; use log::{debug, trace}; -use shared::signature::Sig; +use std::collections::HashMap; pub fn traverse_from_to( repo: &gix::Repository, source_commit: &Commit, target_commit: &Option, + use_mailmap: bool, ) -> anyhow::Result> { - let mailmap = repo.open_mailmap(); + let mut mailmap: Option = None; + if use_mailmap { + mailmap = Some(repo.open_mailmap()) + } let tc_id = match target_commit { None => { debug!("No Target commit specified"); @@ -23,10 +30,6 @@ pub fn traverse_from_to( } }; - let apply_mailmap = |gix_sig: Result, _>| { - gix_sig.ok().map(|sig| Sig::from(mailmap.resolve(sig))) - }; - let sorting = Sorting::TopoOrder; let parents = Parents::All; let commit_graph = repo.commit_graph().ok(); @@ -39,71 +42,52 @@ pub fn traverse_from_to( .build()? .filter_map(|info| { info.ok() - .and_then(|info| Some(gix::revision::walk::Info::new(info, &repo))) + .and_then(|info| Some(Info::new(info, &repo))) }); let walk_result: Vec<_> = traverse_result .map(|a| { let commit = &a.object().unwrap(); - let mut gcm = GitCommitMetric::from(a); - match apply_mailmap(commit.committer()) { + + to_metric(mailmap.clone(), a, commit) + }) + .collect(); + + Ok(walk_result) +} + +fn to_metric(mailmap: Option, a: Info, commit: &Commit) -> GitCommitMetric { + let mut gcm = GitCommitMetric::from(a); + match mailmap { + None => {} + Some(mm) => { + match apply_mailmap(commit.committer(), &mm) { None => {} - Some(mailmap_committer) => gcm.committer = Some(mailmap_committer), + Some(mailmap_committer) => gcm.committer = mailmap_committer, } - match apply_mailmap(commit.author()) { + match apply_mailmap(commit.author(), &mm) { None => {} - Some(mailmap_author) => gcm.author = Some(mailmap_author), + Some(mailmap_author) => gcm.author = mailmap_author, } + } + } - gcm - }) - .collect(); - - Ok(walk_result) + gcm } pub fn traverse_commit_graph( repo: gix::Repository, - branches: Vec, + references: Vec, skip_merges: bool, -) -> anyhow::Result> { - let prefixed_branches: Vec = branches - .iter() - .map(|b| { - if b.contains("origin/") { - format!("refs/remotes/{b}") - } else { - format!("refs/heads/{b}") - } - }) - .collect(); - let references = repo.references()?; - - let local_branches = references.local_branches()?; - let remote_branches = references.remote_branches()?; - let local_and_remote_branches = local_branches - .chain(remote_branches) - .flatten() - .collect::>(); - println!("local_and_remote_branches: {:?}", local_and_remote_branches); - - let available_branches: Vec<&Reference> = local_and_remote_branches - .iter() - .filter(|r| prefixed_branches.contains(&r.name().as_bstr().to_string())) - .collect(); - if available_branches.is_empty() { - // bail!("No branches with '{:?}' available", branches); - return Err(anyhow::anyhow!( - "No branches with '{:?}' available", - branches - )); - } + use_mailmap: bool, +) -> anyhow::Result>> { + let mut branch_commits_map: HashMap> = HashMap::new(); + for reference in references { + let target = reference.clone().target.into_id(); - let mut commit_metric_vec: Vec = Vec::new(); - for branch in available_branches { - let mut val: Vec<_> = if let Ok(id) = branch.clone().peel_to_commit() { - traverse_from_to(&repo, &id, &None)? + let val: Vec<_> = if let Ok(target_commit) = repo.find_commit(target) { + traverse_from_to(&repo, &target_commit, &None, use_mailmap)? } else { Vec::new() } @@ -117,12 +101,13 @@ pub fn traverse_commit_graph( }; }) .map(|mut gcm| { - gcm.branch = Option::from(branch.name().shorten().to_string()); + gcm.branch = Option::from(reference.name.shorten().to_string()); gcm }) .collect(); - commit_metric_vec.append(&mut val); + // branch_commits_map.append((branch.deref(), val)); + branch_commits_map.insert(reference.clone(), val); } - Ok(commit_metric_vec) + Ok(branch_commits_map) } diff --git a/binocular-backend-new/ffi/lib/crates/commits/src/git/utils.rs b/binocular-backend-new/ffi/lib/crates/commits/src/git/utils.rs new file mode 100644 index 000000000..514566906 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/commits/src/git/utils.rs @@ -0,0 +1,23 @@ +use gix::actor::SignatureRef; +use gix::mailmap::Snapshot; +use shared::signature::Sig; + +pub(crate) fn apply_mailmap( + gix_sig: Result, gix_object::decode::Error>, + mailmap: &Snapshot, +) -> Option { + match gix_sig { + Ok(sig) => { + let mailmapped = mailmap.resolve(sig); + Some(Sig::from(mailmapped)) + } + Err(_) => None + } + // gix_sig.ok().map(|sig| { + // // println!("sig = {:?}", sig); + // let mailmapped = mailmap.resolve(sig); + // // println!("mailmapped = {:?}", mailmapped); + // Sig::from(mailmapped) + // }) + // gix_sig.ok().map(|sig| Sig::from(sig)) +} diff --git a/binocular-backend-new/ffi/lib/crates/commits/src/lib.rs b/binocular-backend-new/ffi/lib/crates/commits/src/lib.rs index a9f6af7c6..3ba0416a5 100644 --- a/binocular-backend-new/ffi/lib/crates/commits/src/lib.rs +++ b/binocular-backend-new/ffi/lib/crates/commits/src/lib.rs @@ -1,10 +1,16 @@ mod git { + pub mod error; pub mod metrics; pub mod traverse; + pub mod lookup; + + pub(crate) mod utils; } // pub use crate::git::traverse; +pub use git::error::CommitLookupError; pub use git::metrics::GitCommitMetric; +pub use git::lookup::find_commit; pub mod traversal { pub use crate::git::traverse::{traverse_commit_graph as main, traverse_from_to}; @@ -15,6 +21,7 @@ pub mod traversal { repo: gix::Repository, source: String, target: Option, + use_mailmap: bool, ) -> anyhow::Result> { let binding = repo.clone(); let source_commit = binding @@ -31,6 +38,6 @@ pub mod traversal { }), }; - traverse_from_to(&repo, &source_commit, &target_commit) + traverse_from_to(&repo, &source_commit, &target_commit, use_mailmap) } } diff --git a/binocular-backend-new/ffi/lib/crates/commits/tests/Cargo.toml b/binocular-backend-new/ffi/lib/crates/commits/tests/Cargo.toml new file mode 100644 index 000000000..f2edbefa9 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/commits/tests/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "commits-test" +version = "0.0.0" +edition = "2021" +publish = false + +[[test]] +doctest = false +name = "commits" +path = "./main.rs" + +[dependencies] +itertools = "0.14.0" +commits = { path = "../" } +gix = { workspace = true } +shared = { path = "../../shared" } + +[dev-dependencies] +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +parameterized = { version = "2.1.0" } \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/crates/commits/tests/git/lookup.rs b/binocular-backend-new/ffi/lib/crates/commits/tests/git/lookup.rs new file mode 100644 index 000000000..3aef5259c --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/commits/tests/git/lookup.rs @@ -0,0 +1,234 @@ +mod util { + use std::path::Path; + + pub fn get_demo_repo() -> gix::Repository { + // let repo_workdir_pathbuf = repo_workdir("make_diff_for_rewrites_repo.sh").unwrap(); + + gix::discover(Path::new("./")).unwrap() + } +} + +#[cfg(test)] +mod no_mailmap { + use parameterized::parameterized; + use crate::git::lookup::util::get_demo_repo; + const USE_MAILMAP: bool = false; + + #[parameterized( + commit_sha = { + "9853fe8e0e05871b5757c21a23015f3dd169c568", + "e0c0fafcde92fc28e6945b741c3e500c03416af2" + }, + author_email = { + "tmoer93@gmail.com", + "dave@example.com" + }, + author_name = { + "Thomas Moerbauer", + "Dave" + }, + committer_email = { + "tmoer93@gmail.com", + "dave@example.com" + }, + committer_name = { + "Thomas Moerbauer", + "Dave" + })] + fn test_find_commit(commit_sha: &str, author_email: &str, committer_email: &str, committer_name: &str, author_name: &str) { + let repo = get_demo_repo(); + + let c = commits::find_commit(&repo, commit_sha.to_string(), USE_MAILMAP).unwrap(); + + pretty_assertions::assert_eq!(c.commit.to_string(), commit_sha); + // author + pretty_assertions::assert_eq!(c.author.clone().email.to_string(), author_email); + pretty_assertions::assert_eq!(c.author.name.to_string(), author_name); + // committer + pretty_assertions::assert_eq!(c.committer.clone().email.to_string(), committer_email); + pretty_assertions::assert_eq!(c.committer.name.to_string(), committer_name); + + pretty_assertions::assert_eq!(c.committer.time, c.author.time); + } + + #[parameterized( + commit_sha = { + "1627f2aec571240f2a77f8234613738f7653bf26" + }, + author_email = { + "se.watzinger@gmail.com" + }, + author_name = { + "Sebastian Watzinger" + }, + committer_email = { + "se.watzinger@gmail.com" + }, + committer_name = { + "Sebastian Watzinger" + })] + fn test_find_commit_different_author_committer(commit_sha: &str, author_email: &str, committer_email: &str, committer_name: &str, author_name: &str) { + let repo = get_demo_repo(); + + let c = commits::find_commit(&repo, commit_sha.to_string(), USE_MAILMAP).unwrap(); + + pretty_assertions::assert_eq!(c.commit.to_string(), commit_sha); + // author + pretty_assertions::assert_eq!(c.author.clone().email.to_string(), author_email); + pretty_assertions::assert_eq!(c.author.name.to_string(), author_name); + // committer + pretty_assertions::assert_eq!(c.committer.clone().email.to_string(), committer_email); + pretty_assertions::assert_eq!(c.committer.name.to_string(), committer_name); + + pretty_assertions::assert_ne!(c.committer.time, c.author.time); + } +} + +#[cfg(test)] +mod error_handling { + use commits::CommitLookupError; + use crate::git::lookup::util::get_demo_repo; + + #[test] + fn test_invalid_hash_returns_revision_parse_error() { + let repo = get_demo_repo(); + let invalid_hash = "not-a-valid-hash-at-all!@#$".to_string(); + + let result = commits::find_commit(&repo, invalid_hash.clone(), false); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, CommitLookupError::RevisionParseError { .. }), + "Expected RevisionParseError, got: {:?}", + err + ); + assert_eq!(err.hash(), invalid_hash); + assert!(err.is_not_found()); + } + + #[test] + fn test_nonexistent_commit_returns_revision_parse_error() { + let repo = get_demo_repo(); + // Valid hex format but non-existent commit + let nonexistent_hash = "0000000000000000000000000000000000000000".to_string(); + + let result = commits::find_commit(&repo, nonexistent_hash.clone(), false); + + assert!(result.is_err()); + let err = result.unwrap_err(); + // gix returns RevisionParseError for non-existent commits + assert!( + matches!(err, CommitLookupError::RevisionParseError { .. }), + "Expected RevisionParseError for non-existent commit, got: {:?}", + err + ); + assert_eq!(err.hash(), nonexistent_hash); + assert!(err.is_not_found()); + } + + #[test] + fn test_abbreviated_nonexistent_hash_returns_error() { + let repo = get_demo_repo(); + // Abbreviated hash that doesn't exist + let short_hash = "deadbeef".to_string(); + + let result = commits::find_commit(&repo, short_hash.clone(), false); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.is_not_found()); + assert_eq!(err.hash(), short_hash); + } + + #[test] + fn test_empty_hash_returns_error() { + let repo = get_demo_repo(); + let empty_hash = "".to_string(); + + let result = commits::find_commit(&repo, empty_hash.clone(), false); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, CommitLookupError::RevisionParseError { .. }), + "Expected RevisionParseError for empty hash, got: {:?}", + err + ); + } + + #[test] + fn test_error_message_contains_hash() { + let repo = get_demo_repo(); + let invalid_hash = "invalid-test-hash".to_string(); + + let result = commits::find_commit(&repo, invalid_hash.clone(), false); + + assert!(result.is_err()); + let err = result.unwrap_err(); + let error_message = err.to_string(); + assert!( + error_message.contains(&invalid_hash), + "Error message should contain the invalid hash. Message: {}", + error_message + ); + } + + #[test] + fn test_valid_revision_spec_head_works() { + let repo = get_demo_repo(); + + // HEAD is a valid revision spec + let result = commits::find_commit(&repo, "HEAD".to_string(), false); + + assert!(result.is_ok(), "HEAD should be a valid revision spec"); + } + + #[test] + fn test_valid_abbreviated_hash_works() { + let repo = get_demo_repo(); + // Use first 7 chars of a known commit + let abbreviated = "9853fe8".to_string(); + + let result = commits::find_commit(&repo, abbreviated, false); + + assert!(result.is_ok(), "Abbreviated hash should work"); + let commit = result.unwrap(); + assert!(commit.commit.to_string().starts_with("9853fe8")); + } +} + +#[cfg(test)] +mod with_mailmap { + use crate::git::lookup::util::get_demo_repo; + const USE_MAILMAP: bool = true; + + #[test] + fn test_find_commit() { + let repo = get_demo_repo(); + + let c = commits::find_commit(&repo, "9853fe8e0e05871b5757c21a23015f3dd169c568".to_string(), USE_MAILMAP).unwrap(); + + pretty_assertions::assert_eq!(c.commit.to_string(), "9853fe8e0e05871b5757c21a23015f3dd169c568".to_string()); + // committer + pretty_assertions::assert_eq!(c.committer.clone().email.to_string(), "tmoer93@gmail.com"); + pretty_assertions::assert_eq!(c.committer.name.to_string(), "Thomas Mörbauer"); + // author + pretty_assertions::assert_eq!(c.author.clone().email.to_string(), "tmoer93@gmail.com"); + } + + #[test] + fn test_dave_example_com_mailmap() { + let repo = get_demo_repo(); + + let c = commits::find_commit(&repo, "e0c0fafcde92fc28e6945b741c3e500c03416af2".to_string(), USE_MAILMAP).unwrap(); + + pretty_assertions::assert_eq!(c.commit.to_string(), "e0c0fafcde92fc28e6945b741c3e500c03416af2".to_string()); + // committer + pretty_assertions::assert_eq!(c.committer.clone().email.to_string(), "manuel.stoeger@inso-world.com"); + pretty_assertions::assert_eq!(c.committer.name.to_string(), "Manuel Stöger"); + // author + pretty_assertions::assert_eq!(c.author.clone().email.to_string(), "manuel.stoeger@inso-world.com"); + pretty_assertions::assert_eq!(c.author.name.to_string(), "Manuel Stöger"); + } +} \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/crates/commits/tests/git/mod.rs b/binocular-backend-new/ffi/lib/crates/commits/tests/git/mod.rs new file mode 100644 index 000000000..aa7111ea0 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/commits/tests/git/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod lookup; +pub(crate) mod traverse; \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/crates/commits/tests/git/traverse.rs b/binocular-backend-new/ffi/lib/crates/commits/tests/git/traverse.rs new file mode 100644 index 000000000..cc0e995c4 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/commits/tests/git/traverse.rs @@ -0,0 +1,423 @@ +mod util { + use std::path::Path; + + pub fn get_demo_repo() -> gix::Repository { + // let repo_workdir_pathbuf = repo_workdir("make_diff_for_rewrites_repo.sh").unwrap(); + + gix::discover(Path::new("./")).unwrap() + } +} + +#[cfg(test)] +mod no_mailmap { + use crate::git::traverse::util; + use gix::bstr::{ByteSlice, ByteVec}; + use parameterized::parameterized; + use std::process::Command; + + const USE_MAILMAP: bool = false; + + #[parameterized(branch_name = { + "origin/main", + "origin/develop" + })] + fn traverse_branch_check_author_values(branch_name: &str) { + use gix::bstr::BString; + use std::collections::HashMap; + + let repo = util::get_demo_repo(); + let main_branch = repo.find_reference(branch_name).unwrap().detach(); + + let main_info = + commits::traversal::main(repo, vec![main_branch.clone()], false, USE_MAILMAP) + .unwrap() + .get_key_value(&main_branch) + .unwrap() + .1 + .clone(); + + // Check the actual length before grouping + let original_count = main_info.len(); + println!( + "Original commit count from branch '{}': {}", + branch_name, original_count + ); + + // Group commits by committer identity (name + email only, ignoring time) + // Using a tuple of (name, email) as key instead of Sig (which includes time) + let mut author_commits_map: HashMap<(BString, BString), Vec<_>> = HashMap::new(); + + for gcm in main_info { + let author = gcm.author.clone(); + let key = (author.name.clone(), author.email.clone()); + author_commits_map + .entry(key) + .or_insert_with(Vec::new) + .push(gcm); + } + + // Count total commits after grouping + let total_commits: usize = author_commits_map.values().map(|v| v.len()).sum(); + println!("Total commits after grouping: {}", total_commits); + println!( + "Number of unique author identities: {}", + author_commits_map.len() + ); + + // Verify we didn't lose any commits during grouping + pretty_assertions::assert_eq!(total_commits, original_count); + + // Show top committers + let mut author_stats: Vec<_> = author_commits_map + .iter() + .map(|((name, email), commits)| (name, email, commits.len())) + .collect(); + author_stats.sort_by(|a, b| b.2.cmp(&a.2)); + + let mut git_log_cmd = Command::new("sh"); + git_log_cmd.arg("-c").arg( + format!( + "git log --no-mailmap --format='%an <%ae>' {} | sort -u | wc -l", + branch_name + ) + .to_string(), + ); + let git_log_output = git_log_cmd.output().expect("failed to execute process"); + let value = git_log_output.stdout.to_str().unwrap().trim(); + println!("git log: {}", value); + pretty_assertions::assert_eq!(author_stats.len().to_string().as_str(), value); + + // println!("\nTop 10 committers by identity (name + email):"); + for (name, email, count) in author_stats.iter() { + println!(" {} <{}> - {} commits", name, email, count); + let args = format!( + "git log --no-mailmap --pretty=format:'%an <%ae>' {} | grep '{} <{}>' | wc -l", + branch_name, + name.replace("[", "\\[") + .replace("]", "\\]") + .into_string() + .unwrap(), + email + .replace("[", "\\[") + .replace("]", "\\]") + .into_string() + .unwrap() + ); + let mut git_log_cmd = Command::new("sh"); + git_log_cmd.arg("-c").arg(args); + let git_log_output = git_log_cmd.output().expect("failed to execute process"); + let value = git_log_output.stdout.to_str().unwrap().trim(); + println!("git log: {}", value); + pretty_assertions::assert_eq!(count.to_string().as_str(), value); + } + } + + #[parameterized(branch_name = { + "origin/main", + "origin/develop" + })] + fn traverse_branch_check_committer_values(branch_name: &str) { + use gix::bstr::BString; + use std::collections::HashMap; + + let repo = util::get_demo_repo(); + let main_branch = repo.find_reference(branch_name).unwrap().detach(); + + let main_info = + commits::traversal::main(repo, vec![main_branch.clone()], false, USE_MAILMAP) + .unwrap() + .get_key_value(&main_branch) + .unwrap() + .1 + .clone(); + + // Check the actual length before grouping + let original_count = main_info.len(); + println!( + "Original commit count from branch '{}': {}", + branch_name, original_count + ); + + // Group commits by committer identity (name + email only, ignoring time) + // Using a tuple of (name, email) as key instead of Sig (which includes time) + let mut committer_commits_map: HashMap<(BString, BString), Vec<_>> = HashMap::new(); + + for gcm in main_info { + let committer = gcm.committer.clone(); + let key = (committer.name.clone(), committer.email.clone()); + committer_commits_map + .entry(key) + .or_insert_with(Vec::new) + .push(gcm); + } + + // Count total commits after grouping + let total_commits: usize = committer_commits_map.values().map(|v| v.len()).sum(); + println!("Total commits after grouping: {}", total_commits); + println!( + "Number of unique committer identities: {}", + committer_commits_map.len() + ); + + // Verify we didn't lose any commits during grouping + pretty_assertions::assert_eq!(total_commits, original_count); + + // Show top committers + let mut committer_stats: Vec<_> = committer_commits_map + .iter() + .map(|((name, email), commits)| (name, email, commits.len())) + .collect(); + committer_stats.sort_by(|a, b| b.2.cmp(&a.2)); + + let mut git_log_cmd = Command::new("sh"); + git_log_cmd.arg("-c").arg( + format!( + "git log --no-mailmap --format='%cn <%ce>' {} | sort -u | wc -l", + branch_name + ) + .to_string(), + ); + let git_log_output = git_log_cmd.output().expect("failed to execute process"); + let value = git_log_output.stdout.to_str().unwrap().trim(); + println!("git log: {}", value); + pretty_assertions::assert_eq!(committer_stats.len().to_string().as_str(), value); + + // println!("\nTop 10 committers by identity (name + email):"); + for (name, email, count) in committer_stats.iter() { + println!(" {} <{}> - {} commits", name, email, count); + let args = format!( + "git log --no-mailmap --pretty=format:'%cn <%ce>' {} | grep '{} <{}>' | wc -l", + branch_name, + name.replace("[", "\\[") + .replace("]", "\\]") + .into_string() + .unwrap(), + email + .replace("[", "\\[") + .replace("]", "\\]") + .into_string() + .unwrap() + ); + let mut git_log_cmd = Command::new("sh"); + git_log_cmd.arg("-c").arg(args); + let git_log_output = git_log_cmd.output().expect("failed to execute process"); + let value = git_log_output.stdout.to_str().unwrap().trim(); + println!("git log: {}", value); + pretty_assertions::assert_eq!(count.to_string().as_str(), value); + } + } +} + +#[cfg(test)] +mod with_mailmap { + use crate::git::traverse::util; + use gix::bstr::ByteSlice; + use parameterized::parameterized; + use std::process::Command; + + const USE_MAILMAP: bool = true; + + #[parameterized(branch_name = { + "origin/main", + "origin/develop" + })] + fn traverse_branch_check_committer_values(branch_name: &str) { + use gix::bstr::BString; + use std::collections::HashMap; + + let repo = util::get_demo_repo(); + let main_branch = repo.find_reference(branch_name).unwrap().detach(); + + let main_info = + commits::traversal::main(repo, vec![main_branch.clone()], false, USE_MAILMAP) + .unwrap() + .get_key_value(&main_branch) + .unwrap() + .1 + .clone(); + + // Check the actual length before grouping + let original_count = main_info.len(); + println!( + "Original commit count from branch '{}': {}", + branch_name, original_count + ); + + // Group commits by committer identity (name + email only, ignoring time) + // Using a tuple of (name, email) as key instead of Sig (which includes time) + let mut committer_commits_map: HashMap<(BString, BString), Vec<_>> = HashMap::new(); + + for gcm in main_info { + let committer = gcm.committer.clone(); + let key = (committer.name.clone(), committer.email.clone()); + committer_commits_map + .entry(key) + .or_insert_with(Vec::new) + .push(gcm); + } + + // Count total commits after grouping + let total_commits: usize = committer_commits_map.values().map(|v| v.len()).sum(); + println!("Total commits after grouping: {}", total_commits); + println!( + "Number of unique committer identities: {}", + committer_commits_map.len() + ); + + // Verify we didn't lose any commits during grouping + pretty_assertions::assert_eq!(total_commits, original_count); + + // Show top committers + let mut committer_stats: Vec<_> = committer_commits_map + .iter() + .map(|((name, email), commits)| (name, email, commits.len())) + .collect(); + committer_stats.sort_by(|a, b| b.2.cmp(&a.2)); + + let mut git_log_cmd = Command::new("sh"); + git_log_cmd.arg("-c").arg(format!( + "git log --use-mailmap --format='%cN <%cE>' {} | sort -u | wc -l", + branch_name + )); + let git_log_output = git_log_cmd.output().expect("failed to execute process"); + let value = git_log_output.stdout.to_str().unwrap().trim(); + println!("git log: {}", value); + pretty_assertions::assert_eq!(committer_stats.len().to_string().as_str(), value); + + // println!("\nTop 10 committers by identity (name + email):"); + for (name, email, count) in committer_stats.iter() { + println!(" {} <{}> - {} commits", name, email, count); + let mut git_log_cmd = Command::new("sh"); + git_log_cmd.arg("-c").arg(format!( + "git log --use-mailmap --pretty=format:'%cN <%cE>' {} | grep '{}' | wc -l", + branch_name, email + )); + let git_log_output = git_log_cmd.output().expect("failed to execute process"); + let value = git_log_output.stdout.to_str().unwrap().trim(); + println!("git log: {}", value); + pretty_assertions::assert_eq!(count.to_string().as_str(), value); + } + } + + #[parameterized(branch_name = { + "origin/main", + "origin/develop" + })] + fn traverse_branch_check_author_values(branch_name: &str) { + use gix::bstr::BString; + use std::collections::HashMap; + + let repo = util::get_demo_repo(); + let main_branch = repo.find_reference(branch_name).unwrap().detach(); + + let main_info = + commits::traversal::main(repo, vec![main_branch.clone()], false, USE_MAILMAP) + .unwrap() + .get_key_value(&main_branch) + .unwrap() + .1 + .clone(); + + // Check the actual length before grouping + let original_count = main_info.len(); + println!( + "Original commit count from branch '{}': {}", + branch_name, original_count + ); + + // Group commits by committer identity (name + email only, ignoring time) + // Using a tuple of (name, email) as key instead of Sig (which includes time) + let mut author_commits_map: HashMap<(BString, BString), Vec<_>> = HashMap::new(); + + for gcm in main_info { + let author = gcm.author.clone(); + let key = (author.name.clone(), author.email.clone()); + author_commits_map + .entry(key) + .or_insert_with(Vec::new) + .push(gcm); + } + + // Count total commits after grouping + let total_commits: usize = author_commits_map.values().map(|v| v.len()).sum(); + println!("Total commits after grouping: {}", total_commits); + println!( + "Number of unique author identities: {}", + author_commits_map.len() + ); + + // Verify we didn't lose any commits during grouping + pretty_assertions::assert_eq!(total_commits, original_count); + + // Show top committers + let mut author_stats: Vec<_> = author_commits_map + .iter() + .map(|((name, email), commits)| (name.to_string(), email.to_string(), commits)) + .collect(); + author_stats.sort_by(|a, b| b.2.len().cmp(&a.2.len())); + + let mut git_log_cmd = Command::new("sh"); + git_log_cmd.arg("-c").arg(format!( + "git log --use-mailmap --format='%aN <%aE>' {} | sort -u | wc -l", + branch_name + )); + let git_log_output = git_log_cmd.output().expect("failed to execute process"); + let value = git_log_output.stdout.to_str().unwrap().trim(); + println!("git log (count all): {}", value); + pretty_assertions::assert_eq!(author_stats.len().to_string().as_str(), value); + + // println!("\nTop 10 committers by identity (name + email):"); + for (name, email, count) in author_stats.iter() { + println!(" {} <{}> - {} commits", name, email, count.len()); + let mut git_log_cmd = Command::new("sh"); + git_log_cmd.arg("-c").arg(format!( + "git log --use-mailmap --pretty=format:'%aN <%aE>' {} | grep '{}' | wc -l", + branch_name, + // name.replace("[", "\\[") + // .replace("]", "\\]") + // .into_string() + // .unwrap(), + email + .replace("[", "\\[") + .replace("]", "\\]") + )); + let git_log_output = git_log_cmd.output().expect("failed to execute process"); + let value = git_log_output.stdout.to_str().unwrap().trim(); + println!("git log: {}", value); + pretty_assertions::assert_eq!(count.len().to_string().as_str(), value); + } + } +} + +#[cfg(test)] +mod traversals { + use std::process::Command; + use gix::bstr::ByteSlice; + use crate::git::traverse::util::get_demo_repo; + use parameterized::parameterized; + + #[parameterized(branch_name = { + "origin/main", + "origin/develop", + })] + fn traverse_main_branch(branch_name: &str) { + let repo = get_demo_repo(); + let main_branch = repo.find_reference(branch_name).unwrap().detach(); + + let result = + commits::traversal::main(repo, vec![main_branch.clone()], false, true).unwrap(); + + let mut git_revlist_cmd = Command::new("sh"); + git_revlist_cmd.arg("-c").arg(format!( + "git rev-list --count {}", + branch_name + )); + let git_log_output = git_revlist_cmd.output().expect("failed to execute process"); + let value = git_log_output.stdout.to_str().unwrap().trim(); + println!("git rev-list: {}", value); + + pretty_assertions::assert_eq!(result.len(), 1); + let main_info = result.get_key_value(&main_branch).unwrap().1.clone(); + pretty_assertions::assert_eq!(main_info.len().to_string(), value); + } +} diff --git a/binocular-backend-new/ffi/lib/crates/commits/tests/main.rs b/binocular-backend-new/ffi/lib/crates/commits/tests/main.rs new file mode 100644 index 000000000..381af0ddf --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/commits/tests/main.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +mod git; \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/crates/diff/Cargo.toml b/binocular-backend-new/ffi/lib/crates/diff/Cargo.toml index 47112e833..f08a6aa07 100644 --- a/binocular-backend-new/ffi/lib/crates/diff/Cargo.toml +++ b/binocular-backend-new/ffi/lib/crates/diff/Cargo.toml @@ -19,6 +19,7 @@ crossbeam-queue = { version = "0.3.12" } gix = { workspace = true, features = ["max-performance-safe", "blob-diff", "revision", "mailmap", ] } shared = { path = "../shared" } rayon = { version = "1.11.0" } +commits = { path = "../commits" } log = { workspace = true } anyhow = { workspace = true } diff --git a/binocular-backend-new/ffi/lib/crates/diff/src/git/commit.rs b/binocular-backend-new/ffi/lib/crates/diff/src/git/commit.rs index 9f7f0f179..e4fd83c35 100644 --- a/binocular-backend-new/ffi/lib/crates/diff/src/git/commit.rs +++ b/binocular-backend-new/ffi/lib/crates/diff/src/git/commit.rs @@ -4,13 +4,13 @@ use gix::Commit; use log::{error, info, trace, warn}; pub(crate) fn prepare_commit_list( - repo: &gix::Repository, + repo: &'_ gix::Repository, commitlist: Vec, skip_merges: bool, breadth_first: bool, follow: bool, limit: Option, -) -> anyhow::Result> { +) -> anyhow::Result>> { let result_limit = limit.unwrap_or(usize::MAX); // Do not calculate anything if limit is set to <= 0 if result_limit <= 0 { diff --git a/binocular-backend-new/ffi/lib/crates/diff/src/git/traverse.rs b/binocular-backend-new/ffi/lib/crates/diff/src/git/traverse.rs index f3ad3dbdf..c888cecb7 100644 --- a/binocular-backend-new/ffi/lib/crates/diff/src/git/traverse.rs +++ b/binocular-backend-new/ffi/lib/crates/diff/src/git/traverse.rs @@ -1,45 +1,14 @@ use crate::objects::{Entry, GitDiffOutcome}; use crate::utils; use anyhow::Result; +use commits::GitCommitMetric; use crossbeam_queue::SegQueue; -use gix::{diff::blob::Platform, Commit, ObjectId, Repository}; +use gix::{diff::blob::Platform, Commit, ObjectId}; use log::{debug, error, trace}; -use rayon::ThreadPoolBuilder; -use std::sync::Arc; use std::thread::JoinHandle; #[cfg(feature = "progress")] use tqdm::tqdm; -// pub fn calculate_pairs_single( -// repo: &gix::Repository, -// pairs: Vec<(ObjectId, ObjectId)>, -// max_threads: usize, -// diff_algorithm: Option, -// ) -> Result> { -// trace!("Algorithm: {:?}", diff_algorithm); -// -// let mut rewrite_cache = -// repo.diff_resource_cache(gix::diff::blob::pipeline::Mode::ToGit, Default::default())?; -// rewrite_cache -// .options -// .skip_internal_diff_if_external_is_configured = false; -// rewrite_cache.options.algorithm = diff_algorithm; -// -// let diffs = pairs -// .iter() -// .map(|(suspect, target)| { -// let s_commit = repo.find_object(*suspect).unwrap().into_commit(); -// let p_commit = repo.find_object(*target).unwrap().into_commit(); -// (s_commit, p_commit) -// }) -// .map(|(suspect, target)| compute_diff(&suspect, target, &mut rewrite_cache)) -// .filter(|diff| diff.is_ok()) -// .map(|diff| diff.unwrap()) -// .collect(); -// -// Ok(diffs) -// } - pub fn calculate_pairs( repo: &gix::Repository, pairs: Vec<(ObjectId, Option)>, @@ -74,7 +43,7 @@ pub fn calculate_pairs( |(_repo, rewrite_cache), (suspect, target)| -> Result<()> { // process items on THIS worker, reusing its cache let s_commit = _repo.find_object(*suspect)?.into_commit(); - let p_commit = match target { + let p_commit = match target { Some(_target) => Some(_repo.find_object(*_target)?.into_commit()), None => None, }; @@ -231,9 +200,9 @@ fn compute_diff( ); Ok(GitDiffOutcome::new( change_map, - suspect.id, - match target { - Some(t) => Some(t.id), + GitCommitMetric::from(suspect), + match target { + Some(t) => Some(GitCommitMetric::from(t)), None => None, }, None, @@ -273,7 +242,7 @@ fn compute_diff_with_parent( debug!("commit {:?}\tparents {:?}", commit, parent_commits); let diffs: Vec = parent_trees - .iter() + .into_iter() .map(|(parent_commit, parent_tree)| { let change_map = utils::git_helper::calculate_changes( &parent_tree, @@ -283,9 +252,9 @@ fn compute_diff_with_parent( ); GitDiffOutcome::new( change_map, - commit.id, + GitCommitMetric::from(commit.clone()), match parent_commit { - Some(pc) => Some(pc.id), + Some(pc) => Some(GitCommitMetric::from(pc.clone())), None => None, }, None, diff --git a/binocular-backend-new/ffi/lib/crates/diff/src/objects/outcome.rs b/binocular-backend-new/ffi/lib/crates/diff/src/objects/outcome.rs index e941ee3bd..6b31f0f10 100644 --- a/binocular-backend-new/ffi/lib/crates/diff/src/objects/outcome.rs +++ b/binocular-backend-new/ffi/lib/crates/diff/src/objects/outcome.rs @@ -1,12 +1,13 @@ use crate::objects::file_diff::FileDiff; use gix::ObjectId; +use commits::GitCommitMetric; use shared::signature::Sig; #[derive(Debug, Clone)] pub struct GitDiffOutcome { pub files: Vec, - pub commit: ObjectId, - pub parent: Option, + pub commit: GitCommitMetric, + pub parent: Option, pub committer: Option, pub author: Option, } @@ -15,8 +16,8 @@ impl GitDiffOutcome { pub fn new( // change_map: HashMap, files: Vec, - commit: ObjectId, - parent: Option, + commit: GitCommitMetric, + parent: Option, committer: Option, author: Option, ) -> anyhow::Result { diff --git a/binocular-backend-new/ffi/lib/crates/diff/src/utils/git_helper.rs b/binocular-backend-new/ffi/lib/crates/diff/src/utils/git_helper.rs index 7acf4a3ce..2ca21b5bb 100644 --- a/binocular-backend-new/ffi/lib/crates/diff/src/utils/git_helper.rs +++ b/binocular-backend-new/ffi/lib/crates/diff/src/utils/git_helper.rs @@ -83,7 +83,7 @@ fn gitoxide_diff_calculation( }; let new_file_content = match prep.new.data { Data::Missing => None, - Data::Buffer { buf, is_derived, .. } => { + Data::Buffer { buf, .. } => { String::from_utf8(buf.to_vec()).ok() } Data::Binary { .. } => None diff --git a/binocular-backend-new/ffi/lib/crates/diff/tests/Cargo.toml b/binocular-backend-new/ffi/lib/crates/diff/tests/Cargo.toml new file mode 100644 index 000000000..0b1f30000 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/diff/tests/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "binocular-diff-test" +version = "0.0.0" +edition = "2021" +publish = false + +[[test]] +doctest = false +name = "diff" +path = "./main.rs" + +[dev-dependencies] +gix-testtools = { version = "=0.15.0" } +gix = { workspace = true, features = ["max-performance-safe", "blob-diff", "revision", "mailmap", "blame"] } +binocular-diff = { path = ".." } +shared = { path = "../../shared" } +pretty_assertions = { version = "1.4.1" } +assertables = { version = "9.5.0" } +polars = { workspace = true } \ No newline at end of file diff --git a/binocular-backend-new/cli/src/test/resources/fixtures/make_blame_repo.sh b/binocular-backend-new/ffi/lib/crates/diff/tests/fixtures/make_blame_repo.sh old mode 100755 new mode 100644 similarity index 97% rename from binocular-backend-new/cli/src/test/resources/fixtures/make_blame_repo.sh rename to binocular-backend-new/ffi/lib/crates/diff/tests/fixtures/make_blame_repo.sh index f258f9105..54c82eb23 --- a/binocular-backend-new/cli/src/test/resources/fixtures/make_blame_repo.sh +++ b/binocular-backend-new/ffi/lib/crates/diff/tests/fixtures/make_blame_repo.sh @@ -1,15 +1,5 @@ #!/usr/bin/env bash -set -euo pipefail - -# Verify that exactly one argument (the target directory) is provided. -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -REPO_DIR="$1" -mkdir -p "$REPO_DIR" -cd "$REPO_DIR" +set -eu -o pipefail git config --local diff.algorithm histogram diff --git a/binocular-backend-new/ffi/lib/crates/diff/tests/fixtures/make_diff_for_rewrites_repo.sh b/binocular-backend-new/ffi/lib/crates/diff/tests/fixtures/make_diff_for_rewrites_repo.sh new file mode 100644 index 000000000..5d63c067d --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/diff/tests/fixtures/make_diff_for_rewrites_repo.sh @@ -0,0 +1,797 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +function store_tree() { + local revspec="${1:?the commit to get the tree for}" + local current_commit + current_commit=$(git rev-parse HEAD) + git rev-parse "${current_commit}^{tree}" > ../"$revspec".tree +} + +git init -q + +cat <>.git/config + +[diff "binary-true"] + binary = true +[diff "binary-false"] + binary = false +[diff ""] + command = "empty is ignored" +[diff] + command = "this is also ignored as sub-section name is missing" + algorithm = histogram +[diff "all-but-binary"] + command = command + textconv = textconv + algorithm = histogram + binary = auto +EOF + +git checkout -b main +mkdir dir +touch a b dir/c d +git add . +git commit -q -m "c1 - initial" +store_tree "c1 - initial" + +echo a >> a +echo b >> b +echo dir/c >> dir/c +echo d >> d +git commit -q -am "c2" +store_tree "c2" + +echo a1 >> a +echo dir/c1 >> dir/c +git commit -q -am "c3-modification" +store_tree "c3-modification" + +git mv a dir/a-moved +git commit -m "r1-identity" +store_tree "r1-identity" + +touch s1 s2 s3 +git add s* && git commit -m "c4 - add identical files" +store_tree "c4 - add identical files" + +git mv s1 z && git mv s2 b2 && git mv s3 b1 +git commit -m "r2-ambiguous" +store_tree "r2-ambiguous" + +git mv dir/c dir/c-moved +echo dir/cn >> dir/c-moved +echo n >> b +git commit -am "r3-simple" # modified rename and normal modification +store_tree "r3-simple" + +touch lt1 lt2 +ln -s lt1 link-1 +echo lt1 > no-link # a file that has content like a link and a similar name +ln -s ../lt2 dir/link-2 +git add . && git commit -m "c5 - add links" +store_tree "c5 - add links" + +git mv link-1 renamed-link-1 +git rm no-link +git rm dir/link-2 && ln -s lt1 z-link-2 && git add . +git commit -m "r4-symlinks" # symlinks are only tracked by identity +store_tree "r4-symlinks" + +seq 10 > f1 +seq 11 > f2 +git add . && git commit -m "c6 - two files with more content" +store_tree "c6" + +echo n >> f1 +echo n >> f2 +git mv f1 f1-renamed +git mv f2 f2-renamed + +git commit -am "r5" # two renames +store_tree "r5" + + +seq 9 > base +git add base +git commit -m "c7" # base has to be added +store_tree "c7" + +echo 10 >> base +cp base c1 +cp base c2 +cp base dir/c3 +git add . && git commit -m "tc1-identity" +store_tree "tc1-identity" + +echo 11 >> base +cp base c4 # can be located by identity +cp base c5 && echo 12 >> c5 +cp base dir/c6 && echo 13 >> dir/c6 +git add . && git commit -m "tc2-similarity" +store_tree "tc2-similarity" + +cp base c6 # can be located by identity, but base needs --find-copies-harder +cp base c7 && echo 13 >> c7 # modified copy, similarity and find copies harder +seq 15 > newly-added +echo nn >> b +git add . +git commit -m "tc3-find-harder" +store_tree "tc3-find-harder" + +rm -Rf ./* +# from 92de081dc9ab5660cb18fa750452345dd63550ea~1 of `gitoxide` +while read -r _ _ _ path; do + mkdir -p ${path%/*} && touch $path +done < git-index/tests/index/file/mod.rs +git add . && git commit -m "r1-change" +store_tree "r1-change" + +rm -Rf ./* +# from d7ad650d3~1 of `gitoxide` +while read -r _ _ _ path; do + mkdir -p ${path%/*} && touch $path +done < baseline-3.no-renames +git -c diff.renames=1 show > baseline-3.with-renames +git -c diff.renames=0 show HEAD~2 > baseline-2.no-renames +git -c diff.renames=1 show HEAD~2 > baseline-2.with-renames +git -c diff.renames=0 show HEAD~4 > baseline.no-renames +git -c diff.renames=1 show HEAD~4 > baseline.with-renames + +mv ../*.tree . \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/crates/diff/tests/git/mod.rs b/binocular-backend-new/ffi/lib/crates/diff/tests/git/mod.rs new file mode 100644 index 000000000..0112a9ea5 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/diff/tests/git/mod.rs @@ -0,0 +1 @@ +pub(crate) mod traverse; \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/crates/diff/tests/git/traverse.rs b/binocular-backend-new/ffi/lib/crates/diff/tests/git/traverse.rs new file mode 100644 index 000000000..507f2f9c1 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/diff/tests/git/traverse.rs @@ -0,0 +1,586 @@ +use crate::git::traverse::util::{get_demo_repo, get_demo_repo_merges}; +use cartography_diff::traversal::main; +use gix::date::time::Sign; +use gix_testtools::bstr::BString; +use pretty_assertions::assert_eq; +use assertables::assert_none; +#[test] +fn check_correct_number_of_results_unlimited() { + let local_repo = get_demo_repo(); + let result = main( + &local_repo, // repo + vec!["HEAD".to_string()], // commitlist + 1, // max_threads + false, //no_merges + None, //diff_algo + true, // breadth_first + true, // follow + None, // limit + ) + .unwrap(); + assert_eq!(result.iter().clone().count(), 21); +} + +#[test] +fn check_correct_number_of_results_20_committish() { + let local_repo = get_demo_repo(); + let result = main( + &local_repo, + vec!["HEAD".to_string()], + 1, + false, + None, + true, + true, + Some(20), + ) + .unwrap(); + assert_eq!(result.iter().clone().count(), 20); +} + +#[test] +fn check_correct_number_of_results_3_commitlist_with_limit_1() { + let local_repo = get_demo_repo(); + let commitlist = vec![ + String::from("0cf7a4fe3ad6c49ae7beb394a1c1df7cc5173ce4"), + String::from("a9f4112b75ecad0cb07a45e20e2a363f29729157"), + String::from("d78c63c5ea3149040767e4387e7fc743cda118fd"), + ]; + let result = main( + &local_repo, + commitlist, + 1, + false, + None, + true, + false, + Some(1), + ) + .unwrap(); + assert_eq!(result.iter().clone().count(), 3); +} + +#[test] +fn check_correct_number_of_results_3_commitlist_unlimited() { + let local_repo = get_demo_repo(); + let commitlist = vec![ + String::from("0cf7a4fe3ad6c49ae7beb394a1c1df7cc5173ce4"), + String::from("a9f4112b75ecad0cb07a45e20e2a363f29729157"), + String::from("d78c63c5ea3149040767e4387e7fc743cda118fd"), + ]; + let result = + main(&local_repo, commitlist,1, false, None, true, false, None).unwrap(); + assert_eq!(result.iter().clone().count(), 3); +} + +#[test] +#[should_panic] +fn check_commitlist_fail_on_non_existent_sha() { + let local_repo = get_demo_repo(); + let commitlist = vec![String::from("0cf7a4fe3ad6c49ae7beb394a1c1df7cc5173cad")]; + main(&local_repo, commitlist,1, false, None, true, true , None).unwrap(); +} + +#[test] +fn check_correct_number_of_results_commitlist_empty_input() { + let local_repo = get_demo_repo(); + let result = + main(&local_repo, vec![],1, false, None, true, true, None).unwrap(); + assert_eq!(result.iter().clone().count(), 0); +} + +#[test] +fn check_correct_number_of_results_19_committish() { + let local_repo = get_demo_repo(); + let result = main( + &local_repo, + vec!["HEAD".to_string()], + 1, + false, + None, + true, + true, + Some(19), + ) + .unwrap(); + assert_eq!(result.iter().clone().count(), 19); +} + +#[test] +fn check_correct_number_of_results_21_committish() { + let local_repo = get_demo_repo(); + let result = main( + &local_repo, + vec!["HEAD".to_string()], + 1, + false, + None, + true, + true, + Some(21), + ) + .unwrap(); + assert_eq!(result.iter().clone().count(), 21); +} + +#[test] +fn check_correct_number_of_results_22_committish() { + let local_repo = get_demo_repo(); + let result = main( + &local_repo, + vec!["HEAD".to_string()], + 1, + false, + None, + true, + true, + Some(22), + ) + .unwrap(); + // git rev-list --count --no-merges HEAD returns 21 + assert_eq!(result.iter().clone().count(), 21); +} + +#[test] +fn check_correct_number_of_results_0_committish() { + let local_repo = get_demo_repo(); + let result = main( + &local_repo, + vec!["HEAD".to_string()], + 1, + false, + None, + true, + true, + Some(0), + ) + .unwrap(); + assert!(result.is_empty()); +} +#[test] +fn check_correct_number_of_results_0_commitlist() { + let local_repo = get_demo_repo(); + let result = main( + &local_repo, + vec![String::from("922051b304015810e6056a72d9ef61d55e7763ed")], + 1, + false, + None, + true, + true, + Some(0), + ) + .unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn check_correct_number_of_results_start_hash_922051b304015810e6056a72d9ef61d55e7763ed() { + // first commit, initial + let start_hash = String::from("922051b304015810e6056a72d9ef61d55e7763ed"); + let local_repo = get_demo_repo(); + let result_vec = main( + &local_repo, + vec![start_hash.clone()], + 1, + false, + None, + true, + true, + None, + ) + .unwrap(); + + assert_eq!(result_vec.iter().clone().count(), 1); +} + +#[test] +#[should_panic] +fn check_correct_number_of_results_start_hash_ed292b87739f56b1179f64aa813dc96fb6128555_should_fail_committish( +) { + // first commit, initial + let start_hash = String::from("ed292b87739f56b1179f64aa813dc96fb6128555"); + let local_repo = get_demo_repo(); + let result_vec = main( + &local_repo, + vec![start_hash.clone()], + 1, + false, + None, + true, + true, + None, + ) + .unwrap(); + + assert_eq!(result_vec.iter().clone().count(), 1); +} + +#[test] +#[should_panic] +fn check_correct_number_of_results_start_hash_ed292b87739f56b1179f64aa813dc96fb6128555_should_fail_commitlist( +) { + // first commit, initial + let start_hash = String::from("ed292b87739f56b1179f64aa813dc96fb6128555"); + let local_repo = get_demo_repo(); + let result_vec = main( + &local_repo, + vec![start_hash], + 1, + false, + None, + true, + true, + None, + ) + .unwrap(); + + assert_eq!(result_vec.iter().clone().count(), 1); +} + +#[test] +fn check_correct_result_start_hash_922051b304015810e6056a72d9ef61d55e7763ed() { + // first commit, initial + let start_hash = String::from("922051b304015810e6056a72d9ef61d55e7763ed"); + let local_repo = get_demo_repo(); + let result_vec = main( + &local_repo, + vec![start_hash.clone()], // commitlist + 1, // max_threads + false, // no_merges + None, // diff_algo + true, // breadth_first + true, //follow + None, // limit + ) + .unwrap(); + + let result = result_vec + .get(0) + .expect("Failed to get one and only element"); + assert_eq!(result.commit.to_string(), start_hash.clone()); + assert_eq!(result.parent, None); + + assert_ne!(result.committer, None); + assert_ne!(result.author, None); + let author = & as Clone>::clone(&result.author).unwrap(); + let committer = & as Clone>::clone(&result.committer).unwrap(); + + assert_ne!(author, committer); + assert_eq!(author.name, "author"); + assert_eq!(author.email, "author@example.com"); + assert_eq!( + author.time, + gix::date::Time { + seconds: 946684800, // 1.1.2000 00:00:00 + offset: 0, + sign: Sign::Plus + } + ); + + assert_eq!(committer.name, "committer"); + assert_eq!(committer.email, "committer@example.com"); + assert_eq!( + committer.time, + gix::date::Time { + seconds: 946771200, // 2.1.2000 00:00:00 + offset: 0, + sign: Sign::Plus + } + ); + + assert_ne!(result.change_map.get(&BString::from("a")), None); + assert_eq!(result.change_map.get(&BString::from("a")).unwrap(), &(0, 0)); + + assert_ne!(result.change_map.get(&BString::from("b")), None); + assert_eq!(result.change_map.get(&BString::from("b")).unwrap(), &(0, 0)); + + assert_ne!(result.change_map.get(&BString::from("dir/c")), None); + assert_eq!( + result.change_map.get(&BString::from("dir/c")).unwrap(), + &(0, 0) + ); + + assert_ne!(result.change_map.get(&BString::from("d")), None); + assert_eq!(result.change_map.get(&BString::from("d")).unwrap(), &(0, 0)); +} + +#[test] +fn check_correct_number_of_result_start_hash_11899e89f0d6c9d7fd68aa79f356c9a49a9f319a() { + // first commit, initial + let start_hash = String::from("11899e89f0d6c9d7fd68aa79f356c9a49a9f319a"); + let local_repo = get_demo_repo(); + let result_vec = main( + &local_repo, + vec![start_hash.clone()], + 1, + false, + None, + true, + true, + None, + ) + .unwrap(); + + assert_eq!(result_vec.iter().count(), 2); + + let result_0 = result_vec.get(0).expect("Failed to get first"); + assert_eq!( + result_0.commit.to_string(), + "11899e89f0d6c9d7fd68aa79f356c9a49a9f319a" + ); + assert_ne!(result_0.parent, None); + assert_eq!( + result_0.parent.unwrap().to_string(), + "922051b304015810e6056a72d9ef61d55e7763ed" + ); + + let result_1 = result_vec.get(1).expect("Failed to get second"); + assert_eq!( + result_1.commit.to_string(), + "922051b304015810e6056a72d9ef61d55e7763ed" + ); + assert_none!( + result_1.parent + ) +} + +#[test] +fn check_correct_number_of_result_start_hash_2a8baaceb3d79f157aaf6a7967278eb65288e073() { + // first commit, initial + let start_hash = String::from("2a8baaceb3d79f157aaf6a7967278eb65288e073"); + let local_repo = get_demo_repo(); + let result_vec = main( + &local_repo, + vec![start_hash.clone()], + 1, + false, + None, + true, + true, + Some(2), + ) + .unwrap(); + + assert_eq!(result_vec.iter().count(), 2); + + let result_0 = result_vec.get(0).expect("Failed to get first"); + assert_eq!( + result_0.commit.to_string(), + "2a8baaceb3d79f157aaf6a7967278eb65288e073" + ); + assert_eq!(result_0.change_map.clone().iter().count(), 2); + + let result_1 = result_vec.get(1).expect("Failed to get second"); + assert_eq!( + result_1.commit.to_string(), + "11899e89f0d6c9d7fd68aa79f356c9a49a9f319a" + ); + assert_eq!(result_1.change_map.clone().iter().count(), 4); +} + +#[test] +fn check_correct_number_of_result_start_hash_b6c93f947ec4c96039bac4971c681d7a18bc436d() { + // first commit, initial + let start_hash = String::from("b6c93f947ec4c96039bac4971c681d7a18bc436d"); + let local_repo = get_demo_repo(); + let result_vec = main( + &local_repo, + vec![start_hash.clone()], + 1, + false, + None, + true, + false, + None, + ) + .unwrap(); + + assert_eq!(result_vec.iter().count(), 1); + + let result_0 = result_vec.get(0).expect("Failed to get first"); + assert_eq!( + result_0.commit.to_string(), + "b6c93f947ec4c96039bac4971c681d7a18bc436d" + ); + assert_eq!( + result_0.parent.unwrap().to_string(), + "7fdf7c8b6607b31f5400418e3732d50091265ac5" + ); + println!("{:?}", result_0.change_map); + assert_eq!(result_0.change_map.clone().iter().count(), 2); + + assert_ne!(result_0.change_map.get(&BString::from("b")), None); + assert_eq!( + result_0.change_map.get(&BString::from("b")).unwrap(), + &(1, 0) + ); + assert_ne!(result_0.change_map.get(&BString::from("dir/c-moved")), None); + assert_eq!( + result_0 + .change_map + .get(&BString::from("dir/c-moved")) + .unwrap(), + &(1, 0) + ); +} + +#[test] +fn check_correct_number_of_result_start_hash_f3b695021ac313bd223396abb70e2c472106220a() { + // first commit, initial + let start_hash = String::from("f3b695021ac313bd223396abb70e2c472106220a"); + let local_repo = get_demo_repo(); + let result_vec = main( + &local_repo, + vec![start_hash.clone()], + 1, + false, + None, + true, + false, + None, + ) + .unwrap(); + + assert_eq!(result_vec.iter().count(), 1); + + let result_0 = result_vec.get(0).expect("Failed to get first"); + assert_eq!( + result_0.commit.to_string(), + "f3b695021ac313bd223396abb70e2c472106220a" + ); + assert_eq!( + result_0.parent.unwrap().to_string(), + "de5eea3539a859a57509d986593375ddfa932116" + ); + println!("{:?}", result_0.change_map); + assert_eq!(result_0.change_map.clone().iter().count(), 4); + assert_ne!(result_0.change_map.get(&BString::from("dir/link-2")), None); + assert_ne!(result_0.change_map.get(&BString::from("no-link")), None); + assert_ne!( + result_0.change_map.get(&BString::from("renamed-link-1")), + None + ); + assert_ne!(result_0.change_map.get(&BString::from("z-link-2")), None); + + assert_eq!( + result_0 + .change_map + .get(&BString::from("dir/link-2")) + .unwrap(), + &(0, 1) + ); + + assert_eq!( + result_0.change_map.get(&BString::from("no-link")).unwrap(), + &(0, 1) + ); + + assert_eq!( + result_0 + .change_map + .get(&BString::from("renamed-link-1")) + .unwrap(), + &(0, 0) + ); + + assert_eq!( + result_0.change_map.get(&BString::from("z-link-2")).unwrap(), + &(1, 0) + ); +} + +#[test] +fn check_correct_number_of_results_skip_merges_false() { + let local_repo = get_demo_repo_merges(); + let result_vec = main( + &local_repo, + vec!["HEAD".to_string()], + 1, + false, + None, + true, + true, + None, + ) + .unwrap(); + + assert_eq!(result_vec.iter().count(), 38); +} + +#[test] +fn check_correct_history_of_merges() { + let start_hash = String::from("1823ac918111531ef2984bc3b667f5c199a584b9"); + let local_repo = get_demo_repo_merges(); + let result_vec = main( + &local_repo, + vec![start_hash.clone()], + 1, + false, + None, + true, + false, + None, + ) + .unwrap(); + + assert_eq!(result_vec.iter().count(), 2); + + let result_0 = result_vec.get(0).expect("Failed to get first"); + assert_eq!( + result_0.commit.to_string(), + "1823ac918111531ef2984bc3b667f5c199a584b9" + ); + assert_eq!( + result_0.parent.unwrap().to_string(), + "ed93e447508cdff606d90e9d7ebdaa152833086c" + ); + + let result_1 = result_vec.get(1).expect("Failed to get second"); + assert_eq!( + result_1.commit.to_string(), + "1823ac918111531ef2984bc3b667f5c199a584b9" + ); + assert_eq!( + result_1.parent.unwrap().to_string(), + "49cb9aa3b1fcbb4588e73774e52c24ebb70f65d0" + ); +} + +#[test] +fn check_correct_number_of_results_skip_merges_true() { + let local_repo = get_demo_repo_merges(); + let result_vec = main( + &local_repo, + vec!["HEAD".to_string()], + 1, + true, + None, + true, + true, + None, + ) + .unwrap(); + + assert_eq!(result_vec.iter().count(), 32); +} + +mod util { + use std::path::PathBuf; + + pub fn get_demo_repo() -> gix::Repository { + let repo_workdir_pathbuf = repo_workdir("make_diff_for_rewrites_repo.sh").unwrap(); + + gix::discover(repo_workdir_pathbuf.as_path()).unwrap() + } + + pub fn get_demo_repo_merges() -> gix::Repository { + let repo_workdir_pathbuf = repo_workdir("make_blame_repo.sh").unwrap(); + + gix::discover(repo_workdir_pathbuf.as_path()).unwrap() + } + + fn repo_workdir(script_name: &str) -> gix_testtools::Result { + gix_testtools::scripted_fixture_read_only_standalone(script_name) + } +} diff --git a/binocular-backend-new/ffi/lib/crates/diff/tests/main.rs b/binocular-backend-new/ffi/lib/crates/diff/tests/main.rs new file mode 100644 index 000000000..7fd638993 --- /dev/null +++ b/binocular-backend-new/ffi/lib/crates/diff/tests/main.rs @@ -0,0 +1 @@ +mod git; \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/src/ffi/blame.rs b/binocular-backend-new/ffi/lib/src/ffi/blame.rs new file mode 100644 index 000000000..5334c599b --- /dev/null +++ b/binocular-backend-new/ffi/lib/src/ffi/blame.rs @@ -0,0 +1,45 @@ +use crate::types::error::UniffiError; +use crate::types::repo::GixRepository; +use gix::ThreadSafeRepository; +use std::collections::HashMap; + +/// Calculates blame information for files in commits +/// +/// # Arguments +/// * `gix_repo` - The repository to analyze +/// * `defines` - Map of commit IDs to file paths to blame +/// * `diff_algorithm` - Optional diff algorithm to use +/// * `max_threads` - Maximum number of threads for parallel processing +/// +/// # Returns +/// A vector of blame results for all requested files +/// +/// # Errors +/// - `GixDiscoverError` if repository discovery fails +/// - `GixError` for blame calculation errors +#[uniffi::export] +pub fn blames( + gix_repo: GixRepository, + defines: HashMap>, + diff_algorithm: Option, + max_threads: u8, +) -> Result, UniffiError> { + use binocular_blame::process; + use std::time::Instant; + + log::debug!("blames: processing {} commit definitions", defines.len()); + let binding = ThreadSafeRepository::try_from(gix_repo)?.to_thread_local(); + + let start = Instant::now(); + let iterable = gix::hashtable::HashMap::from_iter(defines); + log::trace!("blames: from_iter() took {:?}", start.elapsed()); + + let start = Instant::now(); + let result = process(&binding, iterable, diff_algorithm, max_threads as usize)?; + log::trace!("blames: process() took {:?}", start.elapsed()); + + Ok(result + .into_iter() + .map(crate::types::blame::GixBlameResult::from) + .collect()) +} \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/src/ffi/branch.rs b/binocular-backend-new/ffi/lib/src/ffi/branch.rs new file mode 100644 index 000000000..963accb8e --- /dev/null +++ b/binocular-backend-new/ffi/lib/src/ffi/branch.rs @@ -0,0 +1,62 @@ +use crate::types::branch::GixBranch; +use crate::types::commit::GixCommit; +use crate::types::repo::GixRepository; +use crate::types::result::BranchTraversalResult; +use crate::types::UniffiError; +use gix::ThreadSafeRepository; + +/// Traverses a specific branch and returns its commits +/// +/// # Arguments +/// * `gix_repo` - The repository containing the branch +/// * `branch` - The name of the branch to traverse (e.g., "refs/heads/main" or "main") +/// * `skip_merges` - Whether to skip merge commits in the result +/// * `use_mailmap` - Whether to apply mailmap transformations to author/committer info +/// +/// # Returns +/// A `BranchTraversalResult` containing the branch metadata and all commits +/// +/// # Errors +/// - `GixDiscoverError` if repository cannot be opened +/// - `ReferenceError` if the branch reference cannot be found +/// - `TraversalError` if the traversal fails or returns unexpected results +#[uniffi::export] +pub fn traverse_branch( + gix_repo: GixRepository, + branch: String, + skip_merges: bool, + use_mailmap: bool, +) -> Result { + log::debug!("traverse_branch: traversing branch '{}'", branch); + let binding = ThreadSafeRepository::try_from(gix_repo)?.to_thread_local(); + + let reference = binding + .find_reference(branch.as_str()) + .map_err(|e| UniffiError::ReferenceError(format!("Failed to get references: {}", e)))? + .detach(); + + match commits::traversal::main(binding, vec![reference], skip_merges, use_mailmap) { + Ok(r) => { + // If you really expect exactly one: + if r.len() != 1 { + return Err(UniffiError::TraversalError(format!( + "expected 1 result, got {} for branch {:?}", + r.len(), + branch + ))); + } + + let result = r + .into_iter() // take ownership of items + .map(|(reference, commits)| BranchTraversalResult { + branch: GixBranch::from(reference), + commits: commits.into_iter().map(|c| GixCommit::from(c)).collect(), + }) + .next() + .expect("len() checked above"); + + Ok(result) + } + Err(e) => Err(UniffiError::TraversalError(e.to_string())), + } +} diff --git a/binocular-backend-new/ffi/lib/src/ffi/commit.rs b/binocular-backend-new/ffi/lib/src/ffi/commit.rs new file mode 100644 index 000000000..b05326f92 --- /dev/null +++ b/binocular-backend-new/ffi/lib/src/ffi/commit.rs @@ -0,0 +1,64 @@ +use crate::types::commit::GixCommit; +use crate::types::error::UniffiError; +use crate::types::repo::GixRepository; +use gix::{ObjectId, ThreadSafeRepository}; + +/// Finds a specific commit by its hash +/// +/// # Arguments +/// * `gix_repo` - The repository to search in +/// * `hash` - The commit hash to find (full or abbreviated) or any valid revision spec +/// * `use_mailmap` - Whether to apply mailmap transformations to author/committer info +/// +/// # Returns +/// The commit metadata if found +/// +/// # Errors +/// - `RevisionParseError` if the hash/revision spec is invalid or malformed +/// - `ObjectError` if the object cannot be found or is not a commit +/// - `CommitLookupError` if author/committer information cannot be read +#[uniffi::export] +pub fn find_commit(gix_repo: GixRepository, hash: String, use_mailmap: bool) -> Result { + log::debug!("find_commit: repo at {:?}", gix_repo); + let repo = ThreadSafeRepository::try_from(gix_repo)?; + + let binding = repo.to_thread_local(); + let gcm = commits::find_commit(&binding, hash, use_mailmap)?; + + Ok(GixCommit::from(gcm)) +} + +/// Traverses commit history from a source commit to an optional target commit +/// +/// # Arguments +/// * `gix_repo` - The repository to traverse +/// * `source_commit` - The starting commit +/// * `target_commit` - Optional ending commit. If None, traverses to repository root +/// +/// # Returns +/// A vector of commits between source and target +/// +/// # Errors +/// - `GixDiscoverError` if repository discovery fails +/// - `CommitLookupError` if commits cannot be found +/// - `GixError` for other traversal errors +#[uniffi::export] +pub fn traverse_history( + gix_repo: &GixRepository, + source_commit: ObjectId, + target_commit: Option, + use_mailmap: bool, +) -> Result, UniffiError> { + let repo = ThreadSafeRepository::discover(&gix_repo.git_dir)?; + + let binding = repo.to_thread_local(); + let cmt = binding.find_commit(source_commit)?; + let trgt = match target_commit { + None => None, + Some(c) => Option::from(binding.find_commit(c)?), + }; + + let result = commits::traversal::traverse_from_to(&binding, &cmt, &trgt, use_mailmap)?; + + Ok(result.into_iter().map(|c| GixCommit::from(c)).collect()) +} diff --git a/binocular-backend-new/ffi/lib/src/ffi/diff.rs b/binocular-backend-new/ffi/lib/src/ffi/diff.rs new file mode 100644 index 000000000..31586baba --- /dev/null +++ b/binocular-backend-new/ffi/lib/src/ffi/diff.rs @@ -0,0 +1,43 @@ +use crate::types::diff::GixDiffInput; +use crate::types::error::UniffiError; +use crate::types::repo::GixRepository; +use gix::ThreadSafeRepository; + +/// Calculates diffs for multiple commit pairs +/// +/// # Arguments +/// * `gix_repo` - The repository to work with +/// * `commit_pairs` - Vector of commit pairs to diff (suspect, target) +/// * `max_threads` - Maximum number of threads to use for parallel processing +/// * `diff_algorithm` - Optional diff algorithm to use +/// +/// # Returns +/// A vector of diff results for each commit pair +/// +/// # Errors +/// - `GixDiscoverError` if repository discovery fails +/// - `GixError` for diff calculation errors +#[uniffi::export] +pub fn diffs( + gix_repo: GixRepository, + commit_pairs: Vec, + max_threads: u8, + diff_algorithm: Option, +) -> Result, UniffiError> { + log::debug!("diffs: processing {} commit pairs", commit_pairs.len()); + let binding = ThreadSafeRepository::try_from(gix_repo)?.to_thread_local(); + + use binocular_diff::calculation::diff_pairs; + + let result = diff_pairs( + &binding, + commit_pairs.iter().map(|c| (c.suspect, c.target)).collect(), + max_threads as usize, + diff_algorithm, + )?; + + Ok(result + .into_iter() + .map(crate::types::diff::GixDiff::from) + .collect()) +} \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/src/ffi/mod.rs b/binocular-backend-new/ffi/lib/src/ffi/mod.rs new file mode 100644 index 000000000..433b79ef2 --- /dev/null +++ b/binocular-backend-new/ffi/lib/src/ffi/mod.rs @@ -0,0 +1,7 @@ +/// FFI exports organized by functionality +pub mod blame; +pub mod branch; +pub mod commit; +pub mod diff; +pub mod repository; +pub mod utils; \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/src/ffi/repository.rs b/binocular-backend-new/ffi/lib/src/ffi/repository.rs new file mode 100644 index 000000000..9d0e2f68c --- /dev/null +++ b/binocular-backend-new/ffi/lib/src/ffi/repository.rs @@ -0,0 +1,75 @@ +use crate::types::branch::GixBranch; +use crate::types::error::UniffiError; +use crate::types::remote::GixRemote; +use crate::types::repo::{discover_repo, GixRepository}; +use gix::bstr::ByteSlice; +use gix::refs::Kind; +use gix::ThreadSafeRepository; + +/// Discovers and opens a Git repository at the given path +/// +/// # Arguments +/// * `path` - Path to the Git repository (can be any path within the repository) +/// +/// # Returns +/// A `GixRepository` containing repository metadata and remotes +/// +/// # Errors +/// Returns `GixDiscoverError` if the repository cannot be discovered at the path +#[uniffi::export] +pub fn find_repo(path: String) -> Result { + log::debug!("find_repo: discovering repository at '{}'", path); + let repo = discover_repo(path)?; + + let binding = repo.to_thread_local(); + + let remotes = binding + .remote_names() + .iter() + .map(|r| binding.find_remote(r.as_bstr())) + .filter_map(Result::ok) + .map(GixRemote::from) + .collect::>(); + + Ok(GixRepository { + git_dir: repo.refs.git_dir().display().to_string(), + work_tree: repo.work_tree.map(|val| val.display().to_string()), + remotes, + }) +} + +/// Finds all branches (local and remote) in a repository +/// +/// # Arguments +/// * `gix_repo` - The repository to query +/// +/// # Returns +/// A vector of all branches found in the repository +/// +/// # Errors +/// - `GixDiscoverError` if repository cannot be opened +/// - `ReferenceError` if branch enumeration fails +#[uniffi::export] +pub fn find_all_branches(gix_repo: GixRepository) -> Result, UniffiError> { + log::debug!("find_all_branches: repo at {:?}", gix_repo); + let binding = ThreadSafeRepository::try_from(gix_repo)?.to_thread_local(); + + let references = binding + .references() + .map_err(|e| UniffiError::ReferenceError(format!("Failed to get references: {}", e)))?; + let local_branches = references + .local_branches() + .map_err(|e| UniffiError::ReferenceError(format!("Failed to get local branches: {}", e)))?; + let remote_branches = references.remote_branches().map_err(|e| { + UniffiError::ReferenceError(format!("Failed to get remote branches: {}", e)) + })?; + + Ok(remote_branches + .chain(local_branches) + .filter(Result::is_ok) + .map(Result::unwrap) + .map(|b| b.inner) + .filter(|b| b.kind() == Kind::Object) + .map(GixBranch::from) + .collect()) +} diff --git a/binocular-backend-new/ffi/lib/src/ffi/utils.rs b/binocular-backend-new/ffi/lib/src/ffi/utils.rs new file mode 100644 index 000000000..6dd829788 --- /dev/null +++ b/binocular-backend-new/ffi/lib/src/ffi/utils.rs @@ -0,0 +1,5 @@ +/// Simple test function for FFI connectivity +#[uniffi::export] +pub fn hello() { + println!("Hello, world!"); +} \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/src/lib.rs b/binocular-backend-new/ffi/lib/src/lib.rs index 12000b253..6442ba584 100644 --- a/binocular-backend-new/ffi/lib/src/lib.rs +++ b/binocular-backend-new/ffi/lib/src/lib.rs @@ -1,37 +1,24 @@ -pub(crate) mod types { +// Internal modules +pub mod types { pub(crate) mod blame; pub(crate) mod branch; pub(crate) mod commit; pub(crate) mod diff; pub(crate) mod error; + pub(crate) mod remote; + pub(crate) mod reference; pub(crate) mod repo; pub(crate) mod signature; + // Re-export error types for FFI modules + pub use error::UniffiError; + + // UniFFI remote type wrappers type AnyhowError = anyhow::Error; - // For interfaces, wrap a unit struct with `#[uniffi::remote]`. #[uniffi::remote(Object)] pub struct AnyhowError; - #[derive(Debug, thiserror::Error, uniffi::Error)] - pub enum UniffiError { - #[error("Invalid input: {0}")] - InvalidInput(String), - - #[error("Operation failed: {0}")] - OperationFailed(String), - - // #[error(transparent)] - // GixDiscoverError(#[from] gix::discover::Error), - #[error("Operation failed: {0}")] - GixDiscoverError(String), - } - type LogLevel = log::Level; - - // Use #[uniffi::remote] to enable support for passing the types across the FFI - - // For records/enums, wrap the item definition with `#[uniffi::remote]`. - // Copy each field/variant definitions exactly as they appear in the remote crate. #[uniffi::remote(Enum)] pub enum LogLevel { Error = 1, @@ -40,198 +27,32 @@ pub(crate) mod types { Debug = 4, Trace = 5, } -} - -pub mod ffi { - use crate::types::branch::BinocularBranch; - use crate::types::commit::GixCommit; - use crate::types::diff::BinocularDiffInput; - use crate::types::error::ProcErrorInterface; - use crate::types::repo::{BinocularRepository, RepositoryRemote}; - use crate::types::UniffiError; - use gix::remote::Direction; - use gix::ThreadSafeRepository; - use std::collections::HashMap; - use std::ops::Deref; - - #[uniffi::export] - fn hello() { - println!("Hello, world!"); - } - - #[uniffi::export] - fn find_repo(path: String) -> anyhow::Result { - let repo = match gix::discover(path) { - Ok(r) => r.into_sync(), - Err(e) => panic!("{:?}", e), - }; - - let binding = repo.to_thread_local(); - - let origin = binding - .find_default_remote(Direction::Push) - .or_else(|| binding.find_default_remote(Direction::Fetch)) - .and_then(Result::ok); - - Ok(BinocularRepository { - git_dir: repo.refs.git_dir().display().to_string(), - work_tree: repo.work_tree.map(|val| val.display().to_string()), - origin: origin.map(|o| RepositoryRemote::from(o)), - }) - } - - #[uniffi::export] - fn find_commit( - binocular_repo: &BinocularRepository, - hash: String, - ) -> anyhow::Result { - println!("repo at {:?}", binocular_repo); - let repo = ThreadSafeRepository::discover(&binocular_repo.git_dir)?; - - let binding = repo.to_thread_local(); - let commit = binding - .rev_parse_single(hash.deref())? - .object()? - .try_into_commit()?; - - Ok(commit.id) - } - - #[uniffi::export] - fn find_all_branches( - binocular_repo: &BinocularRepository, - ) -> anyhow::Result> { - println!("repo at {:?}", binocular_repo); - let repo = ThreadSafeRepository::discover(&binocular_repo.git_dir)?; - - let binding = repo.to_thread_local(); - - let references = binding.references()?; - let local_branches = references.local_branches()?; - let remote_branches = references.remote_branches()?; - - Ok(remote_branches - .chain(local_branches) - .filter(Result::is_ok) - .map(Result::unwrap) - .map(BinocularBranch::from) - .collect()) - } - #[uniffi::export] - fn traverse_branch( - binocular_repo: &BinocularRepository, - branch: String, - ) -> Result, UniffiError> { - let repo = match ThreadSafeRepository::discover(&binocular_repo.git_dir) { - Ok(r) => r, - Err(e) => panic!("{:?}", UniffiError::GixDiscoverError(e.to_string())), - }; - let binding = repo.to_thread_local(); + // Result types + pub mod result { + use crate::types::branch::GixBranch; + use crate::types::commit::GixCommit; - match commits::traversal::main(binding, vec![branch], false) { - Ok(r) => Ok(r), - Err(e) => Err(UniffiError::OperationFailed(e.to_string())), + #[derive(Debug, uniffi::Record)] + pub struct BranchTraversalResult { + pub branch: GixBranch, + pub commits: Vec, } } - #[uniffi::export] - fn traverse( - binocular_repo: &BinocularRepository, - source_commit: GixCommit, - target_commit: Option, - ) -> anyhow::Result> { - let repo = ThreadSafeRepository::discover(&binocular_repo.git_dir)?; - - let binding = repo.to_thread_local(); - let cmt = binding.find_commit(source_commit)?; - let trgt = match target_commit { - None => None, - Some(c) => Option::from(binding.find_commit(c)?), - }; - - let result = commits::traversal::traverse_from_to(&binding, &cmt, &trgt); - - result - } - - #[uniffi::export] - fn diffs( - binocular_repo: &BinocularRepository, - commit_pairs: Vec, - max_threads: u8, - diff_algorithm: Option, - ) -> anyhow::Result, ProcErrorInterface> { - let repo = ThreadSafeRepository::discover(&binocular_repo.git_dir) - .expect(format!("Cannot discover repository at '{}'", binocular_repo.git_dir).as_str()); - - use binocular_diff::calculation::diff_pairs; - - let binding = repo.to_thread_local(); - let r = diff_pairs( - &binding, - commit_pairs.iter().map(|c| (c.suspect, c.target)).collect(), - max_threads as usize, - diff_algorithm, - ) - .unwrap(); - - let mapped = r - .into_iter() - .map(crate::types::diff::BinocularDiffVec::from) - .collect(); - - Ok(mapped) - } - - #[uniffi::export] - fn blames( - binocular_repo: &BinocularRepository, - defines: HashMap>, - diff_algorithm: Option, - max_threads: u8, - ) -> anyhow::Result> { - let repo = ThreadSafeRepository::discover(&binocular_repo.git_dir)?; - - use binocular_blame::process; - use std::time::Instant; - - let binding = repo.to_thread_local(); - - // println!( - // "process(repo={:?},#defines={},algo={:?},threads={})", - // repo, - // defines.len(), - // diff_algorithm, - // max_threads - // ); - - let mut start = Instant::now(); - let iterable = gix::hashtable::HashMap::from_iter(defines); - let mut duration = start.elapsed(); - - println!("Time elapsed in from_iter() is: {:?}", duration); - - start = Instant::now(); - let result = process(&binding, iterable, diff_algorithm, max_threads as usize); - duration = start.elapsed(); - println!("Time elapsed in process() is: {:?}", duration); + pub use repo::GixRepository; +} - // println!("Found {} blames", result?.len()); +// FFI exports +pub mod ffi; - Ok(result? - .into_iter() - .map(crate::types::blame::BinocularBlameResult::from) - .collect()) - // Ok(()) - } - - // uniffi::custom_type!(gix::discover::Error, FfiError), { - // // Remote is required since `Url` is from a different crate - // remote, - // try_lift: | val | Ok(Url::parse( & val) ? ), - // lower: | obj | obj.into(), - // }); -} +// Re-export all FFI functions for use +pub use ffi::blame::blames; +pub use ffi::branch::traverse_branch; +pub use ffi::commit::{find_commit, traverse_history}; +pub use ffi::diff::diffs; +pub use ffi::repository::{find_all_branches, find_repo}; +pub use ffi::utils::hello; -uniffi::setup_scaffolding!(); +// Setup UniFFI scaffolding +uniffi::setup_scaffolding!(); \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/src/types/blame.rs b/binocular-backend-new/ffi/lib/src/types/blame.rs index 36059fb19..70197d73b 100644 --- a/binocular-backend-new/ffi/lib/src/types/blame.rs +++ b/binocular-backend-new/ffi/lib/src/types/blame.rs @@ -3,7 +3,7 @@ use gix::blame::BlameEntry; use gix::ObjectId; #[derive(Debug, uniffi::Record)] -pub struct BinocularBlameEntry { +pub struct GixBlameEntry { pub start_in_blamed_file: u32, pub start_in_source_file: u32, pub len: u32, @@ -11,31 +11,31 @@ pub struct BinocularBlameEntry { } #[derive(Debug, uniffi::Record)] -pub struct BinocularBlameOutcome { - pub entries: Vec, +pub struct GixBlameOutcome { + pub entries: Vec, pub file_path: String, } #[derive(Debug, uniffi::Record)] -pub struct BinocularBlameResult { - pub blames: Vec, +pub struct GixBlameResult { + pub blames: Vec, pub commit: ObjectId, } -impl From for BinocularBlameResult { +impl From for GixBlameResult { fn from(value: BlameResult) -> Self { Self { blames: value .blames .into_iter() - .map(BinocularBlameOutcome::from) + .map(GixBlameOutcome::from) .collect(), commit: value.commit_oid, } } } -impl From for BinocularBlameEntry { +impl From for GixBlameEntry { fn from(value: BlameEntry) -> Self { Self { start_in_blamed_file: value.start_in_blamed_file, @@ -46,13 +46,13 @@ impl From for BinocularBlameEntry { } } -impl From for BinocularBlameOutcome { +impl From for GixBlameOutcome { fn from(value: BlameOutcome) -> Self { Self { entries: value .entries .into_iter() - .map(BinocularBlameEntry::from) + .map(GixBlameEntry::from) .collect(), file_path: value.file_path, } diff --git a/binocular-backend-new/ffi/lib/src/types/branch.rs b/binocular-backend-new/ffi/lib/src/types/branch.rs index 69b168210..9d49ebeb1 100644 --- a/binocular-backend-new/ffi/lib/src/types/branch.rs +++ b/binocular-backend-new/ffi/lib/src/types/branch.rs @@ -1,16 +1,23 @@ -use gix::Reference; +use gix::bstr::BString; +use gix::refs::FullName; +use gix::ObjectId; +use crate::types::reference::GixReferenceCategory; -#[derive(Debug, uniffi::Record)] -pub struct BinocularBranch { +#[derive(Eq, PartialEq, Hash, Debug, uniffi::Record)] +pub struct GixBranch { + pub full_name: FullName, pub name: String, - pub commits: Vec, + pub target: ObjectId, + pub category: GixReferenceCategory, } -impl From> for BinocularBranch { - fn from(reference: Reference) -> Self { - Self { - name: reference.name().as_bstr().to_string(), - commits: vec![], - } - } -} +uniffi::custom_type!(FullName, BString, { + remote, + // Lowering our Rust SerializableStruct into a String. + lower: |s| s.into_inner(), + // Lifting our foreign String into our Rust SerializableStruct + try_lift: |s| { + let a = FullName::try_from(s)?; + Ok(a) + }, +}); diff --git a/binocular-backend-new/ffi/lib/src/types/commit.rs b/binocular-backend-new/ffi/lib/src/types/commit.rs index a2b614515..7a2bbf466 100644 --- a/binocular-backend-new/ffi/lib/src/types/commit.rs +++ b/binocular-backend-new/ffi/lib/src/types/commit.rs @@ -1,15 +1,13 @@ -use gix::{Commit, ObjectId}; +use commits::GitCommitMetric; use gix::bstr::BString; +use gix::ObjectId; -pub type GixCommit = ObjectId; - -type BinocularCommitVec = commits::GitCommitMetric; -#[uniffi::remote(Record)] -pub struct BinocularCommitVec { - pub commit: gix::ObjectId, +#[derive(uniffi::Record, Debug)] +pub struct GixCommit { + pub oid: gix::ObjectId, pub message: String, - pub committer: Option, - pub author: Option, + pub committer: crate::types::signature::GixSignature, + pub author: crate::types::signature::GixSignature, pub branch: Option, pub parents: Vec, pub file_tree: Vec, @@ -20,3 +18,17 @@ uniffi::custom_type!(ObjectId, String, { lower: move |r| r.to_string(), try_lift: |r| Ok(gix::ObjectId::from_hex(r.as_bytes())?), }); + +impl From for GixCommit { + fn from(commit: GitCommitMetric) -> Self { + GixCommit { + oid: commit.commit, + message: commit.message, + committer: commit.committer, + author: commit.author, + branch: commit.branch, + parents: commit.parents, + file_tree: commit.file_tree, + } + } +} diff --git a/binocular-backend-new/ffi/lib/src/types/diff.rs b/binocular-backend-new/ffi/lib/src/types/diff.rs index 1acf2080f..56baeefc7 100644 --- a/binocular-backend-new/ffi/lib/src/types/diff.rs +++ b/binocular-backend-new/ffi/lib/src/types/diff.rs @@ -1,3 +1,4 @@ +use crate::types::commit::GixCommit; use binocular_diff::{ChangeType, FileDiff, GitDiffOutcome}; use gix::bstr::BString; use gix::ObjectId; @@ -5,7 +6,7 @@ use gix::ObjectId; pub type GixDiffAlgorithm = gix::diff::blob::Algorithm; #[derive(Debug, uniffi::Enum)] -pub enum BinocularChangeType { +pub enum GixChangeType { Addition { location: BString, }, @@ -18,15 +19,15 @@ pub enum BinocularChangeType { Rewrite { source_location: BString, location: BString, - copy: bool - } + copy: bool, + }, } #[derive(Debug, uniffi::Record)] -pub struct BinocularFileDiff { +pub struct GixFileDiff { pub insertions: u32, pub deletions: u32, - pub change: BinocularChangeType, + pub change: GixChangeType, pub old_file_content: Option, pub new_file_content: Option, } @@ -39,59 +40,61 @@ pub enum GixDiffAlgorithm { } #[derive(Debug, uniffi::Record)] -pub struct BinocularDiffVec { - pub files: Vec, - pub commit: ObjectId, - pub parent: Option, - pub committer: Option, - pub author: Option, +pub struct GixDiff { + pub files: Vec, + pub commit: GixCommit, + pub parent: Option, } #[derive(Debug, uniffi::Record)] -pub struct BinocularDiffStats { +pub struct GixDiffStats { insertions: u32, deletions: u32, kind: String, } #[derive(Debug, uniffi::Record)] -pub struct BinocularDiffInput { +pub struct GixDiffInput { pub suspect: ObjectId, pub target: Option, } -impl From for BinocularDiffVec { +impl From for GixDiff { fn from(value: GitDiffOutcome) -> Self { Self { - files: value.files.into_iter().map(BinocularFileDiff::from).collect(), - commit: value.commit, - parent: value.parent, - committer: value.committer, - author: value.author, + files: value.files.into_iter().map(GixFileDiff::from).collect(), + commit: GixCommit::from(value.commit), + parent: value.parent.map(GixCommit::from), } } } -impl From for BinocularFileDiff { +impl From for GixFileDiff { fn from(value: FileDiff) -> Self { Self { deletions: value.deletions, insertions: value.insertions, - change: BinocularChangeType::from(value.change), + change: GixChangeType::from(value.change), old_file_content: value.old_file_content, new_file_content: value.new_file_content, } } } -impl From for BinocularChangeType { +impl From for GixChangeType { fn from(v: ChangeType) -> Self { match v { ChangeType::Addition { location } => Self::Addition { location }, ChangeType::Deletion { location } => Self::Deletion { location }, ChangeType::Modification { location } => Self::Modification { location }, - ChangeType::Rewrite { source_location, location, copy } => - Self::Rewrite { source_location, location, copy }, + ChangeType::Rewrite { + source_location, + location, + copy, + } => Self::Rewrite { + source_location, + location, + copy, + }, } } } - diff --git a/binocular-backend-new/ffi/lib/src/types/error.rs b/binocular-backend-new/ffi/lib/src/types/error.rs index c5dd83a9b..d29482f2f 100644 --- a/binocular-backend-new/ffi/lib/src/types/error.rs +++ b/binocular-backend-new/ffi/lib/src/types/error.rs @@ -1,4 +1,104 @@ -// A procmacro as an error +/// Main error type for FFI operations +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum UniffiError { + #[error("Invalid input: {0}")] + InvalidInput(String), + + #[error("Operation failed: {0}")] + OperationFailed(String), + + #[error("Branch traversal failed: {0}")] + TraversalError(String), + + #[error("Git repository discovery failed: {0}")] + GixDiscoverError(String), + + #[error("Commit lookup failed: {0}")] + CommitLookupError(String), + + #[error("Reference operation failed: {0}")] + ReferenceError(String), + + #[error("Object operation failed: {0}")] + ObjectError(String), + + #[error("Revision parsing failed: {0}")] + RevisionParseError(String), + + #[error("Git operation failed: {0}")] + GixError(String), +} + +// Automatic error conversions from gix error types +impl From for UniffiError { + fn from(err: gix::discover::Error) -> Self { + UniffiError::GixDiscoverError(err.to_string()) + } +} + +impl From for UniffiError { + fn from(err: gix::reference::find::existing::Error) -> Self { + UniffiError::ReferenceError(err.to_string()) + } +} + +impl From for UniffiError { + fn from(err: gix::object::find::existing::Error) -> Self { + UniffiError::ObjectError(err.to_string()) + } +} + +impl From for UniffiError { + fn from(err: gix::revision::spec::parse::single::Error) -> Self { + UniffiError::RevisionParseError(err.to_string()) + } +} + +impl From for UniffiError { + fn from(err: gix::object::commit::Error) -> Self { + UniffiError::CommitLookupError(err.to_string()) + } +} + +impl From for UniffiError { + fn from(err: gix::object::try_into::Error) -> Self { + UniffiError::ObjectError(err.to_string()) + } +} + +impl From for UniffiError { + fn from(value: gix::object::find::existing::with_conversion::Error) -> Self { + UniffiError::CommitLookupError(value.to_string()) + } +} + +impl From for UniffiError { + fn from(err: anyhow::Error) -> Self { + UniffiError::GixError(err.to_string()) + } +} + +impl From for UniffiError { + fn from(err: commits::CommitLookupError) -> Self { + match &err { + commits::CommitLookupError::RevisionParseError { .. } => { + UniffiError::RevisionParseError(err.to_string()) + } + commits::CommitLookupError::ObjectNotFound { .. } => { + UniffiError::ObjectError(err.to_string()) + } + commits::CommitLookupError::NotACommit { .. } => { + UniffiError::ObjectError(err.to_string()) + } + commits::CommitLookupError::AuthorReadError { .. } + | commits::CommitLookupError::CommitterReadError { .. } => { + UniffiError::CommitLookupError(err.to_string()) + } + } + } +} + +// Legacy error type - kept for compatibility #[derive(Debug, uniffi::Object, thiserror::Error)] #[uniffi::export(Debug, Display)] pub struct ProcErrorInterface { diff --git a/binocular-backend-new/ffi/lib/src/types/reference.rs b/binocular-backend-new/ffi/lib/src/types/reference.rs new file mode 100644 index 000000000..4af3f7439 --- /dev/null +++ b/binocular-backend-new/ffi/lib/src/types/reference.rs @@ -0,0 +1,54 @@ +use gix::refs::Reference; +use crate::types::branch::GixBranch; + +#[derive(Eq, PartialEq, Hash, Debug, Clone, Copy, uniffi::Enum)] +pub enum GixReferenceCategory { + LocalBranch, + RemoteBranch, + Tag, + Note, + PseudoRef, + Unknown, + MainPseudoRef, + MainRef, + LinkedPseudoRef, + LinkedRef, + Bisect, + Rewritten, + WorktreePrivate, +} + +impl<'a> From> for GixReferenceCategory { + fn from(category: gix::refs::Category) -> Self { + match category { + gix::refs::Category::LocalBranch => GixReferenceCategory::LocalBranch, + gix::refs::Category::RemoteBranch => GixReferenceCategory::RemoteBranch, + gix::refs::Category::Tag => GixReferenceCategory::Tag, + gix::refs::Category::Note => GixReferenceCategory::Note, + gix::refs::Category::PseudoRef => GixReferenceCategory::PseudoRef, + gix::refs::Category::MainPseudoRef => GixReferenceCategory::MainPseudoRef, + gix::refs::Category::MainRef => GixReferenceCategory::MainRef, + gix::refs::Category::LinkedPseudoRef { .. } => GixReferenceCategory::LinkedPseudoRef, + gix::refs::Category::LinkedRef { .. } => GixReferenceCategory::LinkedRef, + gix::refs::Category::Bisect => GixReferenceCategory::Bisect, + gix::refs::Category::Rewritten => GixReferenceCategory::Rewritten, + gix::refs::Category::WorktreePrivate => GixReferenceCategory::WorktreePrivate, + } + } +} + + +impl From for GixBranch { + fn from(r: Reference) -> Self { + let category = r.name.category() + .map(GixReferenceCategory::from) + .unwrap_or(GixReferenceCategory::Unknown); + + Self { + full_name: r.name.clone(), + name: r.name.shorten().to_string(), + target: r.target.into_id(), + category, + } + } +} \ No newline at end of file diff --git a/binocular-backend-new/ffi/lib/src/types/remote.rs b/binocular-backend-new/ffi/lib/src/types/remote.rs new file mode 100644 index 000000000..0ea51d9ff --- /dev/null +++ b/binocular-backend-new/ffi/lib/src/types/remote.rs @@ -0,0 +1,25 @@ +use gix::remote::Direction; +use gix::Remote; + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GixRemote { + pub name: String, + pub url: String, +} + +impl From> for GixRemote { + fn from(remote: Remote) -> Self { + let remote_url = remote + .url(Direction::Fetch) + .or_else(|| remote.url(Direction::Push)) + .expect("should be the remote URL"); + GixRemote { + name: remote + .name() + .expect("remote should have name") + .as_bstr() + .to_string(), + url: remote_url.to_string(), + } + } +} diff --git a/binocular-backend-new/ffi/lib/src/types/repo.rs b/binocular-backend-new/ffi/lib/src/types/repo.rs index 14feec74c..39dd14342 100644 --- a/binocular-backend-new/ffi/lib/src/types/repo.rs +++ b/binocular-backend-new/ffi/lib/src/types/repo.rs @@ -1,12 +1,13 @@ -use std::path::{Path, PathBuf}; -use gix::remote::Direction; -use gix::Remote; +use crate::types::remote::GixRemote; +use crate::types::UniffiError; +use gix::ThreadSafeRepository; +use std::path::PathBuf; -#[derive(Debug, uniffi::Record)] -pub struct BinocularRepository { +#[derive(Debug, Clone, uniffi::Record)] +pub struct GixRepository { pub git_dir: String, pub work_tree: Option, - pub origin: Option, + pub remotes: Vec, } uniffi::custom_type!(PathBuf, String, { @@ -15,31 +16,15 @@ uniffi::custom_type!(PathBuf, String, { try_lift: |r| Ok(PathBuf::from(r)), }); -#[derive(Debug, uniffi::Record)] -pub struct RepositoryRemote { - pub name: Option, - pub url: Option, - pub path: Option, -} +impl TryFrom for ThreadSafeRepository { + type Error = UniffiError; -impl From> for RepositoryRemote { - fn from(remote: Remote) -> Self { - let remote_url = remote - .url(Direction::Push) - .or_else(|| remote.url(Direction::Fetch)); - RepositoryRemote { - name: remote.name().map(|s| s.as_bstr().to_string()), - url: remote_url.and_then(|e| e.host().map(|h| h.to_string())), - path: remote_url.and_then(|e| Option::from(e.path.to_string())), - } + fn try_from(gix_repo: GixRepository) -> Result { + discover_repo(gix_repo.git_dir) } } -// uniffi::custom_type!(ThreadSafeRepository, BinocularRepository, { -// remote, -// lower: move |r| BinocularRepository { -// -// git_dir: r.refs.git_dir().display().to_string() -// }, -// try_lift: |r| Ok(gix::ThreadSafeRepository::discover(r.git_dir)?), -// }); +pub fn discover_repo(path: String) -> Result { + ThreadSafeRepository::discover(path) + .map_err(|e| UniffiError::GixDiscoverError(e.to_string())) +} diff --git a/binocular-backend-new/ffi/lib/src/types/signature.rs b/binocular-backend-new/ffi/lib/src/types/signature.rs index d88e4c42e..46eb8e028 100644 --- a/binocular-backend-new/ffi/lib/src/types/signature.rs +++ b/binocular-backend-new/ffi/lib/src/types/signature.rs @@ -1,21 +1,21 @@ use gix::bstr::BString; use gix::date::{OffsetInSeconds, SecondsSinceUnixEpoch}; -type BinocularTime = gix::date::Time; +type GixTime = gix::date::Time; #[uniffi::remote(Record)] -pub struct BinocularTime { +pub struct GixTime { /// The seconds that passed since UNIX epoch. This makes it UTC, or `+0000`. pub seconds: SecondsSinceUnixEpoch, /// The time's offset in seconds, which may be negative to match the `sign` field. pub offset: OffsetInSeconds, } -pub type BinocularSig = shared::signature::Sig; +pub type GixSignature = shared::signature::Sig; #[uniffi::remote(Record)] -pub struct BinocularSig { +pub struct GixSignature { pub name: BString, pub email: BString, - pub time: BinocularTime, + pub time: GixTime, } uniffi::custom_type!(BString, String, { remote, diff --git a/binocular-backend-new/ffi/lib/tests/ffi_integration_tests.rs b/binocular-backend-new/ffi/lib/tests/ffi_integration_tests.rs new file mode 100644 index 000000000..996804af9 --- /dev/null +++ b/binocular-backend-new/ffi/lib/tests/ffi_integration_tests.rs @@ -0,0 +1,309 @@ +//! Integration tests for the FFI layer of gix-binocular. +//! +//! These tests verify that the FFI functions work correctly with real Git repositories +//! and handle errors appropriately. + +use gix_binocular::*; + +mod util { + use std::path::Path; + use gix_binocular::types::GixRepository; + + /// Gets the path to the current test repository. + pub fn get_test_repo_path() -> String { + Path::new("./").canonicalize().unwrap().display().to_string() + } + + /// Gets a repository through the FFI layer. + pub fn get_test_repo() -> GixRepository { + gix_binocular::find_repo(get_test_repo_path()).expect("Should find test repo") + } +} + +// ============================================================================ +// Repository Discovery Tests +// ============================================================================ + +mod find_repo_tests { + use super::*; + + #[test] + fn test_find_repo_with_valid_path() { + let path = util::get_test_repo_path(); + + let result = find_repo(path); + + assert!(result.is_ok(), "Should find repository at valid path"); + let repo = result.unwrap(); + assert!(!repo.git_dir.is_empty(), "git_dir should not be empty"); + } + + #[test] + fn test_find_repo_with_invalid_path() { + let invalid_path = "/nonexistent/path/to/repository".to_string(); + + let result = find_repo(invalid_path); + + assert!(result.is_err(), "Should fail for invalid path"); + let err = result.unwrap_err(); + // Should be a GixDiscoverError + let err_string = err.to_string(); + assert!( + err_string.contains("discovery") || err_string.contains("Git") || err_string.contains("repository"), + "Error should mention discovery issue: {}", + err_string + ); + } + + #[test] + fn test_find_repo_with_empty_path() { + let empty_path = "".to_string(); + + let result = find_repo(empty_path); + + // Empty path might succeed (discovers from current dir) or fail depending on cwd + // The important thing is it doesn't panic + match result { + Ok(repo) => assert!(!repo.git_dir.is_empty()), + Err(e) => assert!(!e.to_string().is_empty()), + } + } +} + +// ============================================================================ +// Find Commit Tests +// ============================================================================ + +mod find_commit_tests { + use super::*; + + const KNOWN_COMMIT: &str = "9853fe8e0e05871b5757c21a23015f3dd169c568"; + + #[test] + fn test_find_commit_with_valid_hash() { + let repo = util::get_test_repo(); + + let result = find_commit(repo, KNOWN_COMMIT.to_string(), false); + + assert!(result.is_ok(), "Should find known commit"); + let commit = result.unwrap(); + assert!(commit.oid.to_string().starts_with("9853fe8")); + } + + #[test] + fn test_find_commit_with_head() { + let repo = util::get_test_repo(); + + let result = find_commit(repo, "HEAD".to_string(), false); + + assert!(result.is_ok(), "HEAD should be a valid revision spec"); + } + + #[test] + fn test_find_commit_with_abbreviated_hash() { + let repo = util::get_test_repo(); + + let result = find_commit(repo, "9853fe8".to_string(), false); + + assert!(result.is_ok(), "Should find commit with abbreviated hash"); + } + + #[test] + fn test_find_commit_with_invalid_hash() { + let repo = util::get_test_repo(); + let invalid_hash = "not-a-valid-hash!@#$".to_string(); + + let result = find_commit(repo, invalid_hash, false); + + assert!(result.is_err(), "Should fail for invalid hash"); + } + + #[test] + fn test_find_commit_with_nonexistent_hash() { + let repo = util::get_test_repo(); + let nonexistent = "0000000000000000000000000000000000000000".to_string(); + + let result = find_commit(repo, nonexistent, false); + + assert!(result.is_err(), "Should fail for non-existent commit"); + } + + #[test] + fn test_find_commit_with_mailmap() { + let repo = util::get_test_repo(); + + let result = find_commit(repo, KNOWN_COMMIT.to_string(), true); + + assert!(result.is_ok(), "Should work with mailmap enabled"); + } +} + +// ============================================================================ +// Find All Branches Tests +// ============================================================================ + +mod find_all_branches_tests { + use gix_binocular::types::GixRepository; + use super::*; + + #[test] + fn test_find_all_branches_returns_branches() { + let repo = util::get_test_repo(); + + let result = find_all_branches(repo); + + assert!(result.is_ok(), "Should find branches in repository"); + let branches = result.unwrap(); + // Most repos have at least one branch + assert!(!branches.is_empty(), "Should have at least one branch"); + } + + #[test] + fn test_find_all_branches_with_invalid_repo() { + // Create an invalid repo reference + let invalid_repo = GixRepository { + git_dir: "/nonexistent/path/.git".to_string(), + work_tree: None, + remotes: vec![], + }; + + let result = find_all_branches(invalid_repo); + + assert!(result.is_err(), "Should fail with invalid repository"); + } +} + +// ============================================================================ +// Traverse Branch Tests +// ============================================================================ + +mod traverse_branch_tests { + use gix_binocular::types::GixRepository; + use super::*; + + /// Helper to find a valid branch name to test with + fn find_test_branch(repo: &GixRepository) -> Option { + // Try common branch names (must use full ref path, not HEAD) + let branches = find_all_branches(repo.clone()).ok()?; + branches.first().map(|b| b.full_name.to_string()) + } + + #[test] + fn test_traverse_branch_with_real_branch() { + let repo = util::get_test_repo(); + + // Find an actual branch to test with + let branch_name = find_test_branch(&repo); + assert!(branch_name.is_some(), "Should have at least one branch"); + + let result = traverse_branch(repo, branch_name.unwrap(), false, false); + + assert!(result.is_ok(), "Should traverse branch: {:?}", result.err()); + let traversal = result.unwrap(); + assert!(!traversal.commits.is_empty(), "Branch should have commits"); + } + + #[test] + fn test_traverse_branch_with_invalid_ref() { + let repo = util::get_test_repo(); + let invalid_branch = "refs/heads/nonexistent-branch-12345".to_string(); + + let result = traverse_branch(repo, invalid_branch, false, false); + + assert!(result.is_err(), "Should fail for non-existent branch"); + let err = result.unwrap_err(); + let err_string = err.to_string(); + assert!( + err_string.contains("reference") || err_string.contains("Reference"), + "Error should mention reference issue: {}", + err_string + ); + } + + #[test] + fn test_traverse_branch_skip_merges() { + let repo_without = util::get_test_repo(); + let repo_with = util::get_test_repo(); + + // Find an actual branch to test with + let branch_name = find_test_branch(&repo_without); + assert!(branch_name.is_some(), "Should have at least one branch"); + let branch = branch_name.unwrap(); + + // Get results with and without skip_merges + let result_without_skip = traverse_branch(repo_without, branch.clone(), false, false); + let result_with_skip = traverse_branch(repo_with, branch, true, false); + + // Both should succeed + assert!(result_without_skip.is_ok(), "Without skip should succeed"); + assert!(result_with_skip.is_ok(), "With skip should succeed"); + + // With skip_merges, we should have <= commits (assuming there might be merges) + let count_without = result_without_skip.unwrap().commits.len(); + let count_with = result_with_skip.unwrap().commits.len(); + assert!( + count_with <= count_without, + "skip_merges should result in <= commits" + ); + } + + #[test] + fn test_traverse_branch_with_mailmap() { + let repo = util::get_test_repo(); + + // Find an actual branch to test with + let branch_name = find_test_branch(&repo); + assert!(branch_name.is_some(), "Should have at least one branch"); + + let result = traverse_branch(repo, branch_name.unwrap(), false, true); + + assert!(result.is_ok(), "Should work with mailmap enabled"); + } +} + +// ============================================================================ +// Error Type Tests +// ============================================================================ + +mod error_type_tests { + use super::*; + + #[test] + fn test_error_messages_are_descriptive() { + let repo = util::get_test_repo(); + + let result = find_commit(repo, "invalid".to_string(), false); + + assert!(result.is_err()); + let err = result.unwrap_err(); + let msg = err.to_string(); + + // Error message should be non-empty and descriptive + assert!(!msg.is_empty(), "Error message should not be empty"); + assert!( + msg.len() > 10, + "Error message should be descriptive: {}", + msg + ); + } + + #[test] + fn test_different_error_types_have_different_messages() { + let repo_commit = util::get_test_repo(); + let repo_branch = util::get_test_repo(); + + // Invalid commit hash + let commit_err = find_commit(repo_commit, "not-valid".to_string(), false).unwrap_err(); + + // Invalid branch + let branch_err = traverse_branch(repo_branch, "refs/heads/nonexistent".to_string(), false, false) + .unwrap_err(); + + // Error messages should be different + assert_ne!( + commit_err.to_string(), + branch_err.to_string(), + "Different errors should have different messages" + ); + } +} diff --git a/binocular-backend-new/ffi/pom.xml b/binocular-backend-new/ffi/pom.xml index 1918c8b94..caad47fc6 100644 --- a/binocular-backend-new/ffi/pom.xml +++ b/binocular-backend-new/ffi/pom.xml @@ -51,16 +51,33 @@ + + com.inso-world.binocular + domain + ${project.version} + com.inso-world.binocular core ${project.version} + + com.inso-world.binocular + domain + ${project.version} + test + tests + com.inso-world.binocular core ${project.version} + test tests + + + org.springframework.boot + spring-boot-starter-test test @@ -84,6 +101,11 @@ slf4j-api 2.0.17 + + org.springframework.boot + spring-boot-configuration-processor + true + org.assertj assertj-core @@ -124,6 +146,25 @@ kotlin-maven-plugin ${kotlin.version} true + + + kapt + + kapt + + + + ${project.basedir}/src/main/kotlin + + + + org.springframework.boot + spring-boot-configuration-processor + + + + + -Xjsr305=strict diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/BinocularFfi.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/BinocularFfi.kt deleted file mode 100644 index c49c4f736..000000000 --- a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/BinocularFfi.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.inso_world.binocular.ffi - -import com.inso_world.binocular.core.index.GitIndexer -import com.inso_world.binocular.ffi.exception.FfiException -import com.inso_world.binocular.ffi.extensions.toDomain -import com.inso_world.binocular.ffi.extensions.toModel -import com.inso_world.binocular.ffi.internal.AnyhowException -import com.inso_world.binocular.ffi.internal.BinocularDiffInput -import com.inso_world.binocular.ffi.internal.GixDiffAlgorithm -import com.inso_world.binocular.ffi.pojos.toFfi -import com.inso_world.binocular.ffi.pojos.toModel -import com.inso_world.binocular.ffi.util.Utils -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.CommitDiff -import com.inso_world.binocular.model.Repository -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service -import java.nio.file.Path -import kotlin.math.min -import kotlin.streams.asSequence - -@Service -class BinocularFfi : GitIndexer { - companion object { - private var logger: Logger = LoggerFactory.getLogger(BinocularFfi::class.java) - private val ALGORITHM = GixDiffAlgorithm.HISTOGRAM - } - - init { - logger.info("Loading native library...") - val rp = Utils.loadPlatformLibrary("gix_binocular") - logger.debug("Loaded library: $rp") - logger.info("Library loaded successfully.") - } - - fun hello() { - com.inso_world.binocular.ffi.internal - .hello() - } - - @Throws(FfiException::class) - override fun findRepo(path: Path): Repository { - logger.trace("Searching repository... at '{}'", path) - try { - val repo = - com.inso_world.binocular.ffi.internal - .findRepo(path.toString().trim()) - .toModel() - return repo - } catch (e: AnyhowException) { - throw FfiException(e) - } - } - - override fun traverseBranch( - repo: Repository, - branch: Branch, - ): List { - val commitVec = - com.inso_world.binocular.ffi.internal - .traverseBranch(repo.toFfi(), branch.name) - - val commits = commitVec.toDomain(repo) - branch.commits.addAll(commits) - - repo.branches.add(branch) - branch.repository = repo - - return commits - } - - override fun findAllBranches(repo: Repository): List = - com.inso_world.binocular.ffi.internal - .findAllBranches(repo.toFfi()) - .map { - val branch = it.toModel() - branch - } - - override fun findCommit( - repo: Repository, - hash: String, - ): String = - com.inso_world.binocular.ffi.internal - .findCommit(repo.toFfi(), hash) - - override fun traverse( - repo: Repository, - sourceCmt: String, - trgtCmt: String?, - ): List = - com.inso_world.binocular.ffi.internal - .traverse(repo.toFfi(), sourceCmt, trgtCmt) - .toDomain(repo) -} diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/GixConfig.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/GixConfig.kt new file mode 100644 index 000000000..a69a7a94e --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/GixConfig.kt @@ -0,0 +1,14 @@ +package com.inso_world.binocular.ffi + +import com.inso_world.binocular.core.BinocularConfig +import org.springframework.context.annotation.Configuration + +@Configuration +internal open class FfiConfig : BinocularConfig() { + lateinit var gix: GixConfig +} + +class GixConfig( + val skipMerges: Boolean, + val useMailmap: Boolean +) diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/GixIndexer.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/GixIndexer.kt new file mode 100644 index 000000000..5a6a2ac6c --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/GixIndexer.kt @@ -0,0 +1,115 @@ +package com.inso_world.binocular.ffi + +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.index.GitIndexer +import com.inso_world.binocular.ffi.extensions.toDomain +import com.inso_world.binocular.ffi.pojos.toFfi +import com.inso_world.binocular.ffi.pojos.toModel +import com.inso_world.binocular.ffi.util.Utils +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Stats +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import java.nio.file.Path +import kotlin.math.min +import kotlin.streams.asSequence + +@Service +class GixIndexer : GitIndexer { + + @Autowired + private lateinit var cfg: FfiConfig + + companion object Companion { + private val logger by logger() + } + + init { + logger.info("Loading native library...") + val rp = Utils.loadPlatformLibrary("gix_binocular") + logger.debug("Loaded library: $rp") + logger.info("Library loaded successfully.") + } + + fun hello() { + com.inso_world.binocular.ffi.internal + .hello() + } + + override fun findRepo(path: Path, project: Project): Repository { + logger.trace("Searching repository... at '{}'", path) + val repo = + com.inso_world.binocular.ffi.internal + .findRepo(path.toString().trim()) + .toModel(project) + return repo + } + + override fun traverseBranch( + repo: Repository, + branchName: String, + ): Pair> { + logger.trace("Traversing $branchName with skipMerges={}, useMailmap={}", cfg.gix.skipMerges, cfg.gix.useMailmap) + val branchTraversalResult = + com.inso_world.binocular.ffi.internal + .traverseBranch( + repo.toFfi(), + branchName, + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap, + ) + + val commits: List = branchTraversalResult.commits.toDomain(repo) + val branch: Branch = with(commits.associateBy { it.sha }.getValue(branchTraversalResult.branch.target)) { + branchTraversalResult.branch.toDomain( + repo, + this + ) + } + + return Pair(branch, commits) + } + + override fun findAllBranches(repo: Repository): List { + val binocularRepo = repo.toFfi() + return com.inso_world.binocular.ffi.internal + .findAllBranches(binocularRepo) + .map { + val head = com.inso_world.binocular.ffi.internal.findCommit( + binocularRepo, + it.target, + useMailmap = cfg.gix.useMailmap, + ).toDomain(repo) + val branch = it.toDomain(repo, head) + branch + } + } + + override fun findCommit( + repo: Repository, + hash: String, + ): Commit = + com.inso_world.binocular.ffi.internal + .findCommit( + repo.toFfi(), + hash, + useMailmap = cfg.gix.useMailmap + ) + .toDomain(repo) + + override fun traverse( + repo: Repository, + source: Commit, + target: Commit?, + ): List = + com.inso_world.binocular.ffi.internal + .traverseHistory( + repo.toFfi(), source.sha, + target?.sha, + useMailmap = cfg.gix.useMailmap + ) + .toDomain(repo) +} diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/BinocularCommitVec.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/BinocularCommitVec.kt deleted file mode 100644 index f6ec6132e..000000000 --- a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/BinocularCommitVec.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.inso_world.binocular.ffi.extensions - -import com.inso_world.binocular.ffi.internal.BinocularCommitVec -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User -import java.time.LocalDateTime - -/** Map a whole batch in two passes to preserve identity for commits, parents and users. */ -internal fun Collection.toDomain(repository: Repository): List { -// val usersByKey = LinkedHashMap() - val usersByKey = repository.user.associateBy(User::uniqueKey).toMutableMap() -// val commitsBySha = LinkedHashMap() - val commitsBySha = repository.commits.associateBy(Commit::uniqueKey).toMutableMap() - - // 1) create Users & Commits (scalar fields only; no relationships yet) - for (bin in this) { - bin.author?.let { author -> - val key = author.userKey(repository) - val user = usersByKey.computeIfAbsent(key) { author.toDomain(repository) } - repository.user.add(user) - } - bin.committer?.let { committer -> - val key = committer.userKey(repository) - val user = usersByKey.computeIfAbsent(key) { committer.toDomain(repository) } - repository.user.add(user) - } - - // commitDateTime = committer time (fallback to author time; final fallback: now) - val commitDt = (bin.committer?.time ?: bin.author?.time)?.toLocalDateTime() ?: LocalDateTime.now() - val authorDt = bin.author?.time?.toLocalDateTime() - - commitsBySha.computeIfAbsent(bin.commit) { - val cmt = - Commit( - sha = bin.commit, - authorDateTime = authorDt, - commitDateTime = commitDt, - message = bin.message, - repository = repository, - ) -// cmt.committer = committerUser -// cmt.author = authorUser - repository.commits.add(cmt) - cmt - } -// authorUser?.let { it.repository = repository } -// committerUser?.let { it.repository = repository } - } - - // 2) wire relationships: committer, author, parents - for (bin in this) { - val commit = commitsBySha.getValue(bin.commit) - - // users (linking will also populate the inverse sets via domain logic) - bin.committer?.let { commit.committer = usersByKey.getValue(it.userKey(repository)) } - bin.author?.let { commit.author = usersByKey.getValue(it.userKey(repository)) } - - // parents (and implicit children back-links via domain) - for (pSha in bin.parents) { - val parent = - commitsBySha.computeIfAbsent(pSha) { - // unseen parent – create lightweight placeholder (vals require something); - // will still preserve identity and relationships. - Commit( - sha = pSha, - authorDateTime = null, - commitDateTime = commit.commitDateTime, // safe placeholder; can be enriched elsewhere - message = null, - repository = repository, - ) - } - commit.parents.add(parent) - } - } - val cmts = commitsBySha.values.toList() - repository.commits.addAll(cmts) - - return cmts -} diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/BinocularSig.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/BinocularSig.kt deleted file mode 100644 index e2e26887a..000000000 --- a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/BinocularSig.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.inso_world.binocular.ffi.extensions - -import com.inso_world.binocular.ffi.internal.BinocularSig -import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User - -internal fun BinocularSig.toDomain( - repository: Repository, -// usersByKey: MutableMap, -): User { -// val key = userKey(repository) -// return usersByKey.getOrPut(key) { - return User( - name = this.name.toString(), - email = this.email.toString(), - repository = repository, - ) -// } -} - -/** Stable identity key for users per domain’s uniqueKey() contract. */ -internal fun BinocularSig.userKey(repository: Repository): String = "${repository.localPath},${this.email}" diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/BinocularTime.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/BinocularTime.kt deleted file mode 100644 index c26cabf15..000000000 --- a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/BinocularTime.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.inso_world.binocular.ffi.extensions - -import com.inso_world.binocular.ffi.internal.BinocularTime -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneOffset - -/** Translate generator time (epoch seconds + offset) to LocalDateTime. */ -internal fun BinocularTime.toLocalDateTime(): LocalDateTime { - val offset = ZoneOffset.ofTotalSeconds(this.offset) - return Instant.ofEpochSecond(this.seconds).atOffset(offset).toLocalDateTime() -} diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/Branch.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/Branch.kt deleted file mode 100644 index b94eae2d7..000000000 --- a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/Branch.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.inso_world.binocular.ffi.extensions - -import com.inso_world.binocular.ffi.internal.BinocularBranch -import com.inso_world.binocular.model.Branch - -internal fun BinocularBranch.toModel(): Branch = - Branch( - name = this.name.replace("refs/remotes/", "").replace("refs/heads/", ""), - ) diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixBlameResult.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixBlameResult.kt new file mode 100644 index 000000000..5fa4bc939 --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixBlameResult.kt @@ -0,0 +1,6 @@ +package com.inso_world.binocular.ffi.extensions + +import com.inso_world.binocular.ffi.internal.GixBlameResult + +internal fun GixBlameResult.toDomain() { +} diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixBranch.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixBranch.kt new file mode 100644 index 000000000..0e29ab997 --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixBranch.kt @@ -0,0 +1,47 @@ +package com.inso_world.binocular.ffi.extensions + +import com.inso_world.binocular.ffi.internal.GixBranch +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Repository + +/** + * Map an FFI branch into a domain [Branch], **preserving identity**. + * + * ### Semantics + * - **Name normalization:** Strips Git ref prefixes (e.g., `refs/heads/`, `refs/remotes/`, `refs/tags/`) + * to produce readable branch names. Examples: + * - `refs/heads/main` → `main` + * - `refs/remotes/origin/feature` → `origin/feature` + * - `refs/tags/v1.0.0` → `v1.0.0` + * - `plain-name` → `plain-name` (unchanged) + * - **Identity-preserving:** Returns the canonical [Branch] for `(repository, name)`. + * If none exists, a new one is created; the domain model registers it into + * `repository.branches` during `init` (add-only, de-duplicated). + * - **Head update:** If the branch already exists, its [Branch.head] is updated when + * different (setter validates repository consistency). + * + * ### Exceptions + * - [Branch.head] will throw if `head.repository != repository` (domain invariant). + * + * @param repository Owning repository in which the branch must reside. + * @param head The commit to set as the branch head. + * @return The canonical [Branch] instance for the given repository and name. + */ +internal fun GixBranch.toDomain(repository: Repository, head: Commit): Branch { + // Try to reuse existing branch identity in this repository + val existing = repository.branches.firstOrNull { it.uniqueKey.name == this.name } + if (existing != null) { + if (existing.head != head) existing.head = head // repository consistency enforced by setter + return existing + } + + // Otherwise create a new branch; Branch.init will register it into repository.branches + return Branch( + name = this.name, + fullName = this.fullName.toString(), + category = this.category.toDomain(), + repository = repository, + head = head + ) +} diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixCommit.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixCommit.kt new file mode 100644 index 000000000..450e715cd --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixCommit.kt @@ -0,0 +1,109 @@ +package com.inso_world.binocular.ffi.extensions + +import com.inso_world.binocular.ffi.internal.GixCommit +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Repository + +/** + * Validates that this string is a valid 40-character hexadecimal SHA-1 hash. + * + * @throws IllegalArgumentException if the string is not exactly 40 hex characters + */ +private fun String.validateSha(): String { + require(this.length == 40) { + "Invalid SHA '$this': must be exactly 40 characters, got ${this.length}" + } + require(this.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { + "Invalid SHA '$this': must contain only hexadecimal characters [0-9a-fA-F]" + } + return this +} + +/** + * Map a single FFI commit into a domain [Commit]. + * + * ### Semantics + * - Reuses an existing [Commit] from [Repository.commits] by `sha`, otherwise creates one. + * - Sets scalars (`sha`, `authorSignature`, `committerSignature`, `message`). + * - `committerSignature` defaults to author when they are identical; otherwise uses distinct signatures. + * - Does **not** wire parents; the batch mapper handles graph wiring. + * + * ### Performance note + * This single-item mapper uses O(n) lookup when no index is provided. For batch operations, + * prefer [Collection.toDomain] which uses O(1) indexed lookup. + * + * @param repository The owning repository + * @param shaIndex Optional pre-built SHA index for O(1) lookup; used internally by batch mapper + * @throws IllegalArgumentException if SHA format is invalid or `committer.time` is null + */ +internal fun GixCommit.toDomain( + repository: Repository, + shaIndex: Map? = null +): Commit { + // Validate SHA format early at boundary + this.oid.validateSha() + + val authorSignature = this.author.toSignature(repository) + val committerSignature = this.committer.toSignature(repository) + + // Use index if provided (O(1)), otherwise fall back to linear search (O(n)) + val existing = shaIndex?.get(this.oid) + ?: repository.commits.firstOrNull { it.sha == this.oid } + + val commit = + existing ?: Commit( + sha = this.oid, + authorSignature = authorSignature, + committerSignature = if (authorSignature == committerSignature) authorSignature else committerSignature, + message = this.message, + repository = repository, + ) + + return commit +} + +/** + * Map a batch of FFI commits into domain [Commit]s while **preserving identity**. + * + * ### Strategy + * 1) **Pass 1 (materialize):** For every element call the single-item mapper + * [GixCommit.toDomain]. This reuses/creates canonical [Commit] instances and + * sets author/committer identically to the single-item logic. + * 2) **Pass 2 (wire graph):** Wire `parents` edges. All referenced parent commits must already + * exist in either the batch (from Pass 1) or the repository. + * + * ### Parent commit requirements + * - All parent SHAs referenced in `parents` must exist in the repository or batch. + * - Missing parents will cause a [NoSuchElementException] from `getValue()`. + * - Process commits in topological order (parents before children) or ensure parents pre-exist. + * + * ### Guarantees + * - Identity is preserved via `Repository.commits` as the canonical set. + * - Returns commits in the same order as the input collection. + * - Uses O(1) indexed lookup for efficiency. + * + * @throws NoSuchElementException if a parent SHA is referenced but not found in batch or repository + */ +internal fun Collection.toDomain(repository: Repository): List { + // Seed a quick lookup from existing repo state (canonical instances). + val bySha = repository.commits.associateBy { it.uniqueKey.sha }.toMutableMap() + + // ---- Pass 1: materialize commits using the single-item mapper with index ---- + val mappedInOrder: List = this.map { vec -> + val c = vec.toDomain(repository, bySha) // <— reuse single-item logic with O(1) lookup + bySha.putIfAbsent(c.sha, c) + c + } + + // ---- Pass 2: wire parent edges (and implicit child back-links by domain invariants) ---- + this.forEach { vec -> + val child = bySha.getValue(vec.oid) + vec.parents.forEach { parentSha -> + parentSha.validateSha() // Validate parent SHA early + val parent = bySha.getValue(parentSha) + child.parents.add(parent) // domain ensures repository consistency & back-link to children + } + } + + return mappedInOrder +} diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixReference.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixReference.kt new file mode 100644 index 000000000..29855d492 --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixReference.kt @@ -0,0 +1,21 @@ +package com.inso_world.binocular.ffi.extensions + +import com.inso_world.binocular.ffi.internal.GixReferenceCategory +import com.inso_world.binocular.model.vcs.ReferenceCategory + +internal fun GixReferenceCategory.toDomain(): ReferenceCategory = + when (this) { + GixReferenceCategory.LOCAL_BRANCH -> ReferenceCategory.LOCAL_BRANCH + GixReferenceCategory.REMOTE_BRANCH -> ReferenceCategory.REMOTE_BRANCH + GixReferenceCategory.TAG -> ReferenceCategory.TAG + GixReferenceCategory.NOTE -> ReferenceCategory.NOTE + GixReferenceCategory.PSEUDO_REF -> ReferenceCategory.PSEUDO_REF + GixReferenceCategory.UNKNOWN -> ReferenceCategory.UNKNOWN + GixReferenceCategory.MAIN_PSEUDO_REF -> ReferenceCategory.MAIN_PSEUDO_REF + GixReferenceCategory.MAIN_REF -> ReferenceCategory.MAIN_REF + GixReferenceCategory.LINKED_PSEUDO_REF -> ReferenceCategory.LINKED_PSEUDO_REF + GixReferenceCategory.LINKED_REF -> ReferenceCategory.LINKED_REF + GixReferenceCategory.BISECT -> ReferenceCategory.BISECT + GixReferenceCategory.REWRITTEN -> ReferenceCategory.REWRITTEN + GixReferenceCategory.WORKTREE_PRIVATE -> ReferenceCategory.WORKTREE_PRIVATE + } diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixRemote.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixRemote.kt new file mode 100644 index 000000000..088cf84fe --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixRemote.kt @@ -0,0 +1,91 @@ +package com.inso_world.binocular.ffi.extensions + +import com.inso_world.binocular.ffi.internal.GixRemote +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.vcs.Remote + +/** + * Converts a Rust FFI [GixRemote] to a domain [Remote] model with identity preservation. + * + * ### Semantics + * - **Find-or-create pattern:** Searches for an existing [Remote] in [repository].[remotes][Repository.remotes] + * by matching [name]. If found, returns the existing instance; otherwise creates a new one. + * - **URL synchronization:** If an existing remote is found and its URL differs from `this.url`, + * the existing remote's URL is updated to match the FFI remote's URL. + * - **Identity preservation:** Ensures that for a given repository + name combination, there is + * always exactly one canonical [Remote] instance. Prevents orphaned instances that aren't + * in the repository's remotes collection. + * + * ### Invariants & requirements + * - **Precondition:** [repository] must be a valid, non-null repository. + * - **Postcondition (existing remote found):** + * - Returns the existing remote instance (same object identity via `===`). + * - `result.url == this.url` (URL updated if it was different). + * - `result in repository.remotes` (already present). + * - **Postcondition (new remote created):** + * - Returns a newly constructed [Remote] instance. + * - `result in repository.remotes` (auto-added during `Remote` construction). + * - `repository.remotes.size` increases by 1. + * + * ### Trade-offs & guidance + * - **Linear search:** Uses `find` to search through `repository.remotes` (O(n) worst case). + * For repositories with many remotes (typically 1-5), this is acceptable. If performance + * becomes an issue, consider indexing remotes by name. + * - **Mutation of existing:** Updates the URL of existing remotes in-place. This is intentional + * to reflect changes in the Git repository's remote configuration. + * - **Thread-safety:** The `find` + conditional create/update is **not atomic**. If concurrent + * threads call this method for the same remote name, duplicate instances might be created + * temporarily. The `NonRemovingMutableSet` will only keep one based on `uniqueKey`, but + * callers should coordinate externally if this is a concern. + * + * ### Example + * ```kotlin + * val repository = Repository(localPath = "/path/to/repo", project = myProject) + * + * // First call: creates new remote + * val ffiRemote = GixRemote(name = "origin", url = "https://github.com/user/repo.git") + * val remote1 = ffiRemote.toModel(repository) + * check(remote1 in repository.remotes) + * check(repository.remotes.size == 1) + * + * // Second call with same name: returns existing remote + * val ffiRemote2 = GixRemote(name = "origin", url = "https://github.com/user/repo.git") + * val remote2 = ffiRemote2.toModel(repository) + * check(remote1 === remote2) // Same instance + * check(repository.remotes.size == 1) // No duplicate + * + * // Call with updated URL: updates existing remote + * val ffiRemote3 = GixRemote(name = "origin", url = "git@github.com:user/repo.git") + * val remote3 = ffiRemote3.toModel(repository) + * check(remote1 === remote3) // Still same instance + * check(remote3.url == "git@github.com:user/repo.git") // URL updated + * ``` + * + * @param repository The domain repository to which this FFI remote belongs. + * @return The canonical [Remote] instance for this name, either existing or newly created. + */ +internal fun GixRemote.toModel(repository: Repository): Remote { + // Find existing remote by name (business key component) + val existing = repository.remotes.find { it.name == this.name } + + return if (existing != null) { + // Identity preservation: return existing instance + // Update URL if it changed (synchronize with Git remote config) + if (existing.url != this.url) { + existing.url = this.url + } + existing + } else { + // Create new remote (auto-adds to repository.remotes in Remote's init block) + Remote( + name = this.name, + url = this.url, + repository = repository + ) + } +} + +internal fun Remote.toFfi(): GixRemote = GixRemote( + name = this.name, + url = this.url, +) diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixRepository.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixRepository.kt new file mode 100644 index 000000000..3ef4fd652 --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixRepository.kt @@ -0,0 +1,21 @@ +package com.inso_world.binocular.ffi.pojos + +import com.inso_world.binocular.ffi.extensions.toFfi +import com.inso_world.binocular.ffi.internal.GixRepository +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository + +internal fun Repository.toFfi(): GixRepository = + GixRepository( + gitDir = this.localPath, + workTree = null, + remotes = this.remotes.map { it.toFfi() }, + ) + +private fun normalizePath(path: String): String = if (path.endsWith(".git")) path else "$path/.git" + +internal fun GixRepository.toModel(project: Project): Repository = + Repository( + localPath = normalizePath(gitDir), + project = project, + ) diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixSignature.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixSignature.kt new file mode 100644 index 000000000..54b43da92 --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixSignature.kt @@ -0,0 +1,52 @@ +package com.inso_world.binocular.ffi.extensions + +import com.inso_world.binocular.ffi.internal.GixSignature +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature + +/** + * Map an FFI Git signature (`GixSignature`) into a domain [Developer] and [Signature], **preserving identity**. + * + * ### Semantics + * - **Identity-preserving:** Finds the canonical [Developer] in `repository.developers` by + * git signature (`"Name "`). If found, returns it; otherwise creates a new [Developer]. + * - **Repository registration:** A newly constructed [Developer] self-registers into + * `repository.developers` inside its `init` block (add-only, de-duplicated). + * - **Signature creation:** Wraps the developer with the FFI timestamp into a domain [Signature]. + * + * ### Guarantees & constraints + * - Requires non-blank `name` and `email` (enforced by [Developer]). + * - Business key uses trimmed name/email; equality/hash are identity-based (see [Developer.uniqueKey]). + * + * @param repository Owning [Repository] that scopes the developer identity. + * @return The canonical [Developer] instance for the given repository and signature name. + */ +internal fun GixSignature.toDeveloper(repository: Repository): Developer { + val nameTrimmed = this.name.trim() + val emailTrimmed = this.email.trim() + require(nameTrimmed.isNotBlank()) { "Signature name must not be blank" } + require(emailTrimmed.isNotBlank()) { "Signature email must not be blank" } + + val gitSignature = "${nameTrimmed} <${emailTrimmed}>" + val existing = repository.developers.firstOrNull { it.gitSignature == gitSignature } + if (existing != null) { + return existing + } + + // Create new; Developer.init will register into repository.developers + return Developer( + name = nameTrimmed, + email = emailTrimmed, + repository = repository, + ) +} + +/** + * Convert to a domain [Signature] (Developer + timestamp). + */ +internal fun GixSignature.toSignature(repository: Repository): Signature = + Signature( + developer = this.toDeveloper(repository), + timestamp = this.time.toLocalDateTime() + ) diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixTime.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixTime.kt new file mode 100644 index 000000000..4ffa80fe8 --- /dev/null +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/extensions/GixTime.kt @@ -0,0 +1,32 @@ +package com.inso_world.binocular.ffi.extensions + +import com.inso_world.binocular.ffi.internal.GixTime +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset + +/** + * Convert FFI time (epoch seconds + fixed UTC offset in seconds) into a wall-clock [LocalDateTime]. + * + * ### Semantics + * - Interprets [seconds] as Unix epoch seconds and [offset] as a **fixed** UTC offset (in seconds). + * - Applies the offset to the instant and returns the corresponding local date-time at that offset. + * - Independent of the JVM default time zone (uses only the provided offset). + * + * ### Edge cases + * - Offsets outside the supported ±18:00 range are **clamped** to that range to avoid + * `DateTimeException` when constructing a [ZoneOffset]. + * - If sub-second precision is added to the FFI in the future (e.g., `nanos`), extend the + * `Instant.ofEpochSecond(seconds, nanos)` call accordingly. + * + * @return Local date-time at the provided fixed offset. + * @see com.inso_world.binocular.ffi.extensions.toDomain for usage in commit mapping. + */ +internal fun GixTime.toLocalDateTime(): LocalDateTime { + // ZoneOffset supports ±18 hours; clamp defensively + val safeOffsetSeconds = this.offset.coerceIn(-18 * 3600, 18 * 3600) + val zoneOffset = ZoneOffset.ofTotalSeconds(safeOffsetSeconds) + + // If GixTime later exposes sub-second precision, use ofEpochSecond(seconds, nanos) + return Instant.ofEpochSecond(this.seconds).atOffset(zoneOffset).toLocalDateTime() +} diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/internal/gix_binocular.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/internal/gix_binocular.kt index 411992aee..92846b7b2 100644 --- a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/internal/gix_binocular.kt +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/internal/gix_binocular.kt @@ -59,7 +59,7 @@ open class RustBuffer : Structure() { companion object { internal fun alloc(size: ULong = 0UL) = uniffiRustCall() { status -> // Note: need to convert the size to a `Long` value to make this work with JVM. - UniffiLib.INSTANCE.ffi_gix_binocular_rustbuffer_alloc(size.toLong(), status) + UniffiLib.ffi_gix_binocular_rustbuffer_alloc(size.toLong(), status) }.also { if(it.data == null) { throw RuntimeException("RustBuffer.alloc() returned null data pointer (size=${size})") @@ -75,7 +75,7 @@ open class RustBuffer : Structure() { } internal fun free(buf: RustBuffer.ByValue) = uniffiRustCall() { status -> - UniffiLib.INSTANCE.ffi_gix_binocular_rustbuffer_free(buf, status) + UniffiLib.ffi_gix_binocular_rustbuffer_free(buf, status) } } @@ -86,40 +86,6 @@ open class RustBuffer : Structure() { } } -/** - * The equivalent of the `*mut RustBuffer` type. - * Required for callbacks taking in an out pointer. - * - * Size is the sum of all values in the struct. - * - * @suppress - */ -class RustBufferByReference : ByReference(16) { - /** - * Set the pointed-to `RustBuffer` to the given value. - */ - fun setValue(value: RustBuffer.ByValue) { - // NOTE: The offsets are as they are in the C-like struct. - val pointer = getPointer() - pointer.setLong(0, value.capacity) - pointer.setLong(8, value.len) - pointer.setPointer(16, value.data) - } - - /** - * Get a `RustBuffer.ByValue` from this reference. - */ - fun getValue(): RustBuffer.ByValue { - val pointer = getPointer() - val value = RustBuffer.ByValue() - value.writeField("capacity", pointer.getLong(0)) - value.writeField("len", pointer.getLong(8)) - value.writeField("data", pointer.getLong(16)) - - return value - } -} - // This is a helper for safely passing byte references into the rust code. // It's not actually used at the moment, because there aren't many things that you // can take a direct pointer to in the JVM, and if we're going to copy something @@ -339,23 +305,35 @@ internal inline fun uniffiTraitInterfaceCallWithError( } } } +// Initial value and increment amount for handles. +// These ensure that Kotlin-generated handles always have the lowest bit set +private const val UNIFFI_HANDLEMAP_INITIAL = 1.toLong() +private const val UNIFFI_HANDLEMAP_DELTA = 2.toLong() + // Map handles to objects // // This is used pass an opaque 64-bit handle representing a foreign object to the Rust code. internal class UniffiHandleMap { private val map = ConcurrentHashMap() - private val counter = java.util.concurrent.atomic.AtomicLong(0) + // Start + private val counter = java.util.concurrent.atomic.AtomicLong(UNIFFI_HANDLEMAP_INITIAL) val size: Int get() = map.size // Insert a new object into the handle map and get a handle for it fun insert(obj: T): Long { - val handle = counter.getAndAdd(1) + val handle = counter.getAndAdd(UNIFFI_HANDLEMAP_DELTA) map.put(handle, obj) return handle } + // Clone a handle, creating a new one + fun clone(handle: Long): Long { + val obj = map.get(handle) ?: throw InternalException("UniffiHandleMap.clone: Invalid handle") + return insert(obj) + } + // Get an object from the handle map fun get(handle: Long): T { return map.get(handle) ?: throw InternalException("UniffiHandleMap.get: Invalid handle") @@ -378,594 +356,461 @@ private fun findLibraryName(componentName: String): String { return "gix_binocular" } -private inline fun loadIndirect( - componentName: String -): Lib { - return Native.load(findLibraryName(componentName), Lib::class.java) -} - // Define FFI callback types internal interface UniffiRustFutureContinuationCallback : com.sun.jna.Callback { fun callback(`data`: Long,`pollResult`: Byte,) } -internal interface UniffiForeignFutureFree : com.sun.jna.Callback { +internal interface UniffiForeignFutureDroppedCallback : com.sun.jna.Callback { fun callback(`handle`: Long,) } internal interface UniffiCallbackInterfaceFree : com.sun.jna.Callback { fun callback(`handle`: Long,) } +internal interface UniffiCallbackInterfaceClone : com.sun.jna.Callback { + fun callback(`handle`: Long,) + : Long +} @Structure.FieldOrder("handle", "free") -internal open class UniffiForeignFuture( +internal open class UniffiForeignFutureDroppedCallbackStruct( @JvmField internal var `handle`: Long = 0.toLong(), - @JvmField internal var `free`: UniffiForeignFutureFree? = null, + @JvmField internal var `free`: UniffiForeignFutureDroppedCallback? = null, ) : Structure() { class UniffiByValue( `handle`: Long = 0.toLong(), - `free`: UniffiForeignFutureFree? = null, - ): UniffiForeignFuture(`handle`,`free`,), Structure.ByValue + `free`: UniffiForeignFutureDroppedCallback? = null, + ): UniffiForeignFutureDroppedCallbackStruct(`handle`,`free`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFuture) { + internal fun uniffiSetValue(other: UniffiForeignFutureDroppedCallbackStruct) { `handle` = other.`handle` `free` = other.`free` } } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructU8( +internal open class UniffiForeignFutureResultU8( @JvmField internal var `returnValue`: Byte = 0.toByte(), @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Byte = 0.toByte(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructU8(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultU8(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructU8) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultU8) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteU8 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU8.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU8.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructI8( +internal open class UniffiForeignFutureResultI8( @JvmField internal var `returnValue`: Byte = 0.toByte(), @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Byte = 0.toByte(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructI8(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultI8(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructI8) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultI8) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteI8 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI8.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI8.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructU16( +internal open class UniffiForeignFutureResultU16( @JvmField internal var `returnValue`: Short = 0.toShort(), @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Short = 0.toShort(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructU16(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultU16(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructU16) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultU16) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteU16 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU16.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU16.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructI16( +internal open class UniffiForeignFutureResultI16( @JvmField internal var `returnValue`: Short = 0.toShort(), @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Short = 0.toShort(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructI16(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultI16(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructI16) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultI16) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteI16 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI16.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI16.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructU32( +internal open class UniffiForeignFutureResultU32( @JvmField internal var `returnValue`: Int = 0, @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Int = 0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructU32(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultU32(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructU32) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultU32) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteU32 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU32.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU32.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructI32( +internal open class UniffiForeignFutureResultI32( @JvmField internal var `returnValue`: Int = 0, @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Int = 0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructI32(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultI32(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructI32) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultI32) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteI32 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI32.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI32.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructU64( +internal open class UniffiForeignFutureResultU64( @JvmField internal var `returnValue`: Long = 0.toLong(), @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Long = 0.toLong(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructU64(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultU64(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructU64) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultU64) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteU64 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU64.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultU64.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructI64( +internal open class UniffiForeignFutureResultI64( @JvmField internal var `returnValue`: Long = 0.toLong(), @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Long = 0.toLong(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructI64(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultI64(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructI64) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultI64) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteI64 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI64.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultI64.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructF32( +internal open class UniffiForeignFutureResultF32( @JvmField internal var `returnValue`: Float = 0.0f, @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Float = 0.0f, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructF32(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultF32(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructF32) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultF32) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteF32 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructF32.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultF32.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructF64( +internal open class UniffiForeignFutureResultF64( @JvmField internal var `returnValue`: Double = 0.0, @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: Double = 0.0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructF64(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultF64(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructF64) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultF64) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteF64 : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructF64.UniffiByValue,) -} -@Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructPointer( - @JvmField internal var `returnValue`: Pointer = Pointer.NULL, - @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), -) : Structure() { - class UniffiByValue( - `returnValue`: Pointer = Pointer.NULL, - `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructPointer(`returnValue`,`callStatus`,), Structure.ByValue - - internal fun uniffiSetValue(other: UniffiForeignFutureStructPointer) { - `returnValue` = other.`returnValue` - `callStatus` = other.`callStatus` - } - -} -internal interface UniffiForeignFutureCompletePointer : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructPointer.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultF64.UniffiByValue,) } @Structure.FieldOrder("returnValue", "callStatus") -internal open class UniffiForeignFutureStructRustBuffer( +internal open class UniffiForeignFutureResultRustBuffer( @JvmField internal var `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructRustBuffer(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultRustBuffer(`returnValue`,`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructRustBuffer) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultRustBuffer) { `returnValue` = other.`returnValue` `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteRustBuffer : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructRustBuffer.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultRustBuffer.UniffiByValue,) } @Structure.FieldOrder("callStatus") -internal open class UniffiForeignFutureStructVoid( +internal open class UniffiForeignFutureResultVoid( @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), ) : Structure() { class UniffiByValue( `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureStructVoid(`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultVoid(`callStatus`,), Structure.ByValue - internal fun uniffiSetValue(other: UniffiForeignFutureStructVoid) { + internal fun uniffiSetValue(other: UniffiForeignFutureResultVoid) { `callStatus` = other.`callStatus` } } internal interface UniffiForeignFutureCompleteVoid : com.sun.jna.Callback { - fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructVoid.UniffiByValue,) + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureResultVoid.UniffiByValue,) } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// A JNA Library to expose the extern-C FFI definitions. +// This is an implementation detail which will be called internally by the public API. // For large crates we prevent `MethodTooLargeException` (see #2340) -// N.B. the name of the extension is very misleading, since it is -// rather `InterfaceTooLargeException`, caused by too many methods +// N.B. the name of the extension is very misleading, since it is +// rather `InterfaceTooLargeException`, caused by too many methods // in the interface for large crates. // // By splitting the otherwise huge interface into two parts -// * UniffiLib -// * IntegrityCheckingUniffiLib (this) +// * UniffiLib (this) +// * IntegrityCheckingUniffiLib +// And all checksum methods are put into `IntegrityCheckingUniffiLib` // we allow for ~2x as many methods in the UniffiLib interface. -// -// The `ffi_uniffi_contract_version` method and all checksum methods are put -// into `IntegrityCheckingUniffiLib` and these methods are called only once, -// when the library is loaded. -internal interface IntegrityCheckingUniffiLib : Library { - // Integrity check functions only - fun uniffi_gix_binocular_checksum_func_blames( -): Short -fun uniffi_gix_binocular_checksum_func_diffs( -): Short -fun uniffi_gix_binocular_checksum_func_find_all_branches( -): Short -fun uniffi_gix_binocular_checksum_func_find_commit( -): Short -fun uniffi_gix_binocular_checksum_func_find_repo( -): Short -fun uniffi_gix_binocular_checksum_func_hello( -): Short -fun uniffi_gix_binocular_checksum_func_traverse( -): Short -fun uniffi_gix_binocular_checksum_func_traverse_branch( -): Short -fun uniffi_gix_binocular_checksum_method_procerrorinterface_message( -): Short -fun ffi_gix_binocular_uniffi_contract_version( -): Int - +// +// Note: above all written when we used JNA's `loadIndirect` etc. +// We now use JNA's "direct mapping" - unclear if same considerations apply exactly. +internal object IntegrityCheckingUniffiLib { + init { + Native.register(IntegrityCheckingUniffiLib::class.java, findLibraryName(componentName = "gix_binocular")) + uniffiCheckContractApiVersion(this) + uniffiCheckApiChecksums(this) + } + external fun uniffi_gix_binocular_checksum_func_blames( + ): Short + external fun uniffi_gix_binocular_checksum_func_diffs( + ): Short + external fun uniffi_gix_binocular_checksum_func_find_all_branches( + ): Short + external fun uniffi_gix_binocular_checksum_func_find_commit( + ): Short + external fun uniffi_gix_binocular_checksum_func_find_repo( + ): Short + external fun uniffi_gix_binocular_checksum_func_hello( + ): Short + external fun uniffi_gix_binocular_checksum_func_traverse_branch( + ): Short + external fun uniffi_gix_binocular_checksum_func_traverse_history( + ): Short + external fun uniffi_gix_binocular_checksum_method_procerrorinterface_message( + ): Short + external fun ffi_gix_binocular_uniffi_contract_version( + ): Int + + } -// A JNA Library to expose the extern-C FFI definitions. -// This is an implementation detail which will be called internally by the public API. -internal interface UniffiLib : Library { - companion object { - internal val INSTANCE: UniffiLib by lazy { - val componentName = "gix_binocular" - // For large crates we prevent `MethodTooLargeException` (see #2340) - // N.B. the name of the extension is very misleading, since it is - // rather `InterfaceTooLargeException`, caused by too many methods - // in the interface for large crates. - // - // By splitting the otherwise huge interface into two parts - // * UniffiLib (this) - // * IntegrityCheckingUniffiLib - // And all checksum methods are put into `IntegrityCheckingUniffiLib` - // we allow for ~2x as many methods in the UniffiLib interface. - // - // Thus we first load the library with `loadIndirect` as `IntegrityCheckingUniffiLib` - // so that we can (optionally!) call `uniffiCheckApiChecksums`... - loadIndirect(componentName) - .also { lib: IntegrityCheckingUniffiLib -> - uniffiCheckContractApiVersion(lib) - uniffiCheckApiChecksums(lib) - } - // ... and then we load the library as `UniffiLib` - // N.B. we cannot use `loadIndirect` once and then try to cast it to `UniffiLib` - // => results in `java.lang.ClassCastException: com.sun.proxy.$Proxy cannot be cast to ...` - // error. So we must call `loadIndirect` twice. For crates large enough - // to trigger this issue, the performance impact is negligible, running on - // a macOS M1 machine the `loadIndirect` call takes ~50ms. - val lib = loadIndirect(componentName) - // No need to check the contract version and checksums, since - // we already did that with `IntegrityCheckingUniffiLib` above. - // Loading of library with integrity check done. - lib - } - - // The Cleaner for the whole library - internal val CLEANER: UniffiCleaner by lazy { - UniffiCleaner.create() - } - } - - // FFI functions - fun uniffi_gix_binocular_fn_clone_anyhowerror(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): Pointer -fun uniffi_gix_binocular_fn_free_anyhowerror(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): Unit -fun uniffi_gix_binocular_fn_clone_procerrorinterface(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): Pointer -fun uniffi_gix_binocular_fn_free_procerrorinterface(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): Unit -fun uniffi_gix_binocular_fn_method_procerrorinterface_message(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_gix_binocular_fn_method_procerrorinterface_uniffi_trait_debug(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_gix_binocular_fn_method_procerrorinterface_uniffi_trait_display(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_gix_binocular_fn_func_blames(`binocularRepo`: RustBuffer.ByValue,`defines`: RustBuffer.ByValue,`diffAlgorithm`: RustBuffer.ByValue,`maxThreads`: Byte,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_gix_binocular_fn_func_diffs(`binocularRepo`: RustBuffer.ByValue,`commitPairs`: RustBuffer.ByValue,`maxThreads`: Byte,`diffAlgorithm`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_gix_binocular_fn_func_find_all_branches(`binocularRepo`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_gix_binocular_fn_func_find_commit(`binocularRepo`: RustBuffer.ByValue,`hash`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_gix_binocular_fn_func_find_repo(`path`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_gix_binocular_fn_func_hello(uniffi_out_err: UniffiRustCallStatus, -): Unit -fun uniffi_gix_binocular_fn_func_traverse(`binocularRepo`: RustBuffer.ByValue,`sourceCommit`: RustBuffer.ByValue,`targetCommit`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_gix_binocular_fn_func_traverse_branch(`binocularRepo`: RustBuffer.ByValue,`branch`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun ffi_gix_binocular_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun ffi_gix_binocular_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun ffi_gix_binocular_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): Unit -fun ffi_gix_binocular_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun ffi_gix_binocular_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_u8(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_u8(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Byte -fun ffi_gix_binocular_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_i8(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_i8(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Byte -fun ffi_gix_binocular_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_u16(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_u16(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Short -fun ffi_gix_binocular_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_i16(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_i16(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Short -fun ffi_gix_binocular_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_u32(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_u32(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Int -fun ffi_gix_binocular_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_i32(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_i32(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Int -fun ffi_gix_binocular_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_u64(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_u64(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Long -fun ffi_gix_binocular_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_i64(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_i64(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Long -fun ffi_gix_binocular_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_f32(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_f32(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Float -fun ffi_gix_binocular_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_f64(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_f64(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Double -fun ffi_gix_binocular_rust_future_poll_pointer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_pointer(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_pointer(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_pointer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Pointer -fun ffi_gix_binocular_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_rust_buffer(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_rust_buffer(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun ffi_gix_binocular_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, -): Unit -fun ffi_gix_binocular_rust_future_cancel_void(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_free_void(`handle`: Long, -): Unit -fun ffi_gix_binocular_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, -): Unit +internal object UniffiLib { + + // The Cleaner for the whole library + internal val CLEANER: UniffiCleaner by lazy { + UniffiCleaner.create() + } + + init { + Native.register(UniffiLib::class.java, findLibraryName(componentName = "gix_binocular")) + + } + external fun uniffi_gix_binocular_fn_clone_anyhowerror(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_gix_binocular_fn_free_anyhowerror(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_gix_binocular_fn_clone_procerrorinterface(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_gix_binocular_fn_free_procerrorinterface(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_gix_binocular_fn_method_procerrorinterface_message(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_gix_binocular_fn_method_procerrorinterface_uniffi_trait_debug(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_gix_binocular_fn_method_procerrorinterface_uniffi_trait_display(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_gix_binocular_fn_func_blames(`gixRepo`: RustBuffer.ByValue,`defines`: RustBuffer.ByValue,`diffAlgorithm`: RustBuffer.ByValue,`maxThreads`: Byte,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_gix_binocular_fn_func_diffs(`gixRepo`: RustBuffer.ByValue,`commitPairs`: RustBuffer.ByValue,`maxThreads`: Byte,`diffAlgorithm`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_gix_binocular_fn_func_find_all_branches(`gixRepo`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_gix_binocular_fn_func_find_commit(`gixRepo`: RustBuffer.ByValue,`hash`: RustBuffer.ByValue,`useMailmap`: Byte,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_gix_binocular_fn_func_find_repo(`path`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_gix_binocular_fn_func_hello(uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_gix_binocular_fn_func_traverse_branch(`gixRepo`: RustBuffer.ByValue,`branch`: RustBuffer.ByValue,`skipMerges`: Byte,`useMailmap`: Byte,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_gix_binocular_fn_func_traverse_history(`gixRepo`: RustBuffer.ByValue,`sourceCommit`: RustBuffer.ByValue,`targetCommit`: RustBuffer.ByValue,`useMailmap`: Byte,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_gix_binocular_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_gix_binocular_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_gix_binocular_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun ffi_gix_binocular_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_gix_binocular_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_u8(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_u8(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + external fun ffi_gix_binocular_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_i8(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_i8(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + external fun ffi_gix_binocular_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_u16(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_u16(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + external fun ffi_gix_binocular_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_i16(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_i16(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + external fun ffi_gix_binocular_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_u32(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_u32(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + external fun ffi_gix_binocular_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_i32(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_i32(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + external fun ffi_gix_binocular_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_u64(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_u64(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun ffi_gix_binocular_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_i64(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_i64(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun ffi_gix_binocular_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_f32(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_f32(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Float + external fun ffi_gix_binocular_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_f64(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_f64(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Double + external fun ffi_gix_binocular_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_rust_buffer(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_rust_buffer(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun ffi_gix_binocular_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_cancel_void(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_free_void(`handle`: Long, + ): Unit + external fun ffi_gix_binocular_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + + } private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { // Get the bindings contract version from our ComponentInterface - val bindings_contract_version = 29 + val bindings_contract_version = 30 // Get the scaffolding contract version by calling the into the dylib val scaffolding_contract_version = lib.ffi_gix_binocular_uniffi_contract_version() if (bindings_contract_version != scaffolding_contract_version) { @@ -974,28 +819,28 @@ private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { } @Suppress("UNUSED_PARAMETER") private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { - if (lib.uniffi_gix_binocular_checksum_func_blames() != 43528.toShort()) { + if (lib.uniffi_gix_binocular_checksum_func_blames() != 1896.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_gix_binocular_checksum_func_diffs() != 52228.toShort()) { + if (lib.uniffi_gix_binocular_checksum_func_diffs() != 34571.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_gix_binocular_checksum_func_find_all_branches() != 469.toShort()) { + if (lib.uniffi_gix_binocular_checksum_func_find_all_branches() != 347.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_gix_binocular_checksum_func_find_commit() != 25706.toShort()) { + if (lib.uniffi_gix_binocular_checksum_func_find_commit() != 47044.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_gix_binocular_checksum_func_find_repo() != 21682.toShort()) { + if (lib.uniffi_gix_binocular_checksum_func_find_repo() != 7719.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_gix_binocular_checksum_func_hello() != 11164.toShort()) { + if (lib.uniffi_gix_binocular_checksum_func_hello() != 44428.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_gix_binocular_checksum_func_traverse() != 49360.toShort()) { + if (lib.uniffi_gix_binocular_checksum_func_traverse_branch() != 40308.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_gix_binocular_checksum_func_traverse_branch() != 62127.toShort()) { + if (lib.uniffi_gix_binocular_checksum_func_traverse_history() != 2463.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_gix_binocular_checksum_method_procerrorinterface_message() != 46837.toShort()) { @@ -1007,7 +852,10 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { * @suppress */ public fun uniffiEnsureInitialized() { - UniffiLib.INSTANCE + IntegrityCheckingUniffiLib + // UniffiLib() initialized as objects are used, but we still need to explicitly + // reference it so initialization across crates works as expected. + UniffiLib } // Async support @@ -1073,12 +921,23 @@ inline fun T.use(block: (T) -> R) = } } +/** + * Placeholder object used to signal that we're constructing an interface with a FFI handle. + * + * This is the first argument for interface constructors that input a raw handle. It exists is that + * so we can avoid signature conflicts when an interface has a regular constructor than inputs a + * Long. + * + * @suppress + * */ +object UniffiWithHandle + /** * Used to instantiate an interface without an actual pointer, for fakes in tests, mostly. * * @suppress * */ -object NoPointer +object NoHandle /** * The cleaner interface for Object finalization code to run. * This is the entry point to any implementation that we're using. @@ -1317,21 +1176,18 @@ public object FfiConverterString: FfiConverter { } -// This template implements a class for working with a Rust struct via a Pointer/Arc +// This template implements a class for working with a Rust struct via a handle // to the live Rust struct on the other side of the FFI. // -// Each instance implements core operations for working with the Rust `Arc` and the -// Kotlin Pointer to work with the live Rust struct on the other side of the FFI. -// // There's some subtlety here, because we have to be careful not to operate on a Rust // struct after it has been dropped, and because we must expose a public API for freeing // theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: // -// * Each instance holds an opaque pointer to the underlying Rust struct. -// Method calls need to read this pointer from the object's state and pass it in to +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to // the Rust FFI. // -// * When an instance is no longer needed, its pointer should be passed to a +// * When an instance is no longer needed, its handle should be passed to a // special destructor function provided by the Rust FFI, which will drop the // underlying Rust struct. // @@ -1356,13 +1212,13 @@ public object FfiConverterString: FfiConverter { // 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, // or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). // -// If we try to implement this with mutual exclusion on access to the pointer, there is the +// If we try to implement this with mutual exclusion on access to the handle, there is the // possibility of a race between a method call and a concurrent call to `destroy`: // -// * Thread A starts a method call, reads the value of the pointer, but is interrupted -// before it can pass the pointer over the FFI to Rust. +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. // * Thread B calls `destroy` and frees the underlying Rust struct. -// * Thread A resumes, passing the already-read pointer value to Rust and triggering +// * Thread A resumes, passing the already-read handle value to Rust and triggering // a use-after-free. // // One possible solution would be to use a `ReadWriteLock`, with each method call taking @@ -1415,32 +1271,38 @@ public object FfiConverterString: FfiConverter { // -public interface AnyhowExceptionInterface { +// +public interface AnyhowErrorInterface { companion object } +open class AnyhowError: Disposable, AutoCloseable, AnyhowErrorInterface +{ -open class AnyhowException : kotlin.Exception, Disposable, AutoCloseable, AnyhowExceptionInterface { - - - constructor(pointer: Pointer) { - this.pointer = pointer - this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(pointer)) + @Suppress("UNUSED_PARAMETER") + /** + * @suppress + */ + constructor(withHandle: UniffiWithHandle, handle: Long) { + this.handle = handle + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) } /** + * @suppress + * * This constructor can be used to instantiate a fake object. Only used for tests. Any * attempt to actually use an object constructed this way will fail as there is no * connected Rust object. */ @Suppress("UNUSED_PARAMETER") - constructor(noPointer: NoPointer) { - this.pointer = null - this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(pointer)) + constructor(noHandle: NoHandle) { + this.handle = 0 + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(handle)) } - protected val pointer: Pointer? + protected val handle: Long protected val cleanable: UniffiCleaner.Cleanable private val wasDestroyed = AtomicBoolean(false) @@ -1462,7 +1324,7 @@ open class AnyhowException : kotlin.Exception, Disposable, AutoCloseable, Anyhow this.destroy() } - internal inline fun callWithPointer(block: (ptr: Pointer) -> R): R { + internal inline fun callWithHandle(block: (handle: Long) -> R): R { // Check and increment the call counter, to keep the object alive. // This needs a compare-and-set retry loop in case of concurrent updates. do { @@ -1474,9 +1336,9 @@ open class AnyhowException : kotlin.Exception, Disposable, AutoCloseable, Anyhow throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") } } while (! this.callCounter.compareAndSet(c, c + 1L)) - // Now we can safely do the method call without the pointer being freed concurrently. + // Now we can safely do the method call without the handle being freed concurrently. try { - return block(this.uniffiClonePointer()) + return block(this.uniffiCloneHandle()) } finally { // This decrement always matches the increment we performed above. if (this.callCounter.decrementAndGet() == 0L) { @@ -1487,83 +1349,81 @@ open class AnyhowException : kotlin.Exception, Disposable, AutoCloseable, Anyhow // Use a static inner class instead of a closure so as not to accidentally // capture `this` as part of the cleanable's action. - private class UniffiCleanAction(private val pointer: Pointer?) : Runnable { + private class UniffiCleanAction(private val handle: Long) : Runnable { override fun run() { - pointer?.let { ptr -> - uniffiRustCall { status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_free_anyhowerror(ptr, status) - } + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_gix_binocular_fn_free_anyhowerror(handle, status) } } } - fun uniffiClonePointer(): Pointer { + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } return uniffiRustCall() { status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_clone_anyhowerror(pointer!!, status) + UniffiLib.uniffi_gix_binocular_fn_clone_anyhowerror(handle, status) } } + + - companion object ErrorHandler : UniffiRustCallStatusErrorHandler { - override fun lift(error_buf: RustBuffer.ByValue): AnyhowException { - // Due to some mismatches in the ffi converter mechanisms, errors are a RustBuffer. - val bb = error_buf.asByteBuffer() - if (bb == null) { - throw InternalException("?") - } - return FfiConverterTypeAnyhowError.read(bb) - } - } + + /** + * @suppress + */ + companion object } + /** * @suppress */ -public object FfiConverterTypeAnyhowError: FfiConverter { - - override fun lower(value: AnyhowException): Pointer { - return value.uniffiClonePointer() +public object FfiConverterTypeAnyhowError: FfiConverter { + override fun lower(value: AnyhowError): Long { + return value.uniffiCloneHandle() } - override fun lift(value: Pointer): AnyhowException { - return AnyhowException(value) + override fun lift(value: Long): AnyhowError { + return AnyhowError(UniffiWithHandle, value) } - override fun read(buf: ByteBuffer): AnyhowException { - // The Rust code always writes pointers as 8 bytes, and will - // fail to compile if they don't fit. - return lift(Pointer(buf.getLong())) + override fun read(buf: ByteBuffer): AnyhowError { + return lift(buf.getLong()) } - override fun allocationSize(value: AnyhowException) = 8UL + override fun allocationSize(value: AnyhowError) = 8UL - override fun write(value: AnyhowException, buf: ByteBuffer) { - // The Rust code always expects pointers written as 8 bytes, - // and will fail to compile if they don't fit. - buf.putLong(Pointer.nativeValue(lower(value))) + override fun write(value: AnyhowError, buf: ByteBuffer) { + buf.putLong(lower(value)) } } -// This template implements a class for working with a Rust struct via a Pointer/Arc +// This template implements a class for working with a Rust struct via a handle // to the live Rust struct on the other side of the FFI. // -// Each instance implements core operations for working with the Rust `Arc` and the -// Kotlin Pointer to work with the live Rust struct on the other side of the FFI. -// // There's some subtlety here, because we have to be careful not to operate on a Rust // struct after it has been dropped, and because we must expose a public API for freeing // theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: // -// * Each instance holds an opaque pointer to the underlying Rust struct. -// Method calls need to read this pointer from the object's state and pass it in to +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to // the Rust FFI. // -// * When an instance is no longer needed, its pointer should be passed to a +// * When an instance is no longer needed, its handle should be passed to a // special destructor function provided by the Rust FFI, which will drop the // underlying Rust struct. // @@ -1588,13 +1448,13 @@ public object FfiConverterTypeAnyhowError: FfiConverter callWithPointer(block: (ptr: Pointer) -> R): R { + internal inline fun callWithHandle(block: (handle: Long) -> R): R { // Check and increment the call counter, to keep the object alive. // This needs a compare-and-set retry loop in case of concurrent updates. do { @@ -1708,9 +1574,9 @@ open class ProcErrorInterface : kotlin.Exception, Disposable, AutoCloseable, Pro throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") } } while (! this.callCounter.compareAndSet(c, c + 1L)) - // Now we can safely do the method call without the pointer being freed concurrently. + // Now we can safely do the method call without the handle being freed concurrently. try { - return block(this.uniffiClonePointer()) + return block(this.uniffiCloneHandle()) } finally { // This decrement always matches the increment we performed above. if (this.callCounter.decrementAndGet() == 0L) { @@ -1721,28 +1587,37 @@ open class ProcErrorInterface : kotlin.Exception, Disposable, AutoCloseable, Pro // Use a static inner class instead of a closure so as not to accidentally // capture `this` as part of the cleanable's action. - private class UniffiCleanAction(private val pointer: Pointer?) : Runnable { + private class UniffiCleanAction(private val handle: Long) : Runnable { override fun run() { - pointer?.let { ptr -> - uniffiRustCall { status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_free_procerrorinterface(ptr, status) - } + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_gix_binocular_fn_free_procerrorinterface(handle, status) } } } - fun uniffiClonePointer(): Pointer { + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } return uniffiRustCall() { status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_clone_procerrorinterface(pointer!!, status) + UniffiLib.uniffi_gix_binocular_fn_clone_procerrorinterface(handle, status) } } override fun `message`(): kotlin.String { return FfiConverterString.lift( - callWithPointer { + callWithHandle { uniffiRustCall() { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_method_procerrorinterface_message( - it, _status) + UniffiLib.uniffi_gix_binocular_fn_method_procerrorinterface_message( + it, + _status) } } ) @@ -1750,69 +1625,105 @@ open class ProcErrorInterface : kotlin.Exception, Disposable, AutoCloseable, Pro + + + + // The local Rust `Display`/`Debug` implementation. override fun toString(): String { return FfiConverterString.lift( - callWithPointer { + callWithHandle { uniffiRustCall() { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_method_procerrorinterface_uniffi_trait_display( - it, _status) + UniffiLib.uniffi_gix_binocular_fn_method_procerrorinterface_uniffi_trait_display( + it, + _status) } } ) } - - companion object ErrorHandler : UniffiRustCallStatusErrorHandler { - override fun lift(error_buf: RustBuffer.ByValue): ProcErrorInterface { - // Due to some mismatches in the ffi converter mechanisms, errors are a RustBuffer. - val bb = error_buf.asByteBuffer() - if (bb == null) { - throw InternalException("?") - } - return FfiConverterTypeProcErrorInterface.read(bb) - } - } + /** + * @suppress + */ + companion object } + /** * @suppress */ -public object FfiConverterTypeProcErrorInterface: FfiConverter { - - override fun lower(value: ProcErrorInterface): Pointer { - return value.uniffiClonePointer() +public object FfiConverterTypeProcErrorInterface: FfiConverter { + override fun lower(value: ProcErrorInterface): Long { + return value.uniffiCloneHandle() } - override fun lift(value: Pointer): ProcErrorInterface { - return ProcErrorInterface(value) + override fun lift(value: Long): ProcErrorInterface { + return ProcErrorInterface(UniffiWithHandle, value) } override fun read(buf: ByteBuffer): ProcErrorInterface { - // The Rust code always writes pointers as 8 bytes, and will - // fail to compile if they don't fit. - return lift(Pointer(buf.getLong())) + return lift(buf.getLong()) } override fun allocationSize(value: ProcErrorInterface) = 8UL override fun write(value: ProcErrorInterface, buf: ByteBuffer) { - // The Rust code always expects pointers written as 8 bytes, - // and will fail to compile if they don't fit. - buf.putLong(Pointer.nativeValue(lower(value))) + buf.putLong(lower(value)) + } +} + + + +data class BranchTraversalResult ( + var `branch`: GixBranch + , + var `commits`: List + +){ + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeBranchTraversalResult: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): BranchTraversalResult { + return BranchTraversalResult( + FfiConverterTypeGixBranch.read(buf), + FfiConverterSequenceTypeGixCommit.read(buf), + ) + } + + override fun allocationSize(value: BranchTraversalResult) = ( + FfiConverterTypeGixBranch.allocationSize(value.`branch`) + + FfiConverterSequenceTypeGixCommit.allocationSize(value.`commits`) + ) + + override fun write(value: BranchTraversalResult, buf: ByteBuffer) { + FfiConverterTypeGixBranch.write(value.`branch`, buf) + FfiConverterSequenceTypeGixCommit.write(value.`commits`, buf) } } -data class BinocularBlameEntry ( - var `startInBlamedFile`: kotlin.UInt, - var `startInSourceFile`: kotlin.UInt, - var `len`: kotlin.UInt, +data class GixBlameEntry ( + var `startInBlamedFile`: kotlin.UInt + , + var `startInSourceFile`: kotlin.UInt + , + var `len`: kotlin.UInt + , var `commitId`: ObjectId -) { + +){ + + companion object } @@ -1820,9 +1731,9 @@ data class BinocularBlameEntry ( /** * @suppress */ -public object FfiConverterTypeBinocularBlameEntry: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularBlameEntry { - return BinocularBlameEntry( +public object FfiConverterTypeGixBlameEntry: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixBlameEntry { + return GixBlameEntry( FfiConverterUInt.read(buf), FfiConverterUInt.read(buf), FfiConverterUInt.read(buf), @@ -1830,14 +1741,14 @@ public object FfiConverterTypeBinocularBlameEntry: FfiConverterRustBuffer, +data class GixBlameOutcome ( + var `entries`: List + , var `filePath`: kotlin.String -) { + +){ + + companion object } @@ -1858,31 +1773,35 @@ data class BinocularBlameOutcome ( /** * @suppress */ -public object FfiConverterTypeBinocularBlameOutcome: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularBlameOutcome { - return BinocularBlameOutcome( - FfiConverterSequenceTypeBinocularBlameEntry.read(buf), +public object FfiConverterTypeGixBlameOutcome: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixBlameOutcome { + return GixBlameOutcome( + FfiConverterSequenceTypeGixBlameEntry.read(buf), FfiConverterString.read(buf), ) } - override fun allocationSize(value: BinocularBlameOutcome) = ( - FfiConverterSequenceTypeBinocularBlameEntry.allocationSize(value.`entries`) + + override fun allocationSize(value: GixBlameOutcome) = ( + FfiConverterSequenceTypeGixBlameEntry.allocationSize(value.`entries`) + FfiConverterString.allocationSize(value.`filePath`) ) - override fun write(value: BinocularBlameOutcome, buf: ByteBuffer) { - FfiConverterSequenceTypeBinocularBlameEntry.write(value.`entries`, buf) + override fun write(value: GixBlameOutcome, buf: ByteBuffer) { + FfiConverterSequenceTypeGixBlameEntry.write(value.`entries`, buf) FfiConverterString.write(value.`filePath`, buf) } } -data class BinocularBlameResult ( - var `blames`: List, +data class GixBlameResult ( + var `blames`: List + , var `commit`: ObjectId -) { + +){ + + companion object } @@ -1890,31 +1809,39 @@ data class BinocularBlameResult ( /** * @suppress */ -public object FfiConverterTypeBinocularBlameResult: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularBlameResult { - return BinocularBlameResult( - FfiConverterSequenceTypeBinocularBlameOutcome.read(buf), +public object FfiConverterTypeGixBlameResult: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixBlameResult { + return GixBlameResult( + FfiConverterSequenceTypeGixBlameOutcome.read(buf), FfiConverterTypeObjectId.read(buf), ) } - override fun allocationSize(value: BinocularBlameResult) = ( - FfiConverterSequenceTypeBinocularBlameOutcome.allocationSize(value.`blames`) + + override fun allocationSize(value: GixBlameResult) = ( + FfiConverterSequenceTypeGixBlameOutcome.allocationSize(value.`blames`) + FfiConverterTypeObjectId.allocationSize(value.`commit`) ) - override fun write(value: BinocularBlameResult, buf: ByteBuffer) { - FfiConverterSequenceTypeBinocularBlameOutcome.write(value.`blames`, buf) + override fun write(value: GixBlameResult, buf: ByteBuffer) { + FfiConverterSequenceTypeGixBlameOutcome.write(value.`blames`, buf) FfiConverterTypeObjectId.write(value.`commit`, buf) } } -data class BinocularBranch ( - var `name`: kotlin.String, - var `commits`: List -) { +data class GixBranch ( + var `fullName`: FullName + , + var `name`: kotlin.String + , + var `target`: ObjectId + , + var `category`: GixReferenceCategory + +){ + + companion object } @@ -1922,36 +1849,51 @@ data class BinocularBranch ( /** * @suppress */ -public object FfiConverterTypeBinocularBranch: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularBranch { - return BinocularBranch( +public object FfiConverterTypeGixBranch: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixBranch { + return GixBranch( + FfiConverterTypeFullName.read(buf), FfiConverterString.read(buf), - FfiConverterSequenceString.read(buf), + FfiConverterTypeObjectId.read(buf), + FfiConverterTypeGixReferenceCategory.read(buf), ) } - override fun allocationSize(value: BinocularBranch) = ( + override fun allocationSize(value: GixBranch) = ( + FfiConverterTypeFullName.allocationSize(value.`fullName`) + FfiConverterString.allocationSize(value.`name`) + - FfiConverterSequenceString.allocationSize(value.`commits`) + FfiConverterTypeObjectId.allocationSize(value.`target`) + + FfiConverterTypeGixReferenceCategory.allocationSize(value.`category`) ) - override fun write(value: BinocularBranch, buf: ByteBuffer) { + override fun write(value: GixBranch, buf: ByteBuffer) { + FfiConverterTypeFullName.write(value.`fullName`, buf) FfiConverterString.write(value.`name`, buf) - FfiConverterSequenceString.write(value.`commits`, buf) + FfiConverterTypeObjectId.write(value.`target`, buf) + FfiConverterTypeGixReferenceCategory.write(value.`category`, buf) } } -data class BinocularCommitVec ( - var `commit`: ObjectId, - var `message`: kotlin.String, - var `committer`: BinocularSig?, - var `author`: BinocularSig?, - var `branch`: kotlin.String?, - var `parents`: List, +data class GixCommit ( + var `oid`: ObjectId + , + var `message`: kotlin.String + , + var `committer`: GixSignature + , + var `author`: GixSignature + , + var `branch`: kotlin.String? + , + var `parents`: List + , var `fileTree`: List -) { + +){ + + companion object } @@ -1959,34 +1901,34 @@ data class BinocularCommitVec ( /** * @suppress */ -public object FfiConverterTypeBinocularCommitVec: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularCommitVec { - return BinocularCommitVec( +public object FfiConverterTypeGixCommit: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixCommit { + return GixCommit( FfiConverterTypeObjectId.read(buf), FfiConverterString.read(buf), - FfiConverterOptionalTypeBinocularSig.read(buf), - FfiConverterOptionalTypeBinocularSig.read(buf), + FfiConverterTypeGixSignature.read(buf), + FfiConverterTypeGixSignature.read(buf), FfiConverterOptionalString.read(buf), FfiConverterSequenceTypeObjectId.read(buf), FfiConverterSequenceTypeBString.read(buf), ) } - override fun allocationSize(value: BinocularCommitVec) = ( - FfiConverterTypeObjectId.allocationSize(value.`commit`) + + override fun allocationSize(value: GixCommit) = ( + FfiConverterTypeObjectId.allocationSize(value.`oid`) + FfiConverterString.allocationSize(value.`message`) + - FfiConverterOptionalTypeBinocularSig.allocationSize(value.`committer`) + - FfiConverterOptionalTypeBinocularSig.allocationSize(value.`author`) + + FfiConverterTypeGixSignature.allocationSize(value.`committer`) + + FfiConverterTypeGixSignature.allocationSize(value.`author`) + FfiConverterOptionalString.allocationSize(value.`branch`) + FfiConverterSequenceTypeObjectId.allocationSize(value.`parents`) + FfiConverterSequenceTypeBString.allocationSize(value.`fileTree`) ) - override fun write(value: BinocularCommitVec, buf: ByteBuffer) { - FfiConverterTypeObjectId.write(value.`commit`, buf) + override fun write(value: GixCommit, buf: ByteBuffer) { + FfiConverterTypeObjectId.write(value.`oid`, buf) FfiConverterString.write(value.`message`, buf) - FfiConverterOptionalTypeBinocularSig.write(value.`committer`, buf) - FfiConverterOptionalTypeBinocularSig.write(value.`author`, buf) + FfiConverterTypeGixSignature.write(value.`committer`, buf) + FfiConverterTypeGixSignature.write(value.`author`, buf) FfiConverterOptionalString.write(value.`branch`, buf) FfiConverterSequenceTypeObjectId.write(value.`parents`, buf) FfiConverterSequenceTypeBString.write(value.`fileTree`, buf) @@ -1995,10 +1937,16 @@ public object FfiConverterTypeBinocularCommitVec: FfiConverterRustBuffer + , + var `commit`: GixCommit + , + var `parent`: GixCommit? + +){ + + companion object } @@ -2006,32 +1954,38 @@ data class BinocularDiffInput ( /** * @suppress */ -public object FfiConverterTypeBinocularDiffInput: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularDiffInput { - return BinocularDiffInput( - FfiConverterTypeObjectId.read(buf), - FfiConverterOptionalTypeObjectId.read(buf), +public object FfiConverterTypeGixDiff: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixDiff { + return GixDiff( + FfiConverterSequenceTypeGixFileDiff.read(buf), + FfiConverterTypeGixCommit.read(buf), + FfiConverterOptionalTypeGixCommit.read(buf), ) } - override fun allocationSize(value: BinocularDiffInput) = ( - FfiConverterTypeObjectId.allocationSize(value.`suspect`) + - FfiConverterOptionalTypeObjectId.allocationSize(value.`target`) + override fun allocationSize(value: GixDiff) = ( + FfiConverterSequenceTypeGixFileDiff.allocationSize(value.`files`) + + FfiConverterTypeGixCommit.allocationSize(value.`commit`) + + FfiConverterOptionalTypeGixCommit.allocationSize(value.`parent`) ) - override fun write(value: BinocularDiffInput, buf: ByteBuffer) { - FfiConverterTypeObjectId.write(value.`suspect`, buf) - FfiConverterOptionalTypeObjectId.write(value.`target`, buf) + override fun write(value: GixDiff, buf: ByteBuffer) { + FfiConverterSequenceTypeGixFileDiff.write(value.`files`, buf) + FfiConverterTypeGixCommit.write(value.`commit`, buf) + FfiConverterOptionalTypeGixCommit.write(value.`parent`, buf) } } -data class BinocularDiffStats ( - var `insertions`: kotlin.UInt, - var `deletions`: kotlin.UInt, - var `kind`: kotlin.String -) { +data class GixDiffInput ( + var `suspect`: ObjectId + , + var `target`: ObjectId? + +){ + + companion object } @@ -2039,37 +1993,37 @@ data class BinocularDiffStats ( /** * @suppress */ -public object FfiConverterTypeBinocularDiffStats: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularDiffStats { - return BinocularDiffStats( - FfiConverterUInt.read(buf), - FfiConverterUInt.read(buf), - FfiConverterString.read(buf), +public object FfiConverterTypeGixDiffInput: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixDiffInput { + return GixDiffInput( + FfiConverterTypeObjectId.read(buf), + FfiConverterOptionalTypeObjectId.read(buf), ) } - override fun allocationSize(value: BinocularDiffStats) = ( - FfiConverterUInt.allocationSize(value.`insertions`) + - FfiConverterUInt.allocationSize(value.`deletions`) + - FfiConverterString.allocationSize(value.`kind`) + override fun allocationSize(value: GixDiffInput) = ( + FfiConverterTypeObjectId.allocationSize(value.`suspect`) + + FfiConverterOptionalTypeObjectId.allocationSize(value.`target`) ) - override fun write(value: BinocularDiffStats, buf: ByteBuffer) { - FfiConverterUInt.write(value.`insertions`, buf) - FfiConverterUInt.write(value.`deletions`, buf) - FfiConverterString.write(value.`kind`, buf) + override fun write(value: GixDiffInput, buf: ByteBuffer) { + FfiConverterTypeObjectId.write(value.`suspect`, buf) + FfiConverterOptionalTypeObjectId.write(value.`target`, buf) } } -data class BinocularDiffVec ( - var `files`: List, - var `commit`: ObjectId, - var `parent`: ObjectId?, - var `committer`: BinocularSig?, - var `author`: BinocularSig? -) { +data class GixDiffStats ( + var `insertions`: kotlin.UInt + , + var `deletions`: kotlin.UInt + , + var `kind`: kotlin.String + +){ + + companion object } @@ -2077,43 +2031,44 @@ data class BinocularDiffVec ( /** * @suppress */ -public object FfiConverterTypeBinocularDiffVec: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularDiffVec { - return BinocularDiffVec( - FfiConverterSequenceTypeBinocularFileDiff.read(buf), - FfiConverterTypeObjectId.read(buf), - FfiConverterOptionalTypeObjectId.read(buf), - FfiConverterOptionalTypeBinocularSig.read(buf), - FfiConverterOptionalTypeBinocularSig.read(buf), +public object FfiConverterTypeGixDiffStats: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixDiffStats { + return GixDiffStats( + FfiConverterUInt.read(buf), + FfiConverterUInt.read(buf), + FfiConverterString.read(buf), ) } - override fun allocationSize(value: BinocularDiffVec) = ( - FfiConverterSequenceTypeBinocularFileDiff.allocationSize(value.`files`) + - FfiConverterTypeObjectId.allocationSize(value.`commit`) + - FfiConverterOptionalTypeObjectId.allocationSize(value.`parent`) + - FfiConverterOptionalTypeBinocularSig.allocationSize(value.`committer`) + - FfiConverterOptionalTypeBinocularSig.allocationSize(value.`author`) + override fun allocationSize(value: GixDiffStats) = ( + FfiConverterUInt.allocationSize(value.`insertions`) + + FfiConverterUInt.allocationSize(value.`deletions`) + + FfiConverterString.allocationSize(value.`kind`) ) - override fun write(value: BinocularDiffVec, buf: ByteBuffer) { - FfiConverterSequenceTypeBinocularFileDiff.write(value.`files`, buf) - FfiConverterTypeObjectId.write(value.`commit`, buf) - FfiConverterOptionalTypeObjectId.write(value.`parent`, buf) - FfiConverterOptionalTypeBinocularSig.write(value.`committer`, buf) - FfiConverterOptionalTypeBinocularSig.write(value.`author`, buf) + override fun write(value: GixDiffStats, buf: ByteBuffer) { + FfiConverterUInt.write(value.`insertions`, buf) + FfiConverterUInt.write(value.`deletions`, buf) + FfiConverterString.write(value.`kind`, buf) } } -data class BinocularFileDiff ( - var `insertions`: kotlin.UInt, - var `deletions`: kotlin.UInt, - var `change`: BinocularChangeType, - var `oldFileContent`: kotlin.String?, +data class GixFileDiff ( + var `insertions`: kotlin.UInt + , + var `deletions`: kotlin.UInt + , + var `change`: GixChangeType + , + var `oldFileContent`: kotlin.String? + , var `newFileContent`: kotlin.String? -) { + +){ + + companion object } @@ -2121,29 +2076,29 @@ data class BinocularFileDiff ( /** * @suppress */ -public object FfiConverterTypeBinocularFileDiff: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularFileDiff { - return BinocularFileDiff( +public object FfiConverterTypeGixFileDiff: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixFileDiff { + return GixFileDiff( FfiConverterUInt.read(buf), FfiConverterUInt.read(buf), - FfiConverterTypeBinocularChangeType.read(buf), + FfiConverterTypeGixChangeType.read(buf), FfiConverterOptionalString.read(buf), FfiConverterOptionalString.read(buf), ) } - override fun allocationSize(value: BinocularFileDiff) = ( + override fun allocationSize(value: GixFileDiff) = ( FfiConverterUInt.allocationSize(value.`insertions`) + FfiConverterUInt.allocationSize(value.`deletions`) + - FfiConverterTypeBinocularChangeType.allocationSize(value.`change`) + + FfiConverterTypeGixChangeType.allocationSize(value.`change`) + FfiConverterOptionalString.allocationSize(value.`oldFileContent`) + FfiConverterOptionalString.allocationSize(value.`newFileContent`) ) - override fun write(value: BinocularFileDiff, buf: ByteBuffer) { + override fun write(value: GixFileDiff, buf: ByteBuffer) { FfiConverterUInt.write(value.`insertions`, buf) FfiConverterUInt.write(value.`deletions`, buf) - FfiConverterTypeBinocularChangeType.write(value.`change`, buf) + FfiConverterTypeGixChangeType.write(value.`change`, buf) FfiConverterOptionalString.write(value.`oldFileContent`, buf) FfiConverterOptionalString.write(value.`newFileContent`, buf) } @@ -2151,11 +2106,14 @@ public object FfiConverterTypeBinocularFileDiff: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularRepository { - return BinocularRepository( +public object FfiConverterTypeGixRemote: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixRemote { + return GixRemote( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: GixRemote) = ( + FfiConverterString.allocationSize(value.`name`) + + FfiConverterString.allocationSize(value.`url`) + ) + + override fun write(value: GixRemote, buf: ByteBuffer) { + FfiConverterString.write(value.`name`, buf) + FfiConverterString.write(value.`url`, buf) + } +} + + + +data class GixRepository ( + var `gitDir`: kotlin.String + , + var `workTree`: kotlin.String? + , + var `remotes`: List + +){ + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeGixRepository: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixRepository { + return GixRepository( FfiConverterString.read(buf), FfiConverterOptionalString.read(buf), - FfiConverterOptionalTypeRepositoryRemote.read(buf), + FfiConverterSequenceTypeGixRemote.read(buf), ) } - override fun allocationSize(value: BinocularRepository) = ( + override fun allocationSize(value: GixRepository) = ( FfiConverterString.allocationSize(value.`gitDir`) + FfiConverterOptionalString.allocationSize(value.`workTree`) + - FfiConverterOptionalTypeRepositoryRemote.allocationSize(value.`origin`) + FfiConverterSequenceTypeGixRemote.allocationSize(value.`remotes`) ) - override fun write(value: BinocularRepository, buf: ByteBuffer) { + override fun write(value: GixRepository, buf: ByteBuffer) { FfiConverterString.write(value.`gitDir`, buf) FfiConverterOptionalString.write(value.`workTree`, buf) - FfiConverterOptionalTypeRepositoryRemote.write(value.`origin`, buf) + FfiConverterSequenceTypeGixRemote.write(value.`remotes`, buf) } } -data class BinocularSig ( - var `name`: BString, - var `email`: BString, - var `time`: BinocularTime -) { +data class GixSignature ( + var `name`: BString + , + var `email`: BString + , + var `time`: GixTime + +){ + + companion object } @@ -2199,40 +2200,44 @@ data class BinocularSig ( /** * @suppress */ -public object FfiConverterTypeBinocularSig: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularSig { - return BinocularSig( +public object FfiConverterTypeGixSignature: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixSignature { + return GixSignature( FfiConverterTypeBString.read(buf), FfiConverterTypeBString.read(buf), - FfiConverterTypeBinocularTime.read(buf), + FfiConverterTypeGixTime.read(buf), ) } - override fun allocationSize(value: BinocularSig) = ( + override fun allocationSize(value: GixSignature) = ( FfiConverterTypeBString.allocationSize(value.`name`) + FfiConverterTypeBString.allocationSize(value.`email`) + - FfiConverterTypeBinocularTime.allocationSize(value.`time`) + FfiConverterTypeGixTime.allocationSize(value.`time`) ) - override fun write(value: BinocularSig, buf: ByteBuffer) { + override fun write(value: GixSignature, buf: ByteBuffer) { FfiConverterTypeBString.write(value.`name`, buf) FfiConverterTypeBString.write(value.`email`, buf) - FfiConverterTypeBinocularTime.write(value.`time`, buf) + FfiConverterTypeGixTime.write(value.`time`, buf) } } -data class BinocularTime ( +data class GixTime ( /** * The seconds that passed since UNIX epoch. This makes it UTC, or `+0000`. */ - var `seconds`: kotlin.Long, + var `seconds`: kotlin.Long + , /** * The time's offset in seconds, which may be negative to match the `sign` field. */ var `offset`: kotlin.Int -) { + +){ + + companion object } @@ -2240,20 +2245,20 @@ data class BinocularTime ( /** * @suppress */ -public object FfiConverterTypeBinocularTime: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularTime { - return BinocularTime( +public object FfiConverterTypeGixTime: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixTime { + return GixTime( FfiConverterLong.read(buf), FfiConverterInt.read(buf), ) } - override fun allocationSize(value: BinocularTime) = ( + override fun allocationSize(value: GixTime) = ( FfiConverterLong.allocationSize(value.`seconds`) + FfiConverterInt.allocationSize(value.`offset`) ) - override fun write(value: BinocularTime, buf: ByteBuffer) { + override fun write(value: GixTime, buf: ByteBuffer) { FfiConverterLong.write(value.`seconds`, buf) FfiConverterInt.write(value.`offset`, buf) } @@ -2261,63 +2266,43 @@ public object FfiConverterTypeBinocularTime: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): RepositoryRemote { - return RepositoryRemote( - FfiConverterOptionalString.read(buf), - FfiConverterOptionalString.read(buf), - FfiConverterOptionalString.read(buf), - ) - } - - override fun allocationSize(value: RepositoryRemote) = ( - FfiConverterOptionalString.allocationSize(value.`name`) + - FfiConverterOptionalString.allocationSize(value.`url`) + - FfiConverterOptionalString.allocationSize(value.`path`) - ) - - override fun write(value: RepositoryRemote, buf: ByteBuffer) { - FfiConverterOptionalString.write(value.`name`, buf) - FfiConverterOptionalString.write(value.`url`, buf) - FfiConverterOptionalString.write(value.`path`, buf) - } -} - - - -sealed class BinocularChangeType { +sealed class GixChangeType { data class Addition( - val `location`: BString) : BinocularChangeType() { + val `location`: BString) : GixChangeType() + + { + + companion object } data class Deletion( - val `location`: BString) : BinocularChangeType() { + val `location`: BString) : GixChangeType() + + { + + companion object } data class Modification( - val `location`: BString) : BinocularChangeType() { + val `location`: BString) : GixChangeType() + + { + + companion object } data class Rewrite( val `sourceLocation`: BString, val `location`: BString, - val `copy`: kotlin.Boolean) : BinocularChangeType() { + val `copy`: kotlin.Boolean) : GixChangeType() + + { + + companion object } @@ -2329,19 +2314,19 @@ sealed class BinocularChangeType { /** * @suppress */ -public object FfiConverterTypeBinocularChangeType : FfiConverterRustBuffer{ - override fun read(buf: ByteBuffer): BinocularChangeType { +public object FfiConverterTypeGixChangeType : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): GixChangeType { return when(buf.getInt()) { - 1 -> BinocularChangeType.Addition( + 1 -> GixChangeType.Addition( FfiConverterTypeBString.read(buf), ) - 2 -> BinocularChangeType.Deletion( + 2 -> GixChangeType.Deletion( FfiConverterTypeBString.read(buf), ) - 3 -> BinocularChangeType.Modification( + 3 -> GixChangeType.Modification( FfiConverterTypeBString.read(buf), ) - 4 -> BinocularChangeType.Rewrite( + 4 -> GixChangeType.Rewrite( FfiConverterTypeBString.read(buf), FfiConverterTypeBString.read(buf), FfiConverterBoolean.read(buf), @@ -2350,29 +2335,29 @@ public object FfiConverterTypeBinocularChangeType : FfiConverterRustBuffer { + override fun allocationSize(value: GixChangeType) = when(value) { + is GixChangeType.Addition -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( 4UL + FfiConverterTypeBString.allocationSize(value.`location`) ) } - is BinocularChangeType.Deletion -> { + is GixChangeType.Deletion -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( 4UL + FfiConverterTypeBString.allocationSize(value.`location`) ) } - is BinocularChangeType.Modification -> { + is GixChangeType.Modification -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( 4UL + FfiConverterTypeBString.allocationSize(value.`location`) ) } - is BinocularChangeType.Rewrite -> { + is GixChangeType.Rewrite -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( 4UL @@ -2383,24 +2368,24 @@ public object FfiConverterTypeBinocularChangeType : FfiConverterRustBuffer { + is GixChangeType.Addition -> { buf.putInt(1) FfiConverterTypeBString.write(value.`location`, buf) Unit } - is BinocularChangeType.Deletion -> { + is GixChangeType.Deletion -> { buf.putInt(2) FfiConverterTypeBString.write(value.`location`, buf) Unit } - is BinocularChangeType.Modification -> { + is GixChangeType.Modification -> { buf.putInt(3) FfiConverterTypeBString.write(value.`location`, buf) Unit } - is BinocularChangeType.Rewrite -> { + is GixChangeType.Rewrite -> { buf.putInt(4) FfiConverterTypeBString.write(value.`sourceLocation`, buf) FfiConverterTypeBString.write(value.`location`, buf) @@ -2447,6 +2432,47 @@ public object FfiConverterTypeGixDiffAlgorithm: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + GixReferenceCategory.values()[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: GixReferenceCategory) = 4UL + + override fun write(value: GixReferenceCategory, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + + enum class LogLevel { ERROR, @@ -2481,6 +2507,9 @@ public object FfiConverterTypeLogLevel: FfiConverterRustBuffer { +/** + * Main error type for FFI operations + */ sealed class UniffiException: kotlin.Exception() { class InvalidInput( @@ -2499,6 +2528,14 @@ sealed class UniffiException: kotlin.Exception() { get() = "v1=${ v1 }" } + class TraversalException( + + val v1: kotlin.String + ) : UniffiException() { + override val message + get() = "v1=${ v1 }" + } + class GixDiscoverException( val v1: kotlin.String @@ -2507,6 +2544,46 @@ sealed class UniffiException: kotlin.Exception() { get() = "v1=${ v1 }" } + class CommitLookupException( + + val v1: kotlin.String + ) : UniffiException() { + override val message + get() = "v1=${ v1 }" + } + + class ReferenceException( + + val v1: kotlin.String + ) : UniffiException() { + override val message + get() = "v1=${ v1 }" + } + + class ObjectException( + + val v1: kotlin.String + ) : UniffiException() { + override val message + get() = "v1=${ v1 }" + } + + class RevisionParseException( + + val v1: kotlin.String + ) : UniffiException() { + override val message + get() = "v1=${ v1 }" + } + + class GixException( + + val v1: kotlin.String + ) : UniffiException() { + override val message + get() = "v1=${ v1 }" + } + companion object ErrorHandler : UniffiRustCallStatusErrorHandler { override fun lift(error_buf: RustBuffer.ByValue): UniffiException = FfiConverterTypeUniffiError.lift(error_buf) @@ -2529,7 +2606,25 @@ public object FfiConverterTypeUniffiError : FfiConverterRustBuffer UniffiException.OperationFailed( FfiConverterString.read(buf), ) - 3 -> UniffiException.GixDiscoverException( + 3 -> UniffiException.TraversalException( + FfiConverterString.read(buf), + ) + 4 -> UniffiException.GixDiscoverException( + FfiConverterString.read(buf), + ) + 5 -> UniffiException.CommitLookupException( + FfiConverterString.read(buf), + ) + 6 -> UniffiException.ReferenceException( + FfiConverterString.read(buf), + ) + 7 -> UniffiException.ObjectException( + FfiConverterString.read(buf), + ) + 8 -> UniffiException.RevisionParseException( + FfiConverterString.read(buf), + ) + 9 -> UniffiException.GixException( FfiConverterString.read(buf), ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") @@ -2548,11 +2643,41 @@ public object FfiConverterTypeUniffiError : FfiConverterRustBuffer ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) is UniffiException.GixDiscoverException -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL + FfiConverterString.allocationSize(value.v1) ) + is UniffiException.CommitLookupException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UniffiException.ReferenceException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UniffiException.ObjectException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UniffiException.RevisionParseException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is UniffiException.GixException -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) } } @@ -2568,11 +2693,41 @@ public object FfiConverterTypeUniffiError : FfiConverterRustBuffer { + is UniffiException.TraversalException -> { buf.putInt(3) FfiConverterString.write(value.v1, buf) Unit } + is UniffiException.GixDiscoverException -> { + buf.putInt(4) + FfiConverterString.write(value.v1, buf) + Unit + } + is UniffiException.CommitLookupException -> { + buf.putInt(5) + FfiConverterString.write(value.v1, buf) + Unit + } + is UniffiException.ReferenceException -> { + buf.putInt(6) + FfiConverterString.write(value.v1, buf) + Unit + } + is UniffiException.ObjectException -> { + buf.putInt(7) + FfiConverterString.write(value.v1, buf) + Unit + } + is UniffiException.RevisionParseException -> { + buf.putInt(8) + FfiConverterString.write(value.v1, buf) + Unit + } + is UniffiException.GixException -> { + buf.putInt(9) + FfiConverterString.write(value.v1, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -2616,60 +2771,28 @@ public object FfiConverterOptionalString: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterOptionalTypeBinocularSig: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): BinocularSig? { - if (buf.get().toInt() == 0) { - return null - } - return FfiConverterTypeBinocularSig.read(buf) - } - - override fun allocationSize(value: BinocularSig?): ULong { - if (value == null) { - return 1UL - } else { - return 1UL + FfiConverterTypeBinocularSig.allocationSize(value) - } - } - - override fun write(value: BinocularSig?, buf: ByteBuffer) { - if (value == null) { - buf.put(0) - } else { - buf.put(1) - FfiConverterTypeBinocularSig.write(value, buf) - } - } -} - - - - -/** - * @suppress - */ -public object FfiConverterOptionalTypeRepositoryRemote: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): RepositoryRemote? { +public object FfiConverterOptionalTypeGixCommit: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): GixCommit? { if (buf.get().toInt() == 0) { return null } - return FfiConverterTypeRepositoryRemote.read(buf) + return FfiConverterTypeGixCommit.read(buf) } - override fun allocationSize(value: RepositoryRemote?): ULong { + override fun allocationSize(value: GixCommit?): ULong { if (value == null) { return 1UL } else { - return 1UL + FfiConverterTypeRepositoryRemote.allocationSize(value) + return 1UL + FfiConverterTypeGixCommit.allocationSize(value) } } - override fun write(value: RepositoryRemote?, buf: ByteBuffer) { + override fun write(value: GixCommit?, buf: ByteBuffer) { if (value == null) { buf.put(0) } else { buf.put(1) - FfiConverterTypeRepositoryRemote.write(value, buf) + FfiConverterTypeGixCommit.write(value, buf) } } } @@ -2772,24 +2895,52 @@ public object FfiConverterSequenceString: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +public object FfiConverterSequenceTypeGixBlameEntry: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeGixBlameEntry.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeGixBlameEntry.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeGixBlameEntry.write(it, buf) + } + } +} + + + + +/** + * @suppress + */ +public object FfiConverterSequenceTypeGixBlameOutcome: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { - FfiConverterTypeBinocularBlameEntry.read(buf) + return List(len) { + FfiConverterTypeGixBlameOutcome.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypeBinocularBlameEntry.allocationSize(it) }.sum() + val sizeForItems = value.map { FfiConverterTypeGixBlameOutcome.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { - FfiConverterTypeBinocularBlameEntry.write(it, buf) + FfiConverterTypeGixBlameOutcome.write(it, buf) } } } @@ -2800,24 +2951,24 @@ public object FfiConverterSequenceTypeBinocularBlameEntry: FfiConverterRustBuffe /** * @suppress */ -public object FfiConverterSequenceTypeBinocularBlameOutcome: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +public object FfiConverterSequenceTypeGixBlameResult: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { - FfiConverterTypeBinocularBlameOutcome.read(buf) + return List(len) { + FfiConverterTypeGixBlameResult.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypeBinocularBlameOutcome.allocationSize(it) }.sum() + val sizeForItems = value.map { FfiConverterTypeGixBlameResult.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { - FfiConverterTypeBinocularBlameOutcome.write(it, buf) + FfiConverterTypeGixBlameResult.write(it, buf) } } } @@ -2828,24 +2979,24 @@ public object FfiConverterSequenceTypeBinocularBlameOutcome: FfiConverterRustBuf /** * @suppress */ -public object FfiConverterSequenceTypeBinocularBlameResult: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +public object FfiConverterSequenceTypeGixBranch: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { - FfiConverterTypeBinocularBlameResult.read(buf) + return List(len) { + FfiConverterTypeGixBranch.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypeBinocularBlameResult.allocationSize(it) }.sum() + val sizeForItems = value.map { FfiConverterTypeGixBranch.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { - FfiConverterTypeBinocularBlameResult.write(it, buf) + FfiConverterTypeGixBranch.write(it, buf) } } } @@ -2856,24 +3007,24 @@ public object FfiConverterSequenceTypeBinocularBlameResult: FfiConverterRustBuff /** * @suppress */ -public object FfiConverterSequenceTypeBinocularBranch: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +public object FfiConverterSequenceTypeGixCommit: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { - FfiConverterTypeBinocularBranch.read(buf) + return List(len) { + FfiConverterTypeGixCommit.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypeBinocularBranch.allocationSize(it) }.sum() + val sizeForItems = value.map { FfiConverterTypeGixCommit.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { - FfiConverterTypeBinocularBranch.write(it, buf) + FfiConverterTypeGixCommit.write(it, buf) } } } @@ -2884,24 +3035,24 @@ public object FfiConverterSequenceTypeBinocularBranch: FfiConverterRustBuffer
  • > { - override fun read(buf: ByteBuffer): List { +public object FfiConverterSequenceTypeGixDiff: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { - FfiConverterTypeBinocularCommitVec.read(buf) + return List(len) { + FfiConverterTypeGixDiff.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypeBinocularCommitVec.allocationSize(it) }.sum() + val sizeForItems = value.map { FfiConverterTypeGixDiff.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { - FfiConverterTypeBinocularCommitVec.write(it, buf) + FfiConverterTypeGixDiff.write(it, buf) } } } @@ -2912,24 +3063,24 @@ public object FfiConverterSequenceTypeBinocularCommitVec: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceTypeBinocularDiffInput: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +public object FfiConverterSequenceTypeGixDiffInput: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { - FfiConverterTypeBinocularDiffInput.read(buf) + return List(len) { + FfiConverterTypeGixDiffInput.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypeBinocularDiffInput.allocationSize(it) }.sum() + val sizeForItems = value.map { FfiConverterTypeGixDiffInput.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { - FfiConverterTypeBinocularDiffInput.write(it, buf) + FfiConverterTypeGixDiffInput.write(it, buf) } } } @@ -2940,24 +3091,24 @@ public object FfiConverterSequenceTypeBinocularDiffInput: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceTypeBinocularDiffVec: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +public object FfiConverterSequenceTypeGixFileDiff: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { - FfiConverterTypeBinocularDiffVec.read(buf) + return List(len) { + FfiConverterTypeGixFileDiff.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypeBinocularDiffVec.allocationSize(it) }.sum() + val sizeForItems = value.map { FfiConverterTypeGixFileDiff.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { - FfiConverterTypeBinocularDiffVec.write(it, buf) + FfiConverterTypeGixFileDiff.write(it, buf) } } } @@ -2968,24 +3119,24 @@ public object FfiConverterSequenceTypeBinocularDiffVec: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +public object FfiConverterSequenceTypeGixRemote: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { - FfiConverterTypeBinocularFileDiff.read(buf) + return List(len) { + FfiConverterTypeGixRemote.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypeBinocularFileDiff.allocationSize(it) }.sum() + val sizeForItems = value.map { FfiConverterTypeGixRemote.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { - FfiConverterTypeBinocularFileDiff.write(it, buf) + FfiConverterTypeGixRemote.write(it, buf) } } } @@ -3097,6 +3248,16 @@ public typealias FfiConverterTypeBString = FfiConverterString +/** + * Typealias from the type name used in the UDL file to the builtin type. This + * is needed because the UDL type name is used in function/method signatures. + * It's also what we have an external type that references a custom type. + */ +public typealias FullName = BString +public typealias FfiConverterTypeFullName = FfiConverterTypeBString + + + /** * Typealias from the type name used in the UDL file to the builtin type. This * is needed because the UDL type name is used in function/method signatures. @@ -3114,79 +3275,196 @@ public typealias FfiConverterTypeObjectId = FfiConverterString */ public typealias PathBuf = kotlin.String public typealias FfiConverterTypePathBuf = FfiConverterString - @Throws(AnyhowException::class) fun `blames`(`binocularRepo`: BinocularRepository, `defines`: Map>, `diffAlgorithm`: GixDiffAlgorithm?, `maxThreads`: kotlin.UByte): List { - return FfiConverterSequenceTypeBinocularBlameResult.lift( - uniffiRustCallWithError(AnyhowException) { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_func_blames( - FfiConverterTypeBinocularRepository.lower(`binocularRepo`),FfiConverterMapTypeObjectIdSequenceString.lower(`defines`),FfiConverterOptionalTypeGixDiffAlgorithm.lower(`diffAlgorithm`),FfiConverterUByte.lower(`maxThreads`),_status) + /** + * Calculates blame information for files in commits + * + * # Arguments + * * `gix_repo` - The repository to analyze + * * `defines` - Map of commit IDs to file paths to blame + * * `diff_algorithm` - Optional diff algorithm to use + * * `max_threads` - Maximum number of threads for parallel processing + * + * # Returns + * A vector of blame results for all requested files + * + * # Errors + * - `GixDiscoverError` if repository discovery fails + * - `GixError` for blame calculation errors + */ + @Throws(UniffiException::class) fun `blames`(`gixRepo`: GixRepository, `defines`: Map>, `diffAlgorithm`: GixDiffAlgorithm?, `maxThreads`: kotlin.UByte): List { + return FfiConverterSequenceTypeGixBlameResult.lift( + uniffiRustCallWithError(UniffiException) { _status -> + UniffiLib.uniffi_gix_binocular_fn_func_blames( + + FfiConverterTypeGixRepository.lower(`gixRepo`),FfiConverterMapTypeObjectIdSequenceString.lower(`defines`),FfiConverterOptionalTypeGixDiffAlgorithm.lower(`diffAlgorithm`),FfiConverterUByte.lower(`maxThreads`),_status) } ) } - @Throws(ProcErrorInterface::class) fun `diffs`(`binocularRepo`: BinocularRepository, `commitPairs`: List, `maxThreads`: kotlin.UByte, `diffAlgorithm`: GixDiffAlgorithm?): List { - return FfiConverterSequenceTypeBinocularDiffVec.lift( - uniffiRustCallWithError(ProcErrorInterface) { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_func_diffs( - FfiConverterTypeBinocularRepository.lower(`binocularRepo`),FfiConverterSequenceTypeBinocularDiffInput.lower(`commitPairs`),FfiConverterUByte.lower(`maxThreads`),FfiConverterOptionalTypeGixDiffAlgorithm.lower(`diffAlgorithm`),_status) + /** + * Calculates diffs for multiple commit pairs + * + * # Arguments + * * `gix_repo` - The repository to work with + * * `commit_pairs` - Vector of commit pairs to diff (suspect, target) + * * `max_threads` - Maximum number of threads to use for parallel processing + * * `diff_algorithm` - Optional diff algorithm to use + * + * # Returns + * A vector of diff results for each commit pair + * + * # Errors + * - `GixDiscoverError` if repository discovery fails + * - `GixError` for diff calculation errors + */ + @Throws(UniffiException::class) fun `diffs`(`gixRepo`: GixRepository, `commitPairs`: List, `maxThreads`: kotlin.UByte, `diffAlgorithm`: GixDiffAlgorithm?): List { + return FfiConverterSequenceTypeGixDiff.lift( + uniffiRustCallWithError(UniffiException) { _status -> + UniffiLib.uniffi_gix_binocular_fn_func_diffs( + + FfiConverterTypeGixRepository.lower(`gixRepo`),FfiConverterSequenceTypeGixDiffInput.lower(`commitPairs`),FfiConverterUByte.lower(`maxThreads`),FfiConverterOptionalTypeGixDiffAlgorithm.lower(`diffAlgorithm`),_status) } ) } - @Throws(AnyhowException::class) fun `findAllBranches`(`binocularRepo`: BinocularRepository): List { - return FfiConverterSequenceTypeBinocularBranch.lift( - uniffiRustCallWithError(AnyhowException) { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_func_find_all_branches( - FfiConverterTypeBinocularRepository.lower(`binocularRepo`),_status) + /** + * Finds all branches (local and remote) in a repository + * + * # Arguments + * * `gix_repo` - The repository to query + * + * # Returns + * A vector of all branches found in the repository + * + * # Errors + * - `GixDiscoverError` if repository cannot be opened + * - `ReferenceError` if branch enumeration fails + */ + @Throws(UniffiException::class) fun `findAllBranches`(`gixRepo`: GixRepository): List { + return FfiConverterSequenceTypeGixBranch.lift( + uniffiRustCallWithError(UniffiException) { _status -> + UniffiLib.uniffi_gix_binocular_fn_func_find_all_branches( + + FfiConverterTypeGixRepository.lower(`gixRepo`),_status) } ) } - @Throws(AnyhowException::class) fun `findCommit`(`binocularRepo`: BinocularRepository, `hash`: kotlin.String): ObjectId { - return FfiConverterTypeObjectId.lift( - uniffiRustCallWithError(AnyhowException) { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_func_find_commit( - FfiConverterTypeBinocularRepository.lower(`binocularRepo`),FfiConverterString.lower(`hash`),_status) + /** + * Finds a specific commit by its hash + * + * # Arguments + * * `gix_repo` - The repository to search in + * * `hash` - The commit hash to find (full or abbreviated) or any valid revision spec + * * `use_mailmap` - Whether to apply mailmap transformations to author/committer info + * + * # Returns + * The commit metadata if found + * + * # Errors + * - `RevisionParseError` if the hash/revision spec is invalid or malformed + * - `ObjectError` if the object cannot be found or is not a commit + * - `CommitLookupError` if author/committer information cannot be read + */ + @Throws(UniffiException::class) fun `findCommit`(`gixRepo`: GixRepository, `hash`: kotlin.String, `useMailmap`: kotlin.Boolean): GixCommit { + return FfiConverterTypeGixCommit.lift( + uniffiRustCallWithError(UniffiException) { _status -> + UniffiLib.uniffi_gix_binocular_fn_func_find_commit( + + FfiConverterTypeGixRepository.lower(`gixRepo`),FfiConverterString.lower(`hash`),FfiConverterBoolean.lower(`useMailmap`),_status) } ) } - @Throws(AnyhowException::class) fun `findRepo`(`path`: kotlin.String): BinocularRepository { - return FfiConverterTypeBinocularRepository.lift( - uniffiRustCallWithError(AnyhowException) { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_func_find_repo( + /** + * Discovers and opens a Git repository at the given path + * + * # Arguments + * * `path` - Path to the Git repository (can be any path within the repository) + * + * # Returns + * A `GixRepository` containing repository metadata and remotes + * + * # Errors + * Returns `GixDiscoverError` if the repository cannot be discovered at the path + */ + @Throws(UniffiException::class) fun `findRepo`(`path`: kotlin.String): GixRepository { + return FfiConverterTypeGixRepository.lift( + uniffiRustCallWithError(UniffiException) { _status -> + UniffiLib.uniffi_gix_binocular_fn_func_find_repo( + FfiConverterString.lower(`path`),_status) } ) } - fun `hello`() + + /** + * Simple test function for FFI connectivity + */ fun `hello`() = uniffiRustCall() { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_func_hello( + UniffiLib.uniffi_gix_binocular_fn_func_hello( + _status) } - @Throws(AnyhowException::class) fun `traverse`(`binocularRepo`: BinocularRepository, `sourceCommit`: ObjectId, `targetCommit`: ObjectId?): List { - return FfiConverterSequenceTypeBinocularCommitVec.lift( - uniffiRustCallWithError(AnyhowException) { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_func_traverse( - FfiConverterTypeBinocularRepository.lower(`binocularRepo`),FfiConverterTypeObjectId.lower(`sourceCommit`),FfiConverterOptionalTypeObjectId.lower(`targetCommit`),_status) + /** + * Traverses a specific branch and returns its commits + * + * # Arguments + * * `gix_repo` - The repository containing the branch + * * `branch` - The name of the branch to traverse (e.g., "refs/heads/main" or "main") + * * `skip_merges` - Whether to skip merge commits in the result + * * `use_mailmap` - Whether to apply mailmap transformations to author/committer info + * + * # Returns + * A `BranchTraversalResult` containing the branch metadata and all commits + * + * # Errors + * - `GixDiscoverError` if repository cannot be opened + * - `ReferenceError` if the branch reference cannot be found + * - `TraversalError` if the traversal fails or returns unexpected results + */ + @Throws(UniffiException::class) fun `traverseBranch`(`gixRepo`: GixRepository, `branch`: kotlin.String, `skipMerges`: kotlin.Boolean, `useMailmap`: kotlin.Boolean): BranchTraversalResult { + return FfiConverterTypeBranchTraversalResult.lift( + uniffiRustCallWithError(UniffiException) { _status -> + UniffiLib.uniffi_gix_binocular_fn_func_traverse_branch( + + FfiConverterTypeGixRepository.lower(`gixRepo`),FfiConverterString.lower(`branch`),FfiConverterBoolean.lower(`skipMerges`),FfiConverterBoolean.lower(`useMailmap`),_status) } ) } - @Throws(UniffiException::class) fun `traverseBranch`(`binocularRepo`: BinocularRepository, `branch`: kotlin.String): List { - return FfiConverterSequenceTypeBinocularCommitVec.lift( + /** + * Traverses commit history from a source commit to an optional target commit + * + * # Arguments + * * `gix_repo` - The repository to traverse + * * `source_commit` - The starting commit + * * `target_commit` - Optional ending commit. If None, traverses to repository root + * + * # Returns + * A vector of commits between source and target + * + * # Errors + * - `GixDiscoverError` if repository discovery fails + * - `CommitLookupError` if commits cannot be found + * - `GixError` for other traversal errors + */ + @Throws(UniffiException::class) fun `traverseHistory`(`gixRepo`: GixRepository, `sourceCommit`: ObjectId, `targetCommit`: ObjectId?, `useMailmap`: kotlin.Boolean): List { + return FfiConverterSequenceTypeGixCommit.lift( uniffiRustCallWithError(UniffiException) { _status -> - UniffiLib.INSTANCE.uniffi_gix_binocular_fn_func_traverse_branch( - FfiConverterTypeBinocularRepository.lower(`binocularRepo`),FfiConverterString.lower(`branch`),_status) + UniffiLib.uniffi_gix_binocular_fn_func_traverse_history( + + FfiConverterTypeGixRepository.lower(`gixRepo`),FfiConverterTypeObjectId.lower(`sourceCommit`),FfiConverterOptionalTypeObjectId.lower(`targetCommit`),FfiConverterBoolean.lower(`useMailmap`),_status) } ) } diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/pojos/BinocularRepositoryPojo.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/pojos/BinocularRepositoryPojo.kt deleted file mode 100644 index 25d15ff77..000000000 --- a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/pojos/BinocularRepositoryPojo.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.inso_world.binocular.ffi.pojos - -import com.inso_world.binocular.ffi.internal.BinocularRepository -import com.inso_world.binocular.model.Repository - -internal fun Repository.toFfi(): BinocularRepository = - BinocularRepository( - gitDir = this.localPath, - workTree = null, - origin = null, - ) - -private fun normalizePath(path: String): String = if (path.endsWith(".git")) path else "$path/.git" - -internal fun BinocularRepository.toModel(): Repository = - Repository( - localPath = normalizePath(gitDir), -// workTree = workTree, -// origin = origin?.toPojo(), - ) diff --git a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/util/Utils.kt b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/util/Utils.kt index 7c56d76e2..948de3e77 100644 --- a/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/util/Utils.kt +++ b/binocular-backend-new/ffi/src/main/kotlin/com/inso_world/binocular/ffi/util/Utils.kt @@ -1,6 +1,6 @@ package com.inso_world.binocular.ffi.util -import com.inso_world.binocular.ffi.BinocularFfi +import com.inso_world.binocular.ffi.GixIndexer internal class Utils { companion object { @@ -17,7 +17,7 @@ internal class Utils { System.setProperty("uniffi.component.$libBaseName.libraryOverride", resourcePath) - if (BinocularFfi::class.java.getResource(resourcePath) == null) { + if (GixIndexer::class.java.getResource(resourcePath) == null) { throw IllegalStateException("$resourcePath does not exist on the classpath") } @@ -39,7 +39,7 @@ internal class Utils { (os.contains("nux") || os.contains("nix")) && arch == "aarch64" -> "aarch64-unknown-linux-gnu" // Windows - os.contains("win") && (arch == "x86_64" || arch == "amd64") -> "x86_64-pc-windows-msvc" + os.contains("win") && (arch == "x86_64" || arch == "amd64") -> "x86_64-pc-windows-gnu" os.contains("win") && arch == "aarch64" -> "aarch64-pc-windows-msvc" else -> throw UnsupportedOperationException("Unsupported OS/Arch combination: $os/$arch") diff --git a/binocular-backend-new/ffi/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/binocular-backend-new/ffi/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000..cfe3e6659 --- /dev/null +++ b/binocular-backend-new/ffi/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,24 @@ +{ + "groups": [ + { + "name": "binocular.gix", + "type": "com.inso_world.binocular.ffi.GixConfig", + "description": "Configuration for the gix (Rust Git implementation) indexer." + } + ], + "properties": [ + { + "name": "binocular.gix.skip-merges", + "type": "java.lang.Boolean", + "description": "Whether to skip merge commits during repository indexing. When enabled, merge commits are excluded from the indexed data.", + "defaultValue": false + }, + { + "name": "binocular.gix.use-mailmap", + "type": "java.lang.Boolean", + "description": "Whether to use the .mailmap file for author/committer name and email normalization. When enabled, Git's mailmap feature is applied to unify contributor identities.", + "defaultValue": true + } + ], + "hints": [] +} diff --git a/binocular-backend-new/ffi/src/main/resources/aarch64-apple-darwin/libbinocular_ffi.dylib b/binocular-backend-new/ffi/src/main/resources/aarch64-apple-darwin/libbinocular_ffi.dylib deleted file mode 100755 index a29c9c4b5..000000000 Binary files a/binocular-backend-new/ffi/src/main/resources/aarch64-apple-darwin/libbinocular_ffi.dylib and /dev/null differ diff --git a/binocular-backend-new/ffi/src/main/resources/aarch64-apple-darwin/libgix_binocular.dylib b/binocular-backend-new/ffi/src/main/resources/aarch64-apple-darwin/libgix_binocular.dylib old mode 100644 new mode 100755 index 4327ae7a8..fc3e0411a Binary files a/binocular-backend-new/ffi/src/main/resources/aarch64-apple-darwin/libgix_binocular.dylib and b/binocular-backend-new/ffi/src/main/resources/aarch64-apple-darwin/libgix_binocular.dylib differ diff --git a/binocular-backend-new/ffi/src/main/resources/aarch64-unknown-linux-gnu/libgix_binocular.so b/binocular-backend-new/ffi/src/main/resources/aarch64-unknown-linux-gnu/libgix_binocular.so index 32ea7c75d..38e9b447f 100644 Binary files a/binocular-backend-new/ffi/src/main/resources/aarch64-unknown-linux-gnu/libgix_binocular.so and b/binocular-backend-new/ffi/src/main/resources/aarch64-unknown-linux-gnu/libgix_binocular.so differ diff --git a/binocular-backend-new/ffi/src/main/resources/application.yaml b/binocular-backend-new/ffi/src/main/resources/application.yaml new file mode 100644 index 000000000..5e155c955 --- /dev/null +++ b/binocular-backend-new/ffi/src/main/resources/application.yaml @@ -0,0 +1,4 @@ +binocular: + gix: + skip-merges: false + use-mailmap: true diff --git a/binocular-backend-new/ffi/src/main/resources/x86_64-apple-darwin/libgix_binocular.dylib b/binocular-backend-new/ffi/src/main/resources/x86_64-apple-darwin/libgix_binocular.dylib index ded274688..d14008720 100644 Binary files a/binocular-backend-new/ffi/src/main/resources/x86_64-apple-darwin/libgix_binocular.dylib and b/binocular-backend-new/ffi/src/main/resources/x86_64-apple-darwin/libgix_binocular.dylib differ diff --git a/binocular-backend-new/ffi/src/main/resources/x86_64-pc-windows-gnu/gix_binocular.dll b/binocular-backend-new/ffi/src/main/resources/x86_64-pc-windows-gnu/gix_binocular.dll index b99becb34..d720bee03 100644 Binary files a/binocular-backend-new/ffi/src/main/resources/x86_64-pc-windows-gnu/gix_binocular.dll and b/binocular-backend-new/ffi/src/main/resources/x86_64-pc-windows-gnu/gix_binocular.dll differ diff --git a/binocular-backend-new/ffi/src/main/resources/x86_64-unknown-linux-gnu/libgix_binocular.so b/binocular-backend-new/ffi/src/main/resources/x86_64-unknown-linux-gnu/libgix_binocular.so index d8daaf113..dc94805a8 100644 Binary files a/binocular-backend-new/ffi/src/main/resources/x86_64-unknown-linux-gnu/libgix_binocular.so and b/binocular-backend-new/ffi/src/main/resources/x86_64-unknown-linux-gnu/libgix_binocular.so differ diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/BinocularFfiTestApplication.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/BinocularFfiTestApplication.kt new file mode 100644 index 000000000..5505b0e66 --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/BinocularFfiTestApplication.kt @@ -0,0 +1,9 @@ +package com.inso_world.binocular.ffi + +import org.springframework.boot.autoconfigure.SpringBootApplication + + +@SpringBootApplication( + scanBasePackages = ["com.inso_world.binocular.ffi"], +) +internal open class BinocularFfiTestApplication diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/FfiIntegrationTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/FfiIntegrationTest.kt new file mode 100644 index 000000000..26162b197 --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/FfiIntegrationTest.kt @@ -0,0 +1,1036 @@ +package com.inso_world.binocular.ffi.integration + +import com.inso_world.binocular.core.integration.base.BaseFixturesIntegrationTest +import com.inso_world.binocular.ffi.FfiConfig +import com.inso_world.binocular.ffi.BinocularFfiTestApplication +import com.inso_world.binocular.ffi.internal.* +import com.inso_world.binocular.ffi.util.Utils +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.* +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.nio.file.Files +import java.time.LocalDateTime +import java.util.stream.Stream + +/** + * Integration tests for Rust FFI bindings exposed via UniFFI. + * + * Tests all FFI functions defined in the Rust crate to ensure correct: + * - Data marshalling between Kotlin and Rust + * - Error handling across the FFI boundary + * - Git operations via the gix library + * - Memory management and resource cleanup + * + * ### Test Organization + * Tests are organized into nested classes by FFI module: + * - [BasicOperations]: Simple connectivity and repository discovery + * - [RepositoryOperations]: Repository discovery and metadata retrieval + * - [BranchOperations]: Branch enumeration and traversal + * - [CommitOperations]: Commit lookup and history traversal + * - [DiffOperations]: Diff calculation for commit pairs + * - [BlameOperations]: Blame calculation for files + * - [ErrorHandling]: Exception handling across FFI boundary + * - [DataMarshalling]: Type conversion and data integrity + */ +@TestClassOrder(ClassOrderer.OrderAnnotation::class) +@SpringBootTest( + classes = [BinocularFfiTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.NONE, +) +@ExtendWith(SpringExtension::class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) +internal class FfiIntegrationTest : BaseFixturesIntegrationTest() { + + @Autowired + private lateinit var cfg: FfiConfig + + companion object { + private lateinit var simpleRepo: GixRepository + private lateinit var octoRepo: GixRepository + private lateinit var advancedRepo: GixRepository + + @BeforeAll + @JvmStatic + fun loadLibrary() { + // Load the native library + Utils.loadPlatformLibrary("gix_binocular") + } + + @JvmStatic + fun branchTestData(): Stream = Stream.of( + Arguments.of(SIMPLE_REPO, "refs/heads/master", 14), + Arguments.of(SIMPLE_REPO, "refs/remotes/origin/master", 13), + Arguments.of(OCTO_REPO, "refs/heads/master", 19), + Arguments.of(OCTO_REPO, "refs/heads/octo1", 16), + Arguments.of(OCTO_REPO, "refs/heads/octo2", 16), + Arguments.of(OCTO_REPO, "refs/heads/octo3", 16), + Arguments.of(ADVANCED_REPO, "refs/heads/master", 35), + Arguments.of(ADVANCED_REPO, "refs/heads/imported", 4) + ) + + @JvmStatic + fun commitHistoryData(): Stream = Stream.of( + Arguments.of(SIMPLE_REPO, "HEAD", null, 14), + Arguments.of(SIMPLE_REPO, "b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", null, 14), + Arguments.of(SIMPLE_REPO, "48a384a6a9188f376835005cd10fd97542e69bf7", null, 1), + Arguments.of(OCTO_REPO, "HEAD", null, 19), + Arguments.of(OCTO_REPO, "4dedc3c738eee6b69c43cde7d89f146912532cff", null, 19), + Arguments.of(ADVANCED_REPO, "HEAD", null, 35), + Arguments.of(ADVANCED_REPO, "379dc91fb055ba385b5e5446428ffbe38804fa99", null, 35) + ) + + private fun normalizeBranchName(refName: String): String = + refName + .removePrefix("refs/heads/") + .removePrefix("refs/remotes/") + .removePrefix("refs/tags/") + } + + @BeforeEach + fun setUpRepositories() { +// if (!::simpleRepo.isInitialized) { + simpleRepo = findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}") +// } +// if (!::octoRepo.isInitialized) { + octoRepo = findRepo("${FIXTURES_PATH}/${OCTO_REPO}") +// } +// if (!::advancedRepo.isInitialized) { + advancedRepo = findRepo("${FIXTURES_PATH}/${ADVANCED_REPO}") +// } + } + + @Nested + @Order(1) + @DisplayName("Basic FFI operations") + inner class BasicOperations { + + @Test + fun `hello should execute without errors`() { + assertDoesNotThrow { + hello() + } + } + + @Test + fun `hello can be called multiple times`() { + assertDoesNotThrow { + repeat(5) { hello() } + } + } + } + + @Nested + @Order(2) + @DisplayName("Repository operations via FFI") + inner class RepositoryOperations { + + @ParameterizedTest + @ValueSource(strings = [SIMPLE_REPO, OCTO_REPO, ADVANCED_REPO]) + fun `findRepo should discover repository and return metadata`(repoName: String) { + val repo = findRepo("${FIXTURES_PATH}/$repoName") + + assertAll( + "Repository $repoName should be discovered with correct metadata", + { assertThat(repo.gitDir).contains(".git") }, + { assertThat(repo.gitDir).contains(repoName) }, + { assertThat(repo.workTree).isNotNull() }, + { assertThat(repo.remotes).isNotNull() } + ) + } + + @Test + fun `findRepo with simple-repo should have origin remote`() { + val repo = findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}") + + assertAll( + { assertThat(repo.remotes).isNotEmpty() }, + { assertThat(repo.remotes.map { it.name }).contains("origin") } + ) + } + + @Test + fun `findRepo with non-git directory should throw GixDiscoverException`() { + val nonGitPath = Files.createTempDirectory(LocalDateTime.now().toString()) + + val exception = assertThrows { + findRepo(nonGitPath.toString()) + } + + assertThat(exception.message).contains(nonGitPath.toString()) + } + + @Test + fun `findRepo with invalid path should throw GixDiscoverException`() { + assertThrows { + findRepo("/invalid/nonexistent/path") + } + } + + @Test + fun `findRepo returns consistent data for multiple calls`() { + val repo1 = findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}") + val repo2 = findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}") + + assertAll( + { assertThat(repo1.gitDir).isEqualTo(repo2.gitDir) }, + { assertThat(repo1.workTree).isEqualTo(repo2.workTree) }, + { assertThat(repo1.remotes.size).isEqualTo(repo2.remotes.size) } + ) + } + + @Test + fun `findRepo with work tree should populate workTree field`() { + val repo = findRepo("${FIXTURES_PATH}/${SIMPLE_REPO}") + + assertAll( + { assertThat(repo.workTree).isNotNull() }, + { assertThat(repo.workTree).contains(SIMPLE_REPO) }, + { assertThat(repo.workTree).doesNotContain(".git") } + ) + } + } + + @Nested + @Order(3) + @DisplayName("Branch operations via FFI") + inner class BranchOperations { + + @ParameterizedTest + @CsvSource( + "${SIMPLE_REPO},2", + "${OCTO_REPO},7", + "${ADVANCED_REPO},8" + ) + fun `findAllBranches should return all branches in repository`(repoName: String, expectedCount: Int) { + val repo = findRepo("${FIXTURES_PATH}/$repoName") + val branches = findAllBranches(repo) + + assertAll( + "Repository $repoName should have $expectedCount branches", + { assertThat(branches).hasSize(expectedCount) }, + { assertThat(branches).allMatch { it.name.isNotEmpty() } }, + { assertThat(branches).allMatch { it.fullName.isNotEmpty() } }, + { assertThat(branches).allMatch { it.target.isNotEmpty() } } + ) + } + + @Test + fun `findAllBranches should return both local and remote branches for simple-repo`() { + val branches = findAllBranches(simpleRepo) + val branchNames = branches.map(GixBranch::name) + + assertAll( + { assertThat(branches).hasSize(2) }, + { assertThat(branchNames).contains("master") }, + { assertThat(branchNames).contains("origin/master") } + ) + } + + @Test + fun `findAllBranches should distinguish between local and remote branches`() { + val branches = findAllBranches(simpleRepo) + + val localBranches = branches.filter { it.category == GixReferenceCategory.LOCAL_BRANCH } + val remoteBranches = branches.filter { it.category == GixReferenceCategory.REMOTE_BRANCH } + + assertAll( + { assertThat(localBranches).isNotEmpty() }, + { assertThat(remoteBranches).isNotEmpty() }, + { assertThat(localBranches.size + remoteBranches.size).isEqualTo(branches.size) } + ) + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.ffi.integration.FfiIntegrationTest#branchTestData") + fun `traverseBranch should return branch with commits`( + repoName: String, + branchName: String, + expectedCommitCount: Int + ) { + val repo = findRepo("${FIXTURES_PATH}/$repoName") + val result = traverseBranch( + repo, branchName, + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + "Branch $branchName in $repoName", + { assertThat(result.branch.name).isEqualTo(normalizeBranchName(branchName)) }, + { assertThat(result.commits).hasSize(expectedCommitCount) }, + { assertThat(result.commits).allMatch { it.oid.isNotEmpty() } }, + { assertThat(result.commits).allMatch { it.message.isNotEmpty() } } + ) + } + + @Test + fun `traverseBranch should populate commit metadata correctly`() { + val result = traverseBranch( + simpleRepo, "refs/heads/master", + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(result.commits).allMatch { it.oid.length == 40 } }, + { assertThat(result.commits).allMatch { it.message.isNotBlank() } }, + { assertThat(result.commits).allMatch { it.author != null } }, + { assertThat(result.commits).allMatch { it.committer != null } } + ) + } + + @Test + fun `traverseBranch with non-existent branch should throw ReferenceException`() { + assertThrows { + traverseBranch( + simpleRepo, "refs/heads/nonexistent-branch", + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + } + } + + @Test + fun `traverseBranch should populate parent relationships`() { + val result = traverseBranch( + simpleRepo, "refs/heads/master", + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + + val commitsWithParents = result.commits.filter { it.parents.isNotEmpty() } + assertAll( + { assertThat(commitsWithParents).isNotEmpty() }, + { + commitsWithParents.forEach { commit -> + assertThat(commit.parents).allMatch { it.length == 40 } + } + } + ) + } + + @Test + fun `traverseBranch should handle merge commits with multiple parents`() { + val result = traverseBranch( + octoRepo, "refs/heads/master", + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + + val mergeCommits = result.commits.filter { it.parents.size > 1 } + assertAll( + { assertThat(mergeCommits).isNotEmpty() }, + { + mergeCommits.forEach { commit -> + assertThat(commit.parents.size).isGreaterThan(1) + } + } + ) + } + + @Test + fun `traverseBranch should include file tree information`() { + val result = traverseBranch( + simpleRepo, "refs/heads/master", + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + + val commitsWithFiles = result.commits.filter { it.fileTree.isNotEmpty() } + assertAll( + { assertThat(commitsWithFiles).isNotEmpty() }, + { + commitsWithFiles.forEach { commit -> + assertThat(commit.fileTree).allMatch { it.isNotEmpty() } + } + } + ) + } + } + + @Nested + @Order(4) + @DisplayName("Commit operations via FFI") + inner class CommitOperations { + + @ParameterizedTest + @CsvSource( + "${SIMPLE_REPO},HEAD,b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", + "${SIMPLE_REPO},b51199ab8b83e31f64b631e42b2ee0b1c7e3259a,b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", + "${OCTO_REPO},HEAD,4dedc3c738eee6b69c43cde7d89f146912532cff", + "${ADVANCED_REPO},HEAD,379dc91fb055ba385b5e5446428ffbe38804fa99" + ) + fun `findCommit should return commit with correct SHA`( + repoName: String, + commitRef: String, + expectedSha: String + ) { + val repo = findRepo("${FIXTURES_PATH}/$repoName") + val commit = findCommit( + repo, commitRef, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(commit.oid).isEqualTo(expectedSha) }, + { assertThat(commit.message).isNotBlank() }, + { assertThat(commit.author).isNotNull() }, + { assertThat(commit.committer).isNotNull() } + ) + } + + @Test + fun `findCommit should populate signature fields correctly`() { + val commit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + "Commit signatures", + { assertThat(commit.author).isNotNull() }, + { assertThat(commit.author?.name).isNotBlank() }, + { assertThat(commit.author?.email).isNotBlank() }, + { assertThat(commit.committer).isNotNull() }, + { assertThat(commit.committer?.name).isNotBlank() }, + { assertThat(commit.committer?.email).isNotBlank() } + ) + } + + @Test + fun `findCommit with invalid SHA should throw RevisionParseException`() { + assertThrows { + findCommit( + simpleRepo, "invalid-sha-format", + useMailmap = cfg.gix.useMailmap + ) + } + } + + @Test + fun `findCommit with non-existent SHA should throw RevisionParseException`() { + assertThrows { + findCommit( + simpleRepo, "0000000000000000000000000000000000000000", + useMailmap = cfg.gix.useMailmap + ) + } + } + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.ffi.integration.FfiIntegrationTest#commitHistoryData") + fun `traverseHistory should return correct number of commits`( + repoName: String, + startRef: String, + targetSha: String?, + expectedCount: Int + ) { + val repo = findRepo("${FIXTURES_PATH}/$repoName") + val startCommit = findCommit( + repo, startRef, + useMailmap = cfg.gix.useMailmap + ) + val commits = traverseHistory( + repo, startCommit.oid, targetSha, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + "Traversing from $startRef in $repoName", + { assertThat(commits).hasSize(expectedCount) }, + { assertThat(commits.first().oid).isEqualTo(startCommit.oid) }, + { assertThat(commits).allMatch { it.oid.length == 40 } } + ) + } + + @Test + fun `traverseHistory from initial commit should return single commit`() { + val initialSha = "48a384a6a9188f376835005cd10fd97542e69bf7" + val commits = traverseHistory( + simpleRepo, initialSha, null, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(commits).hasSize(1) }, + { assertThat(commits.first().oid).isEqualTo(initialSha) }, + { assertThat(commits.first().parents).isEmpty() } + ) + } + + @Test + fun `traverseHistory with target should stop at target commit`() { + val headCommit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val targetSha = "48a384a6a9188f376835005cd10fd97542e69bf7" + + val commits = traverseHistory( + simpleRepo, headCommit.oid, targetSha, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(commits).isNotEmpty() }, + { assertThat(commits.first().oid).isEqualTo(headCommit.oid) }, + { assertThat(commits.map { it.oid }).doesNotContain(targetSha) } + ) + } + + @Test + fun `traverseHistory should preserve commit order (newest first)`() { + val commits = traverseHistory( + simpleRepo, findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ).oid, null, + useMailmap = cfg.gix.useMailmap + ) + + // Verify chronological order by checking that each commit's timestamp + // is older than or equal to the previous commit + val timestamps = commits.mapNotNull { it.committer?.time?.seconds } + assertThat(timestamps.zipWithNext()).allMatch { (newer, older) -> + newer >= older + } + } + + @Test + fun `traverseHistory should handle merge commits correctly`() { + val commits = traverseHistory( + octoRepo, findCommit( + octoRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ).oid, null, + useMailmap = cfg.gix.useMailmap + ) + + val mergeCommits = commits.filter { it.parents.size > 1 } + assertAll( + { assertThat(mergeCommits).isNotEmpty() }, + { assertThat(commits).contains(*mergeCommits.toTypedArray()) } + ) + } + } + + @Nested + @Order(5) + @DisplayName("Diff operations via FFI") + inner class DiffOperations { + + @Test + fun `diffs should calculate diff for single commit pair`() { + val headCommit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val parentSha = headCommit.parents.firstOrNull() + + val diffInput = GixDiffInput( + suspect = headCommit.oid, + target = parentSha + ) + + val diffs = diffs(simpleRepo, listOf(diffInput), 1u, GixDiffAlgorithm.HISTOGRAM) + + assertAll( + { assertThat(diffs).hasSize(1) }, + { assertThat(diffs.first().commit.oid).isEqualTo(headCommit.oid) }, + { assertThat(diffs.first().parent?.oid).isEqualTo(parentSha) }, + { assertThat(diffs.first().files).isNotEmpty() } + ) + } + + @Test + fun `diffs should handle multiple commit pairs`() { + val commits = traverseHistory( + simpleRepo, findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ).oid, null, + useMailmap = cfg.gix.useMailmap + ) + .take(3) + + val diffInputs = commits.mapNotNull { commit -> + val parent = commit.parents.firstOrNull() + if (parent != null) { + GixDiffInput(commit.oid, parent) + } else null + } + + val diffs = diffs(simpleRepo, diffInputs, 2u, GixDiffAlgorithm.HISTOGRAM) + + assertAll( + { assertThat(diffs).hasSizeGreaterThanOrEqualTo(diffInputs.size) }, + { assertThat(diffs).allMatch { it.files.isNotEmpty() } } + ) + } + + @Test + fun `diffs should populate file change statistics`() { + val headCommit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val diffInput = GixDiffInput(headCommit.oid, headCommit.parents.firstOrNull()) + + val diffs = diffs(simpleRepo, listOf(diffInput), 1u, GixDiffAlgorithm.HISTOGRAM) + val fileDiffs = diffs.first().files + + assertAll( + { assertThat(fileDiffs).isNotEmpty() }, + { + fileDiffs.forEach { file -> + assertThat(file.insertions).isGreaterThanOrEqualTo(0u) + assertThat(file.deletions).isGreaterThanOrEqualTo(0u) + } + } + ) + } + + @Test + fun `diffs should identify change types correctly`() { + val commits = traverseHistory( + simpleRepo, findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ).oid, null, + useMailmap = cfg.gix.useMailmap + ) + val diffInputs = commits.take(5).mapNotNull { commit -> + val parent = commit.parents.firstOrNull() + if (parent != null) GixDiffInput(commit.oid, parent) else null + } + + val diffs = diffs(simpleRepo, diffInputs, 2u, GixDiffAlgorithm.HISTOGRAM) + + val allChanges = diffs.flatMap { it.files } + assertAll( + { assertThat(allChanges).isNotEmpty() }, + { + allChanges.forEach { change -> + assertThat(change.change).isNotNull() + } + } + ) + } + + @Test + fun `diffs with different algorithms should produce results`() { + val headCommit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val diffInput = GixDiffInput(headCommit.oid, headCommit.parents.firstOrNull()) + + val algorithms = listOf( + GixDiffAlgorithm.HISTOGRAM, + GixDiffAlgorithm.MYERS, + GixDiffAlgorithm.MYERS_MINIMAL + ) + + algorithms.forEach { algorithm -> + val result = diffs(simpleRepo, listOf(diffInput), 1u, algorithm) + assertThat(result).isNotEmpty() + } + } + + @Test + fun `diffs should handle initial commit without parent`() { + val initialSha = "48a384a6a9188f376835005cd10fd97542e69bf7" + val diffInput = GixDiffInput(initialSha, null) + + val diffs = diffs(simpleRepo, listOf(diffInput), 1u, GixDiffAlgorithm.HISTOGRAM) + + assertAll( + { assertThat(diffs).hasSize(1) }, + { assertThat(diffs.first().commit.oid).isEqualTo(initialSha) }, + { assertThat(diffs.first().parent).isNull() }, + { assertThat(diffs.first().files).isNotEmpty() } + ) + } + + @Test + fun `diffs with multiple threads should complete successfully`() { + val commits = traverseHistory( + simpleRepo, findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ).oid, null, + useMailmap = cfg.gix.useMailmap + ) + val diffInputs = commits.take(10).mapNotNull { commit -> + val parent = commit.parents.firstOrNull() + if (parent != null) GixDiffInput(commit.oid, parent) else null + } + + val singleThreaded = diffs(simpleRepo, diffInputs, 1u, GixDiffAlgorithm.HISTOGRAM) + val multiThreaded = diffs(simpleRepo, diffInputs, 4u, GixDiffAlgorithm.HISTOGRAM) + + assertAll( + { assertThat(singleThreaded.size).isEqualTo(multiThreaded.size) }, + { assertThat(singleThreaded.map { it.commit }).containsAll(multiThreaded.map { it.commit }) } + ) + } + } + + @Nested + @Order(6) + @DisplayName("Blame operations via FFI") + inner class BlameOperations { + + @Test + fun `blames should calculate blame for single file in commit`() { + val headCommit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val filePath = "file2.txt" + + val defines = mapOf(headCommit.oid to listOf(filePath)) + + val blames = blames(simpleRepo, defines, GixDiffAlgorithm.HISTOGRAM, 1u) + + assertAll( + { assertThat(blames).hasSize(1) }, + { assertThat(blames.first().commit).isEqualTo(headCommit.oid) }, + { assertThat(blames.first().blames).isNotEmpty() } + ) + } + + @Test + fun `blames should populate blame entries correctly`() { + val commit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val filePath = "file2.txt" + val defines = mapOf(commit.oid to listOf(filePath)) + + val blames = blames(simpleRepo, defines, GixDiffAlgorithm.HISTOGRAM, 1u) + val blameOutcome = blames.first().blames.first() + + assertAll( + { assertThat(blameOutcome.filePath).isEqualTo(filePath) }, + { assertThat(blameOutcome.entries).isNotEmpty() }, + { + blameOutcome.entries.forEach { entry -> + assertThat(entry.commitId).isNotEmpty() + assertThat(entry.len).isGreaterThan(0u) + } + } + ) + } + + @Test + fun `blames should handle multiple files in same commit`() { + val commit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val files = listOf("file2.txt", ".gitignore") + + val defines = mapOf(commit.oid to files) + + val blames = blames(simpleRepo, defines, GixDiffAlgorithm.HISTOGRAM, 1u) + + assertAll( + { assertThat(blames).hasSize(1) }, + { assertThat(blames.first().blames).hasSizeGreaterThanOrEqualTo(1) } + ) + } + + @Test + fun `blames should handle multiple commits`() { + val commits = traverseHistory( + simpleRepo, findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ).oid, null, + useMailmap = cfg.gix.useMailmap + ) + .take(3) + + val defines = commits.associate { it.oid to listOf("file2.txt") } + + val blames = blames(simpleRepo, defines, GixDiffAlgorithm.HISTOGRAM, 2u) + + assertAll( + { assertThat(blames).hasSizeGreaterThanOrEqualTo(1) }, + { assertThat(blames.map { it.commit }).containsAnyOf(*commits.map { it.oid }.toTypedArray()) } + ) + } + + @Test + fun `blames with different algorithms should produce results`() { + val commit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val defines = mapOf(commit.oid to listOf("file2.txt")) + + val algorithms = listOf( + GixDiffAlgorithm.HISTOGRAM, + GixDiffAlgorithm.MYERS, + GixDiffAlgorithm.MYERS_MINIMAL + ) + + algorithms.forEach { algorithm -> + val result = blames(simpleRepo, defines, algorithm, 1u) + assertThat(result).isNotEmpty() + } + } + + @Test + fun `blames should verify line ranges are valid`() { + val commit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val defines = mapOf(commit.oid to listOf("file2.txt")) + + val blames = blames(simpleRepo, defines, GixDiffAlgorithm.HISTOGRAM, 1u) + val entries = blames.first().blames.first().entries + + assertAll( + { assertThat(entries).isNotEmpty() }, + { + entries.forEach { entry -> + assertThat(entry.startInBlamedFile).isGreaterThanOrEqualTo(0u) + assertThat(entry.startInSourceFile).isGreaterThanOrEqualTo(0u) + assertThat(entry.len).isGreaterThan(0u) + } + } + ) + } + } + + @Nested + @Order(7) + @DisplayName("Error handling across FFI boundary") + inner class ErrorHandling { + + @Test + fun `invalid repository path should throw appropriate exception`() { + assertThrows { + findRepo("/completely/invalid/path") + } + } + + @Test + fun `invalid commit reference should throw RevisionParseException`() { + assertThrows { + findCommit( + simpleRepo, "not-a-valid-ref", + useMailmap = cfg.gix.useMailmap + ) + } + } + + @Test + fun `invalid branch reference should throw ReferenceException`() { + assertThrows { + traverseBranch( + simpleRepo, "refs/heads/does-not-exist", + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + } + } + + @Test + fun `exception messages should be descriptive`() { + val exception = assertThrows { + findRepo("/invalid/path") + } + + assertThat(exception.message) + .isNotBlank() + .contains("path") + } + + @Test + fun `operations on corrupted repository should fail gracefully`() { + val tempDir = Files.createTempDirectory("corrupted-repo") + Files.createDirectory(tempDir.resolve(".git")) + + assertThrows { + findRepo(tempDir.toString()) + } + } + } + + @Nested + @Order(8) + @DisplayName("Data marshalling and type conversion") + inner class DataMarshalling { + + @Test + fun `ObjectId should be correctly marshalled as SHA string`() { + val commit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(commit.oid).hasSize(40) }, + { assertThat(commit.oid).matches("[0-9a-f]{40}") } + ) + } + + @Test + fun `GixSignature should preserve all fields`() { + val commit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + "Author signature", + { assertThat(commit.author).isNotNull() }, + { assertThat(commit.author?.name).isNotBlank() }, + { assertThat(commit.author?.email).isNotBlank() }, + { assertThat(commit.author?.time).isNotNull() }, + { assertThat(commit.author?.time?.seconds).isGreaterThan(0L) } + ) + } + + @Test + fun `GixBranch should preserve reference category`() { + val branches = findAllBranches(simpleRepo) + + val categories = branches.map { it.category }.toSet() + assertAll( + { assertThat(categories).isNotEmpty() }, + { assertThat(categories).contains(GixReferenceCategory.LOCAL_BRANCH) } + ) + } + + @Test + fun `GixCommit parents should be marshalled as ObjectId list`() { + val commits = traverseHistory( + simpleRepo, findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ).oid, null, + useMailmap = cfg.gix.useMailmap + ) + val commitsWithParents = commits.filter { it.parents.isNotEmpty() } + + assertAll( + { assertThat(commitsWithParents).isNotEmpty() }, + { + commitsWithParents.forEach { commit -> + assertThat(commit.parents).allMatch { it.length == 40 } + } + } + ) + } + + @Test + fun `GixDiff should preserve all file change metadata`() { + val headCommit = findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ) + val diffInput = GixDiffInput(headCommit.oid, headCommit.parents.firstOrNull()) + + val diffs = diffs(simpleRepo, listOf(diffInput), 1u, GixDiffAlgorithm.HISTOGRAM) + val fileDiffs = diffs.first().files + + assertAll( + { assertThat(fileDiffs).isNotEmpty() }, + { + fileDiffs.forEach { file -> + assertThat(file.change).isNotNull() + when (file.change) { + is GixChangeType.Addition -> { + assertThat((file.change as GixChangeType.Addition).location).isNotEmpty() + } + + is GixChangeType.Deletion -> { + assertThat((file.change as GixChangeType.Deletion).location).isNotEmpty() + } + + is GixChangeType.Modification -> { + assertThat((file.change as GixChangeType.Modification).location).isNotEmpty() + } + + is GixChangeType.Rewrite -> { + val rewrite = file.change as GixChangeType.Rewrite + assertThat(rewrite.location).isNotEmpty() + assertThat(rewrite.sourceLocation).isNotEmpty() + } + } + } + } + ) + } + + @Test + fun `Optional fields should be correctly marshalled`() { + val commits = traverseHistory( + simpleRepo, findCommit( + simpleRepo, "HEAD", + useMailmap = cfg.gix.useMailmap + ).oid, null, + useMailmap = cfg.gix.useMailmap + ) + + val initialCommit = commits.find { it.parents.isEmpty() } + assertThat(initialCommit).isNotNull() + + val diffInput = GixDiffInput(initialCommit!!.oid, null) + val diffs = diffs(simpleRepo, listOf(diffInput), 1u, GixDiffAlgorithm.HISTOGRAM) + + assertAll( + { assertThat(diffs.first().parent).isNull() }, + { assertThat(diffs.first().commit).isNotNull() } + ) + } + + @Test + fun `BString should be correctly marshalled to Kotlin String`() { + val result = traverseBranch( + simpleRepo, "refs/heads/master", + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + val fileTree = result.commits.flatMap { it.fileTree } + + assertAll( + { assertThat(fileTree).isNotEmpty() }, + { assertThat(fileTree).allMatch { it.isNotEmpty() } } + ) + } + + @Test + fun `GixRemote should marshal name and url correctly`() { + val remotes = simpleRepo.remotes + + assertAll( + { assertThat(remotes).isNotEmpty() }, + { + remotes.forEach { remote -> + assertThat(remote.name).isNotBlank() + // URL might be null for some remotes + if (remote.url != null) { + assertThat(remote.url).isNotBlank() + } + } + } + ) + } + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/GitIndexerTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/GitIndexerTest.kt new file mode 100644 index 000000000..781b9df11 --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/GitIndexerTest.kt @@ -0,0 +1,722 @@ +package com.inso_world.binocular.ffi.integration + +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.index.GitIndexer +import com.inso_world.binocular.core.integration.base.BaseFixturesIntegrationTest +import com.inso_world.binocular.ffi.BinocularFfiTestApplication +import com.inso_world.binocular.ffi.internal.UniffiException +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.ClassOrderer +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestClassOrder +import org.junit.jupiter.api.TestMethodOrder +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit +import java.util.stream.Stream +import kotlin.io.path.Path + +/** + * Comprehensive integration tests for [com.inso_world.binocular.core.index.GitIndexer]. + * + * Tests the GitIndexer component which wraps the FFI layer for Git operations, verifying: + * - Repository discovery and initialization + * - Branch traversal and retrieval + * - Commit finding and traversal + * - Exception handling and error scenarios + * + * ### Test Organization + * Tests are organized into nested classes by functionality: + * - [RepositoryOperations]: Repository discovery and validation + * - [BranchOperations]: Branch finding and traversal + * - [CommitOperations]: Commit finding and history traversal + * - [ErrorHandling]: Exception handling and error scenarios + * - [Integration]: Full workflow integration scenarios + */ +@TestClassOrder(ClassOrderer.OrderAnnotation::class) +@SpringBootTest( + classes = [BinocularFfiTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.NONE, +) +@ExtendWith(SpringExtension::class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) +internal open class GitIndexerTest : BaseFixturesIntegrationTest() { + + @Autowired + private lateinit var indexer: GitIndexer + + private lateinit var project: Project + + @BeforeEach + fun setUp() { + project = Project(name = "test project") + } + + @Nested + @DisplayName("Repository operations") + inner class RepositoryOperations { + + @ParameterizedTest + @ValueSource(strings = [SIMPLE_REPO, OCTO_REPO, ADVANCED_REPO]) + fun `findRepo should locate existing repositories`(repoName: String) { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/$repoName"), project) + + assertAll( + "Repository $repoName should be found and configured correctly", + { + assertThat(Paths.get(repo.localPath).toString()).isEqualTo( + Paths.get("${FIXTURES_PATH}/$repoName/.git").toString(), + ) + }, + { assertThat(repo.project).isSameAs(project) }, + { assertThat(repo.commits).isEmpty() }, + { assertThat(repo.branches).isEmpty() } + ) + } + + @Test + fun `findRepo with non-git directory should throw exception`() { + val nonGitPath = Files.createTempDirectory(LocalDateTime.now().toString()) + + val e = assertThrows { + indexer.findRepo(nonGitPath, project) + } + assertThat(e.message).contains(nonGitPath.toString()) + } + + @Test + fun `findRepo should create separate Repository instances for different projects`() { + val project1 = Project(name = "project1") + val project2 = Project(name = "project2") + + val repo1 = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project1) + val repo2 = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project2) + + assertAll( + { assertThat(repo1).isNotSameAs(repo2) }, + { assertThat(repo1.project).isSameAs(project1) }, + { assertThat(repo2.project).isSameAs(project2) }, + { assertThat(repo1.localPath).isEqualTo(repo2.localPath) } + ) + } + } + + @Nested + @DisplayName("Branch operations") + @Order(Int.MAX_VALUE) + @TestMethodOrder(MethodOrderer.OrderAnnotation::class) + inner class BranchOperations { + + @ParameterizedTest + @MethodSource("com.inso_world.binocular.ffi.integration.GitIndexerTest#findAllBranchesData") + fun `findAllBranches should return all branches for repository`( + repoName: String, + localBranches: Collection, + remoteBranches: Collection, + expectedBranchCount: Int + ) { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/$repoName"), project) + val branches = indexer.findAllBranches(repo) + val actualBranchNames = branches.map { it.name } + val expectedLocalNames = localBranches.map(::normalizeBranchName) + val expectedRemoteNames = remoteBranches.map(::normalizeBranchName) + + assertAll( + "Repository $repoName should have correct branches", + { assertThat(branches).isNotEmpty() }, + { assertThat(branches).hasSize(expectedBranchCount) }, + { assertThat(actualBranchNames).containsAll(expectedLocalNames) }, + { assertThat(actualBranchNames).containsAll(expectedRemoteNames) }, + ) + } + + @ParameterizedTest + @CsvSource( + "${SIMPLE_REPO},refs/heads/master,14", + "${SIMPLE_REPO},refs/remotes/origin/master,13", + // OCTO + "${OCTO_REPO},refs/heads/master,19", + "${OCTO_REPO},refs/heads/octo1,16", + "${OCTO_REPO},refs/heads/octo2,16", + "${OCTO_REPO},refs/heads/octo3,16", + "${OCTO_REPO},refs/heads/bugfix,17", + "${OCTO_REPO},refs/heads/feature,17", + "${OCTO_REPO},refs/heads/imported,1", + // ADVANCED + "${ADVANCED_REPO},refs/heads/master,35", + "${ADVANCED_REPO},refs/heads/imported,4" + ) + fun `traverseBranch should return branch with correct commit count`( + repoName: String, + branchName: String, + expectedCommitCount: Int + ) { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/$repoName"), project) + val (branch, commits) = indexer.traverseBranch(repo, branchName) + + assertAll( + "Branch $branchName in $repoName should have correct structure", + { assertThat(branch.name).isEqualTo(normalizeBranchName(branchName)) }, + { assertThat(branch.repository).isSameAs(repo) }, + { assertThat(commits).hasSize(expectedCommitCount) }, + { assertThat(commits.map { it.repository }).doesNotContainNull() }, + { assertThat(commits).allSatisfy { assertThat(it.repository).isSameAs(repo) } }, + { assertThat(branch.head).isIn(commits) }, + { assertThat(branch.commits).containsOnly(*commits.toTypedArray()) }, + { assertThat(repo.commits).containsOnly(*commits.toTypedArray()) }, + { assertThat(repo.branches).containsOnly(branch) } + ) + } + + @Test + fun `traverseBranch with non-existent branch should throw exception`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + + assertThrows { + indexer.traverseBranch(repo, "non-existent-branch") + } + } + + @Test + fun `traverseBranch same branch twice should not duplicate in repository`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + + indexer.traverseBranch(repo, "refs/remotes/origin/master") + + assertAll( + "First traversal", + { assertThat(repo.branches).hasSize(1) }, + { assertThat(repo.commits).hasSize(13) }, + ) + + indexer.traverseBranch(repo, "origin/master") + + assertAll( + "Second traversal should not create duplicates", + { assertThat(repo.branches).hasSize(1) }, + { assertThat(repo.commits).hasSize(13) }, + ) + } + + @Test + fun `traverseBranch different branches should add commits correctly`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + + val (originMaster, originCommits) = indexer.traverseBranch(repo, "refs/remotes/origin/master") + + assertAll( + "First branch traversal", + { assertThat(repo.branches).hasSize(1) }, + { assertThat(repo.commits).hasSize(13) }, + { assertThat(originCommits).hasSize(13) }, + { assertThat(originMaster.commits).hasSize(13) }, + { assertThat(repo.commits).containsExactlyInAnyOrder(*originMaster.commits.toTypedArray()) } + ) + + val (master, masterCommits) = indexer.traverseBranch(repo, "refs/heads/master") + + assertAll( + "Second branch with additional commit", + { assertThat(repo.branches).hasSize(2) }, + { assertThat(repo.commits).hasSize(14) }, + { assertThat(masterCommits).hasSize(14) }, + { assertThat(master.commits).hasSize(14) }, + { assertThat(repo.commits).containsExactlyInAnyOrder(*master.commits.toTypedArray()) } + ) + } + + @Test + @Order(Int.MAX_VALUE) + fun `traverseBranch after adding commit should update branch head`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + + run { + val result = indexer.traverseBranch(repo, "refs/heads/master") + + assertAll( + "Initial state", + { assertThat(repo.branches).hasSize(1) }, + { assertThat(repo.commits).hasSize(14) }, + { assertThat(result.first.commits).hasSize(14) }, + { assertThat(result.first.head.sha).isEqualTo("b51199ab8b83e31f64b631e42b2ee0b1c7e3259a") }, + { assertThat(repo.branches.first().head).isSameAs(result.first.head) }, + ) + } + + addCommit() + + run { + val result = indexer.traverseBranch(repo, "refs/heads/master") + + assertAll( + "After adding commit", + { assertThat(repo.branches).hasSize(1) }, + { assertThat(repo.commits).hasSize(15) }, + { assertThat(result.first.commits).hasSize(15) }, + { assertThat(result.first.head.sha).isEqualTo("ab32b11aed9f1760aa7a40391719201e5e76b443") }, + { assertThat(repo.branches.first().head).isSameAs(result.first.head) }, + ) + } + } + } + + @Nested + @DisplayName("Commit operations") + inner class CommitOperations { + + @ParameterizedTest + @CsvSource( + "${SIMPLE_REPO},b51199ab8b83e31f64b631e42b2ee0b1c7e3259a", + "${OCTO_REPO},4dedc3c738eee6b69c43cde7d89f146912532cff", + "${ADVANCED_REPO},379dc91fb055ba385b5e5446428ffbe38804fa99" + ) + fun `findCommit with HEAD should return correct commit`(repoName: String, expectedSha: String) { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/$repoName"), project) + val commit = indexer.findCommit(repo, "HEAD") + + assertAll( + "HEAD commit for $repoName", + { assertThat(commit).isNotNull() }, + { assertThat(commit.sha).isEqualTo(expectedSha) }, + { assertThat(commit.repository).isSameAs(repo) } + ) + } + + @ParameterizedTest + @CsvSource( + "${SIMPLE_REPO},48a384a6a9188f376835005cd10fd97542e69bf7", + "${OCTO_REPO},d16fb2d78e3d867377c078a03aadc5aa34bdb408", + "${ADVANCED_REPO},5c81ebfb36467b8d1f70295adf2f9ae5a93a2c33" + ) + fun `findCommit with specific SHA should return correct commit`(repoName: String, sha: String) { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/$repoName"), project) + val commit = indexer.findCommit(repo, sha) + + assertAll( + { assertThat(commit).isNotNull() }, + { assertThat(commit.sha).isEqualTo(sha) }, + { assertThat(commit.repository).isSameAs(repo) } + ) + } + + @Test + fun `findCommit with invalid SHA should throw exception`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + + assertThrows { + indexer.findCommit(repo, "invalid-sha-that-does-not-exist") + } + } + + @Test + fun `findCommit should return same instance for same SHA in same repository`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + val sha = "48a384a6a9188f376835005cd10fd97542e69bf7" + + val commit1 = indexer.findCommit(repo, sha) + val commit2 = indexer.findCommit(repo, sha) + + assertAll( + { assertThat(commit1.sha).isEqualTo(commit2.sha) }, + { assertThat(commit1.repository).isSameAs(commit2.repository) } + ) + } + + @ParameterizedTest + @CsvSource( + "${SIMPLE_REPO},HEAD,14", + "${SIMPLE_REPO},b51199ab8b83e31f64b631e42b2ee0b1c7e3259a,14", + "${SIMPLE_REPO},3d28b65c324cc8ee0bb7229fb6ac5d7f64129e90,13", + "${SIMPLE_REPO},2403472fd3b2c4487f66961929f1e5895c5013e1,9", + "${SIMPLE_REPO},48a384a6a9188f376835005cd10fd97542e69bf7,1", + // OCTO + "${OCTO_REPO},HEAD,19", + "${OCTO_REPO},4dedc3c738eee6b69c43cde7d89f146912532cff,19", + "${OCTO_REPO},f556329d268afeb5e5298e37fd8bfb5ef2058a9d,15", + "${OCTO_REPO},bf51258d6da9aaca9b75e2580251539026b6246a,16", + "${OCTO_REPO},d5d38cc858bd78498efbe0005052f5cb1fd38cb9,16", + "${OCTO_REPO},42fbbe93509ed894cbbd61e4dbc07a440720c491,16", + "${OCTO_REPO},d16fb2d78e3d867377c078a03aadc5aa34bdb408,17", + "${OCTO_REPO},3e15df55908eefdb720a7bc78065bcadb6b9e9cc,17", + // ADVANCED + "${ADVANCED_REPO},HEAD,35", + "${ADVANCED_REPO},379dc91fb055ba385b5e5446428ffbe38804fa99,35", + "${ADVANCED_REPO},3c47b3a6ba6811bcefd21203809d79b2aa1b4b4b,34", + "${ADVANCED_REPO},82df82770ef416d66c52b383281d21e03376fde0,29", + "${ADVANCED_REPO},09aa9cb6a6322b4ba4506f168b944f0045b11cbe,4", + "${ADVANCED_REPO},ed167f854e871a1566317302c158704f71f8d16c,1", + "${ADVANCED_REPO},5c81ebfb36467b8d1f70295adf2f9ae5a93a2c33,1" + ) + fun `traverse from commit should return correct number of commits`( + repoName: String, + startSha: String, + expectedCount: Int + ) { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/$repoName"), project) + val startCommit = indexer.findCommit(repo, startSha) + val commits = indexer.traverse(repo, startCommit, null) + + assertAll( + "Traversing from $startSha in $repoName", + { assertThat(commits).isNotEmpty() }, + { assertThat(commits).hasSize(expectedCount) }, + { assertThat(commits).allSatisfy { assertThat(it.repository).isSameAs(repo) } }, + { assertThat(commits.first()).isSameAs(startCommit) } + ) + } + + @Test + fun `traverse from initial commit should return single commit`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + val initialCommit = indexer.findCommit(repo, "48a384a6a9188f376835005cd10fd97542e69bf7") + val commits = indexer.traverse(repo, initialCommit, null) + + assertAll( + { assertThat(commits).hasSize(1) }, + { assertThat(commits.first()).isSameAs(initialCommit) }, + { assertThat(commits.first().parents).isEmpty() } + ) + } + + @Test + fun `traverse with source and target should return commits between them`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + val head = indexer.findCommit(repo, "HEAD") + val target = indexer.findCommit(repo, "48a384a6a9188f376835005cd10fd97542e69bf7") + + val commits = indexer.traverse(repo, head, target) + + assertAll( + { assertThat(commits).isNotEmpty() }, + { assertThat(commits.first()).isSameAs(head) }, + { assertThat(commits).doesNotContain(target) } + ) + } + + @Test + fun `traverse should populate commit relationships`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + val head = indexer.findCommit(repo, "HEAD") + val commits = indexer.traverse(repo, head, null) + + val commitsWithParents = commits.filter { it.parents.isNotEmpty() } + assertAll( + { assertThat(commitsWithParents).isNotEmpty() }, + { + assertThat(commitsWithParents).allSatisfy { commit -> + commit.parents.forEach { parent -> + assertThat(parent.repository).isSameAs(repo) + assertThat(commits).contains(parent) + } + } + } + ) + } + } + + @Nested + @DisplayName("Error handling") + inner class ErrorHandling { + + @Test + fun `operations on invalid repository should fail gracefully`() { + val invalidRepo = Repository( + localPath = "/invalid/path", + project = project + ) + + assertAll( + { + assertThrows { + indexer.traverseBranch(invalidRepo, "refs/heads/master") + } + }, + { + assertThrows { + indexer.findCommit(invalidRepo, "HEAD") + } + }, + { + assertThrows { + indexer.findAllBranches(invalidRepo) + } + } + ) + } + } + + @Nested + @DisplayName("Integration scenarios") + inner class Integration { + + @Test + fun `complete workflow - Binocular`() { + val repo = indexer.findRepo(Path("./"), project) + assertThat(repo).isNotNull() + + val (branch, branchCommits) = indexer.traverseBranch(repo, "origin/main") + assertAll( + { assertThat(branchCommits).hasSize(1881) } + ) + } + + @Test + fun `complete workflow - find repo, traverse branch, find commits`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project) + assertThat(repo).isNotNull() + + val (branch, branchCommits) = indexer.traverseBranch(repo, "refs/heads/master") + assertAll( + { assertThat(branch).isNotNull() }, + { assertThat(branchCommits).hasSize(14) }, + { assertThat(repo.branches).containsOnly(branch) }, + { assertThat(repo.commits).hasSize(14) } + ) + + val specificCommit = indexer.findCommit(repo, "48a384a6a9188f376835005cd10fd97542e69bf7") + assertAll( + { assertThat(specificCommit).isNotNull() }, + { assertThat(branchCommits).contains(specificCommit) } + ) + + val commitsFromSpecific = indexer.traverse(repo, specificCommit, null) + assertAll( + { assertThat(commitsFromSpecific).hasSize(1) }, + { assertThat(commitsFromSpecific.first()).isSameAs(specificCommit) } + ) + + val allBranches = indexer.findAllBranches(repo) + assertAll( + { assertThat(allBranches).hasSize(2) }, + { + assertThat(allBranches.map { it.name }).containsExactlyInAnyOrder( + normalizeBranchName("refs/heads/master"), + normalizeBranchName("refs/remotes/origin/master") + ) + } + ) + } + + @Test + fun `multiple repositories should be independent`() { + val project1 = Project(name = "project1") + val project2 = Project(name = "project2") + + val repo1 = indexer.findRepo(Path("${FIXTURES_PATH}/${SIMPLE_REPO}"), project1) + val repo2 = indexer.findRepo(Path("${FIXTURES_PATH}/${OCTO_REPO}"), project2) + + val (branch1, commits1) = indexer.traverseBranch(repo1, "refs/heads/master") + val (branch2, commits2) = indexer.traverseBranch(repo2, "refs/heads/master") + + assertAll( + "Repositories should be independent", + { assertThat(repo1).isNotSameAs(repo2) }, + { assertThat(repo1.project).isNotSameAs(repo2.project) }, + { assertThat(branch1.repository).isSameAs(repo1) }, + { assertThat(branch2.repository).isSameAs(repo2) }, + { assertThat(commits1).hasSize(14) }, + { assertThat(commits2).hasSize(19) }, + { assertThat(commits1).allSatisfy { assertThat(it.repository).isSameAs(repo1) } }, + { assertThat(commits2).allSatisfy { assertThat(it.repository).isSameAs(repo2) } } + ) + } + + @Test + fun `traversing multiple branches in same repository should build complete commit graph`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${OCTO_REPO}"), project) + + val branchNames = listOf("master", "octo1", "octo2", "octo3", "bugfix", "feature").map { "refs/heads/$it" } + val branches = branchNames.map { indexer.traverseBranch(repo, it).first } + + assertAll( + "All branches should be in repository", + { assertThat(repo.branches).hasSizeGreaterThanOrEqualTo(branchNames.size) }, + { assertThat(repo.branches).containsAll(branches) }, + { assertThat(repo.commits).isNotEmpty() }, + { + branches.forEach { branch -> + assertThat(repo.commits).containsAll(branch.commits) + } + } + ) + } + + @Test + fun `commit parent relationships should be correctly established across branches`() { + val repo = indexer.findRepo(Path("${FIXTURES_PATH}/${OCTO_REPO}"), project) + val (masterBranch, _) = indexer.traverseBranch(repo, "refs/heads/master") + + val mergeCommits = masterBranch.commits.filter { it.parents.size > 2 } + + assertAll( + { assertThat(mergeCommits).isNotEmpty() }, + { + mergeCommits.forEach { merge -> + assertAll( + "Merge commit ${merge.sha} parents should be valid", + { assertThat(merge.parents).hasSizeGreaterThan(2) }, + { + merge.parents.forEach { parent -> + assertThat(parent.repository).isSameAs(repo) + assertThat(masterBranch.commits).contains(parent) + } + } + ) + } + } + ) + } + } + + @Nested + inner class CompareToGit { + @ParameterizedTest + @CsvSource( + value = [ + "origin/main", + "origin/develop" + ] + ) + fun `Binocular, traverse branch, check committer`(branchName: String) { + logger.info(branchName) + val repo = indexer.findRepo(Path("./"), project) + assertThat(repo).isNotNull() + + val (_, branchCommits) = indexer.traverseBranch(repo, branchName) + // localPath points to .git directory, so get the parent for git commands + val repoDir = File(repo.localPath).parentFile + + val committerGroups = branchCommits.groupBy { it.committer } + for ((committer, commits) in committerGroups) { + val gitLogProcess = ProcessBuilder( + "git", "log", "--use-mailmap", "--pretty=format:'%cN <%cE>'", + branchName + ) + .directory(repoDir) + .redirectErrorStream(true) + .start() + logger.debug("{}", gitLogProcess.info()) + + val lineCount = gitLogProcess.inputStream.bufferedReader().useLines { lines -> + lines.count { it.contains(committer.email.orEmpty()) } + } + gitLogProcess.waitFor(5, TimeUnit.SECONDS) + + logger.info("Committer: ${committer.email} - Commits: $lineCount") + assertThat(committer.committedCommits.size).isEqualTo(lineCount) + } + } + + @ParameterizedTest + @CsvSource( + value = [ + "origin/main", + "origin/develop" + ] + ) + fun `Binocular, traverse branch, check authors`(branchName: String) { + logger.info(branchName) + val repo = indexer.findRepo(Path("./"), project) + assertThat(repo).isNotNull() + + val (_, branchCommits) = indexer.traverseBranch(repo, branchName) + // localPath points to .git directory, so get the parent for git commands + val repoDir = File(repo.localPath).parentFile + + val authorGroups = branchCommits.filter { it.author != null }.groupBy { requireNotNull(it.author) } + for ((author, commits) in authorGroups) { + val gitLogProcess = ProcessBuilder( + "git", "log", "--use-mailmap", "--pretty=format:'%aN <%aE>'", + branchName + ) + .directory(repoDir) + .redirectErrorStream(true) + .start() + logger.debug("{}", gitLogProcess.info()) + + val lineCount = gitLogProcess.inputStream.bufferedReader().useLines { lines -> + lines.count { it.contains(author.email.orEmpty()) } + } + gitLogProcess.waitFor(5, TimeUnit.SECONDS) + + logger.info("Author: ${author.email} - Commits: $lineCount") + assertAll( + { assertThat(author.authoredCommits).hasSameSizeAs(commits) }, + { assertThat(author.authoredCommits).containsExactlyInAnyOrder(*commits.toTypedArray()) }, + { assertThat(author.authoredCommits.size).isEqualTo(lineCount) } + ) + } + } + } + + companion object { + private val logger by logger() + + private fun normalizeBranchName(refName: String): String = + refName + .removePrefix("refs/heads/") + .removePrefix("refs/remotes/") + .removePrefix("refs/tags/") + + @JvmStatic + fun findAllBranchesData(): Stream = + Stream.of( + Arguments.of( + SIMPLE_REPO, + listOf("refs/heads/master"), + listOf("refs/remotes/origin/master"), + 2 + ), + Arguments.of( + OCTO_REPO, + listOf( + "bugfix", + "feature", + "imported", + "master", + "octo1", + "octo2", + "octo3" + ).map { "refs/heads/$it" }, + emptyList(), + 7 + ), + Arguments.of( + ADVANCED_REPO, + listOf( + "bugfix", + "extra", + "feature", + "imported", + "master", + "octo1", + "octo2", + "octo3" + ).map { "refs/heads/$it" }, + emptyList(), + 8 + ) + ) + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/PerformanceTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/PerformanceTest.kt index 4f5b58385..d6ee862db 100644 --- a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/PerformanceTest.kt +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/PerformanceTest.kt @@ -1,10 +1,9 @@ package com.inso_world.binocular.ffi.integration -import com.inso_world.binocular.ffi.BinocularFfi -import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.ffi.GixIndexer +import com.inso_world.binocular.model.Project import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertTimeout import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource @@ -13,8 +12,8 @@ import kotlin.io.path.Path @Disabled class PerformanceTest { - private val ffi: BinocularFfi = BinocularFfi() - private val repo = ffi.findRepo(Path("../../.git")) + private val ffi: GixIndexer = GixIndexer() + private val repo = ffi.findRepo(Path("../../.git"), project = Project(name = "binocular")) @ParameterizedTest @CsvSource( @@ -22,17 +21,15 @@ class PerformanceTest { "origin/feature/31,592,15", "origin/feature/32,658,15", "origin/main,1881,65", - "origin/develop,2092,65", - "origin/feature/backend-new-gha,2238,65" + "origin/develop,2282,65", + "feature/363,2350,65" ) fun `test branch`(branch: String, expectedCommits: Int, timeout: Long) { - val branch = Branch(name = branch) - this.repo.branches.add(branch) val results = assertTimeout( Duration.ofMillis(timeout) ) { this.ffi.traverseBranch(repo,branch) } - assertThat(results).hasSize(expectedCommits) + assertThat(results.second).hasSize(expectedCommits) } } diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/internal/GitComparison.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/internal/GitComparison.kt new file mode 100644 index 000000000..dc6c7251a --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/integration/internal/GitComparison.kt @@ -0,0 +1,150 @@ +package com.inso_world.binocular.ffi.integration.internal + +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.integration.base.BaseIntegrationTest +import com.inso_world.binocular.ffi.FfiConfig +import com.inso_world.binocular.ffi.BinocularFfiTestApplication +import com.inso_world.binocular.ffi.internal.findRepo +import com.inso_world.binocular.ffi.internal.traverseBranch +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.io.File +import java.util.concurrent.TimeUnit + +@SpringBootTest( + classes = [BinocularFfiTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.NONE, +) +@ExtendWith(SpringExtension::class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) +class GitComparison : BaseIntegrationTest() { + + @Autowired + private lateinit var cfg: FfiConfig + + companion object { + private val logger by logger() + } + + @ParameterizedTest + @CsvSource( + value = [ + "origin/main", + "origin/develop" + ] + ) + fun `Binocular, traverse branch, check commit count`(branchName: String) { + logger.info(branchName) + val repo = findRepo("./") + + val (_, branchCommits) = traverseBranch( + repo, branchName, + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + // localPath points to .git directory, so get the parent for git commands + val repoDir = File(repo.gitDir).parentFile + + val gitLogProcess = ProcessBuilder( + "git", "rev-list", "--count", branchName + ) + .directory(repoDir) + .redirectErrorStream(true) + .start() + logger.debug("{}", gitLogProcess.info()) + + val gitCount = gitLogProcess.inputStream.bufferedReader().readLine().toInt() + logger.debug("gitCount = {}", gitCount) + + assertThat(branchCommits.size).isEqualTo(gitCount) + } + + + @ParameterizedTest + @CsvSource( + value = [ + "origin/main", + "origin/develop" + ] + ) + fun `Binocular, traverse branch, check committer`(branchName: String) { + logger.info(branchName) + val repo = findRepo("./") + assertThat(repo).isNotNull() + + val (_, branchCommits) = traverseBranch( + repo, branchName, + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + // localPath points to .git directory, so get the parent for git commands + val repoDir = File(repo.gitDir).parentFile + + val committerGroups = branchCommits.filter { it.committer != null }.groupBy { it.committer!!.email } + for ((committer, commits) in committerGroups) { + val gitLogProcess = ProcessBuilder( + "git", "log", "--use-mailmap", "--pretty=format:'%cN <%cE>'", + branchName + ) + .directory(repoDir) + .redirectErrorStream(true) + .start() + logger.debug("{}", gitLogProcess.info()) + + val lineCount = gitLogProcess.inputStream.bufferedReader().useLines { lines -> + lines.filter { it.contains(committer) }.count() + } + gitLogProcess.waitFor(5, TimeUnit.SECONDS) + + logger.info("Committer: ${committer} - Commits: $lineCount") + assertThat(commits.size).isEqualTo(lineCount) + } + } + + @ParameterizedTest + @CsvSource( + value = [ + "origin/main", + "origin/develop" + ] + ) + fun `Binocular, traverse branch, check author`(branchName: String) { + logger.info(branchName) + val repo = findRepo("./") + assertThat(repo).isNotNull() + + val (_, branchCommits) = traverseBranch( + repo, branchName, + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + // localPath points to .git directory, so get the parent for git commands + val repoDir = File(repo.gitDir).parentFile + + val authorGroups = branchCommits.filter { it.author != null }.groupBy { it.author!!.email } + for ((author, commits) in authorGroups) { + val gitLogProcess = ProcessBuilder( + "git", "log", "--use-mailmap", "--pretty=format:'%aN <%aE>'", + branchName + ) + .directory(repoDir) + .redirectErrorStream(true) + .start() + logger.debug("{}", gitLogProcess.info()) + + val lineCount = gitLogProcess.inputStream.bufferedReader().useLines { lines -> + lines.filter { it.contains(author) }.count() + } + gitLogProcess.waitFor(5, TimeUnit.SECONDS) + + logger.info("Committer: ${author} - Commits: $lineCount") + assertThat(commits.size).isEqualTo(lineCount) + } + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/BinocularRemoteTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/BinocularRemoteTest.kt new file mode 100644 index 000000000..7d3f50352 --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/BinocularRemoteTest.kt @@ -0,0 +1,849 @@ +package com.inso_world.binocular.ffi.unit.extensions + +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.ffi.extensions.toModel +import com.inso_world.binocular.ffi.internal.GixRemote +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.vcs.Remote +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +class BinocularRemoteTest : BaseUnitTest() { + + private lateinit var project: Project + private lateinit var repository: Repository + + @BeforeEach + fun setUp() { + project = Project(name = "test-project") + repository = Repository( + localPath = "/path/to/repo", + project = project + ) + } + + @Nested + inner class RemoteCreation { + + @Test + fun `toModel creates new remote when no existing remote matches`() { + // Verifies that a new remote is created with all properties set correctly + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result).isNotNull }, + { assertThat(result.name).isEqualTo("origin") }, + { assertThat(result.url).isEqualTo("https://github.com/user/repo.git") }, + { assertThat(result.repository).isSameAs(repository) } + ) + } + + @Test + fun `toModel registers new remote in repository remotes collection`() { + // Ensures newly created remote is automatically added to repository's remote collection + val ffiRemote = GixRemote( + name = "upstream", + url = "https://github.com/upstream/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(repository.remotes).contains(result) }, + { assertThat(repository.remotes).hasSize(1) } + ) + } + + @Test + fun `toModel creates remote with specified name and url`() { + // Validates that FFI remote name and URL are correctly transferred to domain model + val ffiRemote = GixRemote( + name = "fork", + url = "https://github.com/fork/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result.name).isEqualTo("fork") }, + { assertThat(result.url).isEqualTo("https://github.com/fork/repo.git") } + ) + } + } + + @Nested + inner class IdentityPreservation { + + @Test + fun `toModel returns existing remote when name matches exactly`() { + val existingRemote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result).isSameAs(existingRemote) + } + + @Test + fun `toModel does not create duplicate remotes for same name`() { + val ffiRemote1 = GixRemote( + name = "origin", + url = "https://github.com/user/repo1.git" + ) + val ffiRemote2 = GixRemote( + name = "origin", + url = "https://github.com/user/repo2.git" + ) + + val result1 = ffiRemote1.toModel(repository) + val result2 = ffiRemote2.toModel(repository) + + assertAll( + { assertThat(result1).isSameAs(result2) }, + { assertThat(repository.remotes).hasSize(1) } + ) + } + + @Test + fun `toModel correctly identifies remote among multiple remotes`() { + val remote1 = Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + val remote2 = Remote(name = "upstream", url = "https://github.com/upstream/repo.git", repository = repository) + val remote3 = Remote(name = "fork", url = "https://github.com/fork/repo.git", repository = repository) + + val ffiRemote = GixRemote( + name = "upstream", + url = "https://github.com/different/url.git" + ) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result).isSameAs(remote2) }, + { assertThat(repository.remotes).hasSize(3) } // No new remote added + ) + } + + @Test + fun `toModel creates new remote when no match among existing remotes`() { + Remote(name = "origin", url = "https://github.com/user/repo.git", repository = repository) + Remote(name = "upstream", url = "https://github.com/upstream/repo.git", repository = repository) + + val ffiRemote = GixRemote( + name = "fork", + url = "https://github.com/fork/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result.name).isEqualTo("fork") }, + { assertThat(repository.remotes).hasSize(3) } + ) + } + } + + // ========== URL Update Logic ========== + + @Nested + inner class UrlUpdate { + + @Test + fun `toModel updates url when existing remote has different url`() { + val existingRemote = Remote( + name = "origin", + url = "https://github.com/user/old-repo.git", + repository = repository + ) + assertThat(existingRemote.url).isEqualTo("https://github.com/user/old-repo.git") + + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/new-repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result).isSameAs(existingRemote) }, + { assertThat(result.url).isEqualTo("https://github.com/user/new-repo.git") }, + { assertThat(result.url).isNotEqualTo("https://github.com/user/old-repo.git") } + ) + } + + @Test + fun `toModel does not update url when it is already the same`() { + val existingRemote = Remote( + name = "origin", + url = "https://github.com/user/repo.git", + repository = repository + ) + + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result).isSameAs(existingRemote) }, + { assertThat(result.url).isEqualTo("https://github.com/user/repo.git") } + ) + } + + @Test + fun `toModel updates url multiple times on repeated calls with different urls`() { + val existingRemote = Remote( + name = "origin", + url = "https://github.com/user/repo1.git", + repository = repository + ) + + val ffiRemote = GixRemote(name = "origin", url = "") + + // First update + ffiRemote.url = "https://github.com/user/repo2.git" + val result1 = ffiRemote.toModel(repository) + assertThat(result1.url).isEqualTo("https://github.com/user/repo2.git") + + // Second update + ffiRemote.url = "https://github.com/user/repo3.git" + val result2 = ffiRemote.toModel(repository) + assertThat(result2.url).isEqualTo("https://github.com/user/repo3.git") + + assertAll( + { assertThat(result1).isSameAs(existingRemote) }, + { assertThat(result2).isSameAs(existingRemote) } + ) + } + + @Test + fun `toModel updates url from http to https`() { + val existingRemote = Remote( + name = "origin", + url = "http://github.com/user/repo.git", + repository = repository + ) + + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result).isSameAs(existingRemote) }, + { assertThat(result.url).isEqualTo("https://github.com/user/repo.git") } + ) + } + + @Test + fun `toModel updates url from ssh to https`() { + val existingRemote = Remote( + name = "origin", + url = "ssh://git@github.com/user/repo.git", + repository = repository + ) + + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result).isSameAs(existingRemote) }, + { assertThat(result.url).isEqualTo("https://github.com/user/repo.git") } + ) + } + } + + // ========== Standard Git Remote Names ========== + + @Nested + inner class StandardRemoteNames { + + @ParameterizedTest + @ValueSource( + strings = [ + "origin", + "upstream", + "fork", + "backup", + "mirror", + "production", + "staging", + "development" + ] + ) + fun `toModel handles common Git remote names`(remoteName: String) { + val ffiRemote = GixRemote( + name = remoteName, + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.name).isEqualTo(remoteName) + } + + @Test + fun `toModel handles remote name with hyphen`() { + val ffiRemote = GixRemote( + name = "origin-https", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.name).isEqualTo("origin-https") + } + + @Test + fun `toModel handles remote name with underscore`() { + val ffiRemote = GixRemote( + name = "origin_ssh", + url = "ssh://git@github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.name).isEqualTo("origin_ssh") + } + + @Test + fun `toModel handles remote name with dot`() { + val ffiRemote = GixRemote( + name = "origin.backup", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.name).isEqualTo("origin.backup") + } + + @Test + fun `toModel handles remote name with slash`() { + val ffiRemote = GixRemote( + name = "team/fork", + url = "https://github.com/team/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.name).isEqualTo("team/fork") + } + } + + // ========== URL Protocol Support ========== + + @Nested + inner class UrlProtocols { + + @ParameterizedTest + @CsvSource( + "'origin', 'https://github.com/user/repo.git'", + "'upstream', 'http://github.com/user/repo.git'", + "'ssh-remote', 'ssh://git@github.com/user/repo.git'", + "'git-remote', 'git://github.com/user/repo.git'", + "'file-remote', 'file:///path/to/repo.git'", + "'scp-remote', 'git@github.com:user/repo.git'", + "'local-abs', '/path/to/local/repo'", + "'local-rel', '../relative/repo'", + "'local-cur', './current/repo'" + ) + fun `toModel supports various Git URL protocols`(name: String, url: String) { + val ffiRemote = GixRemote(name = name, url = url) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result.name).isEqualTo(name) }, + { assertThat(result.url).isEqualTo(url) } + ) + } + + @Test + fun `toModel handles GitHub HTTPS URL`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repository.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://github.com/user/repository.git") + } + + @Test + fun `toModel handles GitLab HTTPS URL`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://gitlab.com/group/project.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://gitlab.com/group/project.git") + } + + @Test + fun `toModel handles Bitbucket HTTPS URL`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://bitbucket.org/team/repository.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://bitbucket.org/team/repository.git") + } + + @Test + fun `toModel handles SSH URL with git user`() { + val ffiRemote = GixRemote( + name = "origin", + url = "ssh://git@github.com:22/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("ssh://git@github.com:22/user/repo.git") + } + + @Test + fun `toModel handles URL with port number`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://example.com:8080/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://example.com:8080/user/repo.git") + } + + @Test + fun `toModel handles URL with authentication credentials`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://user:token@github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://user:token@github.com/user/repo.git") + } + + @Test + fun `toModel handles URL with query parameters`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git?param=value" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://github.com/user/repo.git?param=value") + } + + @Test + fun `toModel handles URL with fragment`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git#fragment" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://github.com/user/repo.git#fragment") + } + + @Test + fun `toModel handles SCP-like SSH syntax for GitHub`() { + // Tests the git@host:path format commonly used for SSH + val ffiRemote = GixRemote( + name = "origin", + url = "git@github.com:user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("git@github.com:user/repo.git") + } + + @Test + fun `toModel handles SCP-like SSH syntax for GitLab`() { + val ffiRemote = GixRemote( + name = "origin", + url = "git@gitlab.com:group/project.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("git@gitlab.com:group/project.git") + } + + @Test + fun `toModel handles SCP-like SSH syntax with custom user`() { + val ffiRemote = GixRemote( + name = "origin", + url = "deploy@server.example.com:repos/app.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("deploy@server.example.com:repos/app.git") + } + + @Test + fun `toModel handles absolute local path`() { + // Tests absolute filesystem paths + val ffiRemote = GixRemote( + name = "local", + url = "/path/to/local/repository" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("/path/to/local/repository") + } + + @Test + fun `toModel handles relative path with parent directory`() { + // Tests ../ relative paths + val ffiRemote = GixRemote( + name = "sibling", + url = "../sibling-repo" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("../sibling-repo") + } + + @Test + fun `toModel handles relative path with current directory`() { + // Tests ./ relative paths + val ffiRemote = GixRemote( + name = "local", + url = "./local/repo" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("./local/repo") + } + + @Test + fun `toModel handles simple relative path`() { + // Tests simple relative paths without ./ prefix + val ffiRemote = GixRemote( + name = "local", + url = "relative/path/to/repo" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("relative/path/to/repo") + } + } + + // ========== Edge Cases ========== + + @Nested + inner class EdgeCases { + + @Test + fun `toModel handles minimal valid remote name`() { + val ffiRemote = GixRemote( + name = "x", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.name).isEqualTo("x") + } + + @Test + fun `toModel handles very long remote name`() { + val longName = "remote-" + "name-".repeat(50) + val ffiRemote = GixRemote( + name = longName, + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.name).isEqualTo(longName) + } + + @Test + fun `toModel handles very long url`() { + val longPath = "path/".repeat(100) + val longUrl = "https://github.com/user/$longPath/repo.git" + val ffiRemote = GixRemote( + name = "origin", + url = longUrl + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo(longUrl) + } + + @Test + fun `toModel handles url with special characters`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo-name_123.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://github.com/user/repo-name_123.git") + } + + @Test + fun `toModel handles url with subdomain`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://git.example.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://git.example.com/user/repo.git") + } + + @Test + fun `toModel handles url with deep path`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/org/team/project/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.url).isEqualTo("https://github.com/org/team/project/repo.git") + } + + @Test + fun `toModel handles remote name with mixed alphanumeric`() { + val ffiRemote = GixRemote( + name = "origin123", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.name).isEqualTo("origin123") + } + + @Test + fun `toModel handles remote name starting with number`() { + val ffiRemote = GixRemote( + name = "123remote", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result.name).isEqualTo("123remote") + } + } + + // ========== Repository Scoping ========== + + @Test + fun `toModel remotes are scoped to specific repository`() { + val project2 = Project(name = "another-project") + val repository2 = Repository(localPath = "/path/to/repo2", project = project2) + + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git" + ) + + val remoteInRepo1 = ffiRemote.toModel(repository) + val remoteInRepo2 = ffiRemote.toModel(repository2) + + // Different repository instances, so different remotes + assertAll( + { assertThat(remoteInRepo1).isNotSameAs(remoteInRepo2) }, + { assertThat(remoteInRepo1.name).isEqualTo("origin") }, + { assertThat(remoteInRepo2.name).isEqualTo("origin") }, + { assertThat(repository.remotes).containsOnly(remoteInRepo1) }, + { assertThat(repository2.remotes).containsOnly(remoteInRepo2) } + ) + } + + // ========== Combination & Decision Coverage ========== + + @ParameterizedTest + @CsvSource( + // name, url, expectNew + "'origin', 'https://github.com/user/repo.git', true", + "'upstream', 'https://github.com/upstream/repo.git', true", + "'fork', 'ssh://git@github.com/fork/repo.git', true", + "'origin', 'https://github.com/user/different.git', false" + ) + fun `toModel handles various remote scenarios`( + name: String, + url: String, + expectNew: Boolean + ) { + if (!expectNew) { + // Create existing remote + Remote(name = name, url = "https://github.com/user/original.git", repository = repository) + } + + val ffiRemote = GixRemote(name = name, url = url) + + val result = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result.name).isEqualTo(name) }, + { assertThat(result.url).isEqualTo(url) }, + { assertThat(result.repository).isSameAs(repository) } + ) + } + + @Test + fun `toModel decision path - new remote creation`() { + val ffiRemote = GixRemote( + name = "new-remote", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + // Path: no existing remote → create new → register + assertAll( + { assertThat(repository.remotes).hasSize(1) }, + { assertThat(result.name).isEqualTo("new-remote") }, + { assertThat(result.url).isEqualTo("https://github.com/user/repo.git") } + ) + } + + @Test + fun `toModel decision path - existing remote same url`() { + val existingRemote = Remote( + name = "existing", + url = "https://github.com/user/repo.git", + repository = repository + ) + + val ffiRemote = GixRemote( + name = "existing", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + // Path: existing remote found → url same → no update + assertAll( + { assertThat(result).isSameAs(existingRemote) }, + { assertThat(result.url).isEqualTo("https://github.com/user/repo.git") }, + { assertThat(repository.remotes).hasSize(1) } + ) + } + + @Test + fun `toModel decision path - existing remote different url`() { + val existingRemote = Remote( + name = "existing", + url = "https://github.com/user/old.git", + repository = repository + ) + + val ffiRemote = GixRemote( + name = "existing", + url = "https://github.com/user/new.git" + ) + + val result = ffiRemote.toModel(repository) + + // Path: existing remote found → url different → update url + assertAll( + { assertThat(result).isSameAs(existingRemote) }, + { assertThat(result.url).isEqualTo("https://github.com/user/new.git") }, + { assertThat(result.url).isNotEqualTo("https://github.com/user/old.git") }, + { assertThat(repository.remotes).hasSize(1) } + ) + } + + @Test + fun `toModel always returns non-null Remote`() { + // Explicit null check to ensure contract is satisfied + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git" + ) + + val result = ffiRemote.toModel(repository) + + assertThat(result).isNotNull + } + + @Test + fun `toModel multiple remotes do not interfere with each other`() { + val ffiOrigin = GixRemote(name = "origin", url = "https://github.com/user/repo.git") + val ffiUpstream = GixRemote(name = "upstream", url = "https://github.com/upstream/repo.git") + val ffiFork = GixRemote(name = "fork", url = "https://github.com/fork/repo.git") + + val origin = ffiOrigin.toModel(repository) + val upstream = ffiUpstream.toModel(repository) + val fork = ffiFork.toModel(repository) + + assertAll( + { assertThat(repository.remotes).hasSize(3) }, + { assertThat(repository.remotes).containsExactlyInAnyOrder(origin, upstream, fork) }, + { assertThat(origin.name).isEqualTo("origin") }, + { assertThat(upstream.name).isEqualTo("upstream") }, + { assertThat(fork.name).isEqualTo("fork") } + ) + } + + @Test + fun `toModel idempotency - calling multiple times with same data returns same instance`() { + val ffiRemote = GixRemote( + name = "origin", + url = "https://github.com/user/repo.git" + ) + + val result1 = ffiRemote.toModel(repository) + val result2 = ffiRemote.toModel(repository) + val result3 = ffiRemote.toModel(repository) + + assertAll( + { assertThat(result1).isSameAs(result2) }, + { assertThat(result2).isSameAs(result3) }, + { assertThat(repository.remotes).hasSize(1) } + ) + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/BinocularTimeTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/BinocularTimeTest.kt new file mode 100644 index 000000000..c7740c32f --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/BinocularTimeTest.kt @@ -0,0 +1,336 @@ +package com.inso_world.binocular.ffi.unit.extensions + +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.ffi.extensions.toLocalDateTime +import com.inso_world.binocular.ffi.internal.GixTime +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import java.time.LocalDateTime +import java.time.ZoneOffset + +/** + * Unit tests for [toLocalDateTime] extension function. + * + * Provides C4 (Modified Condition/Decision Coverage) testing: + * - All statement paths + * - All branches (offset clamping conditions) + * - All decision combinations + * - Edge cases and boundary values + */ +class GixTimeTest : BaseUnitTest() { + + // ========== Normal Cases: Offsets within ±18 hours ========== + + @Test + fun `toLocalDateTime converts UTC time (offset 0) correctly`() { + // Unix epoch: 1970-01-01T00:00:00Z + val binocularTime = GixTime(seconds = 0L, offset = 0) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isEqualTo(LocalDateTime.of(1970, 1, 1, 0, 0, 0)) + } + + @Test + fun `toLocalDateTime converts positive offset correctly`() { + // 2024-01-01T00:00:00Z with +2 hours offset → 2024-01-01T02:00:00 + val binocularTime = GixTime( + seconds = 1704067200L, // 2024-01-01T00:00:00Z + offset = 2 * 3600 // +02:00 + ) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isEqualTo(LocalDateTime.of(2024, 1, 1, 2, 0, 0)) + } + + @Test + fun `toLocalDateTime converts negative offset correctly`() { + // 2024-01-01T00:00:00Z with -5 hours offset → 2023-12-31T19:00:00 + val binocularTime = GixTime( + seconds = 1704067200L, // 2024-01-01T00:00:00Z + offset = -5 * 3600 // -05:00 + ) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isEqualTo(LocalDateTime.of(2023, 12, 31, 19, 0, 0)) + } + + @ParameterizedTest + @CsvSource( + "0, 0, 1970-01-01T00:00:00", // Unix epoch, UTC + "1704067200, 3600, 2024-01-01T01:00:00", // +1 hour + "1704067200, -3600, 2023-12-31T23:00:00", // -1 hour + "1609459200, 0, 2021-01-01T00:00:00", // 2021 start, UTC + "1609459200, 10800, 2021-01-01T03:00:00", // +3 hours + "1609459200, -7200, 2020-12-31T22:00:00" // -2 hours + ) + fun `toLocalDateTime converts various valid times and offsets`( + seconds: Long, + offset: Int, + expected: LocalDateTime + ) { + val binocularTime = GixTime(seconds = seconds, offset = offset) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isEqualTo(expected) + } + + // ========== Boundary Cases: Exactly at ±18 hours ========== + + @Test + fun `toLocalDateTime handles max positive offset (+18 hours) without clamping`() { + val maxOffset = 18 * 3600 // +18:00 = 64800 seconds + val binocularTime = GixTime( + seconds = 1704067200L, // 2024-01-01T00:00:00Z + offset = maxOffset + ) + + val result = binocularTime.toLocalDateTime() + + // Should apply full +18 hours + assertThat(result).isEqualTo(LocalDateTime.of(2024, 1, 1, 18, 0, 0)) + } + + @Test + fun `toLocalDateTime handles max negative offset (-18 hours) without clamping`() { + val minOffset = -18 * 3600 // -18:00 = -64800 seconds + val binocularTime = GixTime( + seconds = 1704067200L, // 2024-01-01T00:00:00Z + offset = minOffset + ) + + val result = binocularTime.toLocalDateTime() + + // Should apply full -18 hours + assertThat(result).isEqualTo(LocalDateTime.of(2023, 12, 31, 6, 0, 0)) + } + + // ========== Edge Cases: Offsets beyond ±18 hours (Clamping Behavior) ========== + + @Test + fun `toLocalDateTime clamps offset exceeding +18 hours to +18 hours`() { + val excessiveOffset = 20 * 3600 // +20:00 (exceeds limit) + val binocularTime = GixTime( + seconds = 1704067200L, // 2024-01-01T00:00:00Z + offset = excessiveOffset + ) + + val result = binocularTime.toLocalDateTime() + + // Should be clamped to +18:00 + assertThat(result).isEqualTo(LocalDateTime.of(2024, 1, 1, 18, 0, 0)) + } + + @Test + fun `toLocalDateTime clamps offset exceeding -18 hours to -18 hours`() { + val excessiveOffset = -25 * 3600 // -25:00 (exceeds limit) + val binocularTime = GixTime( + seconds = 1704067200L, // 2024-01-01T00:00:00Z + offset = excessiveOffset + ) + + val result = binocularTime.toLocalDateTime() + + // Should be clamped to -18:00 + assertThat(result).isEqualTo(LocalDateTime.of(2023, 12, 31, 6, 0, 0)) + } + + @ParameterizedTest + @CsvSource( + "100, 1970-01-01T18:00:00", // +100 hours → clamped to +18 + "50, 1970-01-01T18:00:00", // +50 hours → clamped to +18 + "-100, 1969-12-31T06:00:00", // -100 hours → clamped to -18 + "-30, 1969-12-31T06:00:00" // -30 hours → clamped to -18 + ) + fun `toLocalDateTime clamps extreme offsets correctly`(offsetHours: Int, expected: LocalDateTime) { + val binocularTime = GixTime( + seconds = 0L, // Unix epoch + offset = offsetHours * 3600 + ) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isEqualTo(expected) + } + + // ========== Boundary Edge: One second beyond valid range ========== + + @Test + fun `toLocalDateTime clamps offset at +18 hours plus 1 second`() { + val binocularTime = GixTime( + seconds = 1704067200L, + offset = (18 * 3600) + 1 // 64801 seconds + ) + + val result = binocularTime.toLocalDateTime() + + // Should be clamped to exactly +18:00 + assertThat(result).isEqualTo(LocalDateTime.of(2024, 1, 1, 18, 0, 0)) + } + + @Test + fun `toLocalDateTime clamps offset at -18 hours minus 1 second`() { + val binocularTime = GixTime( + seconds = 1704067200L, + offset = (-18 * 3600) - 1 // -64801 seconds + ) + + val result = binocularTime.toLocalDateTime() + + // Should be clamped to exactly -18:00 + assertThat(result).isEqualTo(LocalDateTime.of(2023, 12, 31, 6, 0, 0)) + } + + // ========== Negative Epoch Seconds ========== + + @Test + fun `toLocalDateTime handles negative epoch seconds (before 1970)`() { + // 1969-12-31T23:00:00Z (one hour before epoch) + val binocularTime = GixTime( + seconds = -3600L, + offset = 0 + ) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isEqualTo(LocalDateTime.of(1969, 12, 31, 23, 0, 0)) + } + + @Test + fun `toLocalDateTime handles negative epoch seconds with positive offset`() { + val binocularTime = GixTime( + seconds = -3600L, // 1969-12-31T23:00:00Z + offset = 3600 // +01:00 + ) + + val result = binocularTime.toLocalDateTime() + + // -1h UTC + 1h offset = 1970-01-01T00:00:00 + assertThat(result).isEqualTo(LocalDateTime.of(1970, 1, 1, 0, 0, 0)) + } + + // ========== Large Epoch Seconds ========== + + @Test + fun `toLocalDateTime handles far future timestamp`() { + // 2100-01-01T00:00:00Z + val binocularTime = GixTime( + seconds = 4102444800L, + offset = 0 + ) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isEqualTo(LocalDateTime.of(2100, 1, 1, 0, 0, 0)) + } + + // ========== Offset at Zero with Different Epochs ========== + + @Test + fun `toLocalDateTime with zero offset produces UTC time for any epoch`() { + val binocularTime = GixTime( + seconds = 1609459200L, // 2021-01-01T00:00:00Z + offset = 0 + ) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isEqualTo(LocalDateTime.of(2021, 1, 1, 0, 0, 0)) + } + + // ========== Null Safety Verification ========== + + @Test + fun `toLocalDateTime always returns non-null LocalDateTime`() { + // Verify the method satisfies its non-null return type contract. + // Explicit null check to kill Intrinsics::checkNotNullExpressionValue mutant on line 31. + val binocularTime = GixTime( + seconds = 1704067200L, // 2024-01-01T00:00:00Z + offset = 0 + ) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isNotNull() + } + + // ========== Decision Coverage: Verify No Exception on Clamping ========== + + @Test + fun `toLocalDateTime does not throw exception for extreme positive offset`() { + val binocularTime = GixTime( + seconds = 1704067200L, + offset = Int.MAX_VALUE // Extreme value + ) + + assertDoesNotThrow { + binocularTime.toLocalDateTime() + } + } + + @Test + fun `toLocalDateTime does not throw exception for extreme negative offset`() { + val binocularTime = GixTime( + seconds = 1704067200L, + offset = Int.MIN_VALUE // Extreme value + ) + + assertDoesNotThrow { + binocularTime.toLocalDateTime() + } + } + + // ========== Verify Offset Independence from Default Timezone ========== + + @Test + fun `toLocalDateTime result is independent of JVM default timezone`() { + // The function should use only the provided offset, not system timezone + val binocularTime = GixTime( + seconds = 1704067200L, // 2024-01-01T00:00:00Z + offset = 3600 // +01:00 + ) + + val result = binocularTime.toLocalDateTime() + + // Should always produce this result regardless of system timezone + assertThat(result).isEqualTo(LocalDateTime.of(2024, 1, 1, 1, 0, 0)) + + // Verify it's not using system default offset + val systemOffset = ZoneOffset.systemDefault().rules.getOffset(result) + val expectedOffset = ZoneOffset.ofHours(1) + + // Result should correspond to +01:00, not necessarily system offset + assertThat(expectedOffset.totalSeconds).isEqualTo(3600) + } + + // ========== Combination Tests: Decision Path Coverage ========== + + @ParameterizedTest + @CsvSource( + // seconds, offset, expectedDate + "0, 0, 1970-01-01T00:00:00", // Path: no clamping + "1704067200, 64800, 2024-01-01T18:00:00", // Path: max valid positive + "1704067200, -64800, 2023-12-31T06:00:00", // Path: max valid negative + "1704067200, 100000, 2024-01-01T18:00:00", // Path: clamp positive + "1704067200, -100000, 2023-12-31T06:00:00", // Path: clamp negative + "-3600, 3600, 1970-01-01T00:00:00", // Path: negative epoch + positive offset + "4102444800, -3600, 2099-12-31T23:00:00" // Path: far future + negative offset + ) + fun `toLocalDateTime covers all decision paths`( + seconds: Long, + offset: Int, + expected: LocalDateTime + ) { + val binocularTime = GixTime(seconds = seconds, offset = offset) + + val result = binocularTime.toLocalDateTime() + + assertThat(result).isEqualTo(expected) + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/BranchTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/BranchTest.kt new file mode 100644 index 000000000..8148f0ee3 --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/BranchTest.kt @@ -0,0 +1,540 @@ +package com.inso_world.binocular.ffi.unit.extensions + +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.ffi.extensions.toDomain +import com.inso_world.binocular.ffi.internal.GixBranch +import com.inso_world.binocular.ffi.internal.GixReferenceCategory +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.vcs.ReferenceCategory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import java.time.LocalDateTime + +/** + * Unit tests for [toDomain] extension function. + * + * Provides comprehensive C4 coverage testing: + * - Name normalization (removes `refs/heads/`, `refs/remotes/` prefixes) + * - Identity preservation (finding existing branches by business key) + * - Head update logic (updates head when different) + * - Branch registration in repository + * - Repository consistency validation + * - All decision paths and edge cases + */ +class BranchTest : BaseUnitTest() { + + private lateinit var project: Project + private lateinit var repository: Repository + private lateinit var headCommit: Commit + private lateinit var anotherCommit: Commit + + @BeforeEach + fun setUp() { + project = Project(name = "test-project") + repository = Repository( + localPath = "/path/to/repo", + project = project + ) + val developer = Developer(name = "Test Committer", email = "committer@test.com", repository = repository) + val signatureA = Signature(developer = developer, timestamp = LocalDateTime.of(2024, 1, 1, 0, 0)) + val signatureB = Signature(developer = developer, timestamp = LocalDateTime.of(2024, 1, 2, 0, 0)) + headCommit = Commit( + sha = "a".repeat(40), + authorSignature = signatureA, + repository = repository, + ) + anotherCommit = Commit( + sha = "b".repeat(40), + authorSignature = signatureB, + repository = repository, + ) + } + + // ========== Creating New Branches ========== + + @Test + fun `toDomain creates new branch when no existing branch matches`() { + val ffiBranch = gixBranch(name = "main") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result).isNotNull + assertThat(result.name).isEqualTo("main") + assertThat(result.fullName).isEqualTo("main") + assertThat(result.category).isEqualTo(ReferenceCategory.LOCAL_BRANCH) + assertThat(result.head).isSameAs(headCommit) + assertThat(result.repository).isSameAs(repository) + } + + @Test + fun `toDomain registers new branch in repository branches collection`() { + val ffiBranch = gixBranch(name = "feature-branch") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(repository.branches).contains(result) + assertThat(repository.branches).hasSize(1) + } + + @Test + fun `toDomain creates branch with specified head commit`() { + val ffiBranch = gixBranch(name = "develop") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.head).isSameAs(headCommit) + assertThat(result.head.sha).isEqualTo(headCommit.sha) + } + + @Test + fun `toDomain copies full name and category metadata`() { + val ffiBranch = gixBranch( + name = "feature", + fullName = "refs/heads/feature", + category = GixReferenceCategory.LOCAL_BRANCH + ) + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.fullName).isEqualTo("refs/heads/feature") + assertThat(result.category).isEqualTo(ReferenceCategory.LOCAL_BRANCH) + } + + // ========== Identity Preservation (Returning Existing Branches) ========== + + @Nested + inner class IdentityPreservation { + + @Test + fun `toDomain returns existing branch when name matches exactly`() { + val existingBranch = branch(name = "main") + + val ffiBranch = gixBranch(name = "main") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result).isSameAs(existingBranch) + } + + @Test + fun `toDomain returns existing branch when normalized name matches`() { + val existingBranch = branch(name = "refs/heads/feature") + + // FFI provides full ref, but normalized name matches + val ffiBranch = gixBranch(name = "refs/heads/feature") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result).isSameAs(existingBranch) + } + + @Test + fun `toDomain does not create duplicate branches for same normalized name`() { + val ffiBranch1 = gixBranch(name = "refs/heads/main") + val ffiBranch2 = gixBranch(name = "refs/heads/main") + + val result1 = ffiBranch1.toDomain(repository, headCommit) + val result2 = ffiBranch2.toDomain(repository, headCommit) + + assertThat(result1).isSameAs(result2) + assertThat(repository.branches).hasSize(1) + } + + @Test + fun `toDomain correctly identifies branch among multiple branches`() { + val branch1 = branch(name = "main") + val branch2 = branch(name = "develop") + val branch3 = branch(name = "feature") + + val ffiBranch = gixBranch(name = "develop") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result).isSameAs(branch2) + assertThat(repository.branches).hasSize(3) // No new branch added + } + + @Test + fun `toDomain creates new branch when no match among existing branches`() { + branch(name = "main") + branch(name = "develop") + + val ffiBranch = gixBranch(name = "feature") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.name).isEqualTo("feature") + assertThat(repository.branches).hasSize(3) + } + } + + // ========== Head Update Logic ========== + + @Nested + inner class HeadUpdate { + + @Test + fun `toDomain updates head when existing branch has different head`() { + val existingBranch = branch(name = "main") + assertThat(existingBranch.head).isSameAs(headCommit) + + val ffiBranch = gixBranch(name = "main") + + val result = ffiBranch.toDomain(repository, anotherCommit) + + assertThat(result).isSameAs(existingBranch) + assertThat(result.head).isSameAs(anotherCommit) + assertThat(result.head).isNotSameAs(headCommit) + } + + @Test + fun `toDomain does not update head when it is already the same`() { + val existingBranch = branch(name = "main") + + val ffiBranch = gixBranch(name = "main") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result).isSameAs(existingBranch) + assertThat(result.head).isSameAs(headCommit) + } + + @Test + fun `toDomain updates head multiple times on repeated calls with different commits`() { + val committer = Developer(name = "Test Committer", email = "committer@test.com", repository = repository) + val thirdCommit = Commit( + sha = "c".repeat(40), + authorSignature = Signature(developer = committer, timestamp = LocalDateTime.of(2024, 1, 3, 0, 0)), + repository = repository, + ) + + val existingBranch = branch(name = "main") + + val ffiBranch = gixBranch(name = "main") + + // First update + val result1 = ffiBranch.toDomain(repository, anotherCommit) + assertThat(result1.head).isSameAs(anotherCommit) + + // Second update + val result2 = ffiBranch.toDomain(repository, thirdCommit) + assertThat(result2.head).isSameAs(thirdCommit) + + assertThat(result1).isSameAs(existingBranch) + assertThat(result2).isSameAs(existingBranch) + } + } + + // ========== Repository Consistency Validation ========== + + @Nested + inner class RepositoryConsistency { + + @Test + fun `toDomain throws exception when head commit belongs to different repository`() { + val project2 = Project(name = "another-project") + val repository2 = Repository(localPath = "/path/to/repo2", project = project2) + val testCommitter2 = Developer(name = "Test Committer", email = "commit2@test.com", repository = repository2) + val commitFromDifferentRepo = Commit( + sha = "d".repeat(40), + authorSignature = Signature(developer = testCommitter2, timestamp = LocalDateTime.of(2024, 1, 1, 0, 0)), + repository = repository2, + ) + + val ffiBranch = gixBranch(name = "main") + + val exception = assertThrows { + ffiBranch.toDomain(repository, commitFromDifferentRepo) + } + + assertThat(exception.message).contains("Head is from different repository") + } + + @Test + fun `toDomain throws exception when updating head with commit from different repository`() { + val existingBranch = branch(name = "main") + + val project2 = Project(name = "another-project") + val repository2 = Repository(localPath = "/path/to/repo2", project = project2) + val testCommitter2 = Developer(name = "Test Committer", email = "commit2@test.com", repository = repository2) + val commitFromDifferentRepo = Commit( + sha = "d".repeat(40), + authorSignature = Signature(developer = testCommitter2, timestamp = LocalDateTime.of(2024, 1, 1, 0, 0)), + repository = repository2, + ) + + val ffiBranch = gixBranch(name = "main") + + val exception = assertThrows { + ffiBranch.toDomain(repository, commitFromDifferentRepo) + } + + assertThat(exception.message).contains("Head is from different repository") + // Original branch head should remain unchanged + assertThat(existingBranch.head).isSameAs(headCommit) + } + + @Test + fun `toDomain succeeds when head commit is from same repository`() { + val ffiBranch = gixBranch(name = "main") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.head.repository).isSameAs(repository) + assertThat(result.repository).isSameAs(repository) + } + } + + // ========== Edge Cases ========== + + @Nested + inner class EdgeCases { + + @Test + fun `toDomain handles minimal valid branch name`() { + val ffiBranch = gixBranch(name = "x") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.name).isEqualTo("x") + } + + @Test + fun `toDomain handles very long branch name`() { + val longName = "feature/" + "very-long-name-".repeat(50) + val ffiBranch = gixBranch(name = longName) + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.name).isEqualTo(longName) + } + + @Test + fun `toDomain handles branch name with special characters`() { + val ffiBranch = gixBranch(name = "feature/issue-#123") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.name).isEqualTo("feature/issue-#123") + } + + @Test + fun `toDomain handles branch name with Unicode characters`() { + val ffiBranch = gixBranch(name = "功能/新特性") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.name).isEqualTo("功能/新特性") + } + + @Test + fun `toDomain handles branch name with dots`() { + val ffiBranch = gixBranch(name = "release/1.0.0") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.name).isEqualTo("release/1.0.0") + } + + @Test + fun `toDomain handles branch name with underscores and hyphens`() { + val ffiBranch = gixBranch(name = "feature_branch-new-ui") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.name).isEqualTo("feature_branch-new-ui") + } + + @ParameterizedTest + @ValueSource( + strings = [ + "main", + "develop", + "feature/new-ui", + "hotfix/urgent-fix", + "release/v1.0.0", + "bugfix/fix-bug-123", + "experiment/try-something" + ] + ) + fun `toDomain handles common Git branch naming patterns`(branchName: String) { + val ffiBranch = gixBranch(name = branchName) + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.name).isEqualTo(branchName) + } + } + + // ========== Repository Scoping ========== + + @Test + fun `toDomain branches are scoped to specific repository`() { + val project2 = Project(name = "another-project") + val repository2 = Repository(localPath = "/path/to/repo2", project = project2) + val testCommitter2 = Developer(name = "Test Committer", email = "commit2@test.com", repository = repository2) + val commit2 = Commit( + sha = "e".repeat(40), + authorSignature = Signature(developer = testCommitter2, timestamp = LocalDateTime.of(2024, 1, 1, 0, 0)), + repository = repository2, + ) + + val ffiBranch = gixBranch(name = "main") + + val branchInRepo1 = ffiBranch.toDomain(repository, headCommit) + val branchInRepo2 = ffiBranch.toDomain(repository2, commit2) + + // Different repository instances, so different branches + assertThat(branchInRepo1).isNotSameAs(branchInRepo2) + assertThat(branchInRepo1.name).isEqualTo("main") + assertThat(branchInRepo2.name).isEqualTo("main") + assertThat(repository.branches).containsOnly(branchInRepo1) + assertThat(repository2.branches).containsOnly(branchInRepo2) + } + + // ========== Combination & Decision Coverage ========== + + @ParameterizedTest + @CsvSource( + // ffiName, expectedName, createExisting + "'main','refs/heads/main', 'main', false", + "'develop','refs/heads/develop', 'develop', false", + "'origin/feature','refs/remotes/origin/feature', 'origin/feature', false", + "'main','refs/heads/main', 'main', true", + "'main','refs/heads/main', 'main', true" + ) + fun `toDomain handles various branch scenarios`( + name: String, + fullName: String, + expectedName: String, + createExisting: Boolean + ) { + if (createExisting) { + branch(name = expectedName) + } + + val ffiBranch = + gixBranch(name = name, fullName = fullName, category = GixReferenceCategory.LOCAL_BRANCH) + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result.name).isEqualTo(expectedName) + assertThat(result.repository).isSameAs(repository) + assertThat(result.head).isSameAs(headCommit) + } + + @Test + fun `toDomain decision path - new branch creation`() { + val ffiBranch = gixBranch(name = "new-branch", fullName = "refs/heads/new-branch", category = GixReferenceCategory.LOCAL_BRANCH) + + val result = ffiBranch.toDomain(repository, headCommit) + + // Path: no existing branch → create new → register + assertThat(repository.branches).hasSize(1) + assertThat(result.name).isEqualTo("new-branch") + assertThat(result.head).isSameAs(headCommit) + } + + @Test + fun `toDomain decision path - existing branch same head`() { + val existingBranch = branch(name = "existing") + + val ffiBranch = gixBranch(name = "existing", fullName = "refs/heads/existing", category = GixReferenceCategory.LOCAL_BRANCH) + + val result = ffiBranch.toDomain(repository, headCommit) + + // Path: existing branch found → head same → no update + assertThat(result).isSameAs(existingBranch) + assertThat(result.head).isSameAs(headCommit) + assertThat(repository.branches).hasSize(1) + } + + @Test + fun `toDomain decision path - existing branch different head`() { + val existingBranch = branch(name = "existing") + + val ffiBranch = gixBranch(name = "existing") + + val result = ffiBranch.toDomain(repository, anotherCommit) + + // Path: existing branch found → head different → update head + assertThat(result).isSameAs(existingBranch) + assertThat(result.head).isSameAs(anotherCommit) + assertThat(result.head).isNotSameAs(headCommit) + assertThat(repository.branches).hasSize(1) + } + + @Test + fun `toDomain decision path - normalized name creates identity`() { + val ffiBranch1 = gixBranch(name = "refs/heads/feature") + val ffiBranch2 = gixBranch(name = "refs/heads/feature") + val ffiBranch3 = gixBranch(name = "refs/heads/feature") + + // All should resolve to same branch via normalized name "feature" + val result1 = ffiBranch1.toDomain(repository, headCommit) + val result2 = ffiBranch2.toDomain(repository, headCommit) + + assertThat(result1).isSameAs(result2) + assertThat(result1.name).isEqualTo("refs/heads/feature") + assertThat(repository.branches).hasSize(1) + + // But refs/remotes/feature normalizes to just "feature" too (only first prefix removed) + // Actually, let me reconsider: removePrefix only removes if it starts with that exact string + // So "refs/remotes/feature" first tries removePrefix("refs/heads/") -> no change + // Then tries removePrefix("refs/remotes/") -> "feature" + val result3 = ffiBranch3.toDomain(repository, headCommit) + assertThat(result3).isSameAs(result1) + assertThat(repository.branches).hasSize(1) + } + + @Test + fun `toDomain always returns non-null Branch`() { + // Explicit null check to ensure contract is satisfied + val ffiBranch = gixBranch(name = "refs/heads/main") + + val result = ffiBranch.toDomain(repository, headCommit) + + assertThat(result).isNotNull + } + + private fun branch( + name: String, + fullName: String = name, + category: ReferenceCategory = ReferenceCategory.LOCAL_BRANCH, + repository: Repository = this.repository, + head: Commit = this.headCommit + ): Branch = Branch( + name = name, + fullName = fullName, + category = category, + repository = repository, + head = head + ) + + companion object { + private const val DEFAULT_TARGET = "0000000000000000000000000000000000000000" + + private fun gixBranch( + name: String, + fullName: String = name, + target: String = DEFAULT_TARGET, + category: GixReferenceCategory = GixReferenceCategory.LOCAL_BRANCH + ) = GixBranch( + fullName = fullName, + name = name, + target = target, + category = category + ) + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/GixCommitTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/GixCommitTest.kt new file mode 100644 index 000000000..5d3daf37f --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/GixCommitTest.kt @@ -0,0 +1,131 @@ +package com.inso_world.binocular.ffi.unit.extensions + +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.ffi.extensions.toDomain +import com.inso_world.binocular.ffi.internal.GixCommit +import com.inso_world.binocular.ffi.internal.GixSignature +import com.inso_world.binocular.ffi.internal.GixTime +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Instant +import java.time.ZoneOffset + +class GixCommitTest : BaseUnitTest() { + + private lateinit var repository: Repository + + private val validSha = "a".repeat(40) + private val parentSha = "b".repeat(40) + private val authorTime = GixTime(seconds = 1704067200L, offset = 0) // 2024-01-01 + private val committerTime = GixTime(seconds = 1704153600L, offset = 0) // 2024-01-02 + + @BeforeEach + fun setUp() { + repository = Repository( + localPath = "/path/to/repo", + project = Project(name = "test-project") + ) + } + + private fun gixCommit( + sha: String = validSha, + message: String = "msg", + author: GixSignature = GixSignature("Author", "author@test.com", authorTime), + committer: GixSignature = GixSignature("Committer", "committer@test.com", committerTime), + parents: List = emptyList(), + ): GixCommit = + GixCommit( + oid = sha, + message = message, + committer = committer, + author = author, + branch = null, + parents = parents, + fileTree = emptyList() + ) + + private fun GixTime.toLocalDateTime() = + Instant.ofEpochSecond(this.seconds) + .atOffset(ZoneOffset.ofTotalSeconds(this.offset.coerceIn(-18 * 3600, 18 * 3600))) + .toLocalDateTime() + + @Nested + inner class SingleItemMapper { + + @Test + fun `creates commit with author and committer signatures`() { + val result = gixCommit().toDomain(repository) + + assertThat(result.sha).isEqualTo(validSha) + assertThat(result.author.name).isEqualTo("Author") + assertThat(result.committer.name).isEqualTo("Committer") + assertThat(result.authorDateTime).isEqualTo(authorTime.toLocalDateTime()) + assertThat(result.commitDateTime).isEqualTo(committerTime.toLocalDateTime()) + assertThat(repository.commits).contains(result) + assertThat(repository.developers).hasSize(2) + } + + @Test + fun `defaults committer to author when signatures match`() { + val sig = GixSignature("Same Person", "same@test.com", authorTime) + + val result = gixCommit(author = sig, committer = sig).toDomain(repository) + + assertThat(result.author).isSameAs(result.committer) + assertThat(result.commitDateTime).isEqualTo(authorTime.toLocalDateTime()) + } + + @Test + fun `reuses existing commit by sha`() { + val existing = Commit( + sha = validSha, + authorSignature = com.inso_world.binocular.model.Signature( + developer = com.inso_world.binocular.model.Developer( + name = "Existing", + email = "existing@test.com", + repository = repository + ), + timestamp = authorTime.toLocalDateTime() + ), + repository = repository + ) + + val result = gixCommit().toDomain(repository) + + assertThat(result).isSameAs(existing) + } + + @Test + fun `rejects invalid sha`() { + val invalid = gixCommit(sha = "short") + + assertThrows { + invalid.toDomain(repository) + } + } + } + + @Nested + inner class BatchMapper { + + @Test + fun `wires parents bidirectionally`() { + val parentVec = gixCommit(sha = parentSha) + val childVec = gixCommit(sha = validSha, parents = listOf(parentSha)) + + val results = listOf(parentVec, childVec).toDomain(repository) + + val parent = results.first { it.sha == parentSha } + val child = results.first { it.sha == validSha } + + assertThat(child.parents).containsExactly(parent) + assertThat(parent.children).containsExactly(child) + } + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/GixSignatureTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/GixSignatureTest.kt new file mode 100644 index 000000000..5ceb05dbc --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/extensions/GixSignatureTest.kt @@ -0,0 +1,108 @@ +package com.inso_world.binocular.ffi.unit.extensions + +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.ffi.extensions.toDeveloper +import com.inso_world.binocular.ffi.extensions.toSignature +import com.inso_world.binocular.ffi.internal.GixSignature +import com.inso_world.binocular.ffi.internal.GixTime +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * Unit tests for mapping FFI signatures to domain [Developer]/[Signature]. + */ +class GixSignatureTest : BaseUnitTest() { + + private lateinit var project: Project + private lateinit var repository: Repository + + @BeforeEach + fun setUp() { + project = Project(name = "test-project") + repository = Repository( + localPath = "/path/to/repo", + project = project + ) + } + + @Test + fun `toDeveloper creates and registers developer with trimmed values`() { + val ffiSig = GixSignature( + name = " Jane Dev ", + email = " jane@example.com ", + time = GixTime(seconds = 0L, offset = 0) + ) + + val developer = ffiSig.toDeveloper(repository) + + assertThat(developer.name).isEqualTo("Jane Dev") + assertThat(developer.email).isEqualTo("jane@example.com") + assertThat(developer.repository).isSameAs(repository) + assertThat(repository.developers).contains(developer) + } + + @Test + fun `toSignature wraps developer and timestamp`() { + val ffiSig = GixSignature( + name = "John Doe", + email = "john@example.com", + time = GixTime(seconds = 1704067200L, offset = 0) // 2024-01-01T00:00:00Z + ) + + val signature = ffiSig.toSignature(repository) + + assertThat(signature.developer.name).isEqualTo("John Doe") + assertThat(signature.developer.email).isEqualTo("john@example.com") + assertThat(signature.timestamp.year).isEqualTo(2024) + assertThat(signature.timestamp.dayOfMonth).isEqualTo(1) + } + + @Test + fun `toDeveloper reuses existing developer by git signature`() { + val existing = com.inso_world.binocular.model.Developer( + name = "Existing Dev", + email = "existing@example.com", + repository = repository + ) + + val ffiSig = GixSignature( + name = "Existing Dev", + email = "existing@example.com", + time = GixTime(seconds = 0L, offset = 0) + ) + + val result = ffiSig.toDeveloper(repository) + + assertThat(result).isSameAs(existing) + } + + @Test + fun `toDeveloper rejects blank name`() { + val ffiSig = GixSignature( + name = " ", + email = "dev@example.com", + time = GixTime(seconds = 0L, offset = 0) + ) + + assertThrows { + ffiSig.toDeveloper(repository) + } + } + + @Test + fun `toDeveloper rejects blank email`() { + val ffiSig = GixSignature( + name = "Dev", + email = " ", + time = GixTime(seconds = 0L, offset = 0) + ) + + assertThrows { + ffiSig.toDeveloper(repository) + } + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/CommitOperationsTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/CommitOperationsTest.kt new file mode 100644 index 000000000..e30314f6f --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/CommitOperationsTest.kt @@ -0,0 +1,517 @@ +package com.inso_world.binocular.ffi.unit.lib + +import com.inso_world.binocular.ffi.FfiConfig +import com.inso_world.binocular.ffi.GixConfig +import com.inso_world.binocular.ffi.internal.GixRepository +import com.inso_world.binocular.ffi.internal.UniffiException +import com.inso_world.binocular.ffi.internal.findAllBranches +import com.inso_world.binocular.ffi.internal.findCommit +import com.inso_world.binocular.ffi.internal.findRepo +import com.inso_world.binocular.ffi.internal.traverseBranch +import com.inso_world.binocular.ffi.internal.traverseHistory +import com.inso_world.binocular.ffi.unit.lib.base.BaseLibraryUnitTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +/** + * Comprehensive unit tests for commit operations in the FFI layer. + * + * Tests verify: + * - Commit lookup by hash + * - Commit traversal between revisions + * - Branch traversal + * - Commit metadata extraction + * - Error handling for invalid commits + * + * ### Test Organization + * - [FindCommitOperation]: Tests for commit lookup + * - [TraverseOperation]: Tests for commit history traversal + * - [TraverseBranchOperation]: Tests for branch traversal + * - [CommitMetadata]: Tests for commit data validation + * - [ErrorHandling]: Tests for exception scenarios + */ +@DisplayName("Commit Operations") +class CommitOperationsTest : BaseLibraryUnitTest() { + + private val cfg: FfiConfig = FfiConfig().apply { + gix = GixConfig(skipMerges = false, useMailmap = true) + } + + private lateinit var testRepoPath: String + private lateinit var repo: GixRepository + + @BeforeEach + fun setUp() { + testRepoPath = System.getProperty("user.dir") + repo = findRepo(testRepoPath) + } + + @Nested + @DisplayName("findCommit operation") + inner class FindCommitOperation { + + @Test + fun `finds commit by valid SHA-1 hash`() { + // Verifies that findCommit successfully retrieves a commit using its full SHA-1 hash + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(result).isNotNull }, + { assertThat(result.oid).isEqualTo(headCommit) } + ) + } + + @Test + fun `finds commit by short SHA hash`() { + // Tests commit lookup using abbreviated SHA-1 (7+ characters) + val branches = findAllBranches(repo) + val headCommit = branches.first().target + val shortHash = headCommit.toString().substring(0, 7) + + val result = findCommit( + repo, shortHash, + useMailmap = cfg.gix.useMailmap + ) + + assertThat(result).isNotNull() + } + + @Test + fun `found commit has valid metadata`() { + // Ensures retrieved commits contain all required metadata fields + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(result.oid).isNotNull() }, + { assertThat(result.author).isNotNull() }, + { assertThat(result.committer).isNotNull() }, + { assertThat(result.message).isNotNull() } + ) + } + + @Test + fun `commit author has valid signature`() { + // Validates commit author signature contains name, email, and time + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(result.author.name).isNotEmpty() }, + { assertThat(result.author.email).isNotEmpty() }, + { assertThat(result.author.time).isNotNull() } + ) + } + + @Test + fun `commit committer has valid signature`() { + // Validates commit committer signature contains name, email, and time + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(result.committer?.name).isNotEmpty() }, + { assertThat(result.committer?.email).isNotEmpty() }, + { assertThat(result.committer?.time).isNotNull() } + ) + } + + @Test + fun `commit message is non-empty`() { + // Confirms commit messages are extracted correctly + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + assertThat(result.message).isNotEmpty() + } + + @Test + fun `commit has parent information`() { + // Checks that commit parent data is populated (except for root commits) + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + // Note: Root commits have no parents + assertThat(result.parents).isNotNull() + } + } + + @Nested + @DisplayName("traverse operation") + inner class TraverseOperation { + + @Test + fun `traverses from source commit to root when target is null`() { + // Verifies full history traversal from a commit to repository root + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = traverseHistory( + repo, headCommit, null, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(result).isNotNull() }, + { assertThat(result).isNotEmpty() }, + { assertThat(result.first().oid).isEqualTo(headCommit) } + ) + } + + @Test + fun `traverses between two commits`() { + // Tests history traversal between source and target commits + val branches = findAllBranches(repo) + val headCommit = branches.first().target + val commits = traverseHistory( + repo, headCommit, null, + useMailmap = cfg.gix.useMailmap + ) + + if (commits.size >= 2) { + val sourceCommit = commits[0].oid + val targetCommit = commits[commits.size - 1].oid + + val result = traverseHistory(repo, sourceCommit, targetCommit, useMailmap = cfg.gix.useMailmap) + + assertAll( + { assertThat(result).isNotEmpty() }, + { assertThat(result.first().oid).isEqualTo(sourceCommit) } + ) + } + } + + @Test + fun `traversal result contains commit metadata`() { + // Ensures all commits in traversal have complete metadata + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = traverseHistory( + repo, headCommit, null, + useMailmap = cfg.gix.useMailmap + ) + + result.forEach { commit -> + assertAll( + { assertThat(commit.oid).isNotNull() }, + { assertThat(commit.author).isNotNull() }, + { assertThat(commit.committer).isNotNull() }, + { assertThat(commit.message).isNotNull() } + ) + } + } + + @Test + fun `commits are ordered chronologically`() { + // Validates commits are returned in topological order + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = traverseHistory( + repo, headCommit, null, + useMailmap = cfg.gix.useMailmap + ) + + if (result.size >= 2) { + // First commit should be the source (newest) + assertThat(result.first().oid).isEqualTo(headCommit) + } + } + + @Test + fun `traversal includes source commit`() { + // Confirms source commit is included in traversal results + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = traverseHistory( + repo, headCommit, null, + useMailmap = cfg.gix.useMailmap + ) + + assertThat(result.map { it.oid }).contains(headCommit) + } + + @Test + fun `traversal handles merge commits`() { + // Tests that merge commits are properly processed + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = traverseHistory( + repo, headCommit, null, + useMailmap = cfg.gix.useMailmap + ) + + // Merge commits may have multiple parents + val mergeCommits = result.filter { it.parents.size > 1 } + // This is okay - repository may or may not have merge commits + assertThat(mergeCommits).isNotNull() + } + } + + @Nested + @DisplayName("traverseBranch operation") + inner class TraverseBranchOperation { + + @Test + fun `traverses branch and returns commits`() { + // Verifies branch traversal returns commit history + val branches = findAllBranches(repo) + val branchName = branches.first().fullName + + val result = traverseBranch( + repo, branchName, + useMailmap = cfg.gix.useMailmap, skipMerges = cfg.gix.skipMerges + ) + + assertAll( + { assertThat(result).isNotNull() }, + { assertThat(result.branch).isNotNull() }, + { assertThat(result.commits).isNotEmpty() } + ) + } + + @Test + fun `returned branch matches requested branch name`() { + // Ensures returned branch metadata corresponds to requested branch + val branches = findAllBranches(repo) + val branchName = branches.first().name + + val result = traverseBranch( + repo, branchName, + useMailmap = cfg.gix.useMailmap, skipMerges = cfg.gix.skipMerges + ) + + assertThat(result.branch.name).isEqualTo(branchName) + } + + @Test + fun `branch commits have valid metadata`() { + // Validates all commits in branch traversal have complete data + val branches = findAllBranches(repo) + val branchName = branches.first().name + + val result = traverseBranch( + repo, branchName, + useMailmap = cfg.gix.useMailmap, skipMerges = cfg.gix.skipMerges + ) + + result.commits.forEach { commit -> + assertAll( + { assertThat(commit.oid).isNotNull() }, + { assertThat(commit.author).isNotNull() }, + { assertThat(commit.committer).isNotNull() } + ) + } + } + + @Test + fun `branch traversal includes HEAD commit`() { + // Confirms branch traversal includes the current HEAD commit + val branches = findAllBranches(repo) + val branch = branches.first() + + val result = traverseBranch( + repo, branch.name, + skipMerges = cfg.gix.skipMerges, + useMailmap = cfg.gix.useMailmap + ) + + assertThat(result.commits.map { it.oid }).contains(branch.target) + } + } + + @Nested + @DisplayName("Commit Metadata") + inner class CommitMetadata { + + @Test + fun `commit OID is valid SHA-1`() { + // Validates commit OIDs are proper SHA-1 hashes + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(result.oid.toString()).hasSize(40) }, + { assertThat(result.oid.toString()).matches("[0-9a-f]{40}") } + ) + } + + @Test + fun `author and committer timestamps are valid`() { + // Checks that commit timestamps are in valid ranges + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + assertAll( + { assertThat(result.author.time?.seconds).isGreaterThan(0) }, + { assertThat(result.committer?.time?.seconds).isGreaterThan(0) } + ) + } + + @Test + fun `commit message preserves formatting`() { + // Ensures commit messages maintain original formatting + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + // Message should not be null or empty + assertThat(result.message).isNotBlank() + } + + @Test + fun `parent commits are valid OIDs`() { + // Validates parent commit OIDs are proper SHA-1 hashes + val branches = findAllBranches(repo) + val headCommit = branches.first().target + + val result = findCommit( + repo, headCommit, + useMailmap = cfg.gix.useMailmap + ) + + result.parents.forEach { parentOid -> + assertAll( + { assertThat(parentOid.toString()).hasSize(40) }, + { assertThat(parentOid.toString()).matches("[0-9a-f]{40}") } + ) + } + } + } + + @Nested + @DisplayName("Error Handling") + inner class ErrorHandling { + + @Test + fun `throws RevisionParseException for invalid commit hash`() { + // Verifies proper exception for malformed commit hashes + val invalidHash = "invalid_hash_123" + + val exception = assertThrows { + findCommit(repo, invalidHash, useMailmap = cfg.gix.useMailmap) + } + + assertThat(exception.v1).isNotEmpty() + } + + @Test + fun `throws exception for non-existent commit hash`() { + // Tests error handling for valid-format but non-existent commits + val nonExistentHash = "0000000000000000000000000000000000000000" + + val exception = assertThrows { + findCommit(repo, nonExistentHash, useMailmap = cfg.gix.useMailmap) + } + + assertThat(exception).isNotNull() + } + + @ParameterizedTest + @ValueSource( + strings = [ + "", + " ", + "abc", + "zzzzz", + "!@#$%" + ] + ) + fun `throws exception for various invalid hash formats`(invalidHash: String) { + // Ensures consistent error handling across different invalid inputs + assertThrows { + findCommit(repo, invalidHash, useMailmap = cfg.gix.useMailmap) + } + } + + @Test + fun `throws ReferenceException for invalid branch name`() { + // Tests error handling when branch doesn't exist + val invalidBranch = "refs/heads/non_existent_branch_" + System.currentTimeMillis() + + val exception = assertThrows { + traverseBranch(repo, invalidBranch, skipMerges = cfg.gix.skipMerges, useMailmap = cfg.gix.useMailmap) + } + + assertThat(exception.v1).isNotEmpty() + } + + @Test + fun `traverse throws exception for invalid source commit`() { + // Verifies error handling for invalid source commit in traversal + val invalidCommit = "0000000000000000000000000000000000000000" + + assertThrows { + traverseHistory(repo, invalidCommit, null, useMailmap = cfg.gix.useMailmap) + } + } + + @Test + fun `exception messages are descriptive`() { + // Ensures exception messages provide useful debugging information + val exception = assertThrows { + findCommit(repo, "invalid", useMailmap = cfg.gix.useMailmap) + } + + assertAll( + { assertThat(exception.message).isNotEmpty() }, + { assertThat(exception.message).hasSizeGreaterThan(5) } + ) + } + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/ErrorHandlingTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/ErrorHandlingTest.kt new file mode 100644 index 000000000..94ff666d0 --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/ErrorHandlingTest.kt @@ -0,0 +1,502 @@ +package com.inso_world.binocular.ffi.unit.lib + +import com.inso_world.binocular.ffi.FfiConfig +import com.inso_world.binocular.ffi.GixConfig +import com.inso_world.binocular.ffi.internal.UniffiException +import com.inso_world.binocular.ffi.internal.findAllBranches +import com.inso_world.binocular.ffi.internal.findCommit +import com.inso_world.binocular.ffi.internal.findRepo +import com.inso_world.binocular.ffi.internal.traverseBranch +import com.inso_world.binocular.ffi.unit.lib.base.BaseLibraryUnitTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createTempDirectory + +/** + * Comprehensive unit tests for FFI error handling and exception types. + * + * Tests verify: + * - Correct exception types for each error scenario + * - Exception message quality and descriptiveness + * - Error propagation from Rust layer to Kotlin + * - Handling of edge cases and invalid inputs + * + * ### Test Organization + * - [GixDiscoverErrors]: Repository discovery errors + * - [RevisionParseErrors]: Commit hash parsing errors + * - [ObjectErrors]: Git object operation errors + * - [ReferenceErrors]: Branch and reference errors + * - [TraversalErrors]: Commit traversal errors + * - [ExceptionMessages]: Exception message quality + */ +//@DisplayName("Error Handling and Exceptions") +class ErrorHandlingTest : BaseLibraryUnitTest() { + + private val cfg: FfiConfig = FfiConfig().apply { + gix = GixConfig(skipMerges = false, useMailmap = true) + } + + @Nested + @DisplayName("GixDiscoverException scenarios") + inner class GixDiscoverErrors { + + @Test + fun `throws GixDiscoverException when repository not found`() { + // Verifies correct exception type for missing repositories + val nonExistentPath = "/tmp/no_such_repo_" + System.currentTimeMillis() + + val exception = assertThrows { + findRepo(nonExistentPath) + } + + assertAll( + { assertThat(exception).isInstanceOf(UniffiException.GixDiscoverException::class.java) }, + { assertThat(exception.v1).isNotEmpty() } + ) + } + + @Test + fun `GixDiscoverException for directory without git repository`() { + // Tests exception when valid directory exists but isn't a Git repo + val tempDir = createTempDirectory("test_not_git").toString() + + try { + val exception = assertThrows { + findRepo(tempDir) + } + + assertThat(exception.v1).containsIgnoringCase("repository") + } finally { + Files.delete(Path.of(tempDir)) + } + } + + @Test + fun `GixDiscoverException message contains path information`() { + // Ensures exception provides context about failed path + val invalidPath = "/tmp/invalid_path" + + val exception = assertThrows { + findRepo(invalidPath) + } + + assertThat(exception.v1).hasSizeGreaterThan(10) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\n", + "\t" + ]) + fun `GixDiscoverException for whitespace-only paths`(path: String) { + // Tests handling of invalid whitespace paths + assertThrows { + findRepo(path) + } + } + + @Test + fun `GixDiscoverException is catchable as generic UniffiException`() { + // Verifies exception hierarchy allows generic catch + val invalidPath = "/invalid/path" + + val exception = assertThrows { + findRepo(invalidPath) + } + + assertThat(exception).isInstanceOf(UniffiException.GixDiscoverException::class.java) + } + } + + @Nested + @DisplayName("RevisionParseException scenarios") + inner class RevisionParseErrors { + + @Test + fun `throws RevisionParseException for malformed hash`() { + // Tests parsing errors for invalid SHA-1 format + val repo = findRepo(System.getProperty("user.dir")) + val malformedHash = "not_a_valid_hash" + + val exception = assertThrows { + findCommit(repo, malformedHash, useMailmap = cfg.gix.useMailmap) + } + + assertThat(exception.v1).isNotEmpty() + } + + @ParameterizedTest + @CsvSource( + "abc, too short", + "ghijklmn, invalid characters", + "12345, incomplete hash", + "zzzzzzzzzz, non-hex characters" + ) + fun `RevisionParseException for various invalid formats`(hash: String, @Suppress("UNUSED_PARAMETER") description: String) { + // Verifies consistent error handling for different malformed hashes + val repo = findRepo(System.getProperty("user.dir")) + + assertThrows { + findCommit(repo, hash, useMailmap = cfg.gix.useMailmap) + } + } + + @Test + fun `RevisionParseException message explains parsing failure`() { + // Ensures exception provides parsing error context + val repo = findRepo(System.getProperty("user.dir")) + + val exception = assertThrows { + findCommit(repo, "invalid_commit_ref", useMailmap = cfg.gix.useMailmap) + } + + assertAll( + { assertThat(exception.v1).isNotEmpty() }, + { assertThat(exception.message).isNotEmpty() } + ) + } + } + + @Nested + @DisplayName("ObjectException scenarios") + inner class ObjectErrors { + + @Test + fun `throws ObjectException when commit not found`() { + // Tests object lookup errors for non-existent commits + val repo = findRepo(System.getProperty("user.dir")) + val nonExistentHash = "0000000000000000000000000000000000000000" + + val exception = assertThrows { + findCommit(repo, nonExistentHash, useMailmap = cfg.gix.useMailmap) + } + + assertThat(exception).isNotNull() + } + + @Test + fun `ObjectException contains object type information`() { + // Verifies exception indicates what type of object failed + val repo = findRepo(System.getProperty("user.dir")) + val invalidOid = "1111111111111111111111111111111111111111" + + val exception = assertThrows { + findCommit(repo, invalidOid, useMailmap = cfg.gix.useMailmap) + } + + assertThat(exception.message).isNotEmpty() + } + } + + @Nested + @DisplayName("ReferenceException scenarios") + inner class ReferenceErrors { + + @Test + fun `throws ReferenceException for invalid branch operations`() { + // Tests reference errors when branch operations fail + val repo = findRepo(System.getProperty("user.dir")) + // Create corrupted repository reference + val corruptedRepo = repo.copy(gitDir = "/invalid/path") + + val exception = assertThrows { + findAllBranches(corruptedRepo) + } + + assertThat(exception).isNotNull() + } + + @Test + fun `ReferenceException message describes reference problem`() { + // Ensures exception explains reference-specific issues + val repo = findRepo(System.getProperty("user.dir")) + val corruptedRepo = repo.copy(gitDir = "/tmp/corrupt") + + val exception = assertThrows { + findAllBranches(corruptedRepo) + } + + assertThat(exception.message).hasSizeGreaterThan(10) + } + } + + @Nested + @DisplayName("TraversalException scenarios") + inner class TraversalErrors { + + @Test + fun `throws ReferenceException for non-existent branch`() { + // Verifies traversal errors for invalid branch names + val repo = findRepo(System.getProperty("user.dir")) + val invalidBranch = "refs/heads/does_not_exist_" + System.currentTimeMillis() + + val exception = assertThrows { + traverseBranch(repo, invalidBranch, useMailmap = cfg.gix.useMailmap, skipMerges = cfg.gix.skipMerges) + } + + assertThat(exception.v1).isNotEmpty() + } + + @Test + fun `ReferenceException explains traversal failure`() { + // Ensures exception provides traversal context + val repo = findRepo(System.getProperty("user.dir")) + val invalidBranch = "invalid_branch_ref" + + val exception = assertThrows { + traverseBranch(repo, invalidBranch, useMailmap = cfg.gix.useMailmap, skipMerges = cfg.gix.skipMerges) + } + + assertAll( + { assertThat(exception.v1).isNotEmpty() }, + { assertThat(exception.v1).contains("Failed to get references: The reference") }, + { assertThat(exception.v1).contains(invalidBranch) }, + { assertThat(exception.v1).contains("did not exist") }, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "not/a/valid/ref", + "refs/heads/", + "/invalid/ref" + ]) + fun `TraversalException for various invalid branch references`(invalidRef: String) { + // Tests consistent error handling for different invalid references + val repo = findRepo(System.getProperty("user.dir")) + + assertThrows { + traverseBranch(repo, invalidRef, useMailmap = cfg.gix.useMailmap, skipMerges = cfg.gix.skipMerges) + } + } + } + + @Nested + @DisplayName("Exception Message Quality") + inner class ExceptionMessages { + + @Test + fun `all exceptions have non-empty messages`() { + // Verifies all exception types provide messages + val repo = findRepo(System.getProperty("user.dir")) + + // Test GixDiscoverException + val discoverEx = assertThrows { + findRepo("/invalid/path") + } + assertThat(discoverEx.message).isNotEmpty() + + // Test RevisionParseException + val parseEx = assertThrows { + findCommit(repo, "invalid", useMailmap = cfg.gix.useMailmap) + } + assertThat(parseEx.message).isNotEmpty() + + // Test TraversalException + val traversalEx = assertThrows { + traverseBranch(repo, "invalid_branch", useMailmap = cfg.gix.useMailmap, skipMerges = cfg.gix.skipMerges) + } + assertThat(traversalEx.message).isNotEmpty() + } + + @Test + fun `exception messages are descriptive`() { + // Ensures messages provide useful debugging information + val exceptions = mutableListOf() + + // Collect various exceptions + try { + findRepo("/invalid") + } catch (e: UniffiException) { + exceptions.add(e) + } + + val repo = findRepo(System.getProperty("user.dir")) + try { + findCommit(repo, "bad_hash", useMailmap = cfg.gix.useMailmap) + } catch (e: UniffiException) { + exceptions.add(e) + } + + exceptions.forEach { exception -> + assertAll( + { assertThat(exception.message).hasSizeGreaterThan(10) }, + { assertThat(exception.message).doesNotContain("null") } + ) + } + } + + @Test + fun `exception v1 field contains error details`() { + // Verifies v1 field (error string) has meaningful content + val exception = assertThrows { + findRepo("/tmp/nonexistent") + } + + assertAll( + { assertThat(exception.v1).isNotEmpty() }, + { assertThat(exception.v1).hasSizeGreaterThan(5) }, + { assertThat(exception.v1).isNotEqualTo("error") } + ) + } + + @Test + fun `exception toString provides debug information`() { + // Tests that exception string representation is useful + val exception = assertThrows { + findRepo("/invalid/path") + } + + val exceptionString = exception.toString() + assertAll( + { assertThat(exceptionString).isNotEmpty() }, + { assertThat(exceptionString).contains("Exception") } + ) + } + } + + @Nested + @DisplayName("Exception Inheritance and Hierarchy") + inner class ExceptionHierarchy { + + @Test + fun `all FFI exceptions extend UniffiException`() { + // Verifies exception hierarchy for polymorphic handling + val exceptions = mutableListOf() + + // Collect different exception types + try { + findRepo("/invalid") + } catch (e: Exception) { + exceptions.add(e) + } + + val repo = findRepo(System.getProperty("user.dir")) + try { + findCommit(repo, "invalid", useMailmap = cfg.gix.useMailmap) + } catch (e: Exception) { + exceptions.add(e) + } + + try { + traverseBranch(repo, "invalid", useMailmap = cfg.gix.useMailmap, skipMerges = cfg.gix.skipMerges) + } catch (e: Exception) { + exceptions.add(e) + } + + exceptions.forEach { exception -> + assertThat(exception).isInstanceOf(UniffiException::class.java) + } + } + + @Test + fun `UniffiException can be caught generically`() { + // Tests that all specific exceptions can be caught as UniffiException + var caughtException: UniffiException? = null + + try { + findRepo("/invalid/path") + } catch (e: UniffiException) { + caughtException = e + } + + assertThat(caughtException).isNotNull() + } + + @Test + fun `specific exception types can be distinguished`() { + // Verifies ability to catch and differentiate specific exception types + var discoveryError = false + var parseError = false + + try { + findRepo("/invalid") + } catch (e: UniffiException.GixDiscoverException) { + discoveryError = true + } + + val repo = findRepo(System.getProperty("user.dir")) + try { + findCommit(repo, "bad", useMailmap = cfg.gix.useMailmap) + } catch (e: UniffiException.RevisionParseException) { + parseError = true + } + + assertAll( + { assertThat(discoveryError).isTrue() }, + { assertThat(parseError).isTrue() } + ) + } + } + + @Nested + @DisplayName("Edge Cases and Boundary Conditions") + inner class EdgeCases { + + @Test + fun `handles null-like string inputs gracefully`() { + // Tests error handling for edge case string inputs + val edgeCaseInputs = listOf("", " ", "\n", "\t", " \n\t ") + + edgeCaseInputs.forEach { input -> + assertThrows { + findRepo(input) + } + } + } + + @Test + fun `handles very long path names`() { + // Tests behavior with extremely long path strings + val longPath = "/tmp/" + "a".repeat(1000) + + val exception = assertThrows { + findRepo(longPath) + } + + assertThat(exception).isNotNull() + } + + @Test + fun `handles special characters in paths`() { + // Verifies error handling for paths with special characters + val specialPaths = listOf( + "/tmp/with spaces/repo", + "/tmp/with-dashes/repo", + "/tmp/with_underscores/repo", + "/tmp/with.dots/repo" + ) + + specialPaths.forEach { path -> + assertThrows { + findRepo(path) + } + } + } + + @Test + fun `handles unicode in error messages`() { + // Tests that unicode characters in paths don't break error messages + val unicodePath = "/tmp/тест/مستودع/저장소" + + val exception = assertThrows { + findRepo(unicodePath) + } + + assertThat(exception.message).isNotNull() + } + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/RepositoryOperationsTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/RepositoryOperationsTest.kt new file mode 100644 index 000000000..2e86f23ff --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/RepositoryOperationsTest.kt @@ -0,0 +1,340 @@ +package com.inso_world.binocular.ffi.unit.lib + +import com.inso_world.binocular.ffi.internal.UniffiException +import com.inso_world.binocular.ffi.internal.findAllBranches +import com.inso_world.binocular.ffi.internal.findRepo +import com.inso_world.binocular.ffi.unit.lib.base.BaseLibraryUnitTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createTempDirectory + +/** + * Comprehensive unit tests for repository operations in the FFI layer. + * + * Tests verify: + * - Repository discovery and validation + * - Remote repository information extraction + * - Branch enumeration (local and remote) + * - Error handling for invalid paths and repositories + * + * ### Test Organization + * - [FindRepoOperation]: Tests for repository discovery + * - [FindAllBranchesOperation]: Tests for branch enumeration + * - [ErrorHandling]: Tests for exception scenarios + */ +@DisplayName("Repository Operations") +class RepositoryOperationsTest : BaseLibraryUnitTest() { + + @Nested + @DisplayName("findRepo operation") + inner class FindRepoOperation { + + @Test + fun `finds repository at valid git directory path`() { + // Verifies that findRepo successfully discovers a valid Git repository + // and returns repository metadata + val testRepoPath = System.getProperty("user.dir") + + val result = findRepo(testRepoPath) + + assertAll( + { assertThat(result).isNotNull }, + { assertThat(result.gitDir).isNotEmpty() }, + { assertThat(result.gitDir).contains(".git") } + ) + } + + @Test + fun `returns repository with work tree for non-bare repositories`() { + // Ensures non-bare repositories have their work tree path populated + val testRepoPath = System.getProperty("user.dir") + + val result = findRepo(testRepoPath) + + assertAll( + { assertThat(result.workTree).isNotNull() }, + { assertThat(result.workTree).isNotEmpty() } + ) + } + + @Test + fun `extracts remote information from repository`() { + // Validates that repository remotes are correctly extracted and populated + val testRepoPath = System.getProperty("user.dir") + + val result = findRepo(testRepoPath) + + assertThat(result.remotes).isNotNull() + // Note: May be empty if repository has no remotes configured + } + + @Test + fun `repository git directory path is absolute`() { + // Confirms that returned git_dir is an absolute path, not relative + val testRepoPath = System.getProperty("user.dir") + + val result = findRepo(testRepoPath) + + assertAll( + { assertThat(Path.of(result.gitDir)).isAbsolute() }, + ) + + } + + @ParameterizedTest + @ValueSource( + strings = [ + ".", + "..", + "./", + "../" + ] + ) + fun `handles relative paths correctly`(relativePath: String) { + // Tests that relative paths are resolved and repositories are discovered + // Current directory should be a git repository for this test + val result = findRepo(relativePath) + + assertThat(result.gitDir).isNotEmpty() + } + + @Test + fun `discovers repository from subdirectory`() { + // Verifies repository discovery works from subdirectories within the repo + val testRepoPath = System.getProperty("user.dir") + "/src" + + val result = findRepo(testRepoPath) + + assertAll( + { assertThat(result.gitDir).isNotEmpty() }, + { assertThat(result.gitDir).contains(".git") } + ) + } + + @Test + fun `repository remotes have valid structure`() { + // Checks that each remote in the repository has name and url properties + val testRepoPath = System.getProperty("user.dir") + + val result = findRepo(testRepoPath) + + result.remotes.forEach { remote -> + assertAll( + { assertThat(remote.name).isNotEmpty() }, + { assertThat(remote.url).isNotEmpty() } + ) + } + } + } + + @Nested + @DisplayName("findAllBranches operation") + inner class FindAllBranchesOperation { + + @Test + fun `returns list of branches from repository`() { + // Verifies that branch enumeration returns a non-null list + val testRepoPath = System.getProperty("user.dir") + val repo = findRepo(testRepoPath) + + val branches = findAllBranches(repo) + + assertThat(branches).isNotNull() + } + + @Test + fun `branches contain both local and remote references`() { + // Ensures both local and remote branches are included in results + val testRepoPath = System.getProperty("user.dir") + val repo = findRepo(testRepoPath) + + val branches = findAllBranches(repo) + + // Should have at least one branch in any Git repository + assertThat(branches).isNotEmpty() + } + + @Test + fun `each branch has valid name property`() { + // Validates that all returned branches have non-empty names + val testRepoPath = System.getProperty("user.dir") + val repo = findRepo(testRepoPath) + + val branches = findAllBranches(repo) + + branches.forEach { branch -> + assertThat(branch.name).isNotEmpty() + } + } + + @Test + fun `branches have target commit information`() { + // Confirms each branch points to a valid commit + val testRepoPath = System.getProperty("user.dir") + val repo = findRepo(testRepoPath) + + val branches = findAllBranches(repo) + + branches.forEach { branch -> + assertThat(branch.target).isNotNull() + } + } + + @Test + fun `finds main or master branch`() { + // Checks that common default branch names are present + val testRepoPath = System.getProperty("user.dir") + val repo = findRepo(testRepoPath) + + val branches = findAllBranches(repo) + val branchNames = branches.map { it.name } + + assertThat(branchNames).anyMatch { + it.contains("main") || it.contains("master") + } + } + } + + @Nested + @DisplayName("Error Handling") + inner class ErrorHandling { + + @Test + fun `throws GixDiscoverException for non-existent path`() { + // Verifies proper exception is thrown when path doesn't exist + val invalidPath = "/tmp/non_existent_path_" + System.currentTimeMillis() + + val exception = assertThrows { + findRepo(invalidPath) + } + + assertThat(exception.v1).isNotEmpty() + } + + @Test + fun `throws GixDiscoverException for non-git directory`() { + // Ensures exception is thrown for valid paths that aren't Git repositories + val tempDir = createTempDirectory("test_non_git").toString() + + val exception = assertThrows { + findRepo(tempDir) + } + + assertAll( + { assertThat(exception.v1).isNotEmpty() }, + { assertThat(exception.v1).containsAnyOf("not a git", "repository") } + ) + + // Cleanup + Files.delete(Path.of(tempDir)) + } + + @Test + fun `throws GixDiscoverException for empty string path`() { + // Tests error handling for empty path input + val exception = assertThrows { + findRepo("") + } + + assertThat(exception.v1).isNotEmpty() + } + + @ParameterizedTest + @ValueSource( + strings = [ + "/invalid/path/to/repo", + "/tmp/does/not/exist", + "/root/inaccessible/path" + ] + ) + fun `throws GixDiscoverException for various invalid paths`(invalidPath: String) { + // Verifies consistent error handling across different invalid path types + assertThrows { + findRepo(invalidPath) + } + } + + @Test + fun `GixDiscoverException contains descriptive error message`() { + // Ensures exception messages provide useful debugging information + val invalidPath = "/tmp/not_a_repo" + + val exception = assertThrows { + findRepo(invalidPath) + } + + assertAll( + { assertThat(exception.v1).isNotEmpty() }, + { assertThat(exception.v1).hasSizeGreaterThan(10) }, + { assertThat(exception.message).isNotEmpty() } + ) + } + + @Test + fun `findAllBranches throws GixDiscoverException for invalid repository`() { + // Tests error handling when branch enumeration fails + val testRepoPath = System.getProperty("user.dir") + val repo = findRepo(testRepoPath) + // Corrupt the git_dir to simulate invalid repository + val corruptedRepo = repo.copy(gitDir = "/invalid/git/dir") + + val exception = assertThrows { + findAllBranches(corruptedRepo) + } + + assertThat(exception).isNotNull() + } + } + + @Nested + @DisplayName("Repository Remotes") + inner class RepositoryRemotes { + + @Test + fun `remote URLs are valid git URLs`() { + // Validates that remote URLs follow Git URL conventions + val testRepoPath = System.getProperty("user.dir") + val repo = findRepo(testRepoPath) + + repo.remotes.forEach { remote -> + assertThat(remote.url).matches( + "(https?://.*)|(git@.*:.*\\.git)|(.*@.*:.*)|(/.*)" + ) + } + } + + @Test + fun `common remote names are recognized`() { + // Checks for standard remote names like origin, upstream + val testRepoPath = System.getProperty("user.dir") + val repo = findRepo(testRepoPath) + + if (repo.remotes.isNotEmpty()) { + val remoteNames = repo.remotes.map { it.name } + assertThat(remoteNames).anyMatch { + it == "origin" || it == "upstream" + } + } + } + + @Test + fun `remotes list is immutable after retrieval`() { + // Ensures returned remotes list can be safely used without side effects + val testRepoPath = System.getProperty("user.dir") + val repo = findRepo(testRepoPath) + + val remotes1 = repo.remotes + val remotes2 = repo.remotes + + assertThat(remotes1).isEqualTo(remotes2) + } + } +} diff --git a/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/base/BaseLibraryUnitTest.kt b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/base/BaseLibraryUnitTest.kt new file mode 100644 index 000000000..0d9c8a75d --- /dev/null +++ b/binocular-backend-new/ffi/src/test/kotlin/com/inso_world/binocular/ffi/unit/lib/base/BaseLibraryUnitTest.kt @@ -0,0 +1,12 @@ +package com.inso_world.binocular.ffi.unit.lib.base + +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.ffi.util.Utils +import org.junit.jupiter.api.BeforeEach + +abstract class BaseLibraryUnitTest : BaseUnitTest() { + @BeforeEach + fun setup() { + Utils.Companion.loadPlatformLibrary("gix_binocular") + } +} diff --git a/binocular-backend-new/ffi/src/test/resources/application.yaml b/binocular-backend-new/ffi/src/test/resources/application.yaml new file mode 100644 index 000000000..00d2dcee0 --- /dev/null +++ b/binocular-backend-new/ffi/src/test/resources/application.yaml @@ -0,0 +1,11 @@ +binocular: + gix: + skip_merges: false + use_mailmap: true +logging: + level: + com: + inso_world: + binocular: + ffi: trace + root: info diff --git a/binocular-backend-new/ffi/src/test/resources/fixtures/advanced.sh b/binocular-backend-new/ffi/src/test/resources/fixtures/advanced.sh deleted file mode 100755 index 6f34f0183..000000000 --- a/binocular-backend-new/ffi/src/test/resources/fixtures/advanced.sh +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export TZ=UTC - -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -REPO_DIR="$1" -mkdir -p "$REPO_DIR" -cd "$REPO_DIR" - -git init -q -b master - -git_commit() { - local msg="$1"; shift - local date="$1"; shift - local name="$1"; shift - local email="$1" - GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ - GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" \ - GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" \ - git commit -m "$msg" -q -} - -echo "Hello, world!" > file1.txt -git add file1.txt -git_commit "Initial commit" "2023-01-01T12:01:00+00:00" "Alice" "alice@example.com" - -echo "Additional content" >> file1.txt -git add file1.txt -git_commit "Append to file1.txt" "2023-01-01T13:00:00+00:00" "Bob" "bob@example.com" - -echo "This is file2" > file2.txt -git add file2.txt -git_commit "Add file2.txt" "2023-01-01T14:00:00+00:00" "Carol" "carol@example.com" - -echo "More content for file2" >> file2.txt -git add file2.txt -git_commit "Modify file2.txt" "2023-01-01T15:00:00+00:00" "Alice" "alice@example.com" - -git mv file1.txt file1-renamed.txt -git_commit "Rename file1.txt to file1-renamed.txt" "2023-01-01T16:00:00+00:00" "Bob" "bob@example.com" - -git rm file2.txt -git_commit "Delete file2.txt" "2023-01-01T17:00:00+00:00" "Carol" "carol@example.com" - -echo "Content of file3" > file3.txt -git add file3.txt -GIT_AUTHOR_DATE="2023-01-01T18:00:00+00:00" git_commit "Create file3.txt" "2023-01-01T18:05:00+00:00" "Alice" "alice@example.com" - -echo "Appending more to file3" >> file3.txt -git add file3.txt -git_commit "Update file3.txt with more content" "2023-01-01T19:00:00+00:00" "Bob" "bob@example.com" - -mkdir -p dir1 -echo "Inside dir1" > dir1/file4.txt -git add dir1/file4.txt -git_commit "Create dir1 and add file4.txt" "2023-01-01T20:00:00+00:00" "Carol" "carol@example.com" - -git mv dir1/file4.txt dir1/file4-renamed.txt -git_commit "Rename file4.txt to file4-renamed.txt in dir1" "2023-01-01T21:00:00+00:00" "Alice" "alice@example.com" - -dd if=/dev/zero bs=100 count=1 of=file5.bin status=none -git add file5.bin -git_commit "Add binary file file5.bin" "2023-01-01T22:00:00+00:00" "Bob" "bob@example.com" - -git rm file3.txt -git_commit "Delete file3.txt" "2023-01-01T23:00:00+00:00" "Carol" "carol@example.com" - -awk 'NR==1{print; print "Inserted line"; next}1' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -git_commit "Modify file1-renamed.txt by inserting a line" "2023-01-02T00:00:00+00:00" "Alice" "alice@example.com" - -echo "Recreated file2" > file2.txt -git add file2.txt -GIT_AUTHOR_DATE="2023-01-02T00:30:00+00:00" git_commit "Re-add file2.txt with new content" "2023-01-02T01:00:00+00:00" "Bob" "bob@example.com" - -echo "Final update to file1-renamed.txt" >> file1-renamed.txt -echo "Final update to file2.txt" >> file2.txt -git add file1-renamed.txt file2.txt -git_commit "Final update: modify multiple files" "2023-01-02T02:00:00+00:00" "Carol" "carol@example.com" - -git checkout --orphan imported -q -git rm -rf . > /dev/null 2>&1 || true -echo "Imported commit content" > imported.txt -git add imported.txt -git_commit "Imported commit: independent history from another remote" "2023-01-03T00:00:00+00:00" "Dave" "dave@example.com" - -git checkout master -q -git_commit "Merge imported history from remote" "2023-01-03T01:00:00+00:00" "Alice" "alice@example.com" && \ - git merge --allow-unrelated-histories imported -m "$(git log -1 --pretty=%B)" -q - -git checkout -b feature -q -echo "Feature update: appended line" >> file1-renamed.txt -git add file1-renamed.txt -git_commit "Feature: update file1-renamed.txt" "2023-01-02T03:00:00+00:00" "Bob" "bob@example.com" - -echo "Content for file6 from feature branch" > file6.txt -git add file6.txt -git_commit "Feature: add file6.txt" "2023-01-02T03:30:00+00:00" "Carol" "carol@example.com" - -git checkout master -q -git_commit "Merge branch 'feature'" "2023-01-02T04:00:00+00:00" "Alice" "alice@example.com" && \ - git merge --no-ff feature -m "$(git log -1 --pretty=%B)" -q - -git checkout -b bugfix -q -echo "Bugfix: corrected a typo in file2.txt" >> file2.txt -git add file2.txt -git_commit "Bugfix: update file2.txt with correction" "2023-01-02T04:30:00+00:00" "Alice" "alice@example.com" - -echo "Bugfix: final adjustment to file2.txt" >> file2.txt -git add file2.txt -git_commit "Bugfix: further update to file2.txt" "2023-01-02T05:00:00+00:00" "Bob" "bob@example.com" - -git checkout master -q -git_commit "Merge branch 'bugfix'" "2023-01-02T05:30:00+00:00" "Carol" "carol@example.com" && \ - git merge --no-ff bugfix -m "$(git log -1 --pretty=%B)" -q - -for b in octo1 octo2 octo3; do - git checkout -b "$b" master -q - echo "Change from $b" > "$b".txt - git add "$b".txt - case "$b" in - octo1) name="Alice"; email="alice@example.com"; date="2023-01-02T06:00:00+00:00";; - octo2) name="Bob"; email="bob@example.com"; date="2023-01-02T06:30:00+00:00";; - octo3) name="Carol"; email="carol@example.com"; date="2023-01-02T07:00:00+00:00";; - esac - git_commit "Octo ${b: -1}: Add ${b}.txt" "$date" "$name" "$email" -done - -git checkout master -q -GIT_AUTHOR_DATE="2023-01-02T07:30:00+00:00" GIT_COMMITTER_DATE="2023-01-02T07:30:00+00:00" \ -GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_COMMITTER_NAME="Alice" GIT_COMMITTER_EMAIL="alice@example.com" \ - git merge --no-ff octo1 octo2 octo3 -m "Octopus merge of octo1, octo2, and octo3" -q - -# extended commits -echo "Remove the inserted line" > tmp && sed '/Inserted line/d' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -git_commit "Remove inserted line from file1-renamed.txt" "2023-01-02T08:00:00+00:00" "Bob" "bob@example.com" - -echo "Post-merge note" >> file6.txt -git add file6.txt -git_commit "Append post-merge note to file6.txt" "2023-01-02T08:30:00+00:00" "Dave" "dave@example.com" - - -# 3 additional commits on 'imported' branch -git checkout imported -q - -echo "Imported update 1" >> imported.txt -git add imported.txt -git_commit "Imported: update 1 to imported.txt" "2023-01-03T02:00:00+00:00" "Dave" "dave@example.com" - -echo "Imported update 2" >> imported.txt -git add imported.txt -git_commit "Imported: update 2 to imported.txt" "2023-01-03T02:30:00+00:00" "Carol" "carol@example.com" - -echo "Imported update 3" >> imported.txt -git add imported.txt -git_commit "Imported: update 3 to imported.txt" "2023-01-03T03:00:00+00:00" "Bob" "bob@example.com" - -# Merge 'imported' back into master again -git checkout master -q -GIT_AUTHOR_DATE="2023-01-03T03:30:00+00:00" GIT_COMMITTER_DATE="2023-01-03T03:30:00+00:00" \ -GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_COMMITTER_NAME="Alice" GIT_COMMITTER_EMAIL="alice@example.com" \ - git merge --no-ff imported -m "Second merge of imported history" -q --allow-unrelated-histories - -# 3 commits on master post-import -echo "Master post-import 1" >> file1-renamed.txt -git add file1-renamed.txt -git_commit "Master: post-import update 1" "2023-01-03T04:00:00+00:00" "Alice" "alice@example.com" - -echo "Master post-import 2" >> file2.txt -git add file2.txt -git_commit "Master: post-import update 2" "2023-01-03T04:30:00+00:00" "Bob" "bob@example.com" - -echo "Master post-import 3" >> file6.txt -git add file6.txt -git_commit "Master: post-import update 3" "2023-01-03T05:00:00+00:00" "Carol" "carol@example.com" - -# 5 commits on 'extra' branch and merge into master - -git checkout -b extra -q -# 1/5 -echo "Extra change 1" >> file1-renamed.txt -git add file1-renamed.txt -git_commit "Extra: change 1" "2023-01-03T06:00:00+00:00" "Alice" "alice@example.com" -# 2/5 -echo "Extra change 2" >> file2.txt -git add file2.txt -git_commit "Extra: change 2" "2023-01-03T06:30:00+00:00" "Bob" "bob@example.com" -# 3/5 -echo "Extra change 3" > file7.txt -git add file7.txt -git_commit "Extra: add file7.txt" "2023-01-03T07:00:00+00:00" "Carol" "carol@example.com" -# 4/5 -echo "Extra change 4" >> file7.txt -git add file7.txt -git_commit "Extra: update file7.txt" "2023-01-03T07:30:00+00:00" "Alice" "alice@example.com" -# 5/5 -git rm file5.bin -git_commit "Extra: remove file5.bin" "2023-01-03T08:00:00+00:00" "Carol" "carol@example.com" - -git checkout master -q -GIT_AUTHOR_DATE="2023-01-03T08:30:00+00:00" GIT_COMMITTER_DATE="2023-01-03T08:30:00+00:00" \ -GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_COMMITTER_NAME="Alice" GIT_COMMITTER_EMAIL="alice@example.com" \ - git merge --no-ff extra -m "Merge branch 'extra' with five extra changes" -q - -exit 0 diff --git a/binocular-backend-new/ffi/src/test/resources/fixtures/octo.sh b/binocular-backend-new/ffi/src/test/resources/fixtures/octo.sh deleted file mode 100755 index e3a99ef5b..000000000 --- a/binocular-backend-new/ffi/src/test/resources/fixtures/octo.sh +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Force Git to use UTC for all timestamps -export TZ=UTC - -# Usage check -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -REPO_DIR="$1" -mkdir -p "$REPO_DIR" -cd "$REPO_DIR" - -# Initialize on master branch explicitly -git init -q -b master - -############################################################################### -# Helper to commit with fixed author/committer data -############################################################################### -git_commit() { - local msg="$1"; shift - local date="$1"; shift - local name="$1"; shift - local email="$1" - GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ - GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" \ - GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" \ - git commit -m "$msg" -q -} - -############################################################################### -# Commits 1–15: Initial history -############################################################################### - -# 1: Initial commit by Alice -echo "Hello, world!" > file1.txt -git add file1.txt -git_commit "Initial commit" \ - "2023-01-01T12:16:00+00:00" \ - "Alice" "alice@example.com" - -# 2: Append to file1.txt by Bob -echo "Additional content" >> file1.txt -git add file1.txt -git_commit "Append to file1.txt" \ - "2023-01-01T13:00:00+00:00" \ - "Bob" "bob@example.com" - -# 3: Add file2.txt by Carol -echo "This is file2" > file2.txt -git add file2.txt -git_commit "Add file2.txt" \ - "2023-01-01T14:00:00+00:00" \ - "Carol" "carol@example.com" - -# 4: Modify file2.txt by Alice -echo "More content for file2" >> file2.txt -git add file2.txt -git_commit "Modify file2.txt" \ - "2023-01-01T15:00:00+00:00" \ - "Alice" "alice@example.com" - -# 5: Rename file1.txt to file1-renamed.txt by Bob -git mv file1.txt file1-renamed.txt -git_commit "Rename file1.txt to file1-renamed.txt" \ - "2023-01-01T16:00:00+00:00" \ - "Bob" "bob@example.com" - -# 6: Delete file2.txt by Carol -git rm file2.txt -git_commit "Delete file2.txt" \ - "2023-01-01T17:00:00+00:00" \ - "Carol" "carol@example.com" - -# 7: Create file3.txt by Alice (with differing author/committer times) -echo "Content of file3" > file3.txt -git add file3.txt -GIT_AUTHOR_DATE="2023-01-01T18:00:00+00:00" \ -git_commit "Create file3.txt" \ - "2023-01-01T18:05:00+00:00" \ - "Alice" "alice@example.com" - -# 8: Update file3.txt by Bob -echo "Appending more to file3" >> file3.txt -git add file3.txt -git_commit "Update file3.txt with more content" \ - "2023-01-01T19:00:00+00:00" \ - "Bob" "bob@example.com" - -# 9: Create dir1 and add file4.txt by Carol -mkdir -p dir1 -echo "Inside dir1" > dir1/file4.txt -git add dir1/file4.txt -git_commit "Create dir1 and add file4.txt" \ - "2023-01-01T20:00:00+00:00" \ - "Carol" "carol@example.com" - -# 10: Rename file4.txt inside dir1 by Alice -git mv dir1/file4.txt dir1/file4-renamed.txt -git_commit "Rename file4.txt to file4-renamed.txt in dir1" \ - "2023-01-01T21:00:00+00:00" \ - "Alice" "alice@example.com" - -# 11: Add a deterministic binary blob by Bob -dd if=/dev/zero bs=100 count=1 of=file5.bin status=none -git add file5.bin -git_commit "Add binary file file5.bin" \ - "2023-01-01T22:00:00+00:00" \ - "Bob" "bob@example.com" - -# 12: Delete file3.txt by Carol -git rm file3.txt -git_commit "Delete file3.txt" \ - "2023-01-01T23:00:00+00:00" \ - "Carol" "carol@example.com" - -# 13: Insert a line in file1-renamed.txt by Alice -awk 'NR==1{print; print "Inserted line"; next}1' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -git_commit "Modify file1-renamed.txt by inserting a line" \ - "2023-01-02T00:00:00+00:00" \ - "Alice" "alice@example.com" - -# 14: Re-add file2.txt with new content by Bob -echo "Recreated file2" > file2.txt -git add file2.txt -GIT_AUTHOR_DATE="2023-01-02T00:30:00+00:00" \ -git_commit "Re-add file2.txt with new content" \ - "2023-01-02T01:00:00+00:00" \ - "Bob" "bob@example.com" - -# 15: Final multi-file update by Carol -echo "Final update to file1-renamed.txt" >> file1-renamed.txt -echo "Final update to file2.txt" >> file2.txt -git add file1-renamed.txt file2.txt -git_commit "Final update: modify multiple files" \ - "2023-01-02T02:00:00+00:00" \ - "Carol" "carol@example.com" - -############################################################################### -# Orphan commit from another remote + merge -############################################################################### -git checkout --orphan imported -q -git rm -rf . > /dev/null 2>&1 || true -echo "Imported commit content" > imported.txt -git add imported.txt -git_commit "Imported commit: independent history from another remote" \ - "2023-01-03T00:00:00+00:00" \ - "Dave" "dave@example.com" - -git checkout master -q -git_commit "Merge imported history from remote" \ - "2023-01-03T01:00:00+00:00" \ - "Alice" "alice@example.com" \ - && git merge --allow-unrelated-histories imported -m "$(git log -1 --pretty=%B)" -q - -############################################################################### -# Classical branch merges -############################################################################### - -# feature branch -git checkout -b feature -q -echo "Feature update: appended line" >> file1-renamed.txt -git add file1-renamed.txt -git_commit "Feature: update file1-renamed.txt" \ - "2023-01-02T03:00:00+00:00" \ - "Bob" "bob@example.com" - -echo "Content for file6 from feature branch" > file6.txt -git add file6.txt -git_commit "Feature: add file6.txt" \ - "2023-01-02T03:30:00+00:00" \ - "Carol" "carol@example.com" - -git checkout master -q -git_commit "Merge branch 'feature'" \ - "2023-01-02T04:00:00+00:00" \ - "Alice" "alice@example.com" \ - && git merge --no-ff feature -m "$(git log -1 --pretty=%B)" -q - -# bugfix branch -git checkout -b bugfix -q -echo "Bugfix: corrected a typo in file2.txt" >> file2.txt -git add file2.txt -git_commit "Bugfix: update file2.txt with correction" \ - "2023-01-02T04:30:00+00:00" \ - "Alice" "alice@example.com" - -echo "Bugfix: final adjustment to file2.txt" >> file2.txt -git add file2.txt -git_commit "Bugfix: further update to file2.txt" \ - "2023-01-02T05:00:00+00:00" \ - "Bob" "bob@example.com" - -git checkout master -q -git_commit "Merge branch 'bugfix'" \ - "2023-01-02T05:30:00+00:00" \ - "Carol" "carol@example.com" \ - && git merge --no-ff bugfix -m "$(git log -1 --pretty=%B)" -q - -############################################################################### -# Octopus merge of three branches -############################################################################### -# Create each octo branch -for b in octo1 octo2 octo3; do - git checkout -b "$b" master -q - echo "Change from $b" > "$b".txt - git add "$b".txt - - case "$b" in - octo1) - author="Alice"; email="alice@example.com"; date="2023-01-02T06:00:00+00:00" - ;; - octo2) - author="Bob"; email="bob@example.com"; date="2023-01-02T06:30:00+00:00" - ;; - octo3) - author="Carol"; email="carol@example.com"; date="2023-01-02T07:00:00+00:00" - ;; - esac - - msg="Octo ${b: -1}: Add ${b}.txt" - git_commit "$msg" "$date" "$author" "$email" -done - -# Back to master and do a *single* deterministic octopus merge: -git checkout master -q -GIT_AUTHOR_DATE="2023-01-02T07:30:00+00:00" \ -GIT_COMMITTER_DATE="2023-01-02T07:30:00+00:00" \ -GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_COMMITTER_NAME="Alice" GIT_COMMITTER_EMAIL="alice@example.com" \ - git merge --no-ff octo1 octo2 octo3 \ - -m "Octopus merge of octo1, octo2, and octo3" -q - -exit 0 diff --git a/binocular-backend-new/ffi/src/test/resources/fixtures/simple.sh b/binocular-backend-new/ffi/src/test/resources/fixtures/simple.sh deleted file mode 100755 index 665c1c6c4..000000000 --- a/binocular-backend-new/ffi/src/test/resources/fixtures/simple.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Force Git to use UTC for all timestamps -export TZ=UTC - -# Usage check -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -REPO_DIR="$(pwd)/$1" -REMOTE_DIR="${REPO_DIR}_remote.git" - -# Create bare repository to act as dummy remote -mkdir -p "$REMOTE_DIR" -git init --bare "$REMOTE_DIR" >/dev/null 2>&1 - -# Create and populate local repository -mkdir -p "$REPO_DIR" -cd "$REPO_DIR" - -# Initialize on master branch explicitly -git init -q -b master - -############################################################################### -# Helper to commit with fixed author/committer data -############################################################################### -git_commit() { - local msg="$1"; shift - local date="$1"; shift - local name="$1"; shift - local email="$1" - GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ - GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" \ - GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" \ - git commit -m "$msg" -q -} - -# Add dummy remote reference -git remote add origin "$REMOTE_DIR" - -############################################################################### -# Commits 1–15: Initial history -############################################################################### - -# 1: Initial commit by Alice -echo "Hello, world!" > file1.txt -git add file1.txt -git_commit "Initial commit" \ - "2023-01-01T12:00:00+00:00" \ - "Alice" "alice@example.com" - -git push -u origin master -q - -# 2: Append to file1.txt by Bob -echo "Additional content" >> file1.txt -git add file1.txt -git_commit "Append to file1.txt" \ - "2023-01-01T13:00:00+00:00" \ - "Bob" "bob@example.com" - -# 3: Add file2.txt by Carol -echo "This is file2" > file2.txt -git add file2.txt -git_commit "Add file2.txt" \ - "2023-01-01T14:00:00+00:00" \ - "Carol" "carol@example.com" - -# 4: Modify file2.txt by Alice -echo "More content for file2" >> file2.txt -git add file2.txt -git_commit "Modify file2.txt" \ - "2023-01-01T15:00:00+00:00" \ - "Alice" "alice@example.com" - -# 5: Rename file1.txt to file1-renamed.txt by Bob -git mv file1.txt file1-renamed.txt -git_commit "Rename file1.txt to file1-renamed.txt" \ - "2023-01-01T16:00:00+00:00" \ - "Bob" "bob@example.com" - -# 6: Delete file2.txt by Carol -git rm file2.txt -git_commit "Delete file2.txt" \ - "2023-01-01T17:00:00+00:00" \ - "Carol" "carol@example.com" - -# 7: Create file3.txt by Alice (with differing author/committer times) -echo "Content of file3" > file3.txt -git add file3.txt -GIT_AUTHOR_DATE="2023-01-01T18:00:00+00:00" \ -git_commit "Create file3.txt" \ - "2023-01-01T18:05:00+00:00" \ - "Alice" "alice@example.com" - -# 8: Update file3.txt by Bob -echo "Appending more to file3" >> file3.txt -git add file3.txt -git_commit "Update file3.txt with more content" \ - "2023-01-01T19:00:00+00:00" \ - "Bob" "bob@example.com" - -# 9: Create dir1 and add file4.txt by Carol -mkdir -p dir1 -echo "Inside dir1" > dir1/file4.txt -git add dir1/file4.txt -git_commit "Create dir1 and add file4.txt" \ - "2023-01-01T20:00:00+00:00" \ - "Carol" "carol@example.com" - -# 10: Rename file4.txt inside dir1 by Alice -git mv dir1/file4.txt dir1/file4-renamed.txt -git_commit "Rename file4.txt to file4-renamed.txt in dir1" \ - "2023-01-01T21:00:00+00:00" \ - "Alice" "alice@example.com" - -# 11: Add a deterministic binary blob by Bob -dd if=/dev/zero bs=100 count=1 of=file5.bin status=none -git add file5.bin -git_commit "Add binary file file5.bin" \ - "2023-01-01T22:00:00+00:00" \ - "Bob" "bob@example.com" - -# 12: Delete file3.txt by Carol -git rm file3.txt -git_commit "Delete file3.txt" \ - "2023-01-01T23:00:00+00:00" \ - "Carol" "carol@example.com" - -# 13: Insert a line in file1-renamed.txt by Alice -awk 'NR==1{print; print "Inserted line"; next}1' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -git_commit "Modify file1-renamed.txt by inserting a line" \ - "2023-01-02T00:00:00+00:00" \ - "Alice" "alice@example.com" - -# Push current branches to remote -git push origin master -q - -# 14: Re-add file2.txt with new content by Bob -echo "Recreated file2" > file2.txt -git add file2.txt -GIT_AUTHOR_DATE="2023-01-02T00:30:00+00:00" \ -git_commit "Re-add file2.txt with new content" \ - "2023-01-02T01:00:00+00:00" \ - "Bob" "bob@example.com" - -exit 0 diff --git a/binocular-backend-new/infrastructure-arangodb/pom.xml b/binocular-backend-new/infrastructure-arangodb/pom.xml index 583ffadfa..ea4ee2bad 100644 --- a/binocular-backend-new/infrastructure-arangodb/pom.xml +++ b/binocular-backend-new/infrastructure-arangodb/pom.xml @@ -52,10 +52,15 @@ ${project.version} + + org.springframework.boot + spring-boot-configuration-processor + true + com.arangodb arangodb-spring-data - 4.4.2 + 4.6.0 org.springframework.boot @@ -70,7 +75,6 @@ com.github.goodforgod arangodb-testcontainer - ${arangodb-testcontainer.version} test @@ -98,13 +102,36 @@ org.jetbrains.kotlin kotlin-maven-plugin true + + + kapt + + kapt + + + + ${project.basedir}/src/main/kotlin + + + + org.springframework.boot + spring-boot-configuration-processor + + + + + -Xjsr305=strict spring + all-open + + + diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/InfrastructureConfig.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/InfrastructureConfig.kt index ab40c1900..77a3ac1c9 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/InfrastructureConfig.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/InfrastructureConfig.kt @@ -1,18 +1,21 @@ package com.inso_world.binocular.infrastructure.arangodb -import org.springframework.boot.context.properties.ConfigurationProperties +import com.inso_world.binocular.core.BinocularConfig import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Configuration @Configuration -@ConfigurationProperties(prefix = "binocular") @ComponentScan("com.inso_world.binocular.infrastructure.arangodb") -class InfrastructureConfig { +class InfrastructureConfig : BinocularConfig() { + lateinit var arangodb: AdbConfig +} + +class AdbConfig { lateinit var database: DatabaseConfig } class DatabaseConfig( - val databaseName: String, + val name: String, val host: String, val port: String, var user: String?, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/assembler/ProjectAssembler.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/assembler/ProjectAssembler.kt new file mode 100644 index 000000000..367aacdbf --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/assembler/ProjectAssembler.kt @@ -0,0 +1,135 @@ +package com.inso_world.binocular.infrastructure.arangodb.assembler + +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.ProjectMapper +import com.inso_world.binocular.model.Project +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Component + +/** + * Assembler for the Project aggregate. + * + * Orchestrates the mapping of Project domain objects to ProjectEntity persistence entities for ArangoDB, + * including its owned Repository child aggregate. Project is the root aggregate that owns + * all SCM-related data through its Repository. + * + * ## Aggregate Structure + * ``` + * Project (Root Aggregate) + * └── Repository (Owned Secondary Aggregate) + * ├── Commit* (owned children) + * ├── Branch* (owned children) + * └── User* (owned children) + * ``` + * + * ## Responsibilities + * - Convert Project domain to ProjectEntity using ProjectMapper + * - Orchestrate assembly of owned Repository (via RepositoryAssembler) + * - Wire Repository to Project maintaining bidirectional relationship + * - Manage MappingContext to ensure identity preservation + * + * ## Design Notes + * Project is the root aggregate that fully owns Repository. When assembling a Project, + * the complete object graph including Repository and all its children is built. + * This ensures identity preservation throughout the entire aggregate. + */ +@Component +internal class ProjectAssembler { + companion object { + private val logger by logger() + } + + @Autowired + private lateinit var projectMapper: ProjectMapper + + @Autowired + @Lazy + private lateinit var repositoryAssembler: RepositoryAssembler + + @Autowired + private lateinit var ctx: MappingContext + + /** + * Assembles a complete ProjectEntity from a Project domain aggregate. + * + * This method assembles the entire Project aggregate including its owned Repository + * and all Repository children (Commits, Branches, Users). The result is a fully + * identity-preserving object graph. + * + * ## Process + * 1. Check if Project already assembled (identity preservation) + * 2. Map Project structure using ProjectMapper (adds to context) + * 3. If Repository exists, assemble it completely using RepositoryAssembler + * 4. Wire Repository to Project entity + * + * @param domain The Project domain aggregate to assemble + * @return The fully assembled ProjectEntity with Repository and all children + */ + fun toEntity(domain: Project): ProjectEntity { + logger.debug("Assembling ProjectEntity for project: ${domain.name}") + + // Fast-path: Check if already assembled (identity preservation) + ctx.findEntity(domain)?.let { + logger.trace("Project already in context, returning cached entity") + return it + } + + // Phase 1: Map Project structure (adds to context) + val entity = projectMapper.toEntity(domain) + logger.trace("Mapped Project structure: id=${entity.id}") + + // Phase 2: Assemble owned Repository if present + domain.repo?.let { repository -> + logger.trace("Assembling owned Repository for Project") + val repoEntity = repositoryAssembler.toEntity(repository) + entity.repository = repoEntity + logger.trace("Wired Repository to Project: repoId=${repoEntity.id}") + } + + logger.debug("Assembled ProjectEntity with id=${entity.id}, hasRepository=${entity.repository != null}") + return entity + } + + /** + * Assembles a complete Project domain aggregate from a ProjectEntity. + * + * This method assembles the entire Project aggregate including its owned Repository + * and all Repository children. The result is a fully identity-preserving object graph. + * + * ## Process + * 1. Check if Project already assembled (identity preservation) + * 2. Map Project structure using ProjectMapper (adds to context) + * 3. If RepositoryEntity exists, assemble it completely using RepositoryAssembler + * 4. Wire Repository to Project domain + * + * @param entity The ProjectEntity to convert + * @return The fully assembled Project domain aggregate + */ + fun toDomain(entity: ProjectEntity): Project { + logger.debug("Assembling Project domain for entity id=${entity.id}") + + // Fast-path: Check if already assembled (identity preservation) + ctx.findDomain(entity)?.let { + logger.trace("Project already in context, returning cached domain") + return it + } + + // Phase 1: Map Project structure (adds to context) + val domain = projectMapper.toDomain(entity) + logger.trace("Mapped Project structure: ${domain.name}") + + // Phase 2: Assemble owned Repository if present + entity.repository?.let { repoEntity -> + logger.trace("Assembling owned Repository from ProjectEntity") + val repository = repositoryAssembler.toDomain(repoEntity) + domain.repo = repository + logger.trace("Wired Repository to Project: ${repository.localPath}") + } + + logger.debug("Assembled Project domain: ${domain.name}, hasRepository=${domain.repo != null}") + return domain + } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/assembler/RepositoryAssembler.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/assembler/RepositoryAssembler.kt new file mode 100644 index 000000000..2cfbdad16 --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/assembler/RepositoryAssembler.kt @@ -0,0 +1,239 @@ +package com.inso_world.binocular.infrastructure.arangodb.assembler + +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.BranchMapper +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.CommitMapper +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.ProjectMapper +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.DeveloperMapper +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.RepositoryMapper +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Component + +/** + * Assembler for the Repository aggregate. + * + * Orchestrates the complete mapping of Repository aggregate including all owned entities + * (Commits, Branches, Users) for ArangoDB while maintaining a reference to its parent Project aggregate. + * Repository is a secondary aggregate owned by Project, responsible for all SCM-related data. + * + * ## Aggregate Structure + * ``` + * Repository (Secondary Aggregate, owned by Project) + * ├── Commit* (owned children) + * │ ├── parents → Commit* (graph relationships) + * │ └── children → Commit* (graph relationships) + * ├── Branch* (owned children) + * ├── User* (owned children) + * └── → Project (parent aggregate reference) + * ``` + * + * ## Responsibilities + * - Convert Repository domain to RepositoryEntity using RepositoryMapper + * - Orchestrate mapping of all child entities (Commits, Branches, Users) + * - Wire bidirectional relationships within the aggregate: + * - Commit author/committer relationships + * - Commit parent/child graph relationships (two-pass assembly) + * - Branch head references + * - Manage parent Project reference (creates minimal reference if not in context) + * - Coordinate with MappingContext to ensure identity preservation + * + * ## Design Principles + * - **Identity Preservation**: Returns identity-preserving objects for all children + * - **Aggregate Boundaries**: Does NOT fully build parent Project when assembled standalone + * - **Parent Reference**: If Project not in context, creates minimal Project structure (no Repository child) + * - **Top-Down Mapping**: Repository controls mapping of its owned children + * - **Two-Pass Graph Assembly**: Commits mapped first, then parent/child relationships wired second + * - **Context-Aware**: Uses MappingContext for identity map pattern + * - **Separation of Concerns**: Mappers do simple conversion, assembler orchestrates + * + * ## Usage Scenarios + * + * ### Scenario 1: Assembled via ProjectAssembler (typical) + * ```kotlin + * // Project is root aggregate, assembles everything + * val projectEntity = projectAssembler.toEntity(project) + * // Project is already in context when Repository is assembled + * ``` + * + * ### Scenario 2: Assembled standalone + * ```kotlin + * // Repository assembled independently (e.g., for partial updates) + * val repositoryEntity = repositoryAssembler.toEntity(repository) + * // Creates minimal Project reference, doesn't build full Project aggregate + * ``` + * + * ## ArangoDB-Specific Notes + * - ArangoDB uses document-based storage with references + * - Parent/child commit relationships are managed via commit SHA references + * - No separate Remote entities (not applicable for ArangoDB implementation) + */ +@Component +internal class RepositoryAssembler { + companion object { + private val logger by logger() + } + + @Autowired + private lateinit var repositoryMapper: RepositoryMapper + + @Autowired + @Lazy + private lateinit var commitMapper: CommitMapper + + @Autowired + @Lazy + private lateinit var branchMapper: BranchMapper + + @Autowired + @Lazy + private lateinit var developerMapper: DeveloperMapper + + @Autowired + private lateinit var projectMapper: ProjectMapper + + @Autowired + private lateinit var ctx: MappingContext + + /** + * Assembles a complete RepositoryEntity from a Repository domain aggregate. + * + * This method assembles the Repository and all its owned children (Commits, Branches, Users) + * with full identity preservation. It ensures a Project reference exists but does NOT + * fully build the parent Project aggregate when assembled standalone. + * + * ## Process + * 1. Check if Repository already assembled (identity preservation) + * 2. Ensure Project reference exists in context: + * - If found: reuse existing (typical when called via ProjectAssembler) + * - If not found: create minimal Project structure without Repository child + * 3. Map Repository structure using RepositoryMapper + * 4. Map all Commits (first pass): + * - Convert Commit → CommitEntity using CommitMapper + * - Wire author/committer relationships + * - Store in ArangoDB (commits are added to repository context) + * 5. Wire commit parent/child relationships (second pass): + * - Note: For ArangoDB, parent/child relationships are managed via commit SHAs + * - Relationships are typically lazy-loaded from the document store + * 6. Map all Branches and wire to RepositoryEntity + * + * ## ArangoDB Storage Note + * In ArangoDB, commits and branches are stored as separate documents with references. + * The assembler prepares the complete structure but actual relationship storage + * is handled by the ArangoDB persistence layer. + * + * @param domain The Repository domain aggregate to assemble + * @return The fully assembled RepositoryEntity with all children and identity preservation + */ + fun toEntity(domain: Repository): RepositoryEntity { + logger.debug("Assembling RepositoryEntity for repository: ${domain.localPath}") + + // Fast-path: Check if already assembled (identity preservation) + ctx.findEntity(domain)?.let { + logger.trace("Repository already in context, returning cached entity") + return it + } + + // Ensure Project reference exists in context (but don't assemble Repository child) + val projectEntity = ctx.findEntity(domain.project) + ?: run { + logger.trace("Project not in context, mapping minimal Project structure (no Repository child)") + projectMapper.toEntity(domain.project) + } + + logger.trace("Project reference in context: id=${projectEntity.id}") + + // Phase 1: Map Repository structure (without children) + val entity = repositoryMapper.toEntity(domain) + logger.trace("Mapped Repository structure: id=${entity.id}") + + // Phase 2: Map Commits + // Note: In ArangoDB, commits are stored as separate documents + // The mapper handles the conversion, and parent/child relationships + // are managed through commit SHA references in the document store + logger.trace("Mapping ${domain.commits.size} commits") + domain.commits.forEach { commit -> + // Map commit structure - this adds it to the MappingContext + commitMapper.toEntity(commit) + + // Map commit authors and committers + developerMapper.toEntity(commit.author) + developerMapper.toEntity(commit.committer) + } + + // Phase 3: Map Branches + // Note: In ArangoDB, branches reference commits by SHA + logger.trace("Mapping ${domain.branches.size} branches") + domain.branches.forEach { branch -> + branchMapper.toEntity(branch) + } + + logger.debug( + "Assembled RepositoryEntity: id=${entity.id}, " + + "commits=${domain.commits.size}, branches=${domain.branches.size}" + ) + + return entity + } + + /** + * Assembles a complete Repository domain aggregate from a RepositoryEntity. + * + * This method assembles the Repository and all its owned children (Commits, Branches, Users) + * with full identity preservation. It ensures a Project reference exists but does NOT + * fully build the parent Project aggregate when assembled standalone. + * + * ## Process + * 1. Check if Repository already assembled (identity preservation) + * 2. Ensure Project reference exists in context: + * - If found: reuse existing (typical when called via ProjectAssembler) + * - If not found: create minimal Project structure without Repository child + * 3. Map Repository structure using RepositoryMapper + * 4. Note: For ArangoDB, commits, branches, and users are lazy-loaded from the document store + * - The RepositoryEntity doesn't eagerly load all children + * - Relationships are resolved on-demand through ArangoDB queries + * + * ## ArangoDB Loading Note + * In ArangoDB, the Repository aggregate uses lazy loading for its children. + * Unlike SQL where we eagerly load all commits/branches, ArangoDB loads + * these on-demand when accessed. This is more efficient for large repositories. + * + * @param entity The RepositoryEntity to convert + * @return The fully assembled Repository domain aggregate with identity preservation + */ + fun toDomain(entity: RepositoryEntity): Repository { + logger.debug("Assembling Repository domain for entity id=${entity.id}") + + // Fast-path: Check if already assembled (identity preservation) + ctx.findDomain(entity)?.let { + logger.trace("Repository already in context, returning cached domain") + return it + } + + // Ensure Project reference exists in context (but don't assemble Repository child) + val project = ctx.findDomain(entity.project) + ?: run { + logger.trace("Project not in context, mapping minimal Project structure (no Repository child)") + projectMapper.toDomain(entity.project) + } + + logger.trace("Project reference in context: ${project.name}") + + // Phase 1: Map Repository structure + val domain = repositoryMapper.toDomain(entity) + logger.trace("Mapped Repository structure: ${domain.localPath}") + + // Note: In ArangoDB, commits, branches, and users are lazy-loaded + // The domain object is returned with the structure in place, + // and children will be loaded on-demand when accessed + + logger.debug("Assembled Repository domain: ${domain.localPath}") + + return domain + } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/interfaces/node/IRepositoryDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/interfaces/node/IRepositoryDao.kt index 7b953d677..c4f53903d 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/interfaces/node/IRepositoryDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/interfaces/node/IRepositoryDao.kt @@ -1,8 +1,9 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.IDao +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity import com.inso_world.binocular.model.Repository internal interface IRepositoryDao : IDao { - fun findByName(name: String): Repository? + fun findByName(name: String): RepositoryEntity? } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/AccountDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/AccountDao.kt index da0e8df26..fe5ca1a65 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/AccountDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/AccountDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository * - Implementing the specific interface (IAccountDao) */ @Repository -class AccountDao( +internal class AccountDao( @Autowired accountRepository: AccountRepository, @Autowired accountMapper: AccountMapper, ) : MappedArangoDbDao(accountRepository, accountMapper), diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ArangodbAppConfig.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ArangodbAppConfig.kt index 486643166..c14eb0fc0 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ArangodbAppConfig.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ArangodbAppConfig.kt @@ -5,6 +5,7 @@ import com.arangodb.springframework.annotation.EnableArangoRepositories import com.arangodb.springframework.config.ArangoConfiguration import com.inso_world.binocular.infrastructure.arangodb.InfrastructureConfig import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Configuration @Configuration @@ -18,15 +19,15 @@ class ArangodbAppConfig( var builder = ArangoDB .Builder() - .host(infraConfig.database.host, infraConfig.database.port.toInt()) + .host(infraConfig.arangodb.database.host, infraConfig.arangodb.database.port.toInt()) - builder = infraConfig.database.user?.let { builder.user(it) } - builder = infraConfig.database.password?.let { builder.password(it) } + builder = infraConfig.arangodb.database.user?.let { builder.user(it) } + builder = infraConfig.arangodb.database.password?.let { builder.password(it) } return builder } - override fun database(): String = infraConfig.database.databaseName + override fun database(): String = infraConfig.arangodb.database.name override fun returnOriginalEntities(): Boolean = false } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/BranchDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/BranchDao.kt index c4f07954c..c4bc445ce 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/BranchDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/BranchDao.kt @@ -17,7 +17,7 @@ import org.springframework.stereotype.Repository */ @Repository -class BranchDao( +internal class BranchDao( @Autowired branchRepository: BranchRepository, @Autowired branchMapper: BranchMapper, ) : MappedArangoDbDao(branchRepository, branchMapper), diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/BuildDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/BuildDao.kt index 03f7d9d47..6ef6da99c 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/BuildDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/BuildDao.kt @@ -17,7 +17,7 @@ import org.springframework.stereotype.Repository */ @Repository -class BuildDao( +internal class BuildDao( @Autowired buildRepository: BuildRepository, @Autowired buildMapper: BuildMapper, ) : MappedArangoDbDao(buildRepository, buildMapper), diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/FileDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/FileDao.kt index 6aa583c8b..49e96c9ee 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/FileDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/FileDao.kt @@ -17,7 +17,7 @@ import org.springframework.stereotype.Repository */ @Repository -class FileDao( +internal class FileDao( @Autowired fileRepository: FileRepository, @Autowired fileMapper: FileMapper, ) : MappedArangoDbDao(fileRepository, fileMapper), diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/IssueDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/IssueDao.kt index cca896be3..d3e9ed6dd 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/IssueDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/IssueDao.kt @@ -17,7 +17,7 @@ import org.springframework.stereotype.Repository */ @Repository -class IssueDao( +internal class IssueDao( @Autowired issueRepository: IssueRepository, @Autowired issueMapper: IssueMapper, ) : MappedArangoDbDao(issueRepository, issueMapper), diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MappedArangoDbDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MappedArangoDbDao.kt index 888945fc4..6926130e8 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MappedArangoDbDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MappedArangoDbDao.kt @@ -2,6 +2,7 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.dao.nosql.a import com.arangodb.springframework.repository.ArangoRepository import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingSession import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.IDao import org.springframework.data.domain.Pageable @@ -66,6 +67,7 @@ open class MappedArangoDbDao( /** * Finds a page of entities and converts them to domain models */ + @MappingSession override fun findAll(pageable: Pageable): Page { val result = repository.findAll(pageable) val content = toDomainList(result.content) @@ -79,6 +81,7 @@ open class MappedArangoDbDao( * @param entity The domain model to create an entity from * @return The created domain model */ + @MappingSession override fun create(entity: D): D { val mappedEntity = mapper.toEntity(entity) val savedEntity = repository.save(mappedEntity) @@ -90,6 +93,7 @@ open class MappedArangoDbDao( * @param entity The domain model to update an entity from * @return The updated domain model */ + @MappingSession override fun update(entity: D): D { val mappedEntity = mapper.toEntity(entity) val savedEntity = repository.save(mappedEntity) @@ -137,6 +141,7 @@ open class MappedArangoDbDao( /** * Save multiple entities */ + @MappingSession override fun saveAll(entities: Collection): Iterable = entities.map { create(it) } override fun findAllAsStream(): Stream { diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MergeRequestDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MergeRequestDao.kt index 05397e441..b82edecda 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MergeRequestDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MergeRequestDao.kt @@ -9,7 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository @Repository -class MergeRequestDao +internal class MergeRequestDao @Autowired constructor( mergeRequestRepository: MergeRequestRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MilestoneDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MilestoneDao.kt index 0396ad432..34f8fe266 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MilestoneDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/MilestoneDao.kt @@ -9,7 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository @Repository -class MilestoneDao +internal class MilestoneDao @Autowired constructor( milestoneRepository: MilestoneRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ModuleDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ModuleDao.kt index 9123a78e7..1aca0c35b 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ModuleDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ModuleDao.kt @@ -8,7 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository @Repository -class ModuleDao +internal class ModuleDao @Autowired constructor( moduleRepository: ModuleRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/NoteDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/NoteDao.kt index 86fed2fb7..de3526444 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/NoteDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/NoteDao.kt @@ -9,7 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository @Repository -class NoteDao +internal class NoteDao @Autowired constructor( noteRepository: NoteRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ProjectDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ProjectDao.kt index 2cb9cfe67..aefd6a1cb 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ProjectDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/ProjectDao.kt @@ -1,5 +1,7 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.dao.nosql.arangodb +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.context.MappingSession import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node.IProjectDao import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.ProjectEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.ProjectMapper @@ -9,11 +11,15 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository @Repository -class ProjectDao @Autowired constructor( +internal class ProjectDao @Autowired constructor( private val projectRepository: ProjectRepository, projectMapper: ProjectMapper, ) : MappedArangoDbDao(projectRepository, projectMapper), IProjectDao { + companion object { + val logger by logger() + } + @Autowired private lateinit var repositoryDao: RepositoryDao @@ -23,11 +29,35 @@ class ProjectDao @Autowired constructor( } } + fun create(entity: ProjectEntity): ProjectEntity { + logger.debug("Creating new project: {}", entity) + + var savedEntity = projectRepository.save(entity) + + savedEntity = entity.repository?.let { repository -> + val savedRepo = repositoryDao.create(repository) + savedEntity.repository = savedRepo + // update so that @Ref gets updated + return@let projectRepository.save(savedEntity) + } ?: savedEntity + + return savedEntity + } + + @MappingSession override fun create(entity: Project): Project { + logger.debug("Creating new project: {}", entity) + val mappedEntity = mapper.toEntity(entity) - val savedEntity = projectRepository.save(mappedEntity) - val mappedDomain = mapper.toDomain(savedEntity) + var savedEntity = projectRepository.save(mappedEntity) + + savedEntity = mappedEntity.repository?.let { repository -> + val savedRepo = repositoryDao.create(repository) + savedEntity.repository = savedRepo + // update so that @Ref gets updated + return@let projectRepository.save(savedEntity) + } ?: savedEntity - return mappedDomain + return mapper.toDomain(savedEntity) } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/RepositoryDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/RepositoryDao.kt index c4eb4800c..42db66e85 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/RepositoryDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/RepositoryDao.kt @@ -1,37 +1,52 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.dao.nosql.arangodb +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node.IRepositoryDao import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity -import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.ProjectMapper import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.RepositoryMapper import com.inso_world.binocular.infrastructure.arangodb.persistence.repository.RepositoryRepository import com.inso_world.binocular.model.Repository import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Repository as SpringRepository @SpringRepository -class RepositoryDao @Autowired constructor( +internal class RepositoryDao @Autowired constructor( private val repositoryRepository: RepositoryRepository, - repositoryMapper: RepositoryMapper, -) : MappedArangoDbDao(repositoryRepository, repositoryMapper), IRepositoryDao { + private val repositoryMapper: RepositoryMapper, +) + : + MappedArangoDbDao(repositoryRepository, repositoryMapper), + IRepositoryDao +{ - @Autowired - private lateinit var projectMapper: ProjectMapper + companion object { + val logger by logger() + } - override fun findByName(name: String): Repository? { - return this.repositoryRepository.findByName(name)?.let { - this.mapper.toDomain(it) - } + @Autowired @Lazy + private lateinit var projectDao: ProjectDao + +// fun findAll(): Iterable { +// return this.repositoryRepository.findAll() +// } + +// @Autowired +// private lateinit var projectMapper: ProjectMapper + + override fun findByName(name: String): RepositoryEntity? { + return this.repositoryRepository.findByLocalPath(name) } - override fun create(entity: Repository): Repository { - val mappedEntity = mapper.toEntity(entity) - return repository.save(mappedEntity).let { - mapper.toDomain(it) - }.apply { - project = entity.project - requireNotNull(entity.project).repo = this + fun create(entity: RepositoryEntity): RepositoryEntity { + val savedEntity = repositoryRepository.save(entity) + + val existingProject = this.projectDao.findByName(entity.project.name) + if(existingProject == null) { + this.projectDao.create(entity.project) } + + return savedEntity } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/UserDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/UserDao.kt index af0cdf153..bacbab9b9 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/UserDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/UserDao.kt @@ -9,7 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository @Repository -class UserDao +internal class UserDao @Autowired constructor( userRepository: UserRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/BranchFileConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/BranchFileConnectionDao.kt index adf0c3461..cea10984b 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/BranchFileConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/BranchFileConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class BranchFileConnectionDao +internal class BranchFileConnectionDao @Autowired constructor( private val repository: BranchFileConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/BranchFileFileConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/BranchFileFileConnectionDao.kt index 9e5dbcc7b..51b5ea573 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/BranchFileFileConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/BranchFileFileConnectionDao.kt @@ -15,7 +15,7 @@ import org.springframework.stereotype.Repository */ @Repository -class BranchFileFileConnectionDao +internal class BranchFileFileConnectionDao @Autowired constructor( private val repository: BranchFileFileConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitBuildConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitBuildConnectionDao.kt index de135ba53..a0b78416e 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitBuildConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitBuildConnectionDao.kt @@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository */ @Repository -class CommitBuildConnectionDao +internal class CommitBuildConnectionDao @Autowired constructor( private val repository: CommitBuildConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitFileConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitFileConnectionDao.kt index a482439b0..410ded185 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitFileConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitFileConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class CommitFileConnectionDao +internal class CommitFileConnectionDao @Autowired constructor( private val repository: CommitFileConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitFileUserConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitFileUserConnectionDao.kt index d63bb2a04..063ff1007 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitFileUserConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitFileUserConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class CommitFileUserConnectionDao +internal class CommitFileUserConnectionDao @Autowired constructor( private val repository: CommitFileUserConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitModuleConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitModuleConnectionDao.kt index 2b18eb85e..4bc28f030 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitModuleConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitModuleConnectionDao.kt @@ -20,7 +20,7 @@ import org.springframework.stereotype.Repository */ @Repository -class CommitModuleConnectionDao +internal class CommitModuleConnectionDao @Autowired constructor( private val repository: CommitModuleConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitUserConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitUserConnectionDao.kt index 9897c6580..c288c9dfa 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitUserConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/CommitUserConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class CommitUserConnectionDao +internal class CommitUserConnectionDao @Autowired constructor( private val repository: CommitUserConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueAccountConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueAccountConnectionDao.kt index abd2a5fdc..4e08931f7 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueAccountConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueAccountConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class IssueAccountConnectionDao +internal class IssueAccountConnectionDao @Autowired constructor( private val repository: IssueAccountConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueCommitConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueCommitConnectionDao.kt index e730ef88e..92aff79a8 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueCommitConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueCommitConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class IssueCommitConnectionDao +internal class IssueCommitConnectionDao @Autowired constructor( private val repository: IssueCommitConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueMilestoneConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueMilestoneConnectionDao.kt index 0a3970610..0308afaee 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueMilestoneConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueMilestoneConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class IssueMilestoneConnectionDao +internal class IssueMilestoneConnectionDao @Autowired constructor( private val repository: IssueMilestoneConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueNoteConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueNoteConnectionDao.kt index 5b63124bc..9827d5d6b 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueNoteConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueNoteConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class IssueNoteConnectionDao +internal class IssueNoteConnectionDao @Autowired constructor( private val repository: IssueNoteConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueUserConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueUserConnectionDao.kt index 9ac8e0095..a6aa52ace 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueUserConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/IssueUserConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class IssueUserConnectionDao +internal class IssueUserConnectionDao @Autowired constructor( private val repository: IssueUserConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestAccountConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestAccountConnectionDao.kt index 3d6a70755..b9e12ae31 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestAccountConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestAccountConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class MergeRequestAccountConnectionDao +internal class MergeRequestAccountConnectionDao @Autowired constructor( private val repository: MergeRequestAccountConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestMilestoneConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestMilestoneConnectionDao.kt index 93a4a3676..377964742 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestMilestoneConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestMilestoneConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class MergeRequestMilestoneConnectionDao +internal class MergeRequestMilestoneConnectionDao @Autowired constructor( private val repository: MergeRequestMilestoneConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestNoteConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestNoteConnectionDao.kt index bf20c327b..d763f877a 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestNoteConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/MergeRequestNoteConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class MergeRequestNoteConnectionDao +internal class MergeRequestNoteConnectionDao @Autowired constructor( private val repository: MergeRequestNoteConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/ModuleFileConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/ModuleFileConnectionDao.kt index c6b59c8c7..5fde95148 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/ModuleFileConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/ModuleFileConnectionDao.kt @@ -20,7 +20,7 @@ import org.springframework.stereotype.Repository */ @Repository -class ModuleFileConnectionDao +internal class ModuleFileConnectionDao @Autowired constructor( private val repository: ModuleFileConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/ModuleModuleConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/ModuleModuleConnectionDao.kt index 9998d33ed..0bd987616 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/ModuleModuleConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/ModuleModuleConnectionDao.kt @@ -17,7 +17,7 @@ import org.springframework.stereotype.Repository */ @Repository -class ModuleModuleConnectionDao +internal class ModuleModuleConnectionDao @Autowired constructor( private val repository: ModuleModuleConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/NoteAccountConnectionDao.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/NoteAccountConnectionDao.kt index fa42b877c..375519277 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/NoteAccountConnectionDao.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/dao/nosql/arangodb/connection/NoteAccountConnectionDao.kt @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository */ @Repository -class NoteAccountConnectionDao +internal class NoteAccountConnectionDao @Autowired constructor( private val repository: NoteAccountConnectionRepository, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/AccountEntity.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/AccountEntity.kt index 80e18a805..16b610aa2 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/AccountEntity.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/AccountEntity.kt @@ -5,7 +5,6 @@ import com.arangodb.springframework.annotation.Relations import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.IssueAccountConnectionEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.MergeRequestAccountConnectionEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.NoteAccountConnectionEntity -import com.inso_world.binocular.model.Platform import org.springframework.data.annotation.Id /** @@ -15,7 +14,7 @@ import org.springframework.data.annotation.Id data class AccountEntity( @Id var id: String? = null, - var platform: Platform? = null, + var platform: PlatformEntity? = null, var login: String? = null, var name: String? = null, var avatarUrl: String? = null, diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/BranchEntity.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/BranchEntity.kt index d30928306..9c9916060 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/BranchEntity.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/BranchEntity.kt @@ -1,26 +1,53 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.entity import com.arangodb.springframework.annotation.Document +import com.arangodb.springframework.annotation.Field +import com.arangodb.springframework.annotation.PersistentIndexed +import com.arangodb.springframework.annotation.Ref import com.arangodb.springframework.annotation.Relations import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.BranchFileConnectionEntity import org.springframework.data.annotation.Id +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * ArangoDB-specific Branch entity. + * + * Represents the persistence layer for the [Branch][com.inso_world.binocular.model.Branch] domain object. + * + * ### Identity Mapping + * - [id]: ArangoDB internal document ID (_key) + * - [iid]: Domain immutable identity (UUID) + * - [name]: Branch name (business key component with repository) + * + * ### Relationships + * - [repository]: Owning repository (required) + * - [files]: Related files via edge collection + * + * ### Indexes + * - [iid]: Unique persistent index for UUID-based lookups */ +@OptIn(ExperimentalUuidApi::class) @Document("branches") data class BranchEntity( @Id var id: String? = null, - var branch: String? = null, + @Field("iid") + @PersistentIndexed(unique = true) + var iid: Uuid, + var name: String, + var fullName: String, + var category: String, var active: Boolean = false, var tracksFileRenames: Boolean = false, var latestCommit: String? = null, + @Ref(lazy = false) + val repository: RepositoryEntity, @Relations( edges = [BranchFileConnectionEntity::class], lazy = true, maxDepth = 1, direction = Relations.Direction.OUTBOUND, ) - var files: List = emptyList(), + var files: Set = emptySet(), ) diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/CommitEntity.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/CommitEntity.kt index 0366a2af0..c50309968 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/CommitEntity.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/CommitEntity.kt @@ -1,41 +1,112 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.entity -import com.arangodb.springframework.annotation.* +import com.arangodb.springframework.annotation.Document +import com.arangodb.springframework.annotation.Field +import com.arangodb.springframework.annotation.PersistentIndexed +import com.arangodb.springframework.annotation.Ref +import com.arangodb.springframework.annotation.Relations import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.CommitBuildConnectionEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.CommitCommitConnectionEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.CommitFileConnectionEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.CommitModuleConnectionEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.CommitUserConnectionEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.IssueCommitConnectionEntity +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature import org.springframework.data.annotation.Id import java.util.Date +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * ArangoDB-specific Commit entity. */ +@OptIn(ExperimentalUuidApi::class) @Document(collection = "commits") data class CommitEntity( @Id var id: String? = null, @Field("sha") @PersistentIndexed(unique = true) var sha: String, + var iid: Uuid, var date: Date? = null, var message: String? = null, var webUrl: String? = null, var branch: String? = null, var stats: StatsEntity? = null, - @Relations(edges = [CommitCommitConnectionEntity::class], lazy = true, maxDepth = 1, direction = Relations.Direction.OUTBOUND) + @Relations( + edges = [CommitCommitConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.OUTBOUND + ) var parents: List = emptyList(), - @Relations(edges = [CommitCommitConnectionEntity::class], lazy = true, maxDepth = 1, direction = Relations.Direction.INBOUND) + @Relations( + edges = [CommitCommitConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.INBOUND + ) var children: List = emptyList(), - @Relations(edges = [CommitBuildConnectionEntity::class], lazy = true, maxDepth = 1, direction = Relations.Direction.OUTBOUND) + @Relations( + edges = [CommitBuildConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.OUTBOUND + ) var builds: List = emptyList(), - @Relations(edges = [CommitFileConnectionEntity::class], lazy = true, maxDepth = 1, direction = Relations.Direction.OUTBOUND) + @Relations( + edges = [CommitFileConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.OUTBOUND + ) var files: List = emptyList(), - @Relations(edges = [CommitModuleConnectionEntity::class], lazy = true, maxDepth = 1, direction = Relations.Direction.OUTBOUND) + @Relations( + edges = [CommitModuleConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.OUTBOUND + ) var modules: List = emptyList(), - @Relations(edges = [CommitUserConnectionEntity::class], lazy = true, maxDepth = 1, direction = Relations.Direction.OUTBOUND) + @Relations( + edges = [CommitUserConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.OUTBOUND + ) var users: List = emptyList(), - @Relations(edges = [IssueCommitConnectionEntity::class], lazy = true, maxDepth = 1, direction = Relations.Direction.INBOUND) + @Relations( + edges = [IssueCommitConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.INBOUND + ) var issues: List = emptyList(), + @Ref(lazy = false) + val repository: RepositoryEntity ) + +@OptIn(ExperimentalUuidApi::class) +internal fun Commit.toEntity( + repository: RepositoryEntity, + author: DeveloperEntity, + committer: DeveloperEntity, +): CommitEntity = + CommitEntity( + iid = this.iid.value, + sha = this.sha, +// authorDateTime = this.authorSignature.timestamp, +// commitDateTime = (this.committerSignature ?: this.authorSignature).timestamp, + message = this.message, + webUrl = this.webUrl, + repository = repository, +// parents = mutableSetOf(), +// children = mutableSetOf(), +// author = author, +// committer = committer, + ).apply { + this.id = this@toEntity.id?.trim() + } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/DeveloperEntity.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/DeveloperEntity.kt new file mode 100644 index 000000000..8538523b5 --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/DeveloperEntity.kt @@ -0,0 +1,117 @@ +package com.inso_world.binocular.infrastructure.arangodb.persistence.entity + +import com.arangodb.springframework.annotation.Document +import com.arangodb.springframework.annotation.Ref +import com.arangodb.springframework.annotation.Relations +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.CommitFileUserConnectionEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.CommitUserConnectionEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.IssueUserConnectionEntity +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Repository +import org.springframework.data.annotation.Id + +/** + * ArangoDB-specific Developer entity. + * + * This entity maps the domain [Developer] model to ArangoDB storage. + * Unlike the SQL implementation which uses separate name/email columns, + * this stores the combined git signature for consistency with the existing + * UserEntity pattern. + * + * @property id ArangoDB document ID + * @property gitSignature Combined "Name " git signature format + * @property iid Domain-level unique identifier (UUID-based) + * @property repository Reference to the owning repository + */ +@Document("developers") +data class DeveloperEntity( + @Id + var id: String? = null, + var gitSignature: String, + val iid: Developer.Id, + @Ref(lazy = true) + var repository: RepositoryEntity, + @Relations( + edges = [CommitUserConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.INBOUND, + ) + var commits: List = emptyList(), + @Relations( + edges = [IssueUserConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.INBOUND, + ) + var issues: Set = emptySet(), + @Relations( + edges = [CommitFileUserConnectionEntity::class], + lazy = true, + maxDepth = 1, + direction = Relations.Direction.INBOUND, + ) + var files: Set = emptySet(), +) { + /** + * Business key combining repository ID and email for uniqueness. + */ + data class Key(val repositoryId: String?, val email: String) + + /** + * Extracts the name portion from the git signature. + * Format expected: "Name " + */ + val name: String + get() { + val nameRegex = Regex("""^(.+?)\s*<""") + return nameRegex.find(gitSignature)?.groupValues?.get(1)?.trim() + ?: throw IllegalArgumentException("Could not extract name from gitSignature: $gitSignature") + } + + /** + * Extracts the email portion from the git signature. + * Format expected: "Name " + */ + val email: String + get() { + val emailRegex = Regex("""<([^>]+)>$""") + return emailRegex.find(gitSignature)?.groupValues?.get(1) + ?: throw IllegalArgumentException("Could not extract email from gitSignature: $gitSignature") + } + + /** + * Business key for entity lookups. + */ + val uniqueKey: Key + get() = Key(repositoryId = repository.id, email = email) + + /** + * Converts this entity to a domain Developer object. + * + * @param repository The domain repository (must be the owner) + * @return The domain Developer + */ + fun toDomain(repository: Repository): Developer = + Developer( + name = this.name, + email = this.email, + repository = repository, + ).apply { + this.id = this@DeveloperEntity.id + } +} + +/** + * Extension function to convert a domain Developer to an ArangoDB entity. + * + * @param repository The repository entity (must be the owner) + * @return The DeveloperEntity + */ +internal fun Developer.toEntity(repository: RepositoryEntity): DeveloperEntity = + DeveloperEntity( + id = this.id, + gitSignature = this.gitSignature, + iid = this.iid, + repository = repository, + ) \ No newline at end of file diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/PlatformEntity.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/PlatformEntity.kt new file mode 100644 index 000000000..59d4f92bf --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/PlatformEntity.kt @@ -0,0 +1,6 @@ +package com.inso_world.binocular.infrastructure.arangodb.persistence.entity + +enum class PlatformEntity { + GitHub, + GitLab +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/ProjectEntity.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/ProjectEntity.kt index ddc8e3273..9c4ddc90d 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/ProjectEntity.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/ProjectEntity.kt @@ -1,12 +1,69 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.entity import com.arangodb.springframework.annotation.Document +import com.arangodb.springframework.annotation.Field +import com.arangodb.springframework.annotation.PersistentIndexed +import com.arangodb.springframework.annotation.Ref import org.springframework.data.annotation.Id +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +/** + * ArangoDB-specific Project entity. + * + * Represents the persistence layer for the [Project][com.inso_world.binocular.model.Project] domain object. + * + * ### Identity Mapping + * - [id]: ArangoDB internal document ID (_key) + * - [iid]: Domain immutable identity (UUID) + * - [name]: Business key (unique) + * + * ### Relationships + * - [repository]: Optional owning repository (set-once semantics enforced in domain) + * + * ### Indexes + * - [iid]: Unique persistent index for UUID-based lookups + */ +@OptIn(ExperimentalUuidApi::class) @Document("projects") data class ProjectEntity( @Id var id: String? = null, + @Field("iid") + @PersistentIndexed(unique = true) + var iid: Uuid, var name: String, var description: String? = null, +) { + @Ref + var repository: RepositoryEntity? = null + + /** + * Converts this ProjectEntity to a Project domain object. + * + * @param repo Optional repository to associate with the project + * @return Project domain object + */ + fun toDomain(repo: com.inso_world.binocular.model.Repository? = null): com.inso_world.binocular.model.Project { + return com.inso_world.binocular.model.Project( + name = this.name + ).apply { + this.id = this@ProjectEntity.id + this.description = this@ProjectEntity.description + repo?.let { this.repo = it } + } + } +} + +/** + * Converts a Project domain object to ProjectEntity. + * + * @return ProjectEntity for persistence + */ +@OptIn(ExperimentalUuidApi::class) +internal fun com.inso_world.binocular.model.Project.toEntity(): ProjectEntity = ProjectEntity( + id = this.id, + iid = this.iid.value, + name = this.name, + description = this.description ) diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/RepositoryEntity.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/RepositoryEntity.kt index 2a6555065..5dc53e733 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/RepositoryEntity.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/RepositoryEntity.kt @@ -1,13 +1,72 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.entity import com.arangodb.springframework.annotation.Document +import com.arangodb.springframework.annotation.Field +import com.arangodb.springframework.annotation.PersistentIndexed +import com.arangodb.springframework.annotation.Ref import org.springframework.data.annotation.Id +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +/** + * ArangoDB-specific Repository entity. + * + * Represents the persistence layer for the [Repository][com.inso_world.binocular.model.Repository] domain object. + * + * ### Identity Mapping + * - [id]: ArangoDB internal document ID (_key) + * - [iid]: Domain immutable identity (UUID) + * - [localPath]: Business key component along with project + * + * ### Relationships + * - [project]: Owning project (required, establishes bidirectional link) + * + * ### Indexes + * - [iid]: Unique persistent index for UUID-based lookups + */ +@OptIn(ExperimentalUuidApi::class) @Document("repositories") data class RepositoryEntity( @Id var id: String? = null, - var name: String, - // minimal link to project - var projectId: String? = null, -) + @Field("iid") + @PersistentIndexed(unique = true) + var iid: Uuid, + var localPath: String, + @Ref(lazy = true) + val project: ProjectEntity +) { + init { + this.project.repository = this + } + + /** + * Converts this RepositoryEntity to a Repository domain object. + * + * @param project The project domain object to associate with the repository + * @return Repository domain object + */ + fun toDomain(project: com.inso_world.binocular.model.Project): com.inso_world.binocular.model.Repository { + return com.inso_world.binocular.model.Repository( + localPath = this.localPath.trim(), + project = project + ).apply { + this.id = this@RepositoryEntity.id + } + } +} + +/** + * Converts a Repository domain object to RepositoryEntity. + * + * @param project The ProjectEntity to associate with the repository + * @return RepositoryEntity for persistence + */ +@OptIn(ExperimentalUuidApi::class) +internal fun com.inso_world.binocular.model.Repository.toEntity(project: ProjectEntity): RepositoryEntity = + RepositoryEntity( + id = this.id, + iid = this.iid.value, + localPath = this.localPath.trim(), + project = project + ) diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/UserEntity.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/UserEntity.kt index f59c9e75b..f703a10cc 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/UserEntity.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/UserEntity.kt @@ -1,20 +1,47 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.entity import com.arangodb.springframework.annotation.Document +import com.arangodb.springframework.annotation.Field +import com.arangodb.springframework.annotation.PersistentIndexed +import com.arangodb.springframework.annotation.Ref import com.arangodb.springframework.annotation.Relations import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.CommitFileUserConnectionEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.CommitUserConnectionEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.edges.IssueUserConnectionEntity import org.springframework.data.annotation.Id +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * ArangoDB-specific User entity. + * + * @deprecated Use [DeveloperEntity] instead. This entity is maintained for backwards compatibility. + * + * Represents the persistence layer for the [User][com.inso_world.binocular.model.User] domain object. + * + * ### Identity Mapping + * - [id]: ArangoDB internal document ID (_key) + * - [iid]: Domain immutable identity (UUID) + * - [gitSignature]: Business key component along with repository + * + * ### Relationships + * - [repository]: Owning repository (required) + * + * ### Indexes + * - [iid]: Unique persistent index for UUID-based lookups */ +@Deprecated("Use DeveloperEntity instead") +@OptIn(ExperimentalUuidApi::class) @Document("users") data class UserEntity( @Id var id: String? = null, + @Field("iid") + @PersistentIndexed(unique = true) + var iid: Uuid, var gitSignature: String, + @Ref(lazy = true) + val repository: RepositoryEntity, @Relations( edges = [CommitUserConnectionEntity::class], lazy = true, @@ -28,14 +55,14 @@ data class UserEntity( maxDepth = 1, direction = Relations.Direction.INBOUND, ) - var issues: List = emptyList(), + var issues: Set = emptySet(), @Relations( edges = [CommitFileUserConnectionEntity::class], lazy = true, maxDepth = 1, direction = Relations.Direction.INBOUND, ) - var files: List = emptyList(), + var files: Set = emptySet(), ) { val name: String get() { diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/AccountMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/AccountMapper.kt index ce5c70f1d..4ed7b764b 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/AccountMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/AccountMapper.kt @@ -1,15 +1,35 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.AccountEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.PlatformEntity import com.inso_world.binocular.model.Account +import com.inso_world.binocular.model.Platform import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Component +/** + * Mapper for Account domain objects. + * + * Converts between Account domain objects and AccountEntity persistence entities for ArangoDB. + * This mapper handles the conversion and uses lazy loading proxies for relationships to issues, + * merge requests, and notes. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Account structure + * - **Lazy Loading**: Uses RelationshipProxyFactory for lazy-loaded relationships + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. It supports + * lazy loading of related entities through proxy patterns. + */ @Component -class AccountMapper +internal class AccountMapper @Autowired constructor( private val proxyFactory: RelationshipProxyFactory, @@ -17,57 +37,93 @@ class AccountMapper @Lazy private val mergeRequestMapper: MergeRequestMapper, @Lazy private val noteMapper: NoteMapper, ) : EntityMapper { + + @Autowired + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + /** - * Converts a domain Account to an ArangoDB AccountEntity + * Converts an Account domain object to AccountEntity. + * + * Maps basic account properties including platform, login, name, avatar URL, and web URL. + * Note that relationships (issues, merge requests, notes) are not included in the entity + * and are only restored during toDomain through lazy loading. + * + * @param domain The Account domain object to convert + * @return The AccountEntity (structure only, without relationships) */ override fun toEntity(domain: Account): AccountEntity = AccountEntity( id = domain.id, - platform = domain.platform, + platform = domain.platform?.let { toPlatformEntity(it) }, login = domain.login, name = domain.name, avatarUrl = domain.avatarUrl, url = domain.url, - // Relationships are handled by ArangoDB through edges ) /** - * Converts an ArangoDB AccountEntity to a domain Account + * Converts an AccountEntity to Account domain object. + * + * Creates an Account with lazy-loaded relationships to issues, merge requests, and notes. + * The relationships are loaded on-demand using proxy patterns to avoid N+1 query problems. * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. + * @param entity The AccountEntity to convert + * @return The Account domain object with lazy-loaded relationships */ - override fun toDomain(entity: AccountEntity): Account = - Account( - id = entity.id, - platform = entity.platform, - login = entity.login, - name = entity.name, - avatarUrl = entity.avatarUrl, - url = entity.url, - issues = - proxyFactory.createLazyList { - (entity.issues ?: emptyList()).map { issueEntity -> - issueMapper.toDomain(issueEntity) - } - }, - mergeRequests = - proxyFactory.createLazyList { - (entity.mergeRequests ?: emptyList()).map { mergeRequestEntity -> - mergeRequestMapper.toDomain(mergeRequestEntity) - } - }, - notes = - proxyFactory.createLazyList { - (entity.notes ?: emptyList()).map { noteEntity -> - noteMapper.toDomain(noteEntity) - } - }, - ) + override fun toDomain(entity: AccountEntity): Account { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } - /** - * Converts a list of ArangoDB AccountEntity objects to a list of domain Account objects - */ - override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } + val domain = + Account( + id = entity.id, + platform = entity.platform?.let { toPlatform(it) }, + login = entity.login, + name = entity.name, + avatarUrl = entity.avatarUrl, + url = entity.url, + issues = + proxyFactory.createLazyList { + (entity.issues ?: emptyList()).map { issueEntity -> + issueMapper.toDomain(issueEntity) + } + }, + mergeRequests = + proxyFactory.createLazyList { + (entity.mergeRequests ?: emptyList()).map { mergeRequestEntity -> + mergeRequestMapper.toDomain(mergeRequestEntity) + } + }, + notes = + proxyFactory.createLazyList { + (entity.notes ?: emptyList()).map { noteEntity -> + noteMapper.toDomain(noteEntity) + } + }, + ) + + return domain + } + + private fun toPlatformEntity(platform: Platform): PlatformEntity? { + if (platform == Platform.GitHub) { + return PlatformEntity.GitHub + } else if (platform == Platform.GitLab) { + return PlatformEntity.GitLab + } + return null + } + + private fun toPlatform(platformEntity: PlatformEntity): Platform? { + if (platformEntity == PlatformEntity.GitHub) { + return Platform.GitHub + } else if (platformEntity == PlatformEntity.GitLab) { + return Platform.GitLab + } + return null + } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/BranchMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/BranchMapper.kt index ac4dafe9f..7fe61946c 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/BranchMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/BranchMapper.kt @@ -1,57 +1,157 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper -import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.BranchEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Reference +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.vcs.ReferenceCategory import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.Lazy +import org.springframework.data.util.ReflectionUtils.setField import org.springframework.stereotype.Component +import java.time.LocalDateTime +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +/** + * Mapper for Branch domain objects. + * + * Converts between Branch domain objects and BranchEntity persistence entities for ArangoDB. + * This is a **simple mapper** - it only handles basic conversion without orchestrating + * complex relationships. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Branch structure + * - **Aggregate Boundaries**: Expects Repository already in MappingContext (cross-aggregate reference) + * - **No Deep Traversal**: Does not map entire commit history or file structures + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. + */ @Component -class BranchMapper +internal class BranchMapper : EntityMapper { + @Autowired - constructor( - private val proxyFactory: RelationshipProxyFactory, - @Lazy private val fileMapper: FileMapper, - ) : EntityMapper { - /** - * Converts a domain Branch to an ArangoDB BranchEntity - */ - override fun toEntity(domain: Branch): BranchEntity = - BranchEntity( - id = domain.id, - branch = domain.name, - active = domain.active, - tracksFileRenames = domain.tracksFileRenames, - latestCommit = domain.latestCommit, - // Relationships are handled by ArangoDB through edges + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + + /** + * Converts a Branch domain object to BranchEntity. + * + * **Precondition**: The referenced Repository must already be mapped and present in MappingContext. + * This enforces aggregate boundary - Repository is a separate aggregate that must be handled first. + * + * **Note**: This method does NOT map child entities or traverse relationships deeply. + * + * @param domain The Branch domain object to convert + * @return The BranchEntity (structure only) + * @throws IllegalStateException if Repository is not in MappingContext + */ + @OptIn(ExperimentalUuidApi::class) + override fun toEntity(domain: Branch): BranchEntity { + // Fast-path: if this Branch was already mapped in the current context, return it. + ctx.findEntity(domain)?.let { return it } + + // IMPORTANT: Expect Repository already in context (cross-aggregate reference). + val repositoryEntity = ctx.findEntity(domain.repository) + ?: throw IllegalStateException( + "RepositoryEntity must be mapped before BranchEntity. " + + "Ensure RepositoryEntity is in MappingContext before calling toEntity()." + ) + + val entity = BranchEntity( + id = domain.id, + iid = domain.iid.value, + name = domain.name, + fullName = domain.fullName, + category = domain.category.name, + active = domain.active, + tracksFileRenames = domain.tracksFileRenames, + latestCommit = domain.latestCommit, + repository = repositoryEntity + ) + + ctx.remember(domain, entity) + return entity + } + + /** + * Converts a BranchEntity to Branch domain object. + * + * **Precondition**: The Branch must already exist in MappingContext. This is necessary because + * the BranchEntity does not store the head commit reference, which is required by + * the Branch domain model. This reference must be preserved from the original domain object. + * + * **Alternative**: The referenced Repository must be in MappingContext to construct a placeholder. + * + * **Note**: This method does NOT map child entities or traverse relationships deeply. + * + * @param entity The BranchEntity to convert + * @return The Branch domain object from MappingContext or a newly constructed one + * @throws IllegalStateException if neither Branch nor Repository is in MappingContext + */ + override fun toDomain(entity: BranchEntity): Branch { + // Fast-path: Check if already mapped - preferred to preserve head commit reference + ctx.findDomain(entity)?.let { domain -> + domain.id = entity.id + domain.active = entity.active + domain.tracksFileRenames = entity.tracksFileRenames + ctx.remember(domain, entity) + return domain + } + + // Try to find Repository in context + val repository = ctx.findDomain(entity.repository) + ?: throw IllegalStateException( + "Repository must be mapped before Branch. " + + "Ensure Repository is in MappingContext before calling toDomain()." ) - /** - * Converts an ArangoDB BranchEntity to a domain Branch - * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. - */ - override fun toDomain(entity: BranchEntity): Branch = - Branch( - id = entity.id, - name = entity.branch ?: "", - active = entity.active, - tracksFileRenames = entity.tracksFileRenames, - latestCommit = entity.latestCommit, - files = - proxyFactory.createLazyList { - (entity.files).map { fileEntity -> - fileMapper.toDomain(fileEntity) - } - }, + // Construct placeholder head commit (will be replaced by actual commit during full assembly) + val sha = entity.latestCommit?.takeIf { it.length == 40 } ?: "0".repeat(40) + val developer = + Developer( + name = "placeholder", + email = "placeholder@example.com", + repository = repository, ) + val head = + Commit( + sha = sha, + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.now()), + repository = repository, + ) + + // Create domain with iid from entity + val domain = Branch( + name = entity.name, + fullName = entity.fullName, + category = ReferenceCategory.valueOf(entity.category), + repository = repository, + head = head, + ).apply { + id = entity.id + active = entity.active + tracksFileRenames = entity.tracksFileRenames + } + + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + entity.iid + ) - /** - * Converts a list of ArangoDB BranchEntity objects to a list of domain Branch objects - */ - override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } + ctx.remember(domain, entity) + return domain } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/BuildMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/BuildMapper.kt index 588de6d45..a892eeb7c 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/BuildMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/BuildMapper.kt @@ -1,28 +1,63 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.BuildEntity import com.inso_world.binocular.model.Build -import com.inso_world.binocular.model.Job import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Component import java.time.ZoneOffset import java.util.Date +/** + * Mapper for Build domain objects. + * + * Converts between Build domain objects and BuildEntity persistence entities for ArangoDB. + * This mapper handles the conversion of build metadata, timestamps, and jobs, using lazy loading + * for related commits. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Build structure + * - **Lazy Loading**: Uses RelationshipProxyFactory for lazy-loaded commit relationships + * - **Date Conversion**: Converts between LocalDateTime and Date for ArangoDB storage + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. It eagerly maps + * jobs but uses lazy loading for commits to optimize performance. + */ @Component -class BuildMapper +internal class BuildMapper @Autowired constructor( private val proxyFactory: RelationshipProxyFactory, private val jobMapper: JobMapper ) : EntityMapper { - @Lazy @Autowired + + @Autowired + private lateinit var ctx: MappingContext + + @Lazy + @Autowired private lateinit var commitMapper: CommitMapper + companion object { + private val logger by logger() + } + /** - * Converts a domain Build to an ArangoDB BuildEntity + * Converts a Build domain object to BuildEntity. + * + * Converts all timestamp fields from LocalDateTime to Date for ArangoDB storage. + * Eagerly maps all jobs as they are typically accessed together with the build. + * Commit relationships are not persisted in the entity - they are only restored + * during toDomain through lazy loading. + * + * @param domain The Build domain object to convert + * @return The BuildEntity with all properties and jobs */ override fun toEntity(domain: Build): BuildEntity = BuildEntity( @@ -39,67 +74,70 @@ class BuildMapper finishedAt = domain.finishedAt?.let { Date.from(it.toInstant(ZoneOffset.UTC)) }, committedAt = domain.committedAt?.let { Date.from(it.toInstant(ZoneOffset.UTC)) }, duration = domain.duration, - jobs = (domain.jobs).map { job -> - jobMapper.toEntity(job)}, + jobs = domain.jobs.map { jobMapper.toEntity(it) }, webUrl = domain.webUrl, - // Relationships are handled by ArangoDB through edges ) /** - * Converts an ArangoDB BuildEntity to a domain Build + * Converts a BuildEntity to Build domain object. + * + * Converts all timestamp fields from Date to LocalDateTime. Eagerly maps all jobs + * and creates lazy-loaded proxies for commits to avoid loading unnecessary data. * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. + * @param entity The BuildEntity to convert + * @return The Build domain object with eager jobs and lazy commits */ - override fun toDomain(entity: BuildEntity): Build = - Build( - id = entity.id, - sha = entity.sha, - ref = entity.ref, - status = entity.status, - tag = entity.tag, - user = entity.user, - userFullName = entity.userFullName, - createdAt = - entity.createdAt - ?.toInstant() - ?.atZone(ZoneOffset.UTC) - ?.toLocalDateTime(), - updatedAt = - entity.updatedAt - ?.toInstant() - ?.atZone(ZoneOffset.UTC) - ?.toLocalDateTime(), - startedAt = - entity.startedAt - ?.toInstant() - ?.atZone(ZoneOffset.UTC) - ?.toLocalDateTime(), - finishedAt = - entity.finishedAt - ?.toInstant() - ?.atZone(ZoneOffset.UTC) - ?.toLocalDateTime(), - committedAt = - entity.committedAt - ?.toInstant() - ?.atZone(ZoneOffset.UTC) - ?.toLocalDateTime(), - duration = entity.duration, - jobs = (entity.jobs).map { jobEntity -> - jobMapper.toDomain(jobEntity) }, - webUrl = entity.webUrl, - commits = - proxyFactory.createLazyList { - (entity.commits ?: emptyList()).map { commitEntity -> - commitMapper.toDomain(commitEntity) - } - }, - ) + override fun toDomain(entity: BuildEntity): Build { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + val domain = + Build( + id = entity.id, + sha = entity.sha, + ref = entity.ref, + status = entity.status, + tag = entity.tag, + user = entity.user, + userFullName = entity.userFullName, + createdAt = + entity.createdAt + ?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime(), + updatedAt = + entity.updatedAt + ?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime(), + startedAt = + entity.startedAt + ?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime(), + finishedAt = + entity.finishedAt + ?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime(), + committedAt = + entity.committedAt + ?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime(), + duration = entity.duration, + jobs = entity.jobs.map { jobMapper.toDomain(it) }, + webUrl = entity.webUrl, + commits = + proxyFactory.createLazyList { + (entity.commits ?: emptyList()).map { commitEntity -> + commitMapper.toDomain(commitEntity) + } + }, + ) + + return domain + } - /** - * Converts a list of ArangoDB BuildEntity objects to a list of domain Build objects - */ override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/CommitMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/CommitMapper.kt index 3ff1fd9c9..a575b0e21 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/CommitMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/CommitMapper.kt @@ -1,113 +1,188 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper -import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.CommitEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.toEntity import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.Lazy +import org.springframework.data.util.ReflectionUtils.setField import org.springframework.stereotype.Component import java.time.LocalDateTime import java.time.ZoneOffset import java.util.Date +import kotlin.uuid.ExperimentalUuidApi +/** + * Mapper for Commit domain objects. + * + * Converts between Commit domain objects and CommitEntity persistence entities for ArangoDB. + * This is a **simple mapper** - it only handles basic conversion without orchestrating + * complex relationships like full commit history graphs. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Commit structure + * - **No Deep Traversal**: Does not automatically map entire parent/child commit graphs + * - **Context-Dependent toDomain**: Requires Commit already in context (preserves repository and committer references) + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. The `toDomain` + * method requires the Commit to already exist in MappingContext to preserve references + * that cannot be reconstructed from the entity alone. + */ @Component -internal class CommitMapper +internal class CommitMapper : EntityMapper { + + @Autowired + private lateinit var developerMapper: DeveloperMapper + + @Autowired + private lateinit var repositoryMapper: RepositoryMapper + + @Autowired + private lateinit var projectMapper: ProjectMapper + @Autowired - constructor( - private val proxyFactory: RelationshipProxyFactory, - @Lazy private val moduleMapper: ModuleMapper, - @Lazy private val buildMapper: BuildMapper, - @Lazy private val fileMapper: FileMapper, - @Lazy private val userMapper: UserMapper, - @Lazy private val issueMapper: IssueMapper, - private val statsMapper: StatsMapper, - ) : EntityMapper { - /** - * Converts a domain Commit to an ArangoDB CommitEntity - */ - override fun toEntity(domain: Commit): CommitEntity = - CommitEntity( - id = domain.id, - sha = domain.sha, - date = Date.from(domain.commitDateTime?.toInstant(ZoneOffset.UTC)), - message = domain.message, - webUrl = domain.webUrl, - stats = domain.stats?.let { statsMapper.toEntity(it) }, - branch = domain.branch, - // Relationships are handled by ArangoDB through edges + private lateinit var statsMapper: StatsMapper + + @Autowired + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + + /** + * Converts a Commit domain object to CommitEntity. + * + * Converts the commit date from LocalDateTime to Date for ArangoDB storage. + * Maps commit statistics using the StatsMapper if present. + * + * **Note**: This method does NOT map parent/child commit relationships or branches. + * Use assemblers for complete commit graph assembly. + * + * @param domain The Commit domain object to convert + * @return The CommitEntity (structure only, without relationships) + */ + override fun toEntity(domain: Commit): CommitEntity { + // Fast-path: if this Commit was already mapped in the current context, return it. + ctx.findEntity(domain)?.let { return it } + + // Ensure the owning project is mapped before the repository + ctx.findEntity(domain.repository.project) + ?: projectMapper.toEntity(domain.repository.project) + + val repositoryEntity = ctx.findEntity(domain.repository) + ?: repositoryMapper.toEntity(domain.repository) + val author = developerMapper.toEntity(domain.author) + val committer = developerMapper.toEntity(domain.committer) + + val entity = + domain.toEntity( + repository = repositoryEntity, + author = author, + committer = committer, + ).apply { + date = domain.commitDateTime.let { Date.from(it.toInstant(ZoneOffset.UTC)) } + branch = domain.branch + stats = domain.stats?.let { statsMapper.toEntity(it) } + } + + ctx.remember(domain, entity) + return entity + } + + /** + * Converts a CommitEntity to Commit domain object. + * + * **Precondition**: The Commit must already exist in MappingContext. This is necessary because + * the CommitEntity does not store the repository or committer references, which are required by + * the Commit domain model. These references must be preserved from the original domain object. + * + * **Note**: This method does NOT map parent/child commit relationships or branches. + * Use assemblers for complete commit graph assembly. + * + * @param entity The CommitEntity to convert + * @return The Commit domain object from MappingContext + * @throws IllegalStateException if Commit is not already in MappingContext + */ + @OptIn(ExperimentalUuidApi::class) + override fun toDomain(entity: CommitEntity): Commit { + // Fast-path: Check if already mapped - required for repository and committer references + ctx.findDomain(entity)?.let { domain -> + domain.id = entity.id + domain.branch = entity.branch + domain.webUrl = entity.webUrl + domain.stats = entity.stats?.let { statsMapper.toDomain(it) } + + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + entity.iid, ) + ctx.remember(domain, entity) + return domain + } + + // Map owning project/repository first to satisfy commit invariants + val project = ctx.findDomain(entity.repository.project) + ?: projectMapper.toDomain(entity.repository.project) + // Ensure repository mapping sees an already mapped project + val repository = ctx.findDomain(entity.repository) + ?: repositoryMapper.toDomain(entity.repository) - /** - * Converts an ArangoDB CommitEntity to a domain Commit - * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. - */ - override fun toDomain(entity: CommitEntity): Commit { - val cmt = - Commit( - id = entity.id, - sha = entity.sha, - commitDateTime = entity.date?.let { LocalDateTime.ofInstant(it.toInstant(), ZoneOffset.UTC) }, - message = entity.message, - webUrl = entity.webUrl, - stats = entity.stats?.let { statsMapper.toDomain(it) }, - branch = entity.branch, - builds = - proxyFactory.createLazyList { - (entity.builds).map { buildEntity -> - buildMapper.toDomain(buildEntity) - } - }, - files = - proxyFactory.createLazyList { - (entity.files).map { fileEntity -> - fileMapper.toDomain(fileEntity) - } - }, - modules = - proxyFactory.createLazyList { - (entity.modules).map { moduleEntity -> - moduleMapper.toDomain(moduleEntity) - } - }, -// TODO this should be fixed by author and committer -// users = -// proxyFactory.createLazyList { -// (entity.users).map { userEntity -> -// userMapper.toDomain(userEntity) -// } -// }, - issues = - proxyFactory.createLazyList { - (entity.issues).map { issueEntity -> - issueMapper.toDomain(issueEntity) - } - }, + // Derive developer information from related users if available; otherwise fall back to a placeholder + val developer = + entity.users.firstOrNull()?.let { user -> + Developer( + name = user.name, + email = user.email, + repository = repository, ) -// TODO does not work so -// cmt.children.addAll( -// proxyFactory.createLazyMutableSet { -// (entity.children).map { childEntity -> -// toDomain(childEntity) -// } -// }, -// ) -// cmt.parents.addAll( -// proxyFactory.createLazyMutableSet { -// (entity.parents).map { parentEntity -> -// toDomain(parentEntity) -// } -// }, -// ) - - return cmt - } + } ?: Developer( + name = "unknown", + email = "unknown@example.com", + repository = repository, + ) + + val timestamp = + entity.date + ?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime() + ?: LocalDateTime.now() - /** - * Converts a list of ArangoDB CommitEntity objects to a list of domain Commit objects - */ - override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } + val signature = Signature(developer = developer, timestamp = timestamp) + + val domain = + Commit( + sha = entity.sha, + authorSignature = signature, + committerSignature = signature, + repository = repository, + message = entity.message, + ).apply { + id = entity.id + webUrl = entity.webUrl + branch = entity.branch + stats = entity.stats?.let { statsMapper.toDomain(it) } + } + + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + Commit.Id(entity.iid), + ) + ctx.remember(domain, entity) + return domain } + + override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/DeveloperMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/DeveloperMapper.kt new file mode 100644 index 000000000..690d51f66 --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/DeveloperMapper.kt @@ -0,0 +1,120 @@ +package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper + +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.DeveloperEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.toEntity +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Repository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.util.ReflectionUtils.setField +import org.springframework.stereotype.Component + +/** + * Mapper for Developer domain objects. + * + * Converts between Developer domain objects and DeveloperEntity persistence entities for ArangoDB. + * This mapper intentionally keeps the conversion shallow; it does not traverse commit graphs. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Developer structure + * - **No Deep Traversal**: Does not automatically map commit relationships + * - **Context-Dependent**: Requires Repository already in MappingContext + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. The `toDomain` + * method requires the Repository to already exist in MappingContext to preserve the + * repository reference. + */ +@Component +internal class DeveloperMapper : EntityMapper { + @Autowired + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + + /** + * Converts a Developer domain object to DeveloperEntity. + * + * **Precondition**: The referenced Repository must already be mapped and present in MappingContext. + * This enforces aggregate boundary - Repository must be handled first. + * + * **Note**: This method does NOT map commit relationships or other deep structures. + * Use assemblers for complete graph assembly. + * + * @param domain The Developer domain object to convert + * @return The DeveloperEntity (structure only, without relationships) + * @throws IllegalStateException if Repository is not in MappingContext + */ + override fun toEntity(domain: Developer): DeveloperEntity { + ctx.findEntity(domain)?.let { return it } + + val owner = ctx.findEntity(domain.repository) + ?: throw IllegalStateException( + "RepositoryEntity must be mapped before DeveloperEntity. " + + "Ensure RepositoryEntity is in MappingContext before calling toEntity()." + ) + + val entity = domain.toEntity(owner) + ctx.remember(domain, entity) + return entity + } + + /** + * Converts a DeveloperEntity to Developer domain object. + * + * **Precondition**: The referenced Repository must already be mapped and present in MappingContext. + * This enforces aggregate boundary - Repository must be handled first. + * + * **Note**: This method does NOT map commit relationships or other deep structures. + * Use assemblers for complete graph assembly. + * + * @param entity The DeveloperEntity to convert + * @return The Developer domain object (structure only, without relationships) + * @throws IllegalStateException if Repository is not in MappingContext + */ + override fun toDomain(entity: DeveloperEntity): Developer { + ctx.findDomain(entity)?.let { return it } + + val owner = ctx.findDomain(entity.repository) + ?: throw IllegalStateException( + "Repository must be mapped before Developer. " + + "Ensure Repository is in MappingContext before calling toDomain()." + ) + + val domain = entity.toDomain(owner) + setField( + domain.javaClass.superclass.superclass.getDeclaredField("iid"), + domain, + entity.iid + ) + ctx.remember(domain, entity) + return domain + } + + /** + * Refreshes a Developer domain object with data from the corresponding entity. + * + * This method updates the domain object's ID from the entity after persistence. + * It does NOT update nested objects - only top-level Developer properties. + * + * @param target The Developer domain object to refresh + * @param entity The DeveloperEntity with updated data + * @return The refreshed Developer domain object + */ + fun refreshDomain(target: Developer, entity: DeveloperEntity): Developer { + if (target.id.equals(entity.id)) { + return target + } + setField( + target.javaClass.getDeclaredField("id"), + target, + entity.id + ) + return target + } +} \ No newline at end of file diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/FileMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/FileMapper.kt index 36a72bb89..2c812e568 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/FileMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/FileMapper.kt @@ -1,88 +1,86 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper -import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.FileEntity import com.inso_world.binocular.model.File import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Component +/** + * Mapper for File domain objects. + * + * Converts between File domain objects and FileEntity persistence entities for ArangoDB. + * This is a **simple mapper** - it only handles basic conversion without orchestrating + * complex relationships. + * + * ## Design Principles + * - **Single Responsibility**: Only converts File structure + * - **No Deep Traversal**: Does not map file states, commits, or branches + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. It supports + * bidirectional conversion between domain and entity representations. + */ @Component -class FileMapper +internal class FileMapper : EntityMapper { + @Autowired - constructor( - private val proxyFactory: RelationshipProxyFactory, - @Lazy private val branchMapper: BranchMapper, - @Lazy private val moduleMapper: ModuleMapper, - @Lazy private val userMapper: UserMapper, - ) : EntityMapper { - @Lazy - @Autowired - private lateinit var commitMapper: CommitMapper + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + + /** + * Converts a File domain object to FileEntity. + * + * Maps the file path, web URL, and maximum line length. Does not include + * file states or relationships to commits/branches. + * + * @param domain The File domain object to convert + * @return The FileEntity (structure only, without relationships) + */ + override fun toEntity(domain: File): FileEntity { + // Fast-path: if this File was already mapped in the current context, return it. + ctx.findEntity(domain)?.let { return it } - /** - * Converts a domain File to an ArangoDB FileEntity - */ - override fun toEntity(domain: File): FileEntity = + val entity = FileEntity( id = domain.id, path = domain.path, webUrl = domain.webUrl, maxLength = domain.maxLength, - // Relationships are handled by ArangoDB through edges ) - /** - * Converts an ArangoDB FileEntity to a domain File - * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. - */ - override fun toDomain(entity: FileEntity): File { - val file = - File( - id = entity.id, - path = entity.path, - ) - file.webUrl = entity.webUrl -// file.maxLength = entity.maxLength -// file.commits = -// proxyFactory.createLazyList { -// (entity.commits ?: emptyList()).map { commitEntity -> -// commitMapper.toDomain(commitEntity) -// } -// } -// file.branches = -// proxyFactory.createLazyList { -// (entity.branches ?: emptyList()).map { branchEntity -> -// branchMapper.toDomain(branchEntity) -// } -// } -// file.modules = -// proxyFactory.createLazyList { -// (entity.modules ?: emptyList()).map { moduleEntity -> -// moduleMapper.toDomain(moduleEntity) -// } -// } -// file.relatedFiles = -// proxyFactory.createLazyList { -// (entity.relatedFiles ?: emptyList()).map { relatedFileEntity -> -// toDomain(relatedFileEntity) -// } -// } -// file.users = -// proxyFactory.createLazyList { -// (entity.users ?: emptyList()).map { userEntity -> -// userMapper.toDomain(userEntity) -// } -// } - return file + ctx.remember(domain, entity) + return entity + } + + /** + * Converts a FileEntity to File domain object. + * + * Creates a new File domain object from the entity, setting the ID and web URL. + * The maxLength property is stored in the entity but not restored to the domain + * as it's typically recalculated during file processing. + * + * @param entity The FileEntity to convert + * @return The File domain object (structure only, without relationships) + */ + override fun toDomain(entity: FileEntity): File { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + val domain = File(path = entity.path).apply { + id = entity.id + webUrl = entity.webUrl } - /** - * Converts a list of ArangoDB FileEntity objects to a list of domain File objects - */ - override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } + ctx.remember(domain, entity) + return domain } + + override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/IssueMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/IssueMapper.kt index 9aeef4949..c1f67d8bb 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/IssueMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/IssueMapper.kt @@ -1,6 +1,8 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.IssueEntity import com.inso_world.binocular.model.Issue @@ -10,8 +12,26 @@ import org.springframework.stereotype.Component import java.time.ZoneOffset import java.util.Date +/** + * Mapper for Issue domain objects. + * + * Converts between Issue domain objects and IssueEntity persistence entities for ArangoDB. + * This mapper handles the conversion of issue metadata, labels, mentions, and uses lazy loading + * for related accounts, commits, milestones, notes, and users. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Issue structure + * - **Lazy Loading**: Uses RelationshipProxyFactory for lazy-loaded relationships + * - **Date Conversion**: Converts between LocalDateTime and Date for ArangoDB storage + * - **Eager Mentions**: Eagerly maps mentions as they are typically accessed with the issue + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. It eagerly maps + * mentions but uses lazy loading for accounts, commits, milestones, notes, and users to optimize performance. + */ @Component -class IssueMapper +internal class IssueMapper @Autowired constructor( private val proxyFactory: RelationshipProxyFactory, @@ -21,16 +41,33 @@ class IssueMapper @Lazy private val userMapper: UserMapper, private val mentionMapper: MentionMapper, ) : EntityMapper { - @Lazy @Autowired + + @Autowired + private lateinit var ctx: MappingContext + + @Lazy + @Autowired private lateinit var commitMapper: CommitMapper + companion object { + private val logger by logger() + } + /** - * Converts a domain Issue to an ArangoDB IssueEntity + * Converts an Issue domain object to IssueEntity. + * + * Converts timestamp fields from LocalDateTime to Date for ArangoDB storage. + * Eagerly maps all mentions as they are typically accessed together with the issue. + * Relationships to accounts, commits, milestones, notes, and users are not persisted + * in the entity - they are only restored during toDomain through lazy loading. + * + * @param domain The Issue domain object to convert + * @return The IssueEntity with issue metadata, labels, and mentions */ override fun toEntity(domain: Issue): IssueEntity = IssueEntity( id = domain.id, - iid = domain.iid, + iid = domain.platformIid, title = domain.title, description = domain.description, createdAt = domain.createdAt?.let { Date.from(it.toInstant(ZoneOffset.UTC)) }, @@ -39,80 +76,82 @@ class IssueMapper labels = domain.labels, state = domain.state, webUrl = domain.webUrl, - mentions = (domain.mentions).map { mention -> - mentionMapper.toEntity(mention) - }, - // Relationships are handled by ArangoDB through edges + mentions = domain.mentions.map { mentionMapper.toEntity(it) }, ) /** - * Converts an ArangoDB IssueEntity to a domain Issue + * Converts an IssueEntity to Issue domain object. + * + * Converts timestamp fields from Date to LocalDateTime. Eagerly maps mentions + * and creates lazy-loaded proxies for accounts, commits, milestones, notes, and users + * to avoid loading unnecessary data. * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. + * @param entity The IssueEntity to convert + * @return The Issue domain object with eager mentions and lazy relationships */ - override fun toDomain(entity: IssueEntity): Issue = - Issue( - id = entity.id, - iid = entity.iid, - title = entity.title, - description = entity.description, - createdAt = - entity.createdAt - ?.toInstant() - ?.atZone(ZoneOffset.UTC) - ?.toLocalDateTime(), - closedAt = - entity.closedAt - ?.toInstant() - ?.atZone(ZoneOffset.UTC) - ?.toLocalDateTime(), - updatedAt = - entity.updatedAt - ?.toInstant() - ?.atZone(ZoneOffset.UTC) - ?.toLocalDateTime(), - labels = entity.labels, - state = entity.state, - webUrl = entity.webUrl, - mentions = (entity.mentions).map { mentionEntity -> - mentionMapper.toDomain(mentionEntity) - }, - accounts = - proxyFactory.createLazyList { - (entity.accounts ?: emptyList()).map { accountEntity -> - accountMapper.toDomain(accountEntity) - } - }, - commits = - proxyFactory.createLazyList { - (entity.commits ?: emptyList()).map { commitEntity -> - commitMapper.toDomain(commitEntity) - } - }, - milestones = - proxyFactory.createLazyList { - (entity.milestones ?: emptyList()).map { milestoneEntity -> - milestoneMapper.toDomain(milestoneEntity) - } - }, - notes = - proxyFactory.createLazyList { - (entity.notes ?: emptyList()).map { noteEntity -> - noteMapper.toDomain(noteEntity) - } - }, - users = - proxyFactory.createLazyList { - (entity.users ?: emptyList()).map { userEntity -> - userMapper.toDomain(userEntity) - } - }, - ) + override fun toDomain(entity: IssueEntity): Issue { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + val domain = + Issue( + id = entity.id, + platformIid = entity.iid, + title = entity.title, + description = entity.description, + createdAt = + entity.createdAt + ?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime(), + closedAt = + entity.closedAt + ?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime(), + updatedAt = + entity.updatedAt + ?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime(), + labels = entity.labels, + state = entity.state, + webUrl = entity.webUrl, + mentions = entity.mentions.map { mentionMapper.toDomain(it) }, + accounts = + proxyFactory.createLazyList { + (entity.accounts ?: emptyList()).map { accountEntity -> + accountMapper.toDomain(accountEntity) + } + }, + commits = + proxyFactory.createLazyList { + (entity.commits ?: emptyList()).map { commitEntity -> + commitMapper.toDomain(commitEntity) + } + }, + milestones = + proxyFactory.createLazyList { + (entity.milestones ?: emptyList()).map { milestoneEntity -> + milestoneMapper.toDomain(milestoneEntity) + } + }, + notes = + proxyFactory.createLazyList { + (entity.notes ?: emptyList()).map { noteEntity -> + noteMapper.toDomain(noteEntity) + } + }, + users = + proxyFactory.createLazyList { + (entity.users ?: emptyList()).map { userEntity -> + userMapper.toDomain(userEntity) + } + }, + ) + + return domain + } - /** - * Converts a list of ArangoDB IssueEntity objects to a list of domain Issue objects - */ override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/JobMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/JobMapper.kt index b4a51a78b..94271f5b4 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/JobMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/JobMapper.kt @@ -1,46 +1,79 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.JobEntity import com.inso_world.binocular.model.Job +import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component -import java.time.LocalDateTime import java.time.ZoneOffset import java.util.Date -//TODO: add java documentation + +/** + * Mapper for Job value objects. + * + * Converts between Job domain value objects and JobEntity persistence entities for ArangoDB. + * Jobs represent individual CI/CD pipeline jobs within a Build. + * + * ## Design Principles + * - **Value Object**: Job is a value object typically embedded within Build + * - **Date Conversion**: Converts between LocalDateTime and Date for ArangoDB storage + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is primarily used by BuildMapper to convert jobs within build entities. + */ @Component -class JobMapper - constructor( - ) : EntityMapper { - /** - * Converts a domain Job to an ArangoDB JobEntity - */ - override fun toEntity(domain: Job): JobEntity = - JobEntity( - id = domain.id, - name = domain.name, - status = domain.status, - stage = domain.stage, - createdAt = domain.createdAt?.let { Date.from(it.toInstant(ZoneOffset.UTC)) }, - finishedAt = domain.finishedAt?.let { Date.from(it.toInstant(ZoneOffset.UTC)) }, - webUrl = domain.webUrl - ) +internal class JobMapper : EntityMapper { + + @Autowired + private lateinit var ctx: MappingContext + + /** + * Converts a Job value object to JobEntity. + * + * Converts timestamp fields from LocalDateTime to Date for ArangoDB storage. + * + * @param domain The Job value object to convert + * @return The JobEntity with job metadata and timestamps + */ + override fun toEntity(domain: Job): JobEntity = + JobEntity( + id = domain.id, + name = domain.name, + status = domain.status, + stage = domain.stage, + createdAt = domain.createdAt?.let { Date.from(it.toInstant(ZoneOffset.UTC)) }, + finishedAt = domain.finishedAt?.let { Date.from(it.toInstant(ZoneOffset.UTC)) }, + webUrl = domain.webUrl + ) + /** + * Converts a JobEntity to Job value object. + * + * Converts timestamp fields from Date to LocalDateTime. + * + * @param entity The JobEntity to convert + * @return The Job value object with job metadata and timestamps + */ + override fun toDomain(entity: JobEntity): Job { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } - override fun toDomain(entity: JobEntity): Job = - Job( - id = entity.id, - name = entity.name, - status = entity.status, - stage = entity.stage, - createdAt = entity.createdAt?.toInstant() + return Job( + id = entity.id, + name = entity.name, + status = entity.status, + stage = entity.stage, + createdAt = + entity.createdAt?.toInstant() ?.atZone(ZoneOffset.UTC) ?.toLocalDateTime(), - finishedAt = entity.finishedAt?.toInstant() + finishedAt = + entity.finishedAt?.toInstant() ?.atZone(ZoneOffset.UTC) ?.toLocalDateTime(), - webUrl = entity.webUrl - ) - + webUrl = entity.webUrl + ) } - +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MentionMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MentionMapper.kt index 75e2d0754..12f9b9bd9 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MentionMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MentionMapper.kt @@ -1,18 +1,41 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.MentionEntity import com.inso_world.binocular.model.Mention +import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component import java.time.ZoneOffset import java.util.Date +/** + * Mapper for Mention value objects. + * + * Converts between Mention domain value objects and MentionEntity persistence entities for ArangoDB. + * Mentions represent references to commits within issues or merge requests. + * + * ## Design Principles + * - **Value Object**: Mention is a value object typically embedded within Issue + * - **Date Conversion**: Converts between LocalDateTime and Date for ArangoDB storage + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is primarily used by IssueMapper to convert mentions within issue entities. + */ @Component -class MentionMapper -constructor( -) : EntityMapper { +internal class MentionMapper : EntityMapper { + + @Autowired + private lateinit var ctx: MappingContext + /** - * Converts a domain Job to an ArangoDB JobEntity + * Converts a Mention value object to MentionEntity. + * + * Maps commit SHA reference, creation timestamp, and whether the mention closes an issue. + * + * @param domain The Mention value object to convert + * @return The MentionEntity with commit reference and metadata */ override fun toEntity(domain: Mention): MentionEntity = MentionEntity( @@ -21,14 +44,25 @@ constructor( closes = domain.closes ) - //TODO: add java documentation - override fun toDomain(entity: MentionEntity): Mention = - Mention( + /** + * Converts a MentionEntity to Mention value object. + * + * Converts the creation timestamp from Date to LocalDateTime. + * + * @param entity The MentionEntity to convert + * @return The Mention value object with commit reference and metadata + */ + override fun toDomain(entity: MentionEntity): Mention { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + return Mention( commit = entity.commit, - createdAt = entity.createdAt?.toInstant() - ?.atZone(ZoneOffset.UTC) - ?.toLocalDateTime(), + createdAt = + entity.createdAt?.toInstant() + ?.atZone(ZoneOffset.UTC) + ?.toLocalDateTime(), closes = entity.closes ) - + } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MergeRequestMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MergeRequestMapper.kt index f7393f767..7f6559d58 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MergeRequestMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MergeRequestMapper.kt @@ -1,6 +1,8 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.MergeRequestEntity import com.inso_world.binocular.model.MergeRequest @@ -8,8 +10,25 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Component +/** + * Mapper for MergeRequest domain objects. + * + * Converts between MergeRequest domain objects and MergeRequestEntity persistence entities for ArangoDB. + * This mapper handles the conversion of merge request metadata, labels, mentions, and uses lazy loading + * for related accounts, milestones, and notes. + * + * ## Design Principles + * - **Single Responsibility**: Only converts MergeRequest structure + * - **Lazy Loading**: Uses RelationshipProxyFactory for lazy-loaded relationships + * - **Eager Mentions**: Eagerly maps mentions as they are typically accessed with the merge request + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. It eagerly maps + * mentions but uses lazy loading for accounts, milestones, and notes to optimize performance. + */ @Component -class MergeRequestMapper +internal class MergeRequestMapper @Autowired constructor( private val proxyFactory: RelationshipProxyFactory, @@ -18,13 +37,28 @@ class MergeRequestMapper @Lazy private val accountMapper: AccountMapper, private val mentionMapper: MentionMapper, ) : EntityMapper { + + @Autowired + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + /** - * Converts a domain MergeRequest to an ArangoDB MergeRequestEntity + * Converts a MergeRequest domain object to MergeRequestEntity. + * + * Eagerly maps all mentions as they are typically accessed together with the merge request. + * Relationships to accounts, milestones, and notes are not persisted in the entity - they + * are only restored during toDomain through lazy loading. + * + * @param domain The MergeRequest domain object to convert + * @return The MergeRequestEntity with merge request metadata, labels, and mentions */ override fun toEntity(domain: MergeRequest): MergeRequestEntity = MergeRequestEntity( id = domain.id, - iid = domain.iid, + iid = domain.platformIid, title = domain.title, description = domain.description, createdAt = domain.createdAt, @@ -33,23 +67,26 @@ class MergeRequestMapper labels = domain.labels, state = domain.state, webUrl = domain.webUrl, - mentions = (domain.mentions).map { mention -> - mentionMapper.toEntity(mention) - }, + mentions = domain.mentions.map { mentionMapper.toEntity(it) }, // Relationships are handled by ArangoDB through edges ) /** - * Converts an ArangoDB MergeRequestEntity to a domain MergeRequest + * Converts a MergeRequestEntity to MergeRequest domain object. + * + * Eagerly maps mentions and creates lazy-loaded proxies for accounts, milestones, and notes + * to avoid loading unnecessary data. * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. + * @param entity The MergeRequestEntity to convert + * @return The MergeRequest domain object with eager mentions and lazy relationships */ - override fun toDomain(entity: MergeRequestEntity): MergeRequest = - MergeRequest( + override fun toDomain(entity: MergeRequestEntity): MergeRequest { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + return MergeRequest( id = entity.id, - iid = entity.iid, + platformIid = entity.iid, title = entity.title, description = entity.description, createdAt = entity.createdAt, @@ -58,9 +95,7 @@ class MergeRequestMapper labels = entity.labels, state = entity.state, webUrl = entity.webUrl, - mentions = (entity.mentions).map { mentionEntity -> - mentionMapper.toDomain(mentionEntity) - }, + mentions = entity.mentions.map { mentionMapper.toDomain(it) }, accounts = proxyFactory.createLazyList { (entity.accounts ?: emptyList()).map { accountEntity -> @@ -80,6 +115,7 @@ class MergeRequestMapper } }, ) + } /** * Converts a list of ArangoDB MergeRequestEntity objects to a list of domain MergeRequest objects diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MilestoneMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MilestoneMapper.kt index 53b5370e5..1a5ee78a2 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MilestoneMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/MilestoneMapper.kt @@ -1,6 +1,8 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.MilestoneEntity import com.inso_world.binocular.model.Milestone @@ -8,21 +10,52 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Component +/** + * Mapper for Milestone domain objects. + * + * Converts between Milestone domain objects and MilestoneEntity persistence entities for ArangoDB. + * This mapper handles the conversion of milestone metadata and uses lazy loading for related + * issues and merge requests. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Milestone structure + * - **Lazy Loading**: Uses RelationshipProxyFactory for lazy-loaded relationships (issues, merge requests) + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. It uses lazy loading + * for issues and merge requests to optimize performance when accessing milestone metadata. + */ @Component -class MilestoneMapper +internal class MilestoneMapper @Autowired constructor( private val proxyFactory: RelationshipProxyFactory, @Lazy private val issueMapper: IssueMapper, @Lazy private val mergeRequestMapper: MergeRequestMapper, ) : EntityMapper { + + @Autowired + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + /** - * Converts a domain Milestone to an ArangoDB MilestoneEntity + * Converts a Milestone domain object to MilestoneEntity. + * + * Maps all milestone properties including metadata, dates, and state. Relationships + * to issues and merge requests are not persisted in the entity - they are only + * restored during toDomain through lazy loading. + * + * @param domain The Milestone domain object to convert + * @return The MilestoneEntity with milestone metadata */ override fun toEntity(domain: Milestone): MilestoneEntity = MilestoneEntity( id = domain.id, - iid = domain.iid, + iid = domain.platformIid, title = domain.title, description = domain.description, createdAt = domain.createdAt, @@ -32,20 +65,24 @@ class MilestoneMapper state = domain.state, expired = domain.expired, webUrl = domain.webUrl, - // Relationships are handled by ArangoDB through edges ) /** - * Converts an ArangoDB MilestoneEntity to a domain Milestone + * Converts a MilestoneEntity to Milestone domain object. * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. + * Creates lazy-loaded proxies for issues and merge requests to avoid loading + * unnecessary data when only milestone metadata is needed. + * + * @param entity The MilestoneEntity to convert + * @return The Milestone domain object with lazy issues and merge requests */ - override fun toDomain(entity: MilestoneEntity): Milestone = - Milestone( + override fun toDomain(entity: MilestoneEntity): Milestone { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + return Milestone( id = entity.id, - iid = entity.iid, + platformIid = entity.iid, title = entity.title, description = entity.description, createdAt = entity.createdAt, @@ -68,9 +105,7 @@ class MilestoneMapper } }, ) + } - /** - * Converts a list of ArangoDB MilestoneEntity objects to a list of domain Milestone objects - */ override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ModuleMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ModuleMapper.kt index 25f629f1e..c364f78e3 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ModuleMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ModuleMapper.kt @@ -1,41 +1,82 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.ModuleEntity +import com.inso_world.binocular.model.Module import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Component +/** + * Mapper for Module domain objects. + * + * Converts between Module domain objects and ModuleEntity persistence entities for ArangoDB. + * This mapper handles hierarchical module structures with lazy loading for related commits, + * files, and parent/child module relationships. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Module structure + * - **Lazy Loading**: Uses RelationshipProxyFactory for lazy-loaded relationships (commits, files, modules) + * - **Hierarchical Support**: Handles parent-child module relationships with lazy loading + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. It supports + * lazy loading of all relationships to optimize performance when traversing module hierarchies. + */ @Component -class ModuleMapper +internal class ModuleMapper @Autowired constructor( private val proxyFactory: RelationshipProxyFactory, @Lazy private val fileMapper: FileMapper, - ) : EntityMapper { - @Lazy @Autowired + ) : EntityMapper { + + @Autowired + private lateinit var ctx: MappingContext + + @Lazy + @Autowired private lateinit var commitMapper: CommitMapper + companion object { + private val logger by logger() + } + /** - * Converts a domain Module to an ArangoDB ModuleEntity + * Converts a Module domain object to ModuleEntity. + * + * Maps only the basic module properties (ID and path). Relationships to commits, + * files, and other modules are not persisted in the entity - they are only restored + * during toDomain through lazy loading. + * + * @param domain The Module domain object to convert + * @return The ModuleEntity (structure only, without relationships) */ - override fun toEntity(domain: com.inso_world.binocular.model.Module): ModuleEntity = + override fun toEntity(domain: Module): ModuleEntity = ModuleEntity( id = domain.id, path = domain.path, - // Relationships are handled by ArangoDB through edges ) /** - * Converts an ArangoDB ModuleEntity to a domain Module + * Converts a ModuleEntity to Module domain object. * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. + * Creates a Module with lazy-loaded relationships to commits, files, child modules, + * and parent modules. All relationships are loaded on-demand to optimize performance + * when traversing module hierarchies. + * + * @param entity The ModuleEntity to convert + * @return The Module domain object with lazy-loaded relationships */ - override fun toDomain(entity: ModuleEntity): com.inso_world.binocular.model.Module = - com.inso_world.binocular.model.Module( + override fun toDomain(entity: ModuleEntity): Module { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + return Module( id = entity.id, path = entity.path, commits = @@ -63,10 +104,8 @@ class ModuleMapper } }, ) + } - /** - * Converts a list of ArangoDB ModuleEntity objects to a list of domain Module objects - */ - override fun toDomainList(entities: Iterable): List = + override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/NoteMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/NoteMapper.kt index 4fabc67d8..b1d9c25f8 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/NoteMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/NoteMapper.kt @@ -1,6 +1,8 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.NoteEntity import com.inso_world.binocular.model.Note @@ -8,8 +10,24 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Component +/** + * Mapper for Note domain objects. + * + * Converts between Note domain objects and NoteEntity persistence entities for ArangoDB. + * This mapper handles the conversion of note metadata and uses lazy loading for related + * accounts, issues, and merge requests. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Note structure + * - **Lazy Loading**: Uses RelationshipProxyFactory for lazy-loaded relationships + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. It uses lazy loading + * for accounts, issues, and merge requests to optimize performance. + */ @Component -class NoteMapper +internal class NoteMapper @Autowired constructor( private val proxyFactory: RelationshipProxyFactory, @@ -17,8 +35,23 @@ class NoteMapper @Lazy private val issueMapper: IssueMapper, @Lazy private val mergeRequestMapper: MergeRequestMapper, ) : EntityMapper { + + @Autowired + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + /** - * Converts a domain Note to an ArangoDB NoteEntity + * Converts a Note domain object to NoteEntity. + * + * Maps all note properties including body, timestamps, flags (system, resolvable, etc.), + * and import metadata. Relationships to accounts, issues, and merge requests are not + * persisted in the entity - they are only restored during toDomain through lazy loading. + * + * @param domain The Note domain object to convert + * @return The NoteEntity with note metadata */ override fun toEntity(domain: Note): NoteEntity = NoteEntity( @@ -32,18 +65,22 @@ class NoteMapper internal = domain.internal, imported = domain.imported, importedFrom = domain.importedFrom, - // Relationships are handled by ArangoDB through edges ) /** - * Converts an ArangoDB NoteEntity to a domain Note + * Converts a NoteEntity to Note domain object. * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. + * Creates lazy-loaded proxies for accounts, issues, and merge requests to avoid loading + * unnecessary data when only note metadata is needed. + * + * @param entity The NoteEntity to convert + * @return The Note domain object with lazy relationships */ - override fun toDomain(entity: NoteEntity): Note = - Note( + override fun toDomain(entity: NoteEntity): Note { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + return Note( id = entity.id, body = entity.body, createdAt = entity.createdAt, @@ -73,9 +110,7 @@ class NoteMapper } }, ) + } - /** - * Converts a list of ArangoDB NoteEntity objects to a list of domain Note objects - */ override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ProjectMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ProjectMapper.kt index dd2435300..eb2c6b050 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ProjectMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ProjectMapper.kt @@ -1,25 +1,94 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.toEntity import com.inso_world.binocular.model.Project +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.util.ReflectionUtils.setField import org.springframework.stereotype.Component +/** + * Mapper for Project aggregate root. + * + * Converts between Project domain objects and ProjectEntity persistence entities for ArangoDB. + * This is a **simple mapper** - it only handles basic conversion without orchestrating + * child entity mapping. Use assemblers for complete aggregate assembly if needed. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Project structure (not children) + * - **No Orchestration**: Child entities (Repository) are mapped by assemblers + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. Direct usage + * is also supported for `refreshDomain` operations after persistence. + */ @Component -class ProjectMapper : EntityMapper { - override fun toEntity(domain: Project): ProjectEntity = - ProjectEntity( - id = domain.id, - name = domain.name, - description = domain.description, - ) +internal class ProjectMapper : EntityMapper { + @Autowired + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + + /** + * Converts a Project domain object to ProjectEntity. + * + * **Note**: This method does NOT map child entities (Repository). Use assemblers + * for complete aggregate assembly including children. + * + * @param domain The Project domain object to convert + * @return The ProjectEntity (structure only, without children) + */ + override fun toEntity(domain: Project): ProjectEntity { + // Fast-path: if this Project was already mapped in the current context, return it. + ctx.findEntity(domain)?.let { return it } + + val entity = domain.toEntity() - override fun toDomain(entity: ProjectEntity): Project = - Project( - id = entity.id, - name = entity.name, - description = entity.description, + ctx.remember(domain, entity) + return entity + } + + /** + * Converts a ProjectEntity to Project domain object. + * + * **Note**: This method does NOT map child entities (Repository). Use assemblers + * for complete aggregate assembly including children. + * + * @param entity The ProjectEntity to convert + * @return The Project domain object (structure only, without children) + */ + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) + override fun toDomain(entity: ProjectEntity): Project { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + val domain = entity.toDomain() + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + entity.iid ) - override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } + ctx.remember(domain, entity) + return domain + } + + /** + * Refreshes a Project domain object with data from the corresponding entity. + * + * This method updates the domain object's ID from the entity after persistence. + * It does NOT update nested objects - only top-level Project properties. + * + * @param target The Project domain object to refresh + * @param entity The ProjectEntity with updated data + */ + fun refreshDomain(target: Project, entity: ProjectEntity) { + target.id = entity.id + } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/RepositoryMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/RepositoryMapper.kt index 8e432be25..507c7005d 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/RepositoryMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/RepositoryMapper.kt @@ -1,26 +1,129 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.ProjectEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.toEntity import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.util.ReflectionUtils.setField import org.springframework.stereotype.Component +/** + * Mapper for Repository aggregate root. + * + * Converts between Repository domain objects and RepositoryEntity persistence entities for ArangoDB. + * This is a **simple mapper** - it only handles basic conversion without orchestrating + * child entity mapping. Use assemblers for complete aggregate assembly. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Repository structure (not children) + * - **Aggregate Boundaries**: Expects Project already in MappingContext (cross-aggregate reference) + * - **No Orchestration**: Child entities (Commits, Branches) are mapped by assembler + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. Direct usage + * is also supported for `refreshDomain` operations after persistence. + */ @Component -class RepositoryMapper : EntityMapper { - override fun toEntity(domain: Repository): RepositoryEntity = - RepositoryEntity( - id = domain.id, - projectId = domain.project?.id, - name = domain.localPath +internal class RepositoryMapper : EntityMapper { + @Autowired + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + + /** + * Converts a Repository domain object to RepositoryEntity. + * + * **Precondition**: The referenced Project must already be mapped and present in MappingContext. + * This enforces aggregate boundary - Project is a separate aggregate that must be handled first. + * + * **Note**: This method does NOT map child entities (Commits, Branches). Use assemblers + * for complete aggregate assembly including children. + * + * @param domain The Repository domain object to convert + * @return The RepositoryEntity (structure only, without children) + * @throws IllegalStateException if Project is not in MappingContext + */ + override fun toEntity(domain: Repository): RepositoryEntity { + // Fast-path: if this Repository was already mapped in the current context, return it. + ctx.findEntity(domain)?.let { return it } + + // IMPORTANT: Expect Project already in context (cross-aggregate reference). + // Do NOT auto-map Project here - that's a separate aggregate. + val owner: ProjectEntity = ctx.findEntity(domain.project) + ?: throw IllegalStateException( + "ProjectEntity must be mapped before RepositoryEntity. " + + "Ensure ProjectEntity is in MappingContext before calling toEntity()." + ) + + // Create entity and remember in context + val entity = domain.toEntity(owner) + ctx.remember(domain, entity) + + return entity + } + + /** + * Converts a RepositoryEntity to Repository domain object. + * + * **Precondition**: The referenced Project must already be mapped and present in MappingContext. + * This enforces aggregate boundary - Project is a separate aggregate that must be handled first. + * + * **Note**: This method does NOT map child entities (Commits, Branches). Use assemblers + * for complete aggregate assembly including children. + * + * @param entity The RepositoryEntity to convert + * @return The Repository domain object (structure only, without children) + * @throws IllegalStateException if Project is not in MappingContext + */ + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) + override fun toDomain(entity: RepositoryEntity): Repository { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + // IMPORTANT: Expect Project already in context (cross-aggregate reference). + // Do NOT auto-map Project here - that's a separate aggregate. + val owner = ctx.findDomain(entity.project) + ?: throw IllegalStateException( + "Project must be mapped before Repository. " + + "Ensure Project is in MappingContext before calling toDomain()." + ) + + val domain = entity.toDomain(owner) + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + entity.iid ) - override fun toDomain(entity: RepositoryEntity): Repository = - Repository( - id = entity.id, - project = entity.projectId?.let { Project(id = it, name = "") }, - localPath = entity.name, + ctx.remember(domain, entity) + + return domain + } + + /** + * Refreshes a Repository domain object with data from the corresponding entity. + * + * This method updates the domain object's ID from the entity after persistence. + * It does NOT update nested objects - only top-level Repository properties. + * + * @param target The Repository domain object to refresh + * @param entity The RepositoryEntity with updated data + * @return The refreshed Repository domain object + */ + fun refreshDomain(target: Repository, entity: RepositoryEntity): Repository { + setField( + target.javaClass.getDeclaredField("id"), + target, + entity.id ) - override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } + return target + } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/StatsMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/StatsMapper.kt index b2d122f48..3c81d8ed3 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/StatsMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/StatsMapper.kt @@ -1,30 +1,57 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.StatsEntity import com.inso_world.binocular.model.Stats +import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component - +/** + * Mapper for Stats value objects. + * + * Converts between Stats domain value objects and StatsEntity persistence entities for ArangoDB. + * This is a simple value object mapper without complex relationships or lifecycle management. + * + * ## Design Principles + * - **Value Object**: Stats is an immutable value object representing commit statistics + * - **Simple Mapping**: Direct property-to-property conversion without relationships + * - **Context Management**: Uses MappingContext to prevent duplicate mappings + * + * ## Usage + * This mapper is used by CommitMapper and other mappers that need to persist commit statistics. + */ @Component -class StatsMapper - constructor( - ) : EntityMapper { +internal class StatsMapper : EntityMapper { + + @Autowired + private lateinit var ctx: MappingContext - /** - * Converts a domain Job to an ArangoDB JobEntity - */ - override fun toEntity(domain: Stats): StatsEntity = - StatsEntity( - additions = domain.additions, - deletions = domain.deletions - ) + /** + * Converts a Stats value object to StatsEntity. + * + * @param domain The Stats value object to convert + * @return The StatsEntity with additions and deletions counts + */ + override fun toEntity(domain: Stats): StatsEntity = + StatsEntity( + additions = domain.additions, + deletions = domain.deletions + ) - //TODO: add java documentation - override fun toDomain(entity: StatsEntity): Stats = - Stats( - additions = entity.additions, - deletions = entity.deletions - ) + /** + * Converts a StatsEntity to Stats value object. + * + * @param entity The StatsEntity to convert + * @return The Stats value object with additions and deletions counts + */ + override fun toDomain(entity: StatsEntity): Stats { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + return Stats( + additions = entity.additions, + deletions = entity.deletions + ) } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/UserMapper.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/UserMapper.kt index 9365a7c5c..369eecfa4 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/UserMapper.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/UserMapper.kt @@ -1,69 +1,139 @@ package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.mapper.EntityMapper -import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.UserEntity +import com.inso_world.binocular.model.Repository import com.inso_world.binocular.model.User import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.Lazy +import org.springframework.data.util.ReflectionUtils.setField import org.springframework.stereotype.Component +/** + * Mapper for User domain objects. + * + * @deprecated Use [DeveloperMapper] instead. This mapper is maintained for backwards compatibility. + * + * Converts between User domain objects and UserEntity persistence entities for ArangoDB. + * This is a **simple mapper** - it only handles basic conversion without orchestrating + * complex relationships like commit authorship graphs. + * + * ## Design Principles + * - **Single Responsibility**: Only converts User structure + * - **Aggregate Boundaries**: Expects Repository already in MappingContext (cross-aggregate reference) + * - **No Deep Traversal**: Does not automatically map entire authored/committed commit graphs + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. + */ +@Deprecated("Use DeveloperMapper instead") @Component -class UserMapper +internal class UserMapper : EntityMapper { + @Autowired - constructor( - private val proxyFactory: RelationshipProxyFactory, - @Lazy private val issueMapper: IssueMapper, - @Lazy private val fileMapper: FileMapper, - ) : EntityMapper { - @Lazy @Autowired - private lateinit var commitMapper: CommitMapper - - /** - * Converts a domain User to an ArangoDB UserEntity - */ - override fun toEntity(domain: User): UserEntity = - UserEntity( - id = domain.id, - gitSignature = domain.gitSignature, - // Relationships are handled by ArangoDB through edges + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + + /** + * Converts a User domain object to UserEntity. + * + * **Precondition**: The referenced Repository must already be mapped and present in MappingContext. + * This enforces aggregate boundary - Repository is a separate aggregate that must be handled first. + * + * Creates a Git signature string from the user's name and email in the format: + * "Name " or just "Name" if email is not present. + * + * **Note**: This method does NOT map authored/committed commit relationships or other deep structures. + * Use assemblers for complete user graph assembly. + * + * @param domain The User domain object to convert + * @return The UserEntity (structure only, without commit relationships) + * @throws IllegalStateException if Repository is not in MappingContext + */ + override fun toEntity(domain: User): UserEntity { + // Fast-path: if this User was already mapped in the current context, return it. + ctx.findEntity(domain)?.let { return it } + + // IMPORTANT: Expect Repository already in context (cross-aggregate reference). + val repositoryEntity = ctx.findEntity(domain.repository) + ?: throw IllegalStateException( + "RepositoryEntity must be mapped before UserEntity. " + + "Ensure RepositoryEntity is in MappingContext before calling toEntity()." ) - /** - * Converts an ArangoDB UserEntity to a domain User - * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. - */ - override fun toDomain(entity: UserEntity): User = - User( - id = entity.id, - name = entity.name, - email = entity.email, -// committedCommits = mutableSetOf(), -// TODO -// proxyFactory.createLazyList { -// (entity.commits ?: emptyList()).map { commitEntity -> -// commitMapper.toDomain(commitEntity) -// } -// }, - issues = - proxyFactory.createLazyList { - entity.issues.map { issueEntity -> - issueMapper.toDomain(issueEntity) - } - }, - files = - proxyFactory.createLazyList { - entity.files.map { fileEntity -> - fileMapper.toDomain(fileEntity) - } - }, + val signature = buildString { + append(domain.name) + domain.email?.let { append(" <").append(it).append('>') } + } + + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) + val entity = UserEntity( + id = domain.id, + iid = domain.iid.value, + gitSignature = signature, + repository = repositoryEntity + ) + + ctx.remember(domain, entity) + return entity + } + + /** + * Converts a UserEntity to User domain object. + * + * **Precondition**: The User must already exist in MappingContext OR the referenced Repository + * must be in MappingContext. This is necessary because the User domain model requires a repository. + * + * **Note**: This method does NOT map authored/committed commit relationships or other deep structures. + * Use assemblers for complete user graph assembly. + * + * @param entity The UserEntity to convert + * @return The User domain object from MappingContext or a newly constructed one + * @throws IllegalStateException if neither User nor Repository is in MappingContext + */ + override fun toDomain(entity: UserEntity): User { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + // Try to find Repository in context + val repository = ctx.findDomain(entity.repository) + ?: throw IllegalStateException( + "Repository must be mapped before User. " + + "Ensure Repository is in MappingContext before calling toDomain()." ) - /** - * Converts a list of ArangoDB UserEntity objects to a list of domain User objects - */ - override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } + // Extract name and email from git signature + val nameRegex = Regex("""^(.+?)\s*<""") + val emailRegex = Regex("""<([^>]+)>$""") + + val name = nameRegex.find(entity.gitSignature)?.groupValues?.get(1)?.trim() + ?: entity.gitSignature + val email = emailRegex.find(entity.gitSignature)?.groupValues?.get(1) + + // Create domain with iid from entity + val domain = User( + name = name, + repository = repository + ).apply { + id = entity.id + this.email = email + } + + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + entity.iid + ) + + ctx.remember(domain, entity) + return domain } + + override fun toDomainList(entities: Iterable): List = entities.map { toDomain(it) } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/repository/RepositoryRepository.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/repository/RepositoryRepository.kt index 0d292111a..cf3fa660f 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/repository/RepositoryRepository.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/repository/RepositoryRepository.kt @@ -6,5 +6,5 @@ import org.springframework.stereotype.Repository @Repository interface RepositoryRepository : ArangoRepository { - fun findByName(name: String): RepositoryEntity? + fun findByLocalPath(localPath: String): RepositoryEntity? } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/AbstractInfrastructurePort.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/AbstractInfrastructurePort.kt new file mode 100644 index 000000000..bb68f8532 --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/AbstractInfrastructurePort.kt @@ -0,0 +1,8 @@ +package com.inso_world.binocular.infrastructure.arangodb.service + +import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.IDao +import java.io.Serializable + +internal abstract class AbstractInfrastructurePort { + internal lateinit var dao: IDao +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/AccountInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/AccountInfrastructurePortImpl.kt index eed6db32f..684b325be 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/AccountInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/AccountInfrastructurePortImpl.kt @@ -10,6 +10,8 @@ import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Issue import com.inso_world.binocular.model.MergeRequest import com.inso_world.binocular.model.Note +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -17,7 +19,15 @@ import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @Service -class AccountInfrastructurePortImpl : AccountInfrastructurePort { +internal class AccountInfrastructurePortImpl : + AccountInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = accountDao + } + @Autowired private lateinit var accountDao: IAccountDao @@ -41,6 +51,10 @@ class AccountInfrastructurePortImpl : AccountInfrastructurePort { return accountDao.findById(id) } + override fun findByIid(iid: Account.Id): @Valid Account? { + TODO("Not yet implemented") + } + override fun findIssuesByAccountId(accountId: String): List { logger.trace("Getting issues for account: $accountId") return issueAccountConnectionRepository.findIssuesByAccount(accountId) @@ -68,8 +82,6 @@ class AccountInfrastructurePortImpl : AccountInfrastructurePort { override fun update(entity: Account): Account = this.accountDao.update(entity) - override fun updateAndFlush(entity: Account): Account = this.accountDao.updateAndFlush(entity) - override fun deleteById(id: String) { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/BranchInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/BranchInfrastructurePortImpl.kt index b9b9aaf2f..c261b1e2c 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/BranchInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/BranchInfrastructurePortImpl.kt @@ -1,24 +1,36 @@ package com.inso_world.binocular.infrastructure.arangodb.service +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.core.service.BranchInfrastructurePort import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.IBranchFileConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node.IBranchDao +import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.File +import com.inso_world.binocular.model.Reference import com.inso_world.binocular.model.Repository -import org.slf4j.Logger -import org.slf4j.LoggerFactory +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @Service -class BranchInfrastructurePortImpl : BranchInfrastructurePort { +internal class BranchInfrastructurePortImpl : BranchInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = branchDao + } @Autowired private lateinit var branchDao: IBranchDao @Autowired private lateinit var branchFileConnectionRepository: IBranchFileConnectionDao - var logger: Logger = LoggerFactory.getLogger(BranchInfrastructurePortImpl::class.java) + + companion object { + private val logger by logger() + } override fun findAll(pageable: Pageable): Page { logger.trace("Getting all branches with pageable: page=${pageable.pageNumber}, size=${pageable.pageSize}") @@ -30,6 +42,10 @@ class BranchInfrastructurePortImpl : BranchInfrastructurePort { return branchDao.findById(id) } + override fun findByIid(iid: Reference.Id): @Valid Branch? { + TODO("Not yet implemented") + } + override fun findFilesByBranchId(branchId: String): List { logger.trace("Getting files for branch: $branchId") return branchFileConnectionRepository.findFilesByBranch(branchId) @@ -41,24 +57,10 @@ class BranchInfrastructurePortImpl : BranchInfrastructurePort { override fun saveAll(entities: Collection): Iterable = this.branchDao.saveAll(entities) - override fun delete(entity: Branch) = this.branchDao.delete(entity) - override fun update(entity: Branch): Branch { TODO("Not yet implemented") } - override fun updateAndFlush(entity: Branch): Branch { - TODO("Not yet implemented") - } - - override fun deleteById(id: String) { - TODO("Not yet implemented") - } - - override fun deleteAll() { - this.branchDao.deleteAll() - } - override fun findAll(repository: Repository): Iterable { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/BuildInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/BuildInfrastructurePortImpl.kt index e36270803..17c1af8cc 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/BuildInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/BuildInfrastructurePortImpl.kt @@ -4,8 +4,11 @@ import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.core.service.BuildInfrastructurePort import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.ICommitBuildConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node.IBuildDao +import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Build import com.inso_world.binocular.model.Commit +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -15,7 +18,13 @@ import java.time.ZoneOffset import java.util.Date @Service -class BuildInfrastructurePortImpl : BuildInfrastructurePort { +internal class BuildInfrastructurePortImpl : BuildInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = buildDao + } @Autowired private lateinit var buildDao: IBuildDao @Autowired private lateinit var commitBuildConnectionRepository: ICommitBuildConnectionDao @@ -53,6 +62,10 @@ class BuildInfrastructurePortImpl : BuildInfrastructurePort { return buildDao.findById(id) } + override fun findByIid(iid: Build.Id): @Valid Build? { + TODO("Not yet implemented") + } + override fun findCommitsByBuildId(buildId: String): List { logger.trace("Getting commits for build: $buildId") return commitBuildConnectionRepository.findCommitsByBuild(buildId) @@ -70,10 +83,6 @@ class BuildInfrastructurePortImpl : BuildInfrastructurePort { TODO("Not yet implemented") } - override fun updateAndFlush(entity: Build): Build { - TODO("Not yet implemented") - } - override fun deleteById(id: String) { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/CommitInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/CommitInfrastructurePortImpl.kt index 7960a2ad2..899a0a97e 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/CommitInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/CommitInfrastructurePortImpl.kt @@ -1,5 +1,6 @@ package com.inso_world.binocular.infrastructure.arangodb.service +import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.core.service.CommitInfrastructurePort import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.ICommitBuildConnectionDao @@ -9,6 +10,7 @@ import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfac import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.ICommitUserConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.IIssueCommitConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node.ICommitDao +import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Build import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.File @@ -16,15 +18,21 @@ import com.inso_world.binocular.model.Issue import com.inso_world.binocular.model.Module import com.inso_world.binocular.model.Repository import com.inso_world.binocular.model.User -import org.slf4j.Logger -import org.slf4j.LoggerFactory +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import java.time.ZoneOffset @Service -internal class CommitInfrastructurePortImpl : CommitInfrastructurePort { +internal class CommitInfrastructurePortImpl : CommitInfrastructurePort , + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = commitDao + } @Autowired private lateinit var commitDao: ICommitDao @Autowired private lateinit var commitBuildConnectionRepository: ICommitBuildConnectionDao @@ -39,7 +47,9 @@ internal class CommitInfrastructurePortImpl : CommitInfrastructurePort { @Autowired private lateinit var commitUserConnectionRepository: ICommitUserConnectionDao - var logger: Logger = LoggerFactory.getLogger(CommitInfrastructurePortImpl::class.java) + companion object { + private val logger by logger() + } override fun findAll(pageable: Pageable): Page { logger.trace("Getting all commits with pageable: page=${pageable.pageNumber}, size=${pageable.pageSize}") @@ -75,6 +85,10 @@ internal class CommitInfrastructurePortImpl : CommitInfrastructurePort { return commitDao.findById(id) } + override fun findByIid(iid: Commit.Id): @Valid Commit? { + TODO("Not yet implemented") + } + override fun findBuildsByCommitId(commitId: String): List { logger.trace("Getting builds for commit: $commitId") return commitBuildConnectionRepository.findBuildsByCommit(commitId) @@ -116,16 +130,10 @@ internal class CommitInfrastructurePortImpl : CommitInfrastructurePort { override fun saveAll(entities: Collection): Iterable = this.commitDao.saveAll(entities) - override fun delete(entity: Commit) = this.commitDao.delete(entity) - override fun update(entity: Commit): Commit { TODO("Not yet implemented") } - override fun updateAndFlush(entity: Commit): Commit { - TODO("Not yet implemented") - } - override fun findExistingSha( repo: Repository, shas: List, @@ -151,14 +159,6 @@ internal class CommitInfrastructurePortImpl : CommitInfrastructurePort { TODO("Not yet implemented") } - override fun deleteById(id: String) { - TODO("Not yet implemented") - } - - override fun deleteAll() { - this.commitDao.deleteAll() - } - override fun findAll(repo: Repository): Iterable { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/DbExportPortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/DbExportPortImpl.kt index c9ed4df69..3283d9cc5 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/DbExportPortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/DbExportPortImpl.kt @@ -13,7 +13,7 @@ import org.springframework.stereotype.Service * This implementation only works with ArangoDB. */ @Service -class DbExportPortImpl( +internal class DbExportPortImpl( private val arangoConfig: ArangodbAppConfig, ) : DbExportPort { var logger: Logger = LoggerFactory.getLogger(DbExportPortImpl::class.java) diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/FileInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/FileInfrastructurePortImpl.kt index 73b86c01b..4dcdf18c4 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/FileInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/FileInfrastructurePortImpl.kt @@ -8,10 +8,13 @@ import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfac import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.ICommitFileUserConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.IModuleFileConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node.IFileDao +import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.File import com.inso_world.binocular.model.User +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -19,7 +22,13 @@ import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @Service -class FileInfrastructurePortImpl : FileInfrastructurePort { +internal class FileInfrastructurePortImpl : FileInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = fileDao + } @Autowired private lateinit var fileDao: IFileDao @Autowired private lateinit var branchFileConnectionRepository: IBranchFileConnectionDao @@ -43,6 +52,10 @@ class FileInfrastructurePortImpl : FileInfrastructurePort { return fileDao.findById(id) } + override fun findByIid(iid: File.Id): @Valid File? { + TODO("Not yet implemented") + } + override fun findBranchesByFileId(fileId: String): List { logger.trace("Getting branches for file: $fileId") return branchFileConnectionRepository.findBranchesByFile(fileId) @@ -82,10 +95,6 @@ class FileInfrastructurePortImpl : FileInfrastructurePort { TODO("Not yet implemented") } - override fun updateAndFlush(entity: File): File { - TODO("Not yet implemented") - } - override fun deleteById(id: String) { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/IssueInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/IssueInfrastructurePortImpl.kt index c80c206a0..213015e7e 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/IssueInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/IssueInfrastructurePortImpl.kt @@ -14,6 +14,8 @@ import com.inso_world.binocular.model.Issue import com.inso_world.binocular.model.Milestone import com.inso_world.binocular.model.Note import com.inso_world.binocular.model.User +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -21,7 +23,13 @@ import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @Service -class IssueInfrastructurePortImpl : IssueInfrastructurePort { +internal class IssueInfrastructurePortImpl : IssueInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = issueDao + } @Autowired private lateinit var issueDao: IIssueDao @Autowired private lateinit var issueAccountConnectionRepository: IIssueAccountConnectionDao @@ -45,6 +53,10 @@ class IssueInfrastructurePortImpl : IssueInfrastructurePort { return issueDao.findById(id) } + override fun findByIid(iid: Issue.Id): @Valid Issue? { + TODO("Not yet implemented") + } + override fun findAccountsByIssueId(issueId: String): List { logger.trace("Getting accounts for issue: $issueId") return issueAccountConnectionRepository.findAccountsByIssue(issueId) @@ -82,10 +94,6 @@ class IssueInfrastructurePortImpl : IssueInfrastructurePort { TODO("Not yet implemented") } - override fun updateAndFlush(entity: Issue): Issue { - TODO("Not yet implemented") - } - override fun deleteById(id: String) { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/MergeRequestInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/MergeRequestInfrastructurePortImpl.kt index a9b0dfdb1..fc273f93d 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/MergeRequestInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/MergeRequestInfrastructurePortImpl.kt @@ -10,6 +10,8 @@ import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.MergeRequest import com.inso_world.binocular.model.Milestone import com.inso_world.binocular.model.Note +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -21,7 +23,13 @@ import org.springframework.stereotype.Service * This service is database-agnostic and works with both ArangoDB and SQL implementations. */ @Service -class MergeRequestInfrastructurePortImpl : MergeRequestInfrastructurePort { +internal class MergeRequestInfrastructurePortImpl : MergeRequestInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = mergeRequestDao + } @Autowired private lateinit var mergeRequestDao: IMergeRequestDao @Autowired private lateinit var mergeRequestAccountConnectionRepository: IMergeRequestAccountConnectionDao @@ -41,6 +49,10 @@ class MergeRequestInfrastructurePortImpl : MergeRequestInfrastructurePort { return mergeRequestDao.findById(id) } + override fun findByIid(iid: MergeRequest.Id): @Valid MergeRequest? { + TODO("Not yet implemented") + } + override fun findAccountsByMergeRequestId(mergeRequestId: String): List { logger.trace("Getting accounts for merge request: $mergeRequestId") return mergeRequestAccountConnectionRepository.findAccountsByMergeRequest(mergeRequestId) @@ -68,10 +80,6 @@ class MergeRequestInfrastructurePortImpl : MergeRequestInfrastructurePort { TODO("Not yet implemented") } - override fun updateAndFlush(entity: MergeRequest): MergeRequest { - TODO("Not yet implemented") - } - override fun deleteById(id: String) { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/MilestoneInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/MilestoneInfrastructurePortImpl.kt index 2f3b92dba..cb6dc7682 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/MilestoneInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/MilestoneInfrastructurePortImpl.kt @@ -5,9 +5,12 @@ import com.inso_world.binocular.core.service.MilestoneInfrastructurePort import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.IIssueMilestoneConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.IMergeRequestMilestoneConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node.IMilestoneDao +import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Issue import com.inso_world.binocular.model.MergeRequest import com.inso_world.binocular.model.Milestone +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -19,7 +22,13 @@ import org.springframework.stereotype.Service * This service is database-agnostic and works with both ArangoDB and SQL implementations. */ @Service -class MilestoneInfrastructurePortImpl : MilestoneInfrastructurePort { +internal class MilestoneInfrastructurePortImpl : MilestoneInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = milestoneDao + } @Autowired private lateinit var milestoneDao: IMilestoneDao @Autowired private lateinit var issueMilestoneConnectionRepository: IIssueMilestoneConnectionDao @@ -37,6 +46,10 @@ class MilestoneInfrastructurePortImpl : MilestoneInfrastructurePort { return milestoneDao.findById(id) } + override fun findByIid(iid: Milestone.Id): @Valid Milestone? { + TODO("Not yet implemented") + } + override fun findIssuesByMilestoneId(milestoneId: String): List { logger.trace("Getting issues for milestone: $milestoneId") return issueMilestoneConnectionRepository.findIssuesByMilestone(milestoneId) @@ -59,10 +72,6 @@ class MilestoneInfrastructurePortImpl : MilestoneInfrastructurePort { TODO("Not yet implemented") } - override fun updateAndFlush(entity: Milestone): Milestone { - TODO("Not yet implemented") - } - override fun deleteById(id: String) { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/ModuleInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/ModuleInfrastructurePortImpl.kt index 0845aa0f1..3f3eba1b3 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/ModuleInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/ModuleInfrastructurePortImpl.kt @@ -6,9 +6,12 @@ import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfac import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.IModuleFileConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.IModuleModuleConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node.IModuleDao +import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.File import com.inso_world.binocular.model.Module +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -20,7 +23,13 @@ import org.springframework.stereotype.Service * This service is database-agnostic and works with both ArangoDB and SQL implementations. */ @Service -class ModuleInfrastructurePortImpl : ModuleInfrastructurePort { +internal class ModuleInfrastructurePortImpl : ModuleInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = moduleDao + } @Autowired private lateinit var moduleDao: IModuleDao @Autowired private lateinit var commitModuleConnectionRepository: ICommitModuleConnectionDao @@ -40,6 +49,10 @@ class ModuleInfrastructurePortImpl : ModuleInfrastructurePort { return moduleDao.findById(id) } + override fun findByIid(iid: Module.Id): @Valid Module? { + TODO("Not yet implemented") + } + override fun findCommitsByModuleId(moduleId: String): List { logger.trace("Getting commits for module: $moduleId") return commitModuleConnectionRepository.findCommitsByModule(moduleId) @@ -72,10 +85,6 @@ class ModuleInfrastructurePortImpl : ModuleInfrastructurePort { TODO("Not yet implemented") } - override fun updateAndFlush(entity: Module): Module { - TODO("Not yet implemented") - } - override fun deleteById(id: String) { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/NoteInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/NoteInfrastructurePortImpl.kt index c3d63df28..7e3c4ddd8 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/NoteInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/NoteInfrastructurePortImpl.kt @@ -10,6 +10,8 @@ import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Issue import com.inso_world.binocular.model.MergeRequest import com.inso_world.binocular.model.Note +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -21,7 +23,13 @@ import org.springframework.stereotype.Service * This service is database-agnostic and works with both ArangoDB and SQL implementations. */ @Service -class NoteInfrastructurePortImpl : NoteInfrastructurePort { +internal class NoteInfrastructurePortImpl : NoteInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = noteDao + } @Autowired private lateinit var noteDao: INoteDao @Autowired private lateinit var noteAccountConnectionRepository: INoteAccountConnectionDao @@ -41,6 +49,10 @@ class NoteInfrastructurePortImpl : NoteInfrastructurePort { return noteDao.findById(id) } + override fun findByIid(iid: Note.Id): @Valid Note? { + TODO("Not yet implemented") + } + override fun findAccountsByNoteId(noteId: String): List { logger.trace("Getting accounts for note: $noteId") return noteAccountConnectionRepository.findAccountsByNote(noteId) @@ -68,10 +80,6 @@ class NoteInfrastructurePortImpl : NoteInfrastructurePort { TODO("Not yet implemented") } - override fun updateAndFlush(entity: Note): Note { - TODO("Not yet implemented") - } - override fun deleteById(id: String) { TODO("Not yet implemented") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/ProjectInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/ProjectInfrastructurePortImpl.kt index bd8269087..3728c591a 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/ProjectInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/ProjectInfrastructurePortImpl.kt @@ -4,20 +4,28 @@ import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.core.service.ProjectInfrastructurePort import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.nosql.arangodb.ProjectDao -import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.nosql.arangodb.RepositoryDao +import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Project +import jakarta.annotation.PostConstruct import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @Service -class ProjectInfrastructurePortImpl : ProjectInfrastructurePort { +internal class ProjectInfrastructurePortImpl : ProjectInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = projectDao + } companion object { val logger by logger() } @Autowired private lateinit var projectDao: ProjectDao + override fun findAll(): Iterable { return this.projectDao.findAll() } @@ -39,10 +47,6 @@ class ProjectInfrastructurePortImpl : ProjectInfrastructurePort { return this.projectDao.saveAll(values) } - override fun delete(value: Project) { - this.projectDao.delete(value) - } - override fun findByName(name: String): Project? { return this.projectDao.findByName(name) } @@ -51,15 +55,7 @@ class ProjectInfrastructurePortImpl : ProjectInfrastructurePort { TODO("Not yet implemented") } - override fun updateAndFlush(value: Project): Project { + override fun findByIid(iid: Project.Id): Project? { TODO("Not yet implemented") } - - override fun deleteById(id: String) { - this.projectDao.deleteById(id) - } - - override fun deleteAll() { - this.projectDao.deleteAll() - } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/RepositoryInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/RepositoryInfrastructurePortImpl.kt index 5e3e446fa..1e9c8075f 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/RepositoryInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/RepositoryInfrastructurePortImpl.kt @@ -3,22 +3,44 @@ package com.inso_world.binocular.infrastructure.arangodb.service import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.core.service.RepositoryInfrastructurePort +import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.nosql.arangodb.CommitDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.nosql.arangodb.RepositoryDao +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.RepositoryMapper +import com.inso_world.binocular.model.Account +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.Repository +import jakarta.annotation.PostConstruct import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @Service -class RepositoryInfrastructurePortImpl : RepositoryInfrastructurePort { +internal class RepositoryInfrastructurePortImpl : RepositoryInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = repositoryDao + } companion object { val logger by logger() } + @Autowired + private lateinit var commitDao: CommitDao + @Autowired private lateinit var repositoryDao: RepositoryDao + @Autowired + private lateinit var repositoryMapper: RepositoryMapper + + override fun findByIid(iid: Repository.Id): Repository? { + TODO("Not yet implemented") + } + override fun findAll(): Iterable { return this.repositoryDao.findAll() } @@ -32,34 +54,32 @@ class RepositoryInfrastructurePortImpl : RepositoryInfrastructurePort { } override fun create(value: Repository): Repository { - return this.repositoryDao.create(value) + val mappedEntity = repositoryMapper.toEntity(value) + val savedEntity = this.repositoryDao.create(mappedEntity) + return repositoryMapper.toDomain(savedEntity) } override fun saveAll(values: Collection): Iterable { return this.repositoryDao.saveAll(values) } - override fun delete(value: Repository) { - return this.repositoryDao.delete(value) - } - override fun update(value: Repository): Repository { TODO("Not yet implemented") } - override fun updateAndFlush(value: Repository): Repository { - TODO("Not yet implemented") - } - override fun findByName(name: String): Repository? { - return this.repositoryDao.findByName(name) + return this.repositoryDao.findByName(name)?.let { this.repositoryMapper.toDomain(it) } } - override fun deleteById(id: String) { - this.repositoryDao.deleteById(id) + override fun findExistingCommits(repo: Repository, shas: Set): Sequence { + TODO("Not yet implemented") } - override fun deleteAll() { - this.repositoryDao.deleteAll() + + override fun findBranch( + repository: Repository, + name: String + ): Branch? { + TODO("Not yet implemented") } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/UserInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/UserInfrastructurePortImpl.kt index 069a92215..eafabbba6 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/UserInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/service/UserInfrastructurePortImpl.kt @@ -5,13 +5,15 @@ import com.inso_world.binocular.core.service.UserInfrastructurePort import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.ICommitFileUserConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.ICommitUserConnectionDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.edge.IIssueUserConnectionDao -import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.interfaces.node.IUserDao import com.inso_world.binocular.infrastructure.arangodb.persistence.dao.nosql.arangodb.UserDao +import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.File import com.inso_world.binocular.model.Issue import com.inso_world.binocular.model.Repository import com.inso_world.binocular.model.User +import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -23,7 +25,13 @@ import org.springframework.stereotype.Service * This service is database-agnostic and works with both ArangoDB and SQL implementations. */ @Service -class UserInfrastructurePortImpl : UserInfrastructurePort { +internal class UserInfrastructurePortImpl : UserInfrastructurePort, + AbstractInfrastructurePort() { + + @PostConstruct + fun init() { + super.dao = userDao + } @Autowired private lateinit var userDao: UserDao @@ -47,6 +55,10 @@ class UserInfrastructurePortImpl : UserInfrastructurePort { return userDao.findById(id) } + override fun findByIid(iid: User.Id): @Valid User? { + TODO("Not yet implemented") + } + override fun findCommitsByUserId(userId: String): List { logger.trace("Getting commits for user: $userId") return commitUserConnectionRepository.findCommitsByUser(userId) @@ -68,25 +80,17 @@ class UserInfrastructurePortImpl : UserInfrastructurePort { override fun findAll(): Iterable = this.userDao.findAll() - override fun create(entity: User): User = userDao.create(entity) + override fun create(value: User): User { + val repo = requireNotNull(value.repository) + val newUser = userDao.create(value) + repo.user.removeIf { it.email == value.email } + repo.user.add(newUser) + return newUser + } override fun saveAll(values: Collection): Iterable = userDao.saveAll(values) - override fun delete(value: User) = this.userDao.delete(value) - override fun update(value: User): User { TODO("Not yet implemented") } - - override fun updateAndFlush(entity: User): User { - TODO("Not yet implemented") - } - - override fun deleteById(id: String) { - TODO("Not yet implemented") - } - - override fun deleteAll() { - this.userDao.deleteAll() - } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/startup/ArangoCollectionInitializer.kt b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/startup/ArangoCollectionInitializer.kt index dd04c4a5c..0b9a82dae 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/startup/ArangoCollectionInitializer.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/main/kotlin/com/inso_world/binocular/infrastructure/arangodb/startup/ArangoCollectionInitializer.kt @@ -44,6 +44,8 @@ class ArangoCollectionInitializer( ensureDocumentCollection(dbName, "builds") ensureDocumentCollection(dbName, "notes") ensureDocumentCollection(dbName, "accounts") + ensureDocumentCollection(dbName, "projects") + ensureDocumentCollection(dbName, "repositories") // Edge collections ensureEdgeCollection(dbName, "branches-files") diff --git a/binocular-backend-new/infrastructure-arangodb/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/binocular-backend-new/infrastructure-arangodb/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000..7c290f3f3 --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,40 @@ +{ + "groups": [ + { + "name": "binocular.arangodb", + "type": "com.inso_world.binocular.infrastructure.arangodb.InfrastructureConfig", + "description": "Configuration for the gix (Rust Git implementation) indexer." + } + ], + "properties": [ + { + "name": "binocular.arangodb.database.name", + "type": "java.lang.String", + "description": "Name of the database to use", + "defaultValue": "Binocular" + }, + { + "name": "binocular.arangodb.database.host", + "type": "java.lang.String", + "description": "Host of where the database is running", + "defaultValue": "localhost" + }, + { + "name": "binocular.arangodb.database.port", + "type": "java.lang.Integer", + "description": "Port of where the database is running", + "defaultValue": 5432 + }, + { + "name": "binocular.arangodb.database.user", + "type": "java.lang.String", + "description": "User to login with into the ArangoDB instance" + }, + { + "name": "binocular.arangodb.database.password", + "type": "java.lang.String", + "description": "Password to login with into the ArangoDB instance" + } + ], + "hints": [] +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/ArangodbInfrastructureDataSetup.kt b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/ArangodbInfrastructureDataSetup.kt index ad29ca269..ea517efde 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/ArangodbInfrastructureDataSetup.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/ArangodbInfrastructureDataSetup.kt @@ -47,6 +47,8 @@ import com.inso_world.binocular.infrastructure.arangodb.service.AccountInfrastru import com.inso_world.binocular.infrastructure.arangodb.service.BranchInfrastructurePortImpl import com.inso_world.binocular.infrastructure.arangodb.service.BuildInfrastructurePortImpl import com.inso_world.binocular.infrastructure.arangodb.service.CommitInfrastructurePortImpl +import com.inso_world.binocular.infrastructure.arangodb.service.FileInfrastructurePortImpl +import com.inso_world.binocular.infrastructure.arangodb.service.IssueInfrastructurePortImpl import com.inso_world.binocular.infrastructure.arangodb.service.MergeRequestInfrastructurePortImpl import com.inso_world.binocular.infrastructure.arangodb.service.MilestoneInfrastructurePortImpl import com.inso_world.binocular.infrastructure.arangodb.service.ModuleInfrastructurePortImpl @@ -64,8 +66,8 @@ internal class ArangodbInfrastructureDataSetup( @Autowired private val accountRepository: AccountInfrastructurePortImpl, @Autowired private val branchRepository: BranchInfrastructurePortImpl, @Autowired private val buildRepository: BuildInfrastructurePortImpl, - @Autowired private val fileRepository: FileInfrastructurePort, - @Autowired private val issueRepository: IssueInfrastructurePort, + @Autowired private val fileRepository: FileInfrastructurePortImpl, + @Autowired private val issueRepository: IssueInfrastructurePortImpl, @Autowired private val mergeRequestRepository: MergeRequestInfrastructurePortImpl, @Autowired private val moduleRepository: ModuleInfrastructurePortImpl, @Autowired private val noteRepository: NoteInfrastructurePortImpl, @@ -143,6 +145,7 @@ internal class ArangodbInfrastructureDataSetup( this.mockTestData = MockTestDataProvider() // order: create parents first where necessary projectRepository.saveAll(mockTestData.testProjects) + val project = mockTestData.projectsByName.getValue("proj-for-repos") repositoryRepository.saveAll(mockTestData.testRepositories) commitRepository.saveAll(TestDataProvider.testCommits) @@ -185,19 +188,19 @@ internal class ArangodbInfrastructureDataSetup( noteAccountConnectionRepository.deleteAll() // entities - commitRepository.deleteAll() - accountRepository.deleteAll() - branchRepository.deleteAll() - buildRepository.deleteAll() - fileRepository.deleteAll() - issueRepository.deleteAll() - mergeRequestRepository.deleteAll() - milestoneRepository.deleteAll() - moduleRepository.deleteAll() - noteRepository.deleteAll() - userRepository.deleteAll() - repositoryRepository.deleteAll() - projectRepository.deleteAll() + commitRepository.deleteAllEntities() + accountRepository.deleteAllEntities() + branchRepository.deleteAllEntities() + buildRepository.deleteAllEntities() + fileRepository.deleteAllEntities() + issueRepository.deleteAllEntities() + mergeRequestRepository.deleteAllEntities() + milestoneRepository.deleteAllEntities() + moduleRepository.deleteAllEntities() + noteRepository.deleteAllEntities() + userRepository.deleteAllEntities() + repositoryRepository.deleteAllEntities() + projectRepository.deleteAllEntities() logger.info("<<< ArangodbInfrastructureDataSetup teardown") } diff --git a/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/ArangodbTestConfig.kt b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/ArangodbTestConfig.kt index 0e1d9d2fb..04319a705 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/ArangodbTestConfig.kt +++ b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/ArangodbTestConfig.kt @@ -16,7 +16,7 @@ import org.springframework.test.context.ContextConfiguration @ContextConfiguration(initializers = [ArangodbTestConfig.Initializer::class]) @Import(ArangodbAppConfig::class) class ArangodbTestConfig { - companion object Companion { + companion object { val adbContainer = ArangoContainer("arangodb:3.12") .apply { withExposedPorts(8529) } @@ -30,9 +30,9 @@ class ArangodbTestConfig { adbContainer.start() TestPropertyValues.of( - "binocular.database.database_name=infrastructure_arangodb_it", - "binocular.database.host=${adbContainer.host}", - "binocular.database.port=${adbContainer.firstMappedPort}" + "binocular.arangodb.database.name=infrastructure_arangodb_it", + "binocular.arangodb.database.host=${adbContainer.host}", + "binocular.arangodb.database.port=${adbContainer.firstMappedPort}" ).applyTo(ctx.environment) } } diff --git a/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/Extension.kt b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/Extension.kt new file mode 100644 index 000000000..912e5e482 --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/Extension.kt @@ -0,0 +1,10 @@ +package com.inso_world.binocular.infrastructure.arangodb + +import com.inso_world.binocular.infrastructure.arangodb.service.AbstractInfrastructurePort +import org.springframework.transaction.annotation.Transactional +import java.io.Serializable + +@Transactional +internal fun AbstractInfrastructurePort.deleteAllEntities() { + this.dao.deleteAll() +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/DomainModelAlignmentTest.kt b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/DomainModelAlignmentTest.kt new file mode 100644 index 000000000..56b399e6a --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/entity/DomainModelAlignmentTest.kt @@ -0,0 +1,285 @@ +package com.inso_world.binocular.infrastructure.arangodb.persistence.entity + +import com.inso_world.binocular.model.Account +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Build +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.File +import com.inso_world.binocular.model.Issue +import com.inso_world.binocular.model.Job +import com.inso_world.binocular.model.Mention +import com.inso_world.binocular.model.MergeRequest +import com.inso_world.binocular.model.Milestone +import com.inso_world.binocular.model.Note +import com.inso_world.binocular.model.Platform +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Stats +import com.inso_world.binocular.model.User +import com.inso_world.binocular.model.vcs.ReferenceCategory + +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.test.util.AssertionErrors.fail +import java.lang.reflect.ParameterizedType +import kotlin.collections.Map + + +// Test to ensure that the persistence entity classes align with the domain model classes +// Tests should fail if there are mismatches in property types or relations +// and warn if there are extra properties or relations +class DomainModelAlignmentTest { + + // Mapped pairs are based of off mappers + private val mappedClasses = mapOf( + Account::class.java to AccountEntity::class.java, + Branch::class.java to BranchEntity::class.java, + Build::class.java to BuildEntity::class.java, + Commit::class.java to CommitEntity::class.java, + File::class.java to FileEntity::class.java, + Issue::class.java to IssueEntity::class.java, + Job::class.java to JobEntity::class.java, + Mention::class.java to MentionEntity::class.java, + MergeRequest::class.java to MergeRequestEntity::class.java, + Milestone::class.java to MilestoneEntity::class.java, + Module::class.java to ModuleEntity::class.java, + Note::class.java to NoteEntity::class.java, + Platform::class.java to PlatformEntity::class.java, + Stats::class.java to StatsEntity::class.java, + User::class.java to UserEntity::class.java, + Developer::class.java to DeveloperEntity::class.java, + Repository::class.java to RepositoryEntity::class.java, + ReferenceCategory::class.java to String::class.java, + Project::class.java to ProjectEntity::class.java, + ); + + companion object { + @JvmStatic + fun entityModelPairs() = + listOf( + Arguments.of(Account::class.java, AccountEntity::class.java), + Arguments.of(Branch::class.java, BranchEntity::class.java), + Arguments.of(Build::class.java, BuildEntity::class.java), + Arguments.of(Commit::class.java, CommitEntity::class.java), + Arguments.of(File::class.java, FileEntity::class.java), + Arguments.of(Issue::class.java, IssueEntity::class.java), + Arguments.of(Job::class.java, JobEntity::class.java), + Arguments.of(Mention::class.java, MentionEntity::class.java), + Arguments.of(MergeRequest::class.java, MergeRequestEntity::class.java), + Arguments.of(Milestone::class.java, MilestoneEntity::class.java), + Arguments.of(com.inso_world.binocular.model.Module::class.java, ModuleEntity::class.java), + Arguments.of(Note::class.java, NoteEntity::class.java), + Arguments.of(Stats::class.java, StatsEntity::class.java), + Arguments.of(User::class.java, UserEntity::class.java), + Arguments.of(Developer::class.java, DeveloperEntity::class.java), + Arguments.of(Repository::class.java, RepositoryEntity::class.java), + Arguments.of(ReferenceCategory::class.java, String::class.java), + Arguments.of(Project::class.java, ProjectEntity::class.java), + ) + } + + @ParameterizedTest + @MethodSource("entityModelPairs") + fun `compare entity and model alignment`(model: Class<*>, entity: Class<*>) { + var internalModelProperties = emptySet() + var allowedTypePairs = emptyMap, Class>() + var mappedProperties = emptyMap() + var deprecatedProperties = emptySet() + + // handle special cases per model entity pair if applicable + // TODO: spacial cases better defined in companion object? + if (model == Issue::class.java) { + allowedTypePairs = mapOf( + java.time.LocalDateTime::class.java to java.util.Date::class.java + ) + } + if (model == Commit::class.java) { + internalModelProperties = setOf("_parents", "_children", "_branches") + + mappedProperties = mapOf( + "commitDateTime" to "date" + ) + + allowedTypePairs = mapOf( + java.time.LocalDateTime::class.java to java.util.Date::class.java, + java.util.Set::class.java to java.util.List::class.java + ) + + deprecatedProperties = setOf( + "branch" + ) + } + if (model == User::class.java) { + internalModelProperties = setOf("_committedCommits", "_authoredCommits") + } + if (model == Branch::class.java) { + internalModelProperties = setOf("_commits") + mappedProperties = mapOf( + "name" to "branch" + ) + deprecatedProperties = setOf( + "branch" + ) + } + if (model == Build::class.java) { + allowedTypePairs = mapOf( + java.time.LocalDateTime::class.java to java.util.Date::class.java + ) + } + if (model == Job::class.java) { + allowedTypePairs = mapOf( + java.time.LocalDateTime::class.java to java.util.Date::class.java + ) + } + if (model == Mention::class.java) { + allowedTypePairs = mapOf( + java.time.LocalDateTime::class.java to java.util.Date::class.java + ) + } + + //perform test + `compare raw entity and model properties`( + entity, + model, + internalModelProperties, + allowedTypePairs, + mappedProperties, + deprecatedProperties + ) + + `compare entity and model edges`(entity, model) + + } + + // Generic method to compare properties of given entity and model classes + // internalModelProperties: properties that should be ignored in the comparison because they are internal to the model + // allowedTypePairs: map of model types to entity types that are considered equivalent + // mappedProperties: map of model property names to entity property names based on mapping rules + fun `compare raw entity and model properties`(entity: Class<*>, + model: Class<*>, + internalModelProperties: Set, + allowedTypePairs: Map, Class>, + mappedProperties: Map, + deprecatedProperties: Set) { + val entityProps = entity.declaredFields + .associate { it.name to it.type } + val modelProps = model.declaredFields + .associate { it.name to it.type } + + + // check for matching type or allowed equivalence + modelProps.forEach { (name, modelType) -> + val entityPropertyName = mappedProperties[name] ?: name + val entityType = entityProps[entityPropertyName] + + if (deprecatedProperties.contains(name)) { + //if a property is deprecated check for its existence in the entity and mapping status + if (entityType != null) { + if (!mappedProperties.values.contains(entityPropertyName)) { + println("️⚠️ Property '$name' in ${model.simpleName} is deprecated but still exists in ${entity.simpleName}.") + } else { + var key : String = "" + mappedProperties.forEach { pair -> + if (pair.value == entityPropertyName) key = pair.key + } + println("️⚠️ Property '$name' in ${model.simpleName} is deprecated but replaced by property '$key.'") + } + } else { + println("️⚠️ Property '$name' in ${model.simpleName} is deprecated but does not exist in ${entity.simpleName}.") + } + return@forEach + } + + if (entityType == null && name !in internalModelProperties) { + println("⚠️ ${model.simpleName} has extra field '$name' (model type: $modelType)") + return@forEach + } + + val isAllowedMismatch = (allowedTypePairs[modelType] == entityType) || (mappedClasses[modelType] == entityType) + if (entityType != modelType && !isAllowedMismatch) { + fail("❌ Property '$name' type mismatch between ${model.simpleName} and ${entity.simpleName}: expected $modelType but got $entityType") + } + } + + // check that entity has extra fields that are not in model + val extraFields = entityProps.keys - modelProps.keys + for (extraField in extraFields) { + val entityType = entityProps[extraField] + if (extraField !in mappedProperties.values) { + println("️⚠️ ${entity.simpleName} has extra field not in model: $extraField (type: $entityType)") + } + } + } + + fun `compare entity and model edges`(entity: Class<*>, + model: Class<*>,) { + val entityRelations = entity.declaredFields + .filter { field -> + val type = field.genericType + val isRelevant = (!(type is Class<*> && type.isPrimitive) + && type != String::class.java + && type != java.util.Date::class.java) + && !(field.name.contains("_")) + isRelevant + } + .map { field -> field.name to field.genericType } + .toSet() + + // find all fields that don't have primitive or LocalDateTime type in commit model or have names containing"_" + // and save their names and generic type in modelRelations + val modelRelations = model.declaredFields + .filter { field -> + val type = field.genericType + val isRelevant = (!(type is Class<*> && type.isPrimitive) + && type != String::class.java + && type != java.time.LocalDateTime::class.java) + && !(field.name.contains("_")) + isRelevant + } + .map { field -> field.name to field.genericType } + .toSet() + + // check that all model relations exist in entity relations with correct mapped types + modelRelations.forEach { (name, modelType) -> + val entityRelation = entityRelations.find { it.first == name } + if (entityRelation == null) { + // if relation does not exist, warn but do not fail + println("⚠️ ${model.simpleName} has extra relation (edge) through field $name to model class : $modelType.") + } + + if (modelType is ParameterizedType && entityRelation is ParameterizedType) { + val modelRaw = modelType.rawType as Class<*> + val entityRaw = entityRelation.rawType as Class<*> + val modelParam = modelType.actualTypeArguments.firstOrNull() as? Class<*> + val entityParam = entityRelation.actualTypeArguments.firstOrNull() as? Class<*> + + // check if raw types match (List<> mapps to Set<> and vice versa) + val rawMatch = (modelRaw == entityRaw) || (modelRaw == Set::class.java && entityRaw == List::class.java) + // check if parameter type matches based on mapping (e.g. Issue → IssueEntity) + val paramMatch = (mappedClasses[modelParam] == entityParam) || modelParam == entityParam + + if (!rawMatch || !paramMatch) { + fail("❌ Edge '$name' mismatch between ${model.simpleName} and ${entity.simpleName}: expected $modelRaw<$modelParam> but got $entityRaw<$entityParam>") + } + + } else if (modelType is Class<*> && entityRelation is Class<*>) { + val mappedExpected = mappedClasses[modelType] + if (entityRelation != mappedExpected && entityRelation != modelType) { + fail("❌ Edge '$name' mismatch between ${model.simpleName} and ${entity.simpleName}: expected ${mappedExpected ?: modelType} but got $entityRelation") + } + } + } + + // check if entity has extra relations that are not in model + val extraRelations = entityRelations.map { it.first }.toSet() - modelRelations.map { it.first }.toSet() + if (extraRelations.isNotEmpty()) { + for (extraRelation in extraRelations) { + println("⚠️ ${entity.simpleName} has extra relation (edge) through field $extraRelation to entity class : ${entityRelations.find { it.first == extraRelation }?.second}.") + } + } + } + + +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ProjectMapperTest.kt b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ProjectMapperTest.kt new file mode 100644 index 000000000..bb24ba4b0 --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/ProjectMapperTest.kt @@ -0,0 +1,74 @@ +package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper + +import com.inso_world.binocular.core.data.MockTestDataProvider +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.base.BaseMapperTest +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import io.mockk.spyk +import io.mockk.verify +import io.mockk.verifyOrder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll + +internal class ProjectMapperTest : BaseMapperTest() { + private lateinit var mockTestDataProvider: MockTestDataProvider + + @BeforeEach + fun setup() { + super.setUp() + mockTestDataProvider = MockTestDataProvider() + } + + @Test + fun `toEntity maps domain object to entity, without repository`() { + val domain = Project( + name = "test-project", + ).apply { description = "my super long description" } + + val entity = projectMapper.toEntity(domain) + + assertAll( + "check mappings", + { assertThat(entity.id).isEqualTo(domain.id) }, + { assertThat(entity.name).isEqualTo(domain.name) }, + { assertThat(entity.description).isEqualTo(domain.description) }, + { assertThat(entity.repository).isNull() } + ) + + assertThat(ctx.findEntity(domain)).isEqualTo(entity) + + verify(exactly = 1) { ctx.remember(domain, entity) } + verify(exactly = 1) { projectMapper.toEntity(domain) } + verify(exactly = 0) { repositoryMapper.toEntity(any()) } + } + + @Test + fun `toEntity maps domain object to entity, with repository`() { + val domain = requireNotNull(mockTestDataProvider.projectsByName["proj-pg-0"]) + val entity = projectMapper.toEntity(domain) + + assertAll( + "check mappings", + { assertThat(entity.id).isEqualTo(domain.id) }, + { assertThat(entity.name).isEqualTo(domain.name) }, + { assertThat(entity.description).isEqualTo(domain.description) }, + { assertThat(entity.repository).isNull() }, + ) + + assertThat(ctx.findEntity(domain)).isEqualTo(entity) + assertThat(ctx.findEntity(requireNotNull(domain.repo))).isEqualTo( + entity.repository + ) + + verifyOrder { + ctx.findEntity(domain) + ctx.remember(domain, entity) + } + } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/RepositoryMapperTest.kt b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/RepositoryMapperTest.kt new file mode 100644 index 000000000..9f13d7794 --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/RepositoryMapperTest.kt @@ -0,0 +1,61 @@ +package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper + +import com.inso_world.binocular.core.data.MockTestDataProvider +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.base.BaseMapperTest +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import io.mockk.clearMocks +import io.mockk.verify +import io.mockk.verifyOrder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import kotlin.uuid.ExperimentalUuidApi + +internal class RepositoryMapperTest : BaseMapperTest() { + private lateinit var mockTestDataProvider: MockTestDataProvider + + @BeforeEach + fun setup() { + super.setUp() + mockTestDataProvider = MockTestDataProvider() + } + + @OptIn(ExperimentalUuidApi::class) + @Test + fun `toEntity maps domain object to entity, with repository`() { + val domain = requireNotNull(mockTestDataProvider.repositoriesByPath["repo-pg-0"]) + with(domain.project) { + ctx.remember( + this, ProjectEntity( + iid = this.iid.value, + id = this.id, + name = this.name, + description = this.description, + ) + ) + } + // clean mock + clearMocks(ctx) + val entity = repositoryMapper.toEntity(domain) + + assertAll( + "check mappings", + { assertThat(entity.id).isEqualTo(domain.id) }, + { assertThat(entity.localPath).isEqualTo(domain.localPath) }, + { assertThat(entity.project).isNotNull() }, + { assertThat(entity.project.repository).isSameAs(entity) } + ) + + assertThat(ctx.findEntity(requireNotNull(domain.project))).isEqualTo(entity.project) + assertThat(ctx.findEntity(domain)).isEqualTo(entity) + + verifyOrder { + ctx.findEntity(domain) + ctx.remember(domain, entity) + } + } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/base/BaseMapperTest.kt b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/base/BaseMapperTest.kt new file mode 100644 index 000000000..09818c38e --- /dev/null +++ b/binocular-backend-new/infrastructure-arangodb/src/test/kotlin/com/inso_world/binocular/infrastructure/arangodb/persistence/mapper/base/BaseMapperTest.kt @@ -0,0 +1,79 @@ +package com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.base + +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.BranchMapper +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.CommitMapper +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.ProjectMapper +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.RepositoryMapper +import com.inso_world.binocular.infrastructure.arangodb.persistence.mapper.UserMapper +import io.mockk.spyk +import org.junit.jupiter.api.BeforeEach +import org.springframework.data.util.ReflectionUtils.setField + +internal open class BaseMapperTest : BaseUnitTest() { + lateinit var ctx: MappingContext + lateinit var projectMapper: ProjectMapper + lateinit var repositoryMapper: RepositoryMapper + lateinit var branchMapper: BranchMapper + lateinit var userMapper: UserMapper + lateinit var commitMapper: CommitMapper + + @BeforeEach + fun setUp() { + ctx = spyk(MappingContext()) + + commitMapper = spyk(CommitMapper()) + branchMapper = spyk(BranchMapper()) + userMapper = spyk(UserMapper()) + repositoryMapper = spyk(RepositoryMapper()) + + + projectMapper = spyk(ProjectMapper()) + + // wire up projectMapper + with(projectMapper) { + setField( + this.javaClass.getDeclaredField("ctx"), + this, + ctx + ) + } + + // wire up repositoryMapper + with(repositoryMapper) { + setField( + this.javaClass.getDeclaredField("ctx"), + this, + ctx + ) + } + + // wire up commitMapper + with(commitMapper) { + setField( + this.javaClass.getDeclaredField("ctx"), + this, + ctx + ) + } + + // wire up branchMapper + with(branchMapper) { + setField( + this.javaClass.getDeclaredField("ctx"), + this, + ctx + ) + } + + // wire up userMapper + with(userMapper) { + setField( + this.javaClass.getDeclaredField("ctx"), + this, + ctx + ) + } + } +} diff --git a/binocular-backend-new/infrastructure-arangodb/src/test/resources/application.yaml b/binocular-backend-new/infrastructure-arangodb/src/test/resources/application.yaml index 60714333e..f908f4e3c 100644 --- a/binocular-backend-new/infrastructure-arangodb/src/test/resources/application.yaml +++ b/binocular-backend-new/infrastructure-arangodb/src/test/resources/application.yaml @@ -1,15 +1,19 @@ spring: profiles: active: test,arangodb + # Prevent Spring Boot from trying to configure SQL/JPA when running ArangoDB tests + # in IDEs that include all module outputs on classpath + autoconfigure: + exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration,org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration,org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration,org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration binocular: - database: - database_name: "binocular-web-test" -# host: ${ARANGODB_HOST:localhost} - host: localhost - port: ${ARANGODB_PORT:5432} - user: TODO - password: TODO + arangodb: + database: + name: "binocular-web-test" + host: localhost + port: ${ARANGODB_PORT:5432} + user: TODO + password: TODO logging: charset: console: utf-8 diff --git a/binocular-backend-new/infrastructure-sql/README.md b/binocular-backend-new/infrastructure-sql/README.md new file mode 100644 index 000000000..17f90c165 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/README.md @@ -0,0 +1,34 @@ +# `infrastructure-sql` Module + +## Architecture Patterns + +### Mapper vs Assembler Pattern + +The infrastructure-sql module uses two complementary patterns for domain-entity conversion: + +#### Mappers (`mapper/`) +**Purpose**: Simple, focused converters for individual objects + +- Convert between domain models and persistence entities +- Handle **structure-only** conversion (no child entities) +- Enforce aggregate boundaries (expect parent references already in `MappingContext`) +- Use `MappingContext` for identity preservation +- Implement `EntityMapper` interface + +**Example**: `ProjectMapper` converts `Project` ↔ `ProjectEntity` but does NOT handle the Repository child. + +#### Assemblers (`assembler/`) +**Purpose**: Orchestrate complex aggregate assembly + +- Coordinate multiple mappers to build complete object graphs +- Handle aggregate assembly including all children +- Wire bidirectional relationships between entities +- Manage entire aggregate lifecycle (root → children → grandchildren) +- Use `MappingContext` to ensure identity preservation throughout the graph + +**Example**: `ProjectAssembler` orchestrates `ProjectMapper` + `RepositoryAssembler` to build the complete aggregate including all Repository children (Commits, Branches, Users). + +#### When to Use Each + +- **Use Mappers**: For simple conversions, `refreshDomain` operations, when objects are already identity-tracked +- **Use Assemblers**: At service boundaries, when building complete aggregates, when orchestrating multiple related entities diff --git a/binocular-backend-new/infrastructure-sql/pom.xml b/binocular-backend-new/infrastructure-sql/pom.xml index 32597e2ac..76c69442e 100644 --- a/binocular-backend-new/infrastructure-sql/pom.xml +++ b/binocular-backend-new/infrastructure-sql/pom.xml @@ -52,6 +52,13 @@ domain ${project.version} + + com.inso-world.binocular + domain + ${project.version} + tests + test + org.jetbrains.kotlinx @@ -96,13 +103,14 @@ test - io.mockk - mockk-jvm + com.ninja-squad + springmockk + 4.0.2 test org.testcontainers - postgresql + testcontainers-postgresql test diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/SqlAppConfig.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/SqlAppConfig.kt index 3225bf10d..d408de345 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/SqlAppConfig.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/SqlAppConfig.kt @@ -3,6 +3,7 @@ package com.inso_world.binocular.infrastructure.sql import org.springframework.boot.autoconfigure.domain.EntityScan import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy import org.springframework.data.jpa.repository.config.EnableJpaRepositories @EnableJpaRepositories( @@ -17,4 +18,5 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories ) @Configuration @ComponentScan(basePackages = ["com.inso_world.binocular.infrastructure.sql", "com.inso_world.binocular.core"]) +@EnableAspectJAutoProxy class SqlAppConfig diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/assembler/ProjectAssembler.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/assembler/ProjectAssembler.kt new file mode 100644 index 000000000..c6158f98c --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/assembler/ProjectAssembler.kt @@ -0,0 +1,145 @@ +package com.inso_world.binocular.infrastructure.sql.assembler + +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.infrastructure.sql.mapper.ProjectMapper +import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity +import com.inso_world.binocular.model.Project +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Component + +/** + * Assembler for the Project aggregate. + * + * Orchestrates the mapping of Project domain objects to ProjectEntity persistence entities, + * including its owned Repository child aggregate. Project is the root aggregate that owns + * all SCM-related data through its Repository. + * + * ## Aggregate Structure + * ``` + * Project (Root Aggregate) + * └── Repository (Owned Secondary Aggregate) + * ├── Commit* (owned children) + * ├── Branch* (owned children) + * └── User* (owned children) + * ``` + * + * ## Responsibilities + * - Convert Project domain to ProjectEntity using ProjectMapper + * - Orchestrate assembly of owned Repository (via RepositoryAssembler) + * - Wire Repository to Project maintaining bidirectional relationship + * - Manage MappingContext to ensure identity preservation + * + * ## Design Notes + * Project is the root aggregate that fully owns Repository. When assembling a Project, + * the complete object graph including Repository and all its children is built. + * This ensures identity preservation throughout the entire aggregate. + */ +@Component +internal class ProjectAssembler { + companion object { + private val logger by logger() + } + + @Autowired + private lateinit var projectMapper: ProjectMapper + + @Autowired + @Lazy + private lateinit var repositoryAssembler: RepositoryAssembler + + @Autowired + private lateinit var ctx: MappingContext + + /** + * Assembles a complete ProjectEntity from a Project domain aggregate. + * + * This method assembles the entire Project aggregate including its owned Repository + * and all Repository children (Commits, Branches, Users). The result is a fully + * identity-preserving object graph. + * + * ## Process + * 1. Check if Project already assembled (identity preservation) + * 2. Map Project structure using ProjectMapper (adds to context) + * 3. If Repository exists, assemble it completely using RepositoryAssembler + * 4. Wire Repository to Project entity + * + * @param domain The Project domain aggregate to assemble + * @return The fully assembled ProjectEntity with Repository and all children + */ + fun toEntity(domain: Project): ProjectEntity { + logger.debug("Assembling ProjectEntity for project: ${domain.name}") + + // Fast-path: Check if already assembled (identity preservation) + ctx.findEntity(domain)?.let { + logger.trace("Project already in context, returning cached entity") + return it + } + + // Phase 1: Map Project structure (adds to context) + val entity = projectMapper.toEntity(domain) + logger.trace("Mapped Project structure: id=${entity.id}") + + // Phase 2: Assemble owned Repository if present + domain.repo?.let { repository -> + logger.trace("Assembling owned Repository for Project") + val repoEntity = repositoryAssembler.toEntity(repository) + entity.repo = repoEntity + logger.trace("Wired Repository to Project: repoId=${repoEntity.id}") + } + + logger.debug("Assembled ProjectEntity with id=${entity.id}, hasRepository=${entity.repo != null}") + return entity + } + + /** + * Assembles a complete Project domain aggregate from a ProjectEntity. + * + * This method assembles the entire Project aggregate including its owned Repository + * and all Repository children. The result is a fully identity-preserving object graph. + * + * ## Process + * 1. Check if Project already assembled (identity preservation) + * 2. Map Project structure using ProjectMapper (adds to context) + * 3. If RepositoryEntity exists, assemble it completely using RepositoryAssembler + * 4. Wire Repository to Project domain + * + * @param entity The ProjectEntity to convert + * @return The fully assembled Project domain aggregate + */ + fun toDomain(entity: ProjectEntity): Project { + logger.debug("Assembling Project domain for entity id=${entity.id}") + + // Fast-path: Check if already assembled (identity preservation) + ctx.findDomain(entity)?.let { + logger.trace("Project already in context, returning cached domain") + return it + } + + // Phase 1: Map Project structure (adds to context) + val domain = projectMapper.toDomain(entity) + logger.trace("Mapped Project structure: ${domain.name}") + + // Phase 2: Assemble owned Repository if present + entity.repo?.let { repoEntity -> + logger.trace("Assembling owned Repository from ProjectEntity") + val repository = repositoryAssembler.toDomain(repoEntity) + domain.repo = repository + logger.trace("Wired Repository to Project: ${repository.localPath}") + } + + logger.debug("Assembled Project domain: ${domain.name}, hasRepository=${domain.repo != null}") + return domain + } + + fun refresh(domain: Project, entity: ProjectEntity) { + logger.trace("Refreshing Repository domain: ${domain.iid}") + this.projectMapper.refreshDomain(domain, entity) + if (domain.repo != null) { + this.repositoryAssembler.refresh(requireNotNull(domain.repo), requireNotNull(entity.repo)) + } else { + logger.debug("No repository to refresh") + } + } +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/assembler/RepositoryAssembler.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/assembler/RepositoryAssembler.kt new file mode 100644 index 000000000..5e12611ae --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/assembler/RepositoryAssembler.kt @@ -0,0 +1,345 @@ +package com.inso_world.binocular.infrastructure.sql.assembler + +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.infrastructure.sql.mapper.BranchMapper +import com.inso_world.binocular.infrastructure.sql.mapper.CommitMapper +import com.inso_world.binocular.infrastructure.sql.mapper.ProjectMapper +import com.inso_world.binocular.infrastructure.sql.mapper.RemoteMapper +import com.inso_world.binocular.infrastructure.sql.mapper.RepositoryMapper +import com.inso_world.binocular.infrastructure.sql.mapper.DeveloperMapper +import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RemoteEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Component + +/** + * Assembler for the Repository aggregate. + * + * Orchestrates the complete mapping of Repository aggregate including all owned entities + * (Commits, Branches, Users) while maintaining a reference to its parent Project aggregate. + * Repository is a secondary aggregate owned by Project, responsible for all SCM-related data. + * + * ## Aggregate Structure + * ``` + * Repository (Secondary Aggregate, owned by Project) + * ├── Commit* (owned children) + * │ ├── parents → Commit* (graph relationships) + * │ └── children → Commit* (graph relationships) + * ├── Branch* (owned children) + * ├── User* (owned children) + * └── → Project (parent aggregate reference) + * ``` + * + * ## Responsibilities + * - Convert Repository domain to RepositoryEntity using RepositoryMapper + * - Orchestrate mapping of all child entities (Commits, Branches, Users) + * - Wire bidirectional relationships within the aggregate: + * - Commit author/committer relationships + * - Commit parent/child graph relationships (two-pass assembly) + * - Branch head references + * - Manage parent Project reference (creates minimal reference if not in context) + * - Coordinate with MappingContext to ensure identity preservation + * + * ## Design Principles + * - **Identity Preservation**: Returns identity-preserving objects for all children + * - **Aggregate Boundaries**: Does NOT fully build parent Project when assembled standalone + * - **Parent Reference**: If Project not in context, creates minimal Project structure (no Repository child) + * - **Top-Down Mapping**: Repository controls mapping of its owned children + * - **Two-Pass Graph Assembly**: Commits mapped first, then parent/child relationships wired second + * - **Context-Aware**: Uses MappingContext for identity map pattern + * - **Separation of Concerns**: Mappers do simple conversion, assembler orchestrates + * + * ## Usage Scenarios + * + * ### Scenario 1: Assembled via ProjectAssembler (typical) + * ```kotlin + * // Project is root aggregate, assembles everything + * val projectEntity = projectAssembler.toEntity(project) + * // Project is already in context when Repository is assembled + * ``` + * + * ### Scenario 2: Assembled standalone + * ```kotlin + * // Repository assembled independently (e.g., for partial updates) + * val repositoryEntity = repositoryAssembler.toEntity(repository) + * // Creates minimal Project reference, doesn't build full Project aggregate + * ``` + */ +@Component +internal class RepositoryAssembler { + companion object { + private val logger by logger() + } + + @Autowired + private lateinit var repositoryMapper: RepositoryMapper + + @Autowired + @Lazy + private lateinit var commitMapper: CommitMapper + + @Autowired + @Lazy + private lateinit var branchMapper: BranchMapper + + @Autowired + @Lazy + private lateinit var developerMapper: DeveloperMapper + + @Autowired + @Lazy + private lateinit var remoteMapper: RemoteMapper + + @Autowired + private lateinit var projectMapper: ProjectMapper + + @Autowired + private lateinit var ctx: MappingContext + + /** + * Assembles a complete RepositoryEntity from a Repository domain aggregate. + * + * This method assembles the Repository and all its owned children (Commits, Branches, Users) + * with full identity preservation. It ensures a Project reference exists but does NOT + * fully build the parent Project aggregate when assembled standalone. + * + * ## Process + * 1. Check if Repository already assembled (identity preservation) + * 2. Ensure Project reference exists in context: + * - If found: reuse existing (typical when called via ProjectAssembler) + * - If not found: create minimal Project structure without Repository child + * 3. Map Repository structure using RepositoryMapper + * 4. Map all Commits (first pass): + * - Convert Commit → CommitEntity using CommitMapper + * - Wire author/committer relationships + * - Add to RepositoryEntity + * 5. Wire commit parent/child relationships (second pass): + * - Lookup all commits in MappingContext + * - Wire bidirectional parent/child graph + * 6. Map all Branches and wire to RepositoryEntity + * + * @param domain The Repository domain aggregate to assemble + * @return The fully assembled RepositoryEntity with all children and identity preservation + */ + fun toEntity(domain: Repository): RepositoryEntity { + logger.trace("Assembling RepositoryEntity for repository: ${domain.localPath}") + + // Fast-path: Check if already assembled (identity preservation) +// ctx.findEntity(domain)?.let { +// logger.debug("Repository already in context, returning cached entity") +// return it +// } + + // Ensure Project reference exists in context (but don't assemble Repository child) +// val projectEntity = requireNotNull( +// ctx.findEntity(domain.project) +// ) { +// "ProjectEntity must be in context to assemble repository" +// } +// ?: run { +// logger.trace("Project not in context, mapping minimal Project structure (no Repository child)") +// projectMapper.toEntity(domain.project) +// } +// logger.debug("Project reference in context: id=${projectEntity.id}") + + // Phase 1: Map Repository structure (without children) + val entity = repositoryMapper.toEntity(domain) + logger.debug("Mapped Repository structure: id=${entity.id}") + + // Phase 2: Map Commits (developer signatures handled inside CommitMapper) + logger.debug("Mapping ${domain.commits.size} commits") + domain.commits.forEach { commit -> + val commitEntity = commitMapper.toEntity(commit) + entity.commits.add(commitEntity) + } + + // Phase 2b: Wire parent/child commit relationships (second pass) + logger.debug("Wiring parent/child relationships for ${domain.commits.size} commits") + domain.commits.forEach { commit -> + val commitEntity = ctx.findEntity(commit) + ?: throw IllegalStateException("CommitEntity for ${commit.sha} must be in context") + + commit.parents.forEach { parentCommit -> + val parentEntity = ctx.findEntity(parentCommit) + ?: throw IllegalStateException("Parent CommitEntity for ${parentCommit.sha} must be in context") + + // Wire bidirectional relationship (only if not already present) + if (!commitEntity.parents.contains(parentEntity)) { + commitEntity.parents.add(parentEntity) + parentEntity.children.add(commitEntity) + } + } + } + + // Phase 3: Map and wire Branches + logger.debug("Mapping ${domain.branches.size} branches") + domain.branches.forEach { branch -> + val branchEntity = branchMapper.toEntity(branch) + entity.branches.add(branchEntity) + } + + // Phase 4: Map and wire Remotes + logger.debug("Mapping ${domain.remotes.size} remotes") + domain.remotes.forEach { remote -> + val remoteEntity = remoteMapper.toEntity(remote) + entity.remotes.add(remoteEntity) + } + + // Phase 5: Map and wire Developers + logger.debug("Mapping ${domain.developers.size} developers") + domain.developers.forEach { developer -> + val developerEntity = developerMapper.toEntity(developer) + entity.developers.add(developerEntity) + } + + logger.trace( + "Assembled RepositoryEntity: id=${entity.id}, " + + "commits=${entity.commits.size}, branches=${entity.branches.size}, remotes=${entity.remotes.size}, developers=${entity.developers.size}" + ) + + return entity + } + + /** + * Assembles a complete Repository domain aggregate from a RepositoryEntity. + * + * This method assembles the Repository and all its owned children (Commits, Branches, Users) + * with full identity preservation. It ensures a Project reference exists but does NOT + * fully build the parent Project aggregate when assembled standalone. + * + * ## Process + * 1. Check if Repository already assembled (identity preservation) + * 2. Ensure Project reference exists in context: + * - If found: reuse existing (typical when called via ProjectAssembler) + * - If not found: create minimal Project structure without Repository child + * 3. Map Repository structure using RepositoryMapper + * 4. Map all Commits (first pass): + * - Convert CommitEntity → Commit using CommitMapper + * - Wire author/committer relationships + * - Add to Repository + * 5. Wire commit parent/child relationships (second pass): + * - Lookup all commits in MappingContext + * - Wire bidirectional parent/child graph + * - Note: Domain Commit.parents.add() automatically maintains bidirectionality + * 6. Map all BranchEntities to Branches and add to Repository + * + * @param entity The RepositoryEntity to convert + * @return The fully assembled Repository domain aggregate with identity preservation + */ + fun toDomain(entity: RepositoryEntity): Repository { + logger.trace("Assembling Repository domain for entity id=${entity.id}") + + // Fast-path: Check if already assembled (identity preservation) + ctx.findDomain(entity)?.let { + logger.debug("Repository already in context, returning cached domain") + return it + } + + // Ensure Project reference exists in context (but don't assemble Repository child) + val project = ctx.findDomain(entity.project) + ?: run { + logger.debug("Project not in context, mapping minimal Project structure (no Repository child)") + projectMapper.toDomain(entity.project) + } + + logger.debug("Project reference in context: ${project.name}") + + // Phase 2: Map Repository structure + val domain = repositoryMapper.toDomain(entity) + logger.debug("Mapped Repository structure: ${domain.localPath}") + + // Phase 3: Map Developers + logger.debug("Mapping ${entity.developers.size} developers") + entity.developers.forEach { developerEntity -> + val developer = developerMapper.toDomain(developerEntity) + domain.developers.add(developer) + } + + // Phase 4: Map Commits (developers/signatures handled by mapper) + logger.debug("Mapping ${entity.commits.size} commits") + entity.commits.forEach { commitEntity -> + val commit = commitMapper.toDomain(commitEntity) + domain.commits.add(commit) + domain.developers.add(commit.author) + domain.developers.add(commit.committer) + } + + // Phase 4b: Wire parent/child commit relationships (second pass) + logger.debug("Wiring parent/child relationships for ${entity.commits.size} commits") + entity.commits.forEach { commitEntity -> + val commit = ctx.findDomain(commitEntity) + ?: throw IllegalStateException("Commit for ${commitEntity.sha} must be in context") + + commitEntity.parents.forEach { parentEntity -> + val parentCommit = ctx.findDomain(parentEntity) + ?: throw IllegalStateException("Parent Commit for ${parentEntity.sha} must be in context") + + if (!commit.parents.contains(parentCommit)) { + commit.parents.add(parentCommit) + } + } + } + + // Phase 5: Map and wire Branches + logger.debug("Mapping ${entity.branches.size} branches") + entity.branches.forEach { branchEntity -> + val branch = branchMapper.toDomain(branchEntity) + domain.branches.add(branch) + } + + // Phase 6: Map and wire Remotes + logger.debug("Mapping ${entity.remotes.size} remotes") + entity.remotes.forEach { remoteEntity -> + val remote = remoteMapper.toDomain(remoteEntity) + domain.remotes.add(remote) + } + + logger.trace( + "Assembled Repository domain: ${domain.localPath}, " + + "commits=${domain.commits.size}, branches=${domain.branches.size}, remotes=${domain.remotes.size}" + ) + + return domain + } + + fun refresh(domain: Repository, entity: RepositoryEntity) : Repository { + logger.trace("Refreshing Repository domain: ${domain.iid}") + this.repositoryMapper.refreshDomain(domain, entity) + + with(entity.commits.associateBy(CommitEntity::iid)) { + domain.commits.parallelStream().forEach { commit -> + this@RepositoryAssembler.commitMapper.refreshDomain(commit, this.getValue(commit.iid)) + } + } + + with(entity.branches.associateBy(BranchEntity::iid)) { + domain.branches.parallelStream().forEach { branch -> + this@RepositoryAssembler.branchMapper.refreshDomain(branch, this.getValue(branch.iid)) + } + } + + with(entity.remotes.associateBy(RemoteEntity::iid)) { + domain.remotes.parallelStream().forEach { remotes -> + this@RepositoryAssembler.remoteMapper.refreshDomain(remotes, this.getValue(remotes.iid)) + } + } + + with(entity.developers.associateBy(DeveloperEntity::iid)) { + domain.developers.forEach { developer -> + this@RepositoryAssembler.developerMapper.refreshDomain(developer, this.getValue(developer.iid)) + } + } + + return domain + } +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/BranchMapper.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/BranchMapper.kt index f41bc4d8b..c0a162bef 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/BranchMapper.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/BranchMapper.kt @@ -1,113 +1,144 @@ package com.inso_world.binocular.infrastructure.sql.mapper -import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingContext -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingScope +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.toEntity import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.Repository -import jakarta.persistence.EntityManager -import org.slf4j.Logger -import org.slf4j.LoggerFactory +import jakarta.validation.Valid import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy +import org.springframework.data.util.ReflectionUtils.setField import org.springframework.stereotype.Component -import org.springframework.transaction.support.TransactionTemplate +/** + * Mapper for Branch domain objects. + * + * Converts between Branch domain objects and BranchEntity persistence entities. + * This is a **simple mapper** - it only handles basic conversion without orchestrating + * complex relationships. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Branch structure + * - **Aggregate Boundaries**: Expects Repository and Commit already in MappingContext (cross-aggregate references) + * - **No Deep Traversal**: Does not map entire commit history or file structures + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. Direct usage + * is also supported for `refreshDomain` operations after persistence. + */ @Component -internal class BranchMapper { - private val logger: Logger = LoggerFactory.getLogger(BranchMapper::class.java) - - @Autowired - private lateinit var commitMapper: CommitMapper - - @Autowired - private lateinit var proxyFactory: RelationshipProxyFactory - - @Autowired - @Lazy - private lateinit var transactionTemplate: TransactionTemplate - - @Autowired - private lateinit var mappingScope: MappingScope - - @Autowired - @Lazy - private lateinit var entityManager: EntityManager - +internal class BranchMapper : EntityMapper { @Autowired private lateinit var ctx: MappingContext + companion object { + private val logger by logger() + } + /** - * Converts a domain Branch to a SQL BranchEntity + * Converts a Branch domain object to BranchEntity. + * + * **Precondition**: The referenced Repository must already be mapped and present in MappingContext. + * The referenced head Commit must also be mapped and present in MappingContext. + * This enforces aggregate boundaries - Repository and Commit are separate aggregates. + * + * **Note**: This method does NOT map child entities or traverse relationships deeply. + * + * @param domain The Branch domain object to convert + * @return The BranchEntity (structure only) + * @throws IllegalStateException if Repository or head Commit is not in MappingContext */ - fun toEntity(domain: Branch): BranchEntity { - val branchContextKey = domain.uniqueKey() - ctx.entity.branch[branchContextKey]?.let { + override fun toEntity(domain: Branch): BranchEntity { + // Fast-path: if this Branch was already mapped in the current context, return it. + ctx.findEntity(domain)?.let { return it } - - val entity = domain.toEntity() - ctx.entity.branch.computeIfAbsent(branchContextKey) { entity } - - // Commit is a child of Branch, hence it is mapped here - domain.commits - .map { - requireNotNull(ctx.entity.commit[it.sha]) { - "Commit sha $it not found in context" - } - }.forEach { - entity.addCommit(it) - } + // IMPORTANT: Expect Repository already in context (cross-aggregate reference). + // Do NOT auto-map Repository here - that's a separate aggregate. + val owner = ctx.findEntity(domain.repository) + ?: throw IllegalStateException( + "RepositoryEntity must be mapped before BranchEntity. " + + "Ensure RepositoryEntity is in MappingContext before calling toDomain()." + ) + // IMPORTANT: Expect Commit already in context (cross-aggregate reference). + // Do NOT auto-map Commit here - that's a separate aggregate. + val head = ctx.findEntity(domain.head) + ?: throw IllegalStateException( + "CommitEntity must be mapped before BranchEntity. " + + "Ensure CommitEntity is in MappingContext before calling toDomain()." + ) + + val entity = domain.toEntity(owner, head) + ctx.remember(domain, entity) return entity } /** - * Converts a SQL BranchEntity to a domain Branch + * Converts a BranchEntity to Branch domain object. * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. + * **Precondition**: The referenced Repository must already be mapped and present in MappingContext. + * The referenced head Commit must also be mapped and present in MappingContext. + * This enforces aggregate boundaries - Repository and Commit are separate aggregates. + * + * **Note**: This method does NOT map child entities or traverse relationships deeply. + * + * @param entity The BranchEntity to convert + * @return The Branch domain object (structure only) + * @throws IllegalStateException if Repository or head Commit is not in MappingContext */ - fun toDomain(entity: BranchEntity): Branch { - val branchContextKey = entity.uniqueKey() - ctx.domain.branch[branchContextKey]?.let { return it } - - val domain = entity.toDomain() - - // Commit is a child of Branch, hence it is mapped here - commitMapper.toDomainGraph(entity.commits.asSequence()).map { - // add all parents also to the branch (git behavior of git rev-list --count ) - (setOf(it) + it.parents).forEach { relative -> domain.commits.add(relative) } -// domain.addCommit(it) - } - - ctx.domain.branch.computeIfAbsent(branchContextKey) { domain } + override fun toDomain(entity: BranchEntity): @Valid Branch { + // Fast-path: if this Branch was already mapped in the current context, return it. + ctx.findDomain(entity)?.let { return it } + + // IMPORTANT: Expect Repository already in context (cross-aggregate reference). + // Do NOT auto-map Repository here - that's a separate aggregate. + val owner = ctx.findDomain(entity.repository) + ?: throw IllegalStateException( + "Repository must be mapped before Branch. " + + "Ensure Repository is in MappingContext before calling toDomain()." + ) + // IMPORTANT: Expect Commit already in context (cross-aggregate reference). + // Do NOT auto-map Commit here - that's a separate aggregate. + val head = ctx.findDomain(entity.head) + ?: throw IllegalStateException( + "Commit must be mapped before Branch. " + + "Ensure Commit is in MappingContext before calling toDomain()." + ) + + val domain = entity.toDomain(owner, head) + setField( + domain.javaClass.superclass.superclass.getDeclaredField("iid"), + domain, + entity.iid + ) + ctx.remember(domain, entity) return domain } - fun toDomainFull( - entity: BranchEntity, - repository: Repository, - ): Branch { - val branchContextKey = entity.uniqueKey() - ctx.domain.branch[branchContextKey]?.let { return it } - - val domain = entity.toDomain() - - // Commit is a child of Branch, hence it is mapped here - commitMapper.toDomainFull(entity.commits, repository).map { - // add all parents also to the branch (git behavior of git rev-list --count ) - (setOf(it) + it.parents).forEach { relative -> domain.commits.add(relative) } - } - - repository.branches.add(domain) - - ctx.domain.branch.computeIfAbsent(branchContextKey) { domain } - - return domain + /** + * Refreshes a Branch domain object with data from the corresponding entity. + * + * This method updates the domain object's ID from the entity after persistence. + * It does NOT update nested objects - only top-level Branch properties. + * + * @param target The Branch domain object to refresh + * @param entity The BranchEntity with updated data + * @return The refreshed Branch domain object + */ + fun refreshDomain(target: Branch, entity: BranchEntity): Branch { + setField( + target.javaClass.getDeclaredField("id"), + target, + entity.id?.toString() + ) + return target } } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/CommitMapper.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/CommitMapper.kt index 394bf0529..8c95f6f19 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/CommitMapper.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/CommitMapper.kt @@ -1,269 +1,140 @@ package com.inso_world.binocular.infrastructure.sql.mapper -import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory -import com.inso_world.binocular.infrastructure.sql.exception.IllegalMappingStateException -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingContext +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.toEntity import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.Repository -import jakarta.validation.constraints.NotEmpty -import org.slf4j.Logger -import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.Lazy +import org.springframework.data.util.ReflectionUtils.setField import org.springframework.stereotype.Component +/** + * Mapper for Commit domain objects. + * + * Converts between Commit domain objects and CommitEntity persistence entities. + * This is a **simple mapper** - it only handles basic conversion without orchestrating + * complex relationships like full commit history graphs. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Commit structure + * - **Aggregate Boundaries**: Expects Repository already in MappingContext (cross-aggregate reference) + * - **No Deep Traversal**: Does not automatically map entire parent/child commit graphs + * + * ## Usage + * This mapper is typically called by infrastructure ports and assemblers. Direct usage + * is also supported for `refreshDomain` operations after persistence. + */ @Component -internal class CommitMapper { - private val logger: Logger = LoggerFactory.getLogger(CommitMapper::class.java) - +internal class CommitMapper : EntityMapper { @Autowired private lateinit var ctx: MappingContext @Autowired - @Lazy - private lateinit var userMapper: UserMapper - - @Autowired - @Lazy - private lateinit var proxyFactory: RelationshipProxyFactory - - @Autowired - @Lazy - private lateinit var branchMapper: BranchMapper + private lateinit var developerMapper: DeveloperMapper - enum class Options { - NO_RELATIONSHIPS, - CHILDREN, - PARENTS, - FULL, + companion object { + private val logger by logger() } /** - * Converts a domain Commit to a SQL CommitEntity + * Converts a Commit domain object to CommitEntity. + * + * **Precondition**: The referenced Repository must already be mapped and present in MappingContext. + * This enforces aggregate boundaries - Repository is a separate aggregate. + * + * **Note**: This method does NOT map parent/child commit relationships or branches. + * Use assemblers for complete commit graph assembly. + * + * @param domain The Commit domain object to convert + * @return The CommitEntity (structure only, without relationships) + * @throws IllegalStateException if Repository is not in MappingContext */ - fun toEntity(root: Commit): CommitEntity { - toEntityGraph(sequenceOf(root)) - - // return the root node - return ctx.entity.commit[root.sha] - ?: throw IllegalMappingStateException("Root was not mapped") - } - - fun toEntityFull( - values: Set, - repository: RepositoryEntity, - ): Set { - val mappedEntities = toEntityGraph(values.asSequence()) - - (values + values.flatMap { it.children } + values.flatMap { it.parents }) - .toSet() - .forEach { cmt -> - val entity = - ctx.entity.commit[cmt.sha] - ?: throw IllegalStateException("Cannot map Commit$cmt with its entity ${cmt.sha}") - repository.addCommit(entity) - cmt.committer?.let { - val u = userMapper.toEntity(it) - repository.addUser(u) - u.addCommittedCommit(entity) - } - cmt.author?.let { - val u = userMapper.toEntity(it) - repository.addUser(u) - u.addAuthoredCommit(entity) - } - cmt.branches.forEach { - val b = branchMapper.toEntity(it) - repository.addBranch(b) - b.addCommit(entity) - } - } - - return mappedEntities - } - - fun toEntityFull( - value: Commit, - repository: RepositoryEntity, - ): CommitEntity = toEntityFull(setOf(value), repository).toList()[0] - - fun toDomain( - root: CommitEntity, - options: Options = Options.NO_RELATIONSHIPS, - ): Commit { - toDomainGraph(sequenceOf(root), options) - - // return the root node - return ctx.domain.commit[root.sha] - ?: throw IllegalMappingStateException("Root was not mapped") + override fun toEntity(domain: Commit): CommitEntity { + // Fast-path: if this Commit was already mapped in the current context, return it. + ctx.findEntity(domain)?.let { return it } + + // IMPORTANT: Expect Repository already in context (cross-aggregate reference). + // Do NOT auto-map Repository here - that's a separate aggregate. + val owner = ctx.findEntity(domain.repository) + ?: throw IllegalStateException( + "RepositoryEntity must be mapped before CommitEntity. " + + "Ensure CommitEntity is in MappingContext before calling toDomain()." + ) + + val authorEntity = developerMapper.toEntity(domain.author) + val committerEntity = developerMapper.toEntity(domain.committer) + + val entity = domain.toEntity( + repository = owner, + author = authorEntity, + committer = committerEntity, + ) + ctx.remember(domain, entity) + + return entity } - fun toEntityGraph( - @NotEmpty domains: Sequence, - ): MutableSet { - // --- PHASE 1: discover & create all CommitEntity instances --- - - val domainBySha = domains.associateBy { it.sha }.toMutableMap() - // we'll do a simple BFS (or DFS) from the root Commit - val queue = ArrayDeque() - queue.addAll(domains) - - // 2) instantiate *all* domain nodes, register in identity map - while (queue.isNotEmpty()) { - val dom = queue.removeFirst() - domainBySha.computeIfAbsent(dom.sha) { dom } - val sha = dom.sha - if (ctx.entity.commit.containsKey(sha)) { - continue - } - val ent = dom.toEntity() - - // 1b) register it in our identity–map - ctx.entity.commit[sha] = ent - - // 1c) schedule its neighbours - queue += dom.parents - queue += dom.children - } - // now every CommitEntity is sitting in ctx.entity.commit - - // 2) wire up parent/child links in one sweep - for ((sha, dom) in domainBySha) { - val ent = ctx.entity.commit[sha]!! - dom.parents.map { parentDom -> - val parentEntity = - ctx.entity.commit[parentDom.sha] ?: throw IllegalMappingStateException( - "Parent ${parentDom.sha} must be present when wire up parent", - ) - parentEntity.addChild(ent) - } - dom.children.map { childDom -> - val childEntity = - ctx.entity.commit[childDom.sha] ?: throw IllegalMappingStateException( - "Child ${childDom.sha} must be present when wire up child", - ) - childEntity.addParent(ent) - } - } - - val requiredCommits = domainBySha.values.map { it.sha }.toSet() - return ctx.entity.commit.values - .filter { it.sha in requiredCommits } - .toMutableSet() - } - - fun toDomainGraph( - @NotEmpty entities: Sequence, - options: Options = Options.FULL, - ): Set { - // 1) build a sha→entity map (we assume entities already contains *all* commits) - val entityBySha = entities.associateBy { it.sha } - - // we'll do a simple BFS (or DFS) from the root Commit - val queue = ArrayDeque() - queue.addAll(entityBySha.values) - - // 2) instantiate *all* domain nodes, register in identity map - while (queue.isNotEmpty()) { - val ent = queue.removeFirst() - val sha = ent.sha - - logger.trace("Mapping commit $sha") - if (ctx.domain.commit.containsKey(sha)) { - continue - } - val dom = ent.toDomain() - ctx.domain.commit.computeIfAbsent(sha) { dom } - queue += - ent.children.filter { child -> - entityBySha[sha]?.children?.map { it.sha }?.contains(child.sha) == true - } - } - - for ((sha, ent) in entityBySha) { - val dom = - ctx.domain.commit[sha] - ?: throw IllegalMappingStateException("Commit domain $sha must be mapped to map user") - ent.committer - ?.let { userMapper.toDomain(it) } - ?.also { - dom.committer = it - } - ent.author - ?.let { userMapper.toDomain(it) } - ?.also { - dom.author = it - } - } - - // 3) wire up parent/child links in one sweep - for ((sha, ent) in entityBySha) { - val dom = - ctx.domain.commit[sha] - ?: throw IllegalMappingStateException("Commit domain $sha must be mapped to wire up user") - if (options == Options.PARENTS || options == Options.FULL) { - ent.parents - .map { parentDom -> - ctx.domain.commit[parentDom.sha] - ?: throw IllegalMappingStateException("Parent Commit domain ${parentDom.sha} must be mapped to wire up parent") - }.forEach { - dom.parents.add(it) - } - } - if (options == Options.CHILDREN || options == Options.FULL) { - ent.children - .map { childDom -> - ctx.domain.commit[childDom.sha] - ?: throw IllegalMappingStateException("Child Commit domain ${childDom.sha} must be mapped to wire up child") - }.forEach { dom.children.add(it) } - } - } - - val requiredCommits = entityBySha.values.map { it.sha }.toSet() - return ctx.domain.commit.values - .filter { it.sha in requiredCommits } - .toSet() + /** + * Converts a CommitEntity to Commit domain object. + * + * **Precondition**: The referenced Repository must already be mapped and present in MappingContext. + * This enforces aggregate boundaries - Repository is a separate aggregate. + * + * **Note**: This method does NOT map parent/child commit relationships or branches. + * Use assemblers for complete commit graph assembly. + * + * @param entity The CommitEntity to convert + * @return The Commit domain object (structure only, without relationships) + * @throws IllegalStateException if Repository is not in MappingContext + */ + override fun toDomain(entity: CommitEntity): Commit { + ctx.findDomain(entity)?.let { return it } + + // IMPORTANT: Expect Repository already in context (cross-aggregate reference). + // Do NOT auto-map Repository here - that's a separate aggregate. + val owner = ctx.findDomain(entity.repository) + ?: throw IllegalStateException( + "Repository must be mapped before Commit. " + + "Ensure Repository is in MappingContext before calling toDomain()." + ) + + val author = developerMapper.toDomain(entity.author) + val committer = developerMapper.toDomain(entity.committer) + + val domain = entity.toDomain(owner, author, committer) + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + entity.iid + ) + ctx.remember(domain, entity) + + return domain } - fun toDomainFull( - values: Set, - repository: Repository, - options: Options = Options.FULL, - ): Set { - val mappedValues = toDomainGraph(values.asSequence(), options) - - (values + values.flatMap { it.children } + values.flatMap { it.parents }) - .toSet() - .forEach { cmt -> - val domain = - ctx.domain.commit[cmt.sha] - ?: throw IllegalStateException("Cannot map Commit$cmt with its entity ${cmt.sha}") - repository.commits.add(domain) - cmt.committer?.let { - val u = userMapper.toDomain(it) - repository.user.add(u) - u.committedCommits.add(domain) - } - cmt.author?.let { - val u = userMapper.toDomain(it) - repository.user.add(u) - u.authoredCommits.add(domain) - } - cmt.branches.forEach { - val b = branchMapper.toDomain(it) - repository.branches.add(b) - b.commits.add(domain) - } - } - - return mappedValues + /** + * Refreshes a Commit domain object with data from the corresponding entity. + * + * This method updates the domain object's ID from the entity after persistence. + * It also recursively refreshes parent and child commits, as well as author/committer. + * + * **Note**: This method performs recursive updates on parent/child relationships. + * + * @param target The Commit domain object to refresh + * @param entity The CommitEntity with updated data + * @return The refreshed Commit domain object + */ + fun refreshDomain(target: Commit, entity: CommitEntity): Commit { + setField( + target.javaClass.getDeclaredField("id"), + target, + entity.id?.toString() + ) + + return target } - - fun toDomainFull( - value: CommitEntity, - repository: Repository, - ): Commit = toDomainFull(setOf(value), repository).toList()[0] } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/DeveloperMapper.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/DeveloperMapper.kt new file mode 100644 index 000000000..c5a957f33 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/DeveloperMapper.kt @@ -0,0 +1,74 @@ +package com.inso_world.binocular.infrastructure.sql.mapper + +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.toEntity +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Repository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.util.ReflectionUtils.setField +import org.springframework.stereotype.Component + +/** + * Mapper for Developer domain objects. + * + * Converts between Developer domain objects and DeveloperEntity persistence entities. + * This mapper intentionally keeps the conversion shallow; it does not traverse commit graphs. + */ +@Component +internal class DeveloperMapper : EntityMapper { + @Autowired + private lateinit var ctx: MappingContext + + companion object { + private val logger by logger() + } + + override fun toEntity(domain: Developer): DeveloperEntity { + ctx.findEntity(domain)?.let { return it } + + val owner = ctx.findEntity(domain.repository) + ?: throw IllegalStateException( + "RepositoryEntity must be mapped before DeveloperEntity. " + + "Ensure RepositoryEntity is in MappingContext before calling toEntity()." + ) + + val entity = domain.toEntity(owner) + ctx.remember(domain, entity) + return entity + } + + override fun toDomain(entity: DeveloperEntity): Developer { + ctx.findDomain(entity)?.let { return it } + + val owner = ctx.findDomain(entity.repository) + ?: throw IllegalStateException( + "Repository must be mapped before Developer. " + + "Ensure Repository is in MappingContext before calling toDomain()." + ) + + val domain = entity.toDomain(owner) + setField( + domain.javaClass.superclass.superclass.getDeclaredField("iid"), + domain, + entity.iid + ) + ctx.remember(domain, entity) + return domain + } + + fun refreshDomain(target: Developer, entity: DeveloperEntity): Developer { + if (target.id.equals(entity.id?.toString())) { + return target + } + setField( + target.javaClass.getDeclaredField("id"), + target, + entity.id?.toString() + ) + return target + } +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/ProjectMapper.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/ProjectMapper.kt index 4a808c8ab..a75b51b5e 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/ProjectMapper.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/ProjectMapper.kt @@ -1,45 +1,51 @@ package com.inso_world.binocular.infrastructure.sql.mapper -import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.toEntity import com.inso_world.binocular.model.Project -import org.slf4j.Logger -import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.Lazy +import org.springframework.data.util.ReflectionUtils.setField import org.springframework.stereotype.Component @Component -internal class ProjectMapper +internal class ProjectMapper : EntityMapper { + companion object { + val logger by logger() + } + @Autowired - constructor( - @Lazy private val repoMapper: RepositoryMapper, - ) { - var logger: Logger = LoggerFactory.getLogger(ProjectMapper::class.java) - - fun toEntity(domain: Project): ProjectEntity { - val p = domain.toEntity() - - p.repo = - domain.repo?.let { - repoMapper.toEntity(it, p) - } - - return p - } - - fun toDomain(entity: ProjectEntity): Project { - val id = entity.id ?: throw IllegalStateException("Entity ID cannot be null") - - val p = entity.toDomain() - - p.repo = - entity.repo?.let { r -> - r.id?.let { - repoMapper.toDomain(r, p) - } - } - return p - } + private lateinit var ctx: MappingContext + + override fun toEntity(domain: Project): ProjectEntity { + ctx.findEntity(domain)?.let { return it } + + val entity = domain.toEntity() + + ctx.remember(domain, entity) + + return entity + } + + + override fun toDomain(entity: ProjectEntity): Project { + ctx.findDomain(entity)?.let { return it } + + val domain = entity.toDomain() + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + entity.iid + ) + + ctx.remember(domain, entity) + + return domain + } + + fun refreshDomain(target: Project, entity: ProjectEntity) { + target.id = entity.id?.toString() } +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/RemoteMapper.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/RemoteMapper.kt new file mode 100644 index 000000000..15ee943bb --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/RemoteMapper.kt @@ -0,0 +1,72 @@ +package com.inso_world.binocular.infrastructure.sql.mapper + +import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RemoteEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.toEntity +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.vcs.Remote +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.util.ReflectionUtils.setField +import org.springframework.stereotype.Component + +@Component +internal class RemoteMapper : EntityMapper { + @Autowired + private lateinit var ctx: MappingContext + + override fun toEntity(domain: Remote): RemoteEntity { + ctx.findEntity(domain)?.let { return it } + + val repository = ctx.findEntity(domain.repository) + ?: throw IllegalStateException( + "RepositoryEntity must be mapped before RemoteEntity. " + + "Ensure RepositoryEntity is in MappingContext before calling toEntity()." + ) + + val entity = domain.toEntity(repository) + ctx.remember(domain, entity) + + return entity + } + + override fun toDomain(entity: RemoteEntity): Remote { + ctx.findDomain(entity)?.let { return it } + + val repository = ctx.findDomain(entity.repository) + ?: throw IllegalStateException( + "Repository must be mapped before Remote. " + + "Ensure Repository is in MappingContext before calling toDomain()." + ) + + val domain = entity.toDomain(repository) + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + entity.iid + ) + + ctx.remember(domain, entity) + return domain + } + + /** + * Refreshes a Remote domain object with data from the corresponding entity. + * + * This method updates the domain object's ID from the entity after persistence. + * It does NOT update nested objects - only top-level Remote properties. + * + * @param target The Remote domain object to refresh + * @param entity The RemoteEntity with updated data + * @return The refreshed Remote domain object + */ + fun refreshDomain(target: Remote, entity: RemoteEntity): Remote { + setField( + RemoteEntity::class.java.getDeclaredField("id"), + target, + entity.id?.toString() + ) + return target + } +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/RepositoryMapper.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/RepositoryMapper.kt index 955b1b001..16d424fb2 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/RepositoryMapper.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/RepositoryMapper.kt @@ -1,169 +1,129 @@ package com.inso_world.binocular.infrastructure.sql.mapper -import com.inso_world.binocular.core.persistence.proxy.RelationshipProxyFactory -import com.inso_world.binocular.infrastructure.sql.exception.IllegalMappingStateException -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingContext -import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.EntityMapper +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.toEntity +import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository -import jakarta.persistence.EntityManager -import org.slf4j.Logger -import org.slf4j.LoggerFactory +import com.inso_world.binocular.model.User import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy +import org.springframework.data.util.ReflectionUtils.setField import org.springframework.stereotype.Component -import org.springframework.transaction.support.TransactionTemplate +/** + * Mapper for Repository aggregate root. + * + * Converts between Repository domain objects and RepositoryEntity persistence entities. + * This is a **simple mapper** - it only handles basic conversion without orchestrating + * child entity mapping. Use [RepositoryAssembler] for complete aggregate assembly. + * + * ## Design Principles + * - **Single Responsibility**: Only converts Repository structure (not children) + * - **Aggregate Boundaries**: Expects Project already in MappingContext (cross-aggregate reference) + * - **No Orchestration**: Child entities (Commits, Branches) are mapped by assembler + * + * ## Usage + * Prefer using [RepositoryAssembler] at the service layer. This mapper is called by the assembler + * and is also used for `refreshDomain` operations after persistence. + * + * @see com.inso_world.binocular.infrastructure.sql.assembler.RepositoryAssembler + */ @Component -internal class RepositoryMapper +internal class RepositoryMapper : EntityMapper { @Autowired - constructor( - private val proxyFactory: RelationshipProxyFactory, - @Lazy private val commitMapper: CommitMapper, - @Lazy private val branchMapper: BranchMapper, - @Lazy private val userMapper: UserMapper, - ) { - @Autowired - @Lazy - private lateinit var entityManager: EntityManager + private lateinit var ctx: MappingContext - @Autowired - private lateinit var ctx: MappingContext - - @Autowired - @Lazy - private lateinit var transactionTemplate: TransactionTemplate - - private val logger: Logger = LoggerFactory.getLogger(RepositoryMapper::class.java) - - fun toEntity( - domain: Repository, - project: ProjectEntity, - ): RepositoryEntity { - logger.debug("toEntity({})", domain) - - val entity = domain.toEntity(project) - - run { - val allCommitsOfDomain = - (domain.commits + domain.commits.flatMap { it.parents } + domain.commits.flatMap { it.children }) - commitMapper - .toEntityGraph(allCommitsOfDomain.asSequence()) -// wire up commit->repository - .also { it.forEach { c -> entity.addCommit(c) } } -// wire up commit->user - allCommitsOfDomain.associateBy(Commit::sha).values.forEach { cmt -> - val commitEntity = - requireNotNull(ctx.entity.commit[cmt.sha]) { - "Cannot map Commit$cmt with its entity ${cmt.sha}" - } - cmt.committer - ?.let { user -> -// commitEntity.add(userMapper.toEntity(user)) - commitEntity.committer = userMapper.toEntity(user) - } - cmt.author - ?.let { user -> -// commitEntity.add(userMapper.toEntity(user)) - commitEntity.author = userMapper.toEntity(user) - } -// wire up commit->branch - cmt.branches.forEach { branch -> - commitEntity.addBranch(branchMapper.toEntity(branch)) - } - } - } - -// wire up repository->branch - domain.branches - .map { it -> - branchMapper.toEntity(it) - }.also { it.forEach { b -> entity.addBranch(b) } } - .toMutableSet() - -// wire up repository->user - domain.user - .map { it -> - userMapper.toEntity(it) - }.also { it.forEach { u -> entity.addUser(u) } } - .toMutableSet() + companion object { + private val logger by logger() + } - entity.project.repo = entity + /** + * Converts a Repository domain object to RepositoryEntity. + * + * **Precondition**: The referenced Project must already be mapped and present in MappingContext. + * This enforces aggregate boundary - Project is a separate aggregate that must be handled first. + * + * **Note**: This method does NOT map child entities (Commits, Branches). Use [RepositoryAssembler] + * for complete aggregate assembly including children. + * + * @param domain The Repository domain object to convert + * @return The RepositoryEntity (structure only, without children) + * @throws IllegalStateException if Project is not in MappingContext + */ + override fun toEntity( + domain: Repository, + ): RepositoryEntity { + // Fast-path: if this Repository was already mapped in the current context, return it. + ctx.findEntity(domain)?.let { return it } + + // IMPORTANT: Expect Project already in context (cross-aggregate reference). + // Do NOT auto-map Project here - that's a separate aggregate. + val owner: ProjectEntity = ctx.findEntity(domain.project) + ?: throw IllegalStateException( + "ProjectEntity must be mapped before RepositoryEntity. " + + "Ensure ProjectEntity is in MappingContext before calling toEntity()." + ) - return entity - } + // Create entity and remember in context + val entity = domain.toEntity(owner) + ctx.remember(domain, entity) - fun toDomain( - entity: RepositoryEntity, - project: Project?, - ): Repository { - val id = entity.id ?: throw IllegalStateException("Entity ID cannot be null") + // Delegate to overload with explicit owner + return entity + } - val domain = entity.toDomain(project) + /** + * Converts a RepositoryEntity to Repository domain object. + * + * **Precondition**: The referenced Project must already be mapped and present in MappingContext. + * This enforces aggregate boundary - Project is a separate aggregate that must be handled first. + * + * **Note**: This method does NOT map child entities (Commits, Branches). Use [RepositoryAssembler] + * for complete aggregate assembly including children. + * + * @param entity The RepositoryEntity to convert + * @return The Repository domain object (structure only, without children) + * @throws IllegalStateException if Project is not in MappingContext + */ + override fun toDomain( + entity: RepositoryEntity, + ): Repository { + // Fast-path: Check if already mapped + ctx.findDomain(entity)?.let { return it } + + // IMPORTANT: Expect Project already in context (cross-aggregate reference). + // Do NOT auto-map Project here - that's a separate aggregate. + val owner = ctx.findDomain(entity.project) + ?: throw IllegalStateException( + "Project must be mapped before Repository. " + + "Ensure Project is in MappingContext before calling toDomain()." + ) - // load the *entire* set of CommitEntity once - val allCommits: Set = - transactionTemplate.execute { - val fresh = entityManager.find(RepositoryEntity::class.java, entity.id) - fresh.commits - } ?: throw IllegalStateException("Cannot load the entire set of CommitEntity once") + val domain = entity.toDomain(owner) + setField( + domain.javaClass.superclass.getDeclaredField("iid"), + domain, + entity.iid + ) - // now map them in one go - domain.commits.addAll( - commitMapper.toDomainGraph(allCommits.asSequence()), - ) - domain.commits.forEach { it.repository = domain } + ctx.remember(domain, entity) - // do similar bulk‑mapping for branches and user - domain.branches.addAll( - transactionTemplate.execute { - val fresh = entityManager.find(RepositoryEntity::class.java, entity.id) - fresh.branches.map { branchMapper.toDomain(it) }.toMutableSet() - } ?: throw IllegalStateException("Cannot bulk-map branches"), - ) -// domain.branches.forEach { -// it.repository = domain -// } + return domain + } - domain.user.addAll( - transactionTemplate.execute { - val domCommitMap = domain.commits.associateBy { it.sha } - val fresh = entityManager.find(RepositoryEntity::class.java, entity.id) - fresh.user - .map { userEntity -> - val u = - userMapper - .toDomain(userEntity) - .apply { - this.committedCommits.addAll( - userEntity.committedCommits - .map { - domCommitMap[it.sha] - ?: throw IllegalMappingStateException( - "Commit ${it.sha} was not mapped (committedCommits)", - ) - }, - ) - this.authoredCommits.addAll( - userEntity.authoredCommits - .map { - domCommitMap[it.sha] - ?: throw IllegalMappingStateException( - "Commit ${it.sha} was not mapped (authoredCommits)", - ) - }, - ) - } - u - }.toMutableSet() - } ?: throw IllegalStateException("Cannot bulk-map user"), - ) -// domain.user.forEach { it.repository = domain } + fun refreshDomain(target: Repository, entity: RepositoryEntity): Repository { + setField( + target.javaClass.getDeclaredField("id"), + target, + entity.id?.toString() + ) - return domain - } + return target } +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/UserMapper.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/UserMapper.kt deleted file mode 100644 index 9d06a4ea3..000000000 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/UserMapper.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.mapper - -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingContext -import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.toEntity -import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.stereotype.Component - -@Component -internal class UserMapper { - private val logger: Logger = LoggerFactory.getLogger(UserMapper::class.java) - - @Autowired - private lateinit var ctx: MappingContext - - @Autowired - private lateinit var commitMapper: CommitMapper - - /** - * Converts a domain User to a SQL UserEntity - */ - fun toEntity(domain: User): UserEntity { - val userContextKey = domain.uniqueKey() - ctx.entity.user[userContextKey]?.let { - logger.trace("toEntity: User-Cache hit: '$userContextKey'") - return it - } - - val entity = domain.toEntity() - - ctx.entity.user.computeIfAbsent(userContextKey) { entity } - - return entity - } - - /** - * Converts a SQL UserEntity to a domain User - * - * Uses lazy loading proxies for relationships, which will only be loaded - * when accessed. This provides a consistent API regardless of the database - * implementation and avoids the N+1 query problem. - */ - fun toDomain(entity: UserEntity): User { - val userContextKey = entity.uniqueKey() - ctx.domain.user[userContextKey]?.let { - logger.trace("toDomain: User-Cache hit: '$userContextKey'") - return it - } - - val domain = entity.toDomain() - - ctx.domain.user.computeIfAbsent(userContextKey) { domain } - - return domain - } - - fun toDomainFull( - entity: UserEntity, - repository: Repository, - ): User { - val mappedDomain = toDomain(entity) - - mappedDomain.committedCommits.addAll( - commitMapper.toDomainFull(entity.committedCommits, repository), - ) - mappedDomain.authoredCommits.addAll( - commitMapper.toDomainFull(entity.authoredCommits, repository), - ) - - return mappedDomain - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingContext.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingContext.kt deleted file mode 100644 index bcbeebf8b..000000000 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/mapper/context/MappingContext.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.mapper.context - -import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.User -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.springframework.context.annotation.Scope -import org.springframework.context.annotation.ScopedProxyMode -import org.springframework.stereotype.Component -import java.util.concurrent.ConcurrentHashMap - -@Component -@Scope("mapping", proxyMode = ScopedProxyMode.TARGET_CLASS) -internal class MappingContext { - companion object { - private val logger: Logger = LoggerFactory.getLogger(MappingContext::class.java) - } - - // entity → domain - val domain = DomainMaps() - - // domain → entity - val entity = EntityMaps() - - /** Clear all maps in this context */ - fun clear() { - logger.debug("Clearing mapping context") - domain.run { - user.clear() - commit.clear() - branch.clear() - } - entity.run { - user.clear() - commit.clear() - branch.clear() - } - logger.debug("Mapping context cleared") - } - - class DomainMaps { - val user = ConcurrentHashMap() - val commit = ConcurrentHashMap() - val branch = ConcurrentHashMap() - } - - class EntityMaps { - val user = ConcurrentHashMap() - val commit = ConcurrentHashMap() - val branch = ConcurrentHashMap() - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/converter/KotlinUuidConverter.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/converter/KotlinUuidConverter.kt new file mode 100644 index 000000000..c2a78a4b5 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/converter/KotlinUuidConverter.kt @@ -0,0 +1,19 @@ +package com.inso_world.binocular.infrastructure.sql.persistence.converter + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter +import java.util.UUID +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid + +@Converter +@OptIn(ExperimentalUuidApi::class) +internal class KotlinUuidConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: Uuid?): UUID? = + attribute?.toJavaUuid() + + override fun convertToEntityAttribute(dbData: UUID?): Uuid? = + dbData?.toKotlinUuid() +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/BranchDao.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/BranchDao.kt index d19e9782a..82f3e3e0b 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/BranchDao.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/BranchDao.kt @@ -36,8 +36,8 @@ internal class BranchDao( repo: RepositoryEntity, name: String, ): BranchEntity? { - if (repo.id == null) throw PersistenceException("Cannot search for repo without valid ID") - return this.repo.findByRepository_IdAndName(repo.id, name) + val rId = repo.id ?: throw PersistenceException("Cannot search for repo without valid ID") + return this.repo.findByRepository_IdAndName(rId, name) } override fun findAll(repository: com.inso_world.binocular.model.Repository): Iterable = diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/CommitDao.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/CommitDao.kt index 161234e61..ee835d652 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/CommitDao.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/CommitDao.kt @@ -5,12 +5,13 @@ import com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces.IC import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity import com.inso_world.binocular.infrastructure.sql.persistence.repository.CommitRepository -import com.inso_world.binocular.model.Commit import jakarta.persistence.criteria.JoinType import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Repository import java.util.stream.Stream +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * SQL implementation of ICommitDao. @@ -26,17 +27,21 @@ internal class CommitDao( this.setRepository(repo) } + @OptIn(ExperimentalUuidApi::class) + override fun findByIid(iid: com.inso_world.binocular.model.Commit.Id): CommitEntity? = repo.findByIid(iid.value) + private object CommitEntitySpecification { - fun hasRepositoryId(repoId: Long): Specification = + @OptIn(ExperimentalUuidApi::class) + fun hasRepositoryIid(iid: com.inso_world.binocular.model.Repository.Id): Specification = Specification { root, query, cb -> if (query?.resultType != Long::class.java) { root.fetch("committer", JoinType.LEFT) root.fetch("author", JoinType.LEFT) } - cb.equal(root.get("repository").get("id"), repoId) + cb.equal(root.get("repository").get("iid"), iid.value) } - fun hasShaIn(shas: List): Specification = + fun hasShaIn(shas: Collection): Specification = Specification { root, _, cb -> root.get("sha").`in`(shas) } @@ -44,48 +49,43 @@ internal class CommitDao( override fun findExistingSha( repository: com.inso_world.binocular.model.Repository, - shas: List, + shas: Collection, ): Iterable { - val rid = repository.id ?: throw PersistenceException("Cannot search for repo without valid ID") + val rid = repository.iid val shas = this.repo.findAll( Specification.allOf( CommitEntitySpecification - .hasRepositoryId(rid.toLong()) + .hasRepositoryIid(rid) .and(CommitEntitySpecification.hasShaIn(shas)), ), ) return shas } + @OptIn(ExperimentalUuidApi::class) override fun findHeadForBranch( - repository: com.inso_world.binocular.model.Repository, + repository: RepositoryEntity, branch: String, ): CommitEntity? { - val rid = repository.id - if (rid == null) throw PersistenceException("Cannot search for repo without valid ID") - return this.repo.findLeafCommitsByRepository(rid.toLong(), branch) + return this.repo.findLeafCommitsByRepository(repository.iid.value, branch) } - override fun findAllLeafCommits(repository: com.inso_world.binocular.model.Repository): Iterable { - val rid = repository.id - if (rid == null) throw PersistenceException("Cannot search for repo without valid ID") - return this.repo.findAllLeafCommits(rid.toLong()) + @OptIn(ExperimentalUuidApi::class) + override fun findAllLeafCommits(repository: RepositoryEntity): Iterable { + return this.repo.findAllLeafCommits(repository.iid.value) } + @OptIn(ExperimentalUuidApi::class) override fun findBySha( repository: RepositoryEntity, sha: String, ): CommitEntity? { - val rid = repository.id - if (rid == null) throw PersistenceException("Cannot search for repo without valid ID") - return this.repo.findByRepository_IdAndSha(rid, sha) + return this.repo.findByRepository_IidAndSha(repository.iid.value, sha) } override fun findAll(repository: com.inso_world.binocular.model.Repository): Stream { - val rid = repository.id - if (rid == null) throw PersistenceException("Cannot search for repo without valid ID") - return this.repo.findAllByRepository_Id(rid.toLong()) + return this.repo.findAllByRepository_Iid(repository.iid) } override fun findAllAsStream(): Stream = repo.findAllAsStream() diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/UserDao.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/DeveloperDao.kt similarity index 65% rename from binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/UserDao.kt rename to binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/DeveloperDao.kt index bc12747b9..c5c96b5f5 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/UserDao.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/DeveloperDao.kt @@ -1,29 +1,30 @@ package com.inso_world.binocular.infrastructure.sql.persistence.dao import com.inso_world.binocular.core.persistence.exception.PersistenceException -import com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces.IUserDao +import com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces.IDeveloperDao +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity -import com.inso_world.binocular.infrastructure.sql.persistence.repository.UserRepository +import com.inso_world.binocular.infrastructure.sql.persistence.repository.DeveloperRepository +import com.inso_world.binocular.model.Repository as DomainRepository import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Repository import java.util.stream.Stream @Repository -internal class UserDao( +internal class DeveloperDao( @Autowired - private val repo: UserRepository, -) : SqlDao(), - IUserDao { + private val repo: DeveloperRepository, +) : SqlDao(), + IDeveloperDao { init { - this.setClazz(UserEntity::class.java) + this.setClazz(DeveloperEntity::class.java) this.setRepository(repo) } - private object UserSpecification { - fun hasRepository(repository: RepositoryEntity): Specification = - Specification { root, query, cb -> + private object DeveloperSpecification { + fun hasRepository(repository: RepositoryEntity): Specification = + Specification { root, _, cb -> cb.equal( root.get("repository").get("local_path"), repository.localPath, @@ -31,14 +32,15 @@ internal class UserDao( } } - override fun findAllByGitSignatureIn(emails: Collection): Stream = repo.findAllByEmailIn(emails) + override fun findAllByGitSignatureIn(emails: Collection): Stream = + repo.findAllByEmailIn(emails) - override fun findAll(repository: RepositoryEntity): Iterable = + override fun findAll(repository: RepositoryEntity): Iterable = this.repo.findAll( - Specification.allOf(UserSpecification.hasRepository(repository)), + Specification.allOf(DeveloperSpecification.hasRepository(repository)), ) - override fun findAllAsStream(repository: com.inso_world.binocular.model.Repository): Stream { + override fun findAllAsStream(repository: DomainRepository): Stream { val rid = repository.id if (rid == null) throw PersistenceException("Cannot search for repo without valid ID") return this.repo.findAllByRepository_Id(rid.toLong()) diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/ProjectDao.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/ProjectDao.kt index 5f54240cd..392fe7056 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/ProjectDao.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/ProjectDao.kt @@ -3,9 +3,11 @@ package com.inso_world.binocular.infrastructure.sql.persistence.dao import com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces.IProjectDao import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity import com.inso_world.binocular.infrastructure.sql.persistence.repository.ProjectRepository +import com.inso_world.binocular.model.Project import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Repository +import kotlin.uuid.ExperimentalUuidApi @Repository internal class ProjectDao( @@ -20,6 +22,9 @@ internal class ProjectDao( override fun findByName(name: String): ProjectEntity? = repo.findByName(name) + @OptIn(ExperimentalUuidApi::class) + override fun findByIid(iid: Project.Id): ProjectEntity? = repo.findByIid(iid.value) + // @Transactional // override fun delete(entity: ProjectEntity) { // val toDelete = diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/RepositoryDao.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/RepositoryDao.kt index b06cb06c8..634dac573 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/RepositoryDao.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/RepositoryDao.kt @@ -1,11 +1,14 @@ package com.inso_world.binocular.infrastructure.sql.persistence.dao import com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces.IRepositoryDao +import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity import com.inso_world.binocular.infrastructure.sql.persistence.repository.RepositoryRepository +import jakarta.validation.constraints.Size import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Repository +import kotlin.uuid.ExperimentalUuidApi @Repository internal class RepositoryDao( @@ -22,13 +25,6 @@ internal class RepositoryDao( override fun findByName(name: String): RepositoryEntity? = repo.findByLocalPath(name) - private object RepositorySpecification { - fun hasRepository(name: String): Specification = - Specification { root, query, cb -> - cb.equal( - root.get("repository").get("name"), - name, - ) - } - } + @OptIn(ExperimentalUuidApi::class) + override fun findByIid(iid: com.inso_world.binocular.model.Repository.Id): RepositoryEntity? = this.repo.findByIid(iid.value) } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/ICommitDao.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/ICommitDao.kt index 495d1c3a4..c7474b226 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/ICommitDao.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/ICommitDao.kt @@ -7,18 +7,20 @@ import com.inso_world.binocular.model.Repository import java.util.stream.Stream internal interface ICommitDao : IDao { + fun findByIid(iid: com.inso_world.binocular.model.Commit.Id): CommitEntity? + fun findExistingSha( repository: Repository, - shas: List, + shas: Collection, ): Iterable // TODO branch should be required! fun findHeadForBranch( - repository: Repository, + repository: RepositoryEntity, branch: String, ): CommitEntity? - fun findAllLeafCommits(repository: Repository): Iterable + fun findAllLeafCommits(repository: RepositoryEntity): Iterable fun findBySha( // TODO change to repository: Repository, after refactoring @Commit diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IUserDao.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IDeveloperDao.kt similarity index 59% rename from binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IUserDao.kt rename to binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IDeveloperDao.kt index 997cfa254..c62dc9cea 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IUserDao.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IDeveloperDao.kt @@ -1,14 +1,12 @@ package com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity import com.inso_world.binocular.model.Repository import java.util.stream.Stream -internal interface IUserDao : IDao { - fun findAllByGitSignatureIn(emails: Collection): Stream - - fun findAll(repository: RepositoryEntity): Iterable - - fun findAllAsStream(repository: Repository): Stream +internal interface IDeveloperDao : IDao { + fun findAllByGitSignatureIn(emails: Collection): Stream + fun findAll(repository: RepositoryEntity): Iterable + fun findAllAsStream(repository: Repository): Stream } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IProjectDao.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IProjectDao.kt index 5017da827..00d800746 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IProjectDao.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IProjectDao.kt @@ -1,7 +1,9 @@ package com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity internal interface IProjectDao : IDao { fun findByName(name: String): ProjectEntity? + fun findByIid(iid: com.inso_world.binocular.model.Project.Id): ProjectEntity? } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IRepositoryDao.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IRepositoryDao.kt index 73339501e..c4d1d7a5e 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IRepositoryDao.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/dao/interfaces/IRepositoryDao.kt @@ -1,8 +1,11 @@ package com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces +import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity +import jakarta.validation.constraints.Size internal interface IRepositoryDao : IDao { + fun findByIid(iid: com.inso_world.binocular.model.Repository.Id): RepositoryEntity? fun findByName(name: String): RepositoryEntity? fun findByIdWithAllRelations(id: Long): RepositoryEntity? diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/AbstractEntity.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/AbstractEntity.kt index 09e722a53..c2d98f64c 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/AbstractEntity.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/AbstractEntity.kt @@ -1,5 +1,21 @@ package com.inso_world.binocular.infrastructure.sql.persistence.entity -abstract class AbstractEntity { - abstract fun uniqueKey(): String +abstract class AbstractEntity { + abstract var id: Id? + + abstract val uniqueKey: Key + + override fun hashCode(): Int = uniqueKey.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AbstractEntity<*, *> + + if (uniqueKey == other.uniqueKey) return true + if (id != other.id) return false + + return true + } } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/BranchEntity.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/BranchEntity.kt index 667081b3b..f1ed081ed 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/BranchEntity.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/BranchEntity.kt @@ -1,20 +1,25 @@ package com.inso_world.binocular.infrastructure.sql.persistence.entity +import com.inso_world.binocular.infrastructure.sql.persistence.converter.KotlinUuidConverter import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Reference +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.vcs.ReferenceCategory import jakarta.persistence.Column +import jakarta.persistence.Convert import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Index import jakarta.persistence.JoinColumn -import jakarta.persistence.ManyToMany import jakarta.persistence.ManyToOne -import jakarta.persistence.PreRemove import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint -import org.hibernate.annotations.BatchSize /** * SQL-specific Branch entity. @@ -28,65 +33,65 @@ import org.hibernate.annotations.BatchSize ], ) internal data class BranchEntity( - @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE) - var id: Long? = null, @Column(nullable = false) val name: String, - @BatchSize(size = 256) - @ManyToMany(mappedBy = "branches", fetch = FetchType.LAZY, cascade = []) - val commits: MutableSet = mutableSetOf(), + @Column(name = "full_name", nullable = false) + val fullName: String, + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 64) + val category: ReferenceCategory, + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "fk_commit_id", nullable = false, updatable = true) + val head: CommitEntity, @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "repository_id", nullable = false, updatable = false) - var repository: RepositoryEntity? = null, -) : AbstractEntity() { - @PreRemove - fun preRemove() { -// this.repository?.branches?.remove(this) -// this.repository = null - } + val repository: RepositoryEntity, + @Column(nullable = false, updatable = false, unique = true) + @Convert(KotlinUuidConverter::class) + val iid: Reference.Id +) : AbstractEntity() { + data class Key(val repositoryIid: Repository.Id, val name: String) - fun addCommit(commit: CommitEntity) { - this.commits.add(commit) - commit.branches.add(this) + init { + repository.branches.add(this) } - fun toDomain(): Branch = + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + override var id: Long? = null + + fun toDomain(repository: Repository, head: Commit): Branch = Branch( - id = this.id?.toString(), name = this.name, - repository = null, - ) + fullName = this.fullName, + category = this.category, + repository = repository, + head = head, + ).apply { + this.id = this@BranchEntity.id?.toString() + } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as BranchEntity - - if (id != other.id) return false - if (name != other.name) return false -// if (repository?.uniqueKey() != other.repository?.uniqueKey()) return false - - return true - } + override fun equals(other: Any?): Boolean = super.equals(other) override fun hashCode(): Int = super.hashCode() - override fun toString(): String = "BranchEntity(id=$id, name='$name', commits=${commits.map { it.sha }})" + override fun toString(): String = "BranchEntity(id=$id, name='$name', headId=${head.id})" - override fun uniqueKey(): String { - val repo = - requireNotNull(this.repository) { - "RepositoryEntity required for uniqueKey" - } - return "${repo.localPath},$name" - } + override val uniqueKey: Key + get() = Key( + repository.iid, + name + ) } -internal fun Branch.toEntity(): BranchEntity = +internal fun Branch.toEntity(repository: RepositoryEntity, head: CommitEntity): BranchEntity = BranchEntity( - id = this.id?.toLong(), + iid = this.iid, name = this.name, - repository = null, - ) + fullName = this.fullName, + category = this.category, + repository = repository, + head = head, + ).apply { + id = this@toEntity.id?.trim()?.toLongOrNull() + } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/CommitEntity.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/CommitEntity.kt index c47b12695..1ab2f41e0 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/CommitEntity.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/CommitEntity.kt @@ -1,8 +1,12 @@ package com.inso_world.binocular.infrastructure.sql.persistence.entity +import com.inso_world.binocular.infrastructure.sql.persistence.converter.KotlinUuidConverter import com.inso_world.binocular.model.Commit -import jakarta.persistence.CascadeType +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature import jakarta.persistence.Column +import jakarta.persistence.Convert import jakarta.persistence.Entity import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue @@ -14,18 +18,14 @@ import jakarta.persistence.JoinTable import jakarta.persistence.Lob import jakarta.persistence.ManyToMany import jakarta.persistence.ManyToOne -import jakarta.persistence.PreRemove import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint -import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size import org.hibernate.annotations.BatchSize +import org.hibernate.annotations.OnDelete +import org.hibernate.annotations.OnDeleteAction import java.time.LocalDateTime -import java.util.Objects -/** - * SQL-specific Commit entity. - */ @Entity @Table( name = "commits", @@ -33,16 +33,16 @@ import java.util.Objects uniqueConstraints = [], ) internal data class CommitEntity( - @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE) - var id: Long? = null, + @Column(nullable = false, updatable = false, unique = true) + @Convert(KotlinUuidConverter::class) + val iid: Commit.Id, @Column(unique = true, updatable = false) @field:Size(min = 40, max = 40) val sha: String, - @Column(name = "author_dt") - val authorDateTime: LocalDateTime? = null, - @Column(name = "commit_dt") - val commitDateTime: LocalDateTime? = null, + @Column(name = "author_dt", nullable = false) + var authorDateTime: LocalDateTime, + @Column(name = "commit_dt", nullable = false) + var commitDateTime: LocalDateTime, @Column(columnDefinition = "TEXT") @Lob var message: String? = null, @@ -67,128 +67,81 @@ internal data class CommitEntity( @BatchSize(size = 256) @ManyToMany(mappedBy = "parents", fetch = FetchType.LAZY) var children: MutableSet = mutableSetOf(), - @BatchSize(size = 256) - @ManyToMany(targetEntity = BranchEntity::class, fetch = FetchType.LAZY, cascade = []) - @JoinTable( - name = "commit_branches", - joinColumns = [JoinColumn(name = "commit_id", nullable = false)], - inverseJoinColumns = [JoinColumn(name = "branch_id", nullable = false)], - uniqueConstraints = [ - UniqueConstraint(columnNames = ["commit_id", "branch_id"]), - ], - ) - var branches: MutableSet = mutableSetOf(), -// @ManyToOne(fetch = FetchType.LAZY, optional = true, cascade = [CascadeType.PERSIST]) -// var author: UserEntity? = null, + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "author_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + var author: DeveloperEntity, + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "committer_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + var committer: DeveloperEntity, @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "repository_id", nullable = false, updatable = false) - var repository: RepositoryEntity? = null, -) : AbstractEntity() { - @ManyToOne(fetch = FetchType.LAZY, optional = false, cascade = [CascadeType.PERSIST]) - var committer: UserEntity? = null - set(value) { - if (value == this.committer) { - return - } - if (this.committer != null) { - throw IllegalArgumentException("Committer already set for Commit $sha: $committer") - } - field = value - field!!.committedCommits.add(this) - field - } -// get() = field - - @ManyToOne(fetch = FetchType.LAZY, optional = false, cascade = [CascadeType.PERSIST]) - var author: UserEntity? = null - set( - @NotNull value, - ) { - if (value == this.author) { - return - } - if (this.author != null) { - throw IllegalArgumentException("Author already set for Commit $sha: $author") - } - field = value - field!!.authoredCommits.add(this) - field - } -// get() = author + @OnDelete(action = OnDeleteAction.CASCADE) + val repository: RepositoryEntity, +) : AbstractEntity() { + data class Key(val sha: String) - override fun uniqueKey(): String = this.sha - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is CommitEntity) return false - return sha != null && sha == other.sha + init { + this.repository.commits.add(this) } - fun addParent(parent: CommitEntity) { - this.parents.add(parent) - parent.children.add(this) - } + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + override var id: Long? = null - fun addChild(child: CommitEntity) { - this.children.add(child) - child.parents.add(this) - } + override val uniqueKey: Key + get() = Key(this.sha) - fun addBranch(branch: BranchEntity) { - this.branches.add(branch) - branch.commits.add(this) - } + override fun equals(other: Any?): Boolean = super.equals(other) - override fun hashCode(): Int = Objects.hashCode(sha) + override fun hashCode(): Int = super.hashCode() - fun toDomain(): Commit = - Commit( - id = this.id?.toString(), + fun toDomain( + repository: Repository, + author: Developer, + committer: Developer, + ): Commit { + val authorSignature = Signature(developer = author, timestamp = authorDateTime) + val committerSignature = + if (committer == author && commitDateTime == authorDateTime) { + authorSignature + } else { + Signature(developer = committer, timestamp = commitDateTime) + } + return Commit( sha = this.sha, - commitDateTime = this.commitDateTime, - authorDateTime = this.authorDateTime, + authorSignature = authorSignature, + committerSignature = committerSignature, + repository = repository, message = this.message, - webUrl = this.webUrl, - ) - - @PreRemove - fun preRemove() { - this.committer?.let { user -> - user.committedCommits.remove(this) - user.authoredCommits.remove(this) - } - this.author?.let { user -> - user.committedCommits.remove(this) - user.authoredCommits.remove(this) - } - - this.branches.forEach { branch -> - branch.commits.remove(this) - } - - // Clear parent/child relationships - this.parents.clear() - this.children.forEach { child -> - child.parents.remove(this) + ).apply { + this.id = this@CommitEntity.id?.toString() + this.webUrl = this@CommitEntity.webUrl } } override fun toString(): String = - "CommitEntity(id=$id, sha='$sha', authorDateTime=$authorDateTime, commitDateTime=$commitDateTime, repository=${repository?.localPath})" + "CommitEntity(id=$id, sha='$sha', authorDateTime=$authorDateTime, commitDateTime=$commitDateTime, repository=${repository.localPath})" } -internal fun Commit.toEntity(): CommitEntity = +internal fun Commit.toEntity( + repository: RepositoryEntity, + author: DeveloperEntity, + committer: DeveloperEntity, +): CommitEntity = CommitEntity( - id = this.id?.toLong(), + iid = this.iid, sha = this.sha, - commitDateTime = this.commitDateTime, - authorDateTime = this.authorDateTime, + authorDateTime = this.authorSignature.timestamp, + commitDateTime = (this.committerSignature ?: this.authorSignature).timestamp, message = this.message, webUrl = this.webUrl, - repository = null, + repository = repository, parents = mutableSetOf(), children = mutableSetOf(), - branches = mutableSetOf(), -// committer = null, -// author = null, - ) + author = author, + committer = committer, + ).apply { + this.id = this@toEntity.id?.trim()?.toLongOrNull() + } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/DeveloperEntity.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/DeveloperEntity.kt new file mode 100644 index 000000000..beaf06797 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/DeveloperEntity.kt @@ -0,0 +1,80 @@ +package com.inso_world.binocular.infrastructure.sql.persistence.entity + +import com.inso_world.binocular.infrastructure.sql.persistence.converter.KotlinUuidConverter +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Repository +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +/** + * SQL-specific Developer entity stored in the legacy `users` table. + * + * This maps the refactored domain `Developer` (required name + email) while + * keeping the existing table name/constraints for compatibility. + */ +@Entity +@Table( + name = "users", + uniqueConstraints = [ + UniqueConstraint(columnNames = ["repository_id", "email"]), + ], +) +internal data class DeveloperEntity( + @Column(nullable = false) + var name: String, + @Column(nullable = false) + var email: String, + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "repository_id", nullable = false, updatable = false) + var repository: RepositoryEntity, + @Column(nullable = false, updatable = false, unique = true) + @Convert(KotlinUuidConverter::class) + val iid: Developer.Id, +) : AbstractEntity() { + data class Key(val repositoryIid: Repository.Id, val email: String) + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + override var id: Long? = null + + init { + repository.developers.add(this) + } + + override val uniqueKey: Key + get() = Key(repositoryIid = repository.iid, email = email) + + override fun equals(other: Any?): Boolean = super.equals(other) + override fun hashCode(): Int = super.hashCode() + + fun toDomain(repository: Repository): Developer = + Developer( + name = this.name, + email = this.email, + repository = repository, + ).apply { + this.id = this@DeveloperEntity.id?.toString() + } + + override fun toString(): String = + "DeveloperEntity(id=$id, iid=$iid, name='$name', email='$email', repositoryId=${repository.id})" +} + +internal fun Developer.toEntity(repository: RepositoryEntity): DeveloperEntity = + DeveloperEntity( + iid = this.iid, + email = this.email, + name = this.name, + repository = repository, + ).apply { + id = this@toEntity.id?.trim()?.toLongOrNull() + } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/IssueEntity.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/IssueEntity.kt index bfa8ea9ed..80e07d0a3 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/IssueEntity.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/IssueEntity.kt @@ -13,6 +13,7 @@ import jakarta.persistence.Temporal import jakarta.persistence.TemporalType import java.time.LocalDateTime import java.util.Objects +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity /** * SQL-specific Issue entity. @@ -72,7 +73,7 @@ internal data class IssueEntity( joinColumns = [JoinColumn(name = "issue_id")], inverseJoinColumns = [JoinColumn(name = "user_id")], ) - var users: MutableList = mutableListOf(), + var developers: MutableList = mutableListOf(), ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -89,10 +90,10 @@ internal data class IssueEntity( if (updatedAt != other.updatedAt) return false if (state != other.state) return false if (webUrl != other.webUrl) return false - if (users != other.users) return false + if (developers != other.developers) return false return true } - override fun hashCode(): Int = Objects.hash(id, iid, title, description, createdAt, closedAt, updatedAt, state, webUrl, users) + override fun hashCode(): Int = Objects.hash(id, iid, title, description, createdAt, closedAt, updatedAt, state, webUrl, developers) } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/ProjectEntity.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/ProjectEntity.kt index 5b6289667..30147f4e9 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/ProjectEntity.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/ProjectEntity.kt @@ -1,92 +1,88 @@ package com.inso_world.binocular.infrastructure.sql.persistence.entity +import com.inso_world.binocular.infrastructure.sql.persistence.converter.KotlinUuidConverter import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository import jakarta.persistence.CascadeType import jakarta.persistence.Column +import jakarta.persistence.Convert import jakarta.persistence.Entity -import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.OneToOne import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint import jakarta.validation.constraints.NotBlank @Entity -@Table(name = "projects") +@Table(name = "projects", uniqueConstraints = [UniqueConstraint(columnNames = ["name"])]) internal data class ProjectEntity( - @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) var id: Long? = null, - @Column(nullable = false, unique = true) @field:NotBlank val name: String, - @Column(nullable = true, unique = false) val description: String? = null, -// @ManyToMany(mappedBy = "projects", fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) -// var members: MutableSet = mutableSetOf(), -// @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "project", orphanRemoval = true) -// var feature: ProjectFeature? = null, -// @OneToMany( -// fetch = FetchType.LAZY, -// cascade = [CascadeType.ALL], -// mappedBy = "project", -// orphanRemoval = true, -// ) var issues: MutableSet = mutableSetOf(), -// @OneToMany( -// fetch = FetchType.LAZY, -// cascade = [CascadeType.ALL], -// mappedBy = "project", -// orphanRemoval = true, -// ) var mergeRequests: MutableSet = mutableSetOf(), + @Column(nullable = false, unique = true, updatable = false) @field:NotBlank val name: String, + @Column(nullable = false, updatable = false, unique = true) + @Convert(KotlinUuidConverter::class) + val iid: Project.Id +) : AbstractEntity() { + + @Column(nullable = true, unique = false, length = MAX_DESCRIPTION_LENGTH) + var description: String? = null + set(value) { + require(value == null || value.length <= MAX_DESCRIPTION_LENGTH) { + "Description must not exceed $MAX_DESCRIPTION_LENGTH characters." + } + field = value + } + + data class Key(val name: String) // value object for lookups + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + override var id: Long? = null + @OneToOne( - fetch = FetchType.LAZY, - cascade = [CascadeType.REMOVE, CascadeType.PERSIST], + cascade = [CascadeType.ALL], optional = true, mappedBy = "project", ) - @JoinColumn(name = "fk_repository", referencedColumnName = "id") - var repo: RepositoryEntity? = null, -) : AbstractEntity() { - // fun addMember(pm: ProjectMember) { -// this.members.add(pm) -// pm.projects.add(this) -// } + var repo: RepositoryEntity? = null + set(value) { + requireNotNull(value) { "Cannot set repo to null" } + if (value == this.repo) { + return + } + if (this.repo != null) { + throw IllegalArgumentException("Repository already set for Project $repo") + } + field = value + } -// fun addIssue(e: Issue) { -// this.issues.add(e) -// e.project = this -// } + override fun toString(): String = "ProjectEntity(id=$id, iid=$iid, name=$name, description=$description, repo=$repo)" - override fun toString(): String = "Project(id=$id, name=$name, description=$description, repo=$repo)" - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ProjectEntity - -// if (id != other.id) return false - if (name != other.name) return false - if (description != other.description) return false -// if (repo?.id != other.repo?.id) return false - - return true - } + override val uniqueKey: ProjectEntity.Key + get() = ProjectEntity.Key(this.name) + // Entities compare by immutable identity only + override fun equals(other: Any?) = super.equals(other) override fun hashCode(): Int = super.hashCode() - override fun uniqueKey(): String = this.name + fun toDomain(repo: Repository? = null): Project = Project( + name = this.name, + ).apply { + this.id = this@ProjectEntity.id?.toString() + this.description = this@ProjectEntity.description + repo?.let { this.repo = it } + } - fun toDomain(): Project = - Project( - id = this.id?.toString(), - name = this.name, - description = this.description, - repo = null, - ) + companion object { + private const val MAX_DESCRIPTION_LENGTH = 255 + } } -internal fun Project.toEntity(): ProjectEntity = - ProjectEntity( - id = this.id?.toLong(), - name = this.name, - description = this.description, - repo = null, - ) +internal fun Project.toEntity(): ProjectEntity = ProjectEntity( + iid = this.iid, + name = this@toEntity.name, +).apply { + id = this@toEntity.id?.trim()?.toLongOrNull() + description = this@toEntity.description +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/RemoteEntity.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/RemoteEntity.kt new file mode 100644 index 000000000..a78e9b841 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/RemoteEntity.kt @@ -0,0 +1,80 @@ +package com.inso_world.binocular.infrastructure.sql.persistence.entity + +import com.inso_world.binocular.infrastructure.sql.persistence.converter.KotlinUuidConverter +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.vcs.Remote +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "remotes", + indexes = [Index(name = "idx_remote_name", columnList = "name")], + uniqueConstraints = [ + UniqueConstraint(columnNames = ["repository_id", "name"]), + ], +) +internal data class RemoteEntity( + @Column(nullable = false) + val name: String, + @Column(nullable = false) + var url: String, + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "repository_id", nullable = false, updatable = false) + val repository: RepositoryEntity, + @Column(nullable = false, updatable = false, unique = true) + @Convert(KotlinUuidConverter::class) + val iid: Remote.Id +) : AbstractEntity() { + + data class Key(val repositoryIid: Repository.Id, val name: String) + + init { + repository.remotes.add(this) + } + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + override var id: Long? = null + + fun toDomain(repository: Repository): Remote = + Remote( + name = this.name, + url = this.url, + repository = repository + ).apply { + this.id = this@RemoteEntity.id?.toString() + } + + override fun equals(other: Any?): Boolean = super.equals(other) + + override fun hashCode(): Int = super.hashCode() + + override fun toString(): String = "RemoteEntity(id=$id, name='$name', url='$url')" + + override val uniqueKey: Key + get() = Key( + repository.iid, + name + ) +} + +internal fun Remote.toEntity(repository: RepositoryEntity): RemoteEntity = + RemoteEntity( + iid = this.iid, + name = this.name, + url = this.url, + repository = repository, + ).apply { + id = this@toEntity.id?.trim()?.toLongOrNull() + } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/RepositoryEntity.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/RepositoryEntity.kt index b746402ab..a2126937a 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/RepositoryEntity.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/RepositoryEntity.kt @@ -1,9 +1,12 @@ package com.inso_world.binocular.infrastructure.sql.persistence.entity +import com.inso_world.binocular.infrastructure.sql.persistence.converter.KotlinUuidConverter import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity import jakarta.persistence.CascadeType import jakarta.persistence.Column +import jakarta.persistence.Convert import jakarta.persistence.Entity import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue @@ -20,9 +23,9 @@ import org.hibernate.annotations.BatchSize @Entity @Table(name = "repositories") internal data class RepositoryEntity( - @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE) - val id: Long? = null, + @Column(nullable = false, updatable = false, unique = true) + @Convert(KotlinUuidConverter::class) + val iid: Repository.Id, @Column(unique = true, nullable = false, updatable = false) @field:NotBlank var localPath: String, @@ -37,12 +40,12 @@ internal data class RepositoryEntity( @BatchSize(size = 256) @OneToMany( fetch = FetchType.LAZY, - targetEntity = UserEntity::class, + targetEntity = DeveloperEntity::class, cascade = [CascadeType.ALL], orphanRemoval = true, mappedBy = "repository", ) - var user: MutableSet = mutableSetOf(), + var developers: MutableSet = mutableSetOf(), @BatchSize(size = 256) @OneToMany( fetch = FetchType.LAZY, @@ -52,89 +55,90 @@ internal data class RepositoryEntity( mappedBy = "repository", ) var branches: MutableSet = mutableSetOf(), + @BatchSize(size = 256) + @OneToMany( + fetch = FetchType.LAZY, + targetEntity = RemoteEntity::class, + cascade = [CascadeType.ALL], + orphanRemoval = true, + mappedBy = "repository", + ) + var remotes: MutableSet = mutableSetOf(), @OneToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "fk_project", referencedColumnName = "id", updatable = false, nullable = false) + @JoinColumn(name = "fk_project_id") var project: ProjectEntity, -) : AbstractEntity() { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as RepositoryEntity +) : AbstractEntity() { - if (id != other.id) return false - if (localPath != other.localPath) return false -// if (commits != other.commits) return false -// if (user != other.user) return false -// if (project.uniqueKey() != other.project.uniqueKey()) return false + data class Key(val projectIid: Project.Id, val localPath: String) // value object for lookups - return true + init { + project.repo = this } - fun addBranch(branch: BranchEntity): Boolean { - if (branch.repository != null && branch.repository != this) { - throw IllegalArgumentException("Trying to add a branch where branch.repository != this") - } + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + override var id: Long? = null - return this.branches.add(branch).also { added -> - if (added) branch.repository = this - } + override fun equals(other: Any?): Boolean = super.equals(other) + + fun addBranch(branch: BranchEntity): Boolean { +// if (branch.repository != null && branch.repository != this) { +// throw IllegalArgumentException("Trying to add a branch where branch.repository != this") +// } + + return this.branches.add(branch) +// .also { added -> +// if (added) branch.repository = this +// } } fun addCommit(commit: CommitEntity): Boolean { - if (commit.repository != null && commit.repository != this) { - throw IllegalArgumentException("Trying to add a commit where commit.repository != this") - } +// if (commit.repository != null && commit.repository != this) { +// throw IllegalArgumentException("Trying to add a commit where commit.repository != this") +// } return commits .add(commit) - .also { added -> - if (added) commit.repository = this - } +// .also { added -> +// if (added) commit.repository = this +// } } - fun addUser(user: UserEntity): Boolean { - if (user.repository != null && user.repository != this) { - throw IllegalArgumentException("Trying to add a user where user.repository != this") + fun addDeveloper(developer: DeveloperEntity): Boolean { + if (developer.repository != this) { + throw IllegalArgumentException("Trying to add a developer where developer.repository != this") } - return this.user - .add(user) - .also { added -> - if (added) user.repository = this - } + return this.developers.add(developer) } - override fun uniqueKey(): String { - val project = this.project - return "${project.name},$localPath" + fun addRemote(remote: RemoteEntity): Boolean { + return this.remotes.add(remote) } + override val uniqueKey: Key + get() = Key(project.iid, localPath) + override fun hashCode(): Int = super.hashCode() override fun toString(): String = "RepositoryEntity(id=$id, localPath='$localPath')" - fun toDomain(project: Project?): Repository { + fun toDomain(project: Project): Repository { val repo = Repository( - id = this.id?.toString(), - localPath = this.localPath, - project = project, - ) - project?.repo = repo + localPath = this.localPath.trim(), + project = project + ).apply { + this.id = this@RepositoryEntity.id?.toString() + } return repo } - - @PreRemove - fun preRemove() { - project.repo = null - } } internal fun Repository.toEntity(project: ProjectEntity): RepositoryEntity = RepositoryEntity( - id = this.id?.toLong(), - localPath = this.localPath, + iid = this.iid, + localPath = this.localPath.trim(), project = project, - ) + ).apply { id = this@toEntity.id?.trim()?.toLongOrNull() } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/UserEntity.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/UserEntity.kt deleted file mode 100644 index 15c94820c..000000000 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/entity/UserEntity.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.persistence.entity - -import com.inso_world.binocular.model.User -import jakarta.persistence.CascadeType -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.FetchType -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.JoinColumn -import jakarta.persistence.ManyToOne -import jakarta.persistence.OneToMany -import jakarta.persistence.PreRemove -import jakarta.persistence.Table -import jakarta.persistence.UniqueConstraint -import org.hibernate.annotations.BatchSize -import java.util.Objects - -/** - * SQL-specific User entity. - */ -@Entity -@Table( - name = "users", - uniqueConstraints = [ - UniqueConstraint(columnNames = ["repository_id", "email"]), - ], -) -internal data class UserEntity( - @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE) - var id: Long? = null, - @Column(nullable = false) - var name: String? = null, - @Column(nullable = false) - var email: String? = null, - @BatchSize(size = 256) - @OneToMany(fetch = FetchType.LAZY, targetEntity = CommitEntity::class, cascade = [CascadeType.ALL]) - var committedCommits: MutableSet = mutableSetOf(), - @BatchSize(size = 256) - @OneToMany(fetch = FetchType.LAZY, targetEntity = CommitEntity::class, cascade = [CascadeType.ALL]) - var authoredCommits: MutableSet = mutableSetOf(), - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "repository_id", nullable = false, updatable = false) - var repository: RepositoryEntity? = null, -) : AbstractEntity() { - override fun uniqueKey(): String { - val repo = requireNotNull(this.repository) { "RepositoryEntity required for uniqueKey" } - return "${repo.localPath},$email" - } - - @PreRemove - fun preRemove() { -// this.repository?.user?.remove(this) -// this.repository = null - } - - fun addCommittedCommit(commit: CommitEntity) { - committedCommits.add(commit) - commit.committer = this - } - - fun addAuthoredCommit(commit: CommitEntity) { - authoredCommits.add(commit) - commit.author = this - } - - fun toDomain(): User = - User( - id = this.id?.toString(), - email = this.email, - name = this.name, - repository = null, - ) - - override fun toString(): String = super.toString() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as UserEntity - - if (id != other.id) return false - if (name != other.name) return false - if (email != other.email) return false -// if (repository?.uniqueKey() != other.repository?.uniqueKey()) return false - - return true - } - - override fun hashCode(): Int { - var result = Objects.hashCode(id) - result = 31 * result + Objects.hashCode(name) - result = 31 * result + Objects.hashCode(email) - return result - } -} - -internal fun User.toEntity(): UserEntity = - UserEntity( - id = this.id?.toLong(), - email = this.email, - name = this.name, - repository = null, - committedCommits = mutableSetOf(), - authoredCommits = mutableSetOf(), - ) diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/CommitRepository.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/CommitRepository.kt index 6f1abe394..7c27b1c75 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/CommitRepository.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/CommitRepository.kt @@ -1,16 +1,17 @@ package com.inso_world.binocular.infrastructure.sql.persistence.repository import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity -import org.springframework.data.jpa.repository.EntityGraph +import com.inso_world.binocular.model.Repository 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 org.springframework.stereotype.Repository import java.util.stream.Stream +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import org.springframework.stereotype.Repository as SpringRepository -@Repository +@SpringRepository internal interface CommitRepository : JpaRepository, JpaSpecificationExecutor { /** * Fetches only the SHAs that actually exist in the database. @@ -22,58 +23,58 @@ internal interface CommitRepository : JpaRepository, JpaSpec ): Set @Suppress("ktlint:standard:function-naming") - fun findAllByRepository_Id(repoId: Long): Stream + fun findAllByRepository_Iid(iid: Repository.Id): Stream @Query("SELECT c FROM CommitEntity c") // @EntityGraph("Commit.full") fun findAllAsStream(): Stream + @OptIn(ExperimentalUuidApi::class) @Query( """ - SELECT c FROM CommitEntity c - JOIN c.branches b - WHERE (b.name = :branch) - AND (c.repository.id = :repoId) - AND c.sha NOT IN ( - SELECT p.sha FROM CommitEntity c2 JOIN c2.parents p WHERE c2.repository.id = :repoId - ) + SELECT b.head FROM BranchEntity b WHERE (b.name = :branch) AND (b.repository.iid = :repoIid) """, ) fun findLeafCommitsByRepository( - @Param("repoId") repoId: Long, + @Param("repoIid") repoId: Uuid, @Param("branch") branch: String, ): CommitEntity? + @OptIn(ExperimentalUuidApi::class) @Query( """ - SELECT c FROM CommitEntity c - WHERE (c.repository.id = :repoId) - AND c.sha NOT IN ( - SELECT p.sha FROM CommitEntity c2 JOIN c2.parents p WHERE c2.repository.id = :repoId - ) + SELECT b.head FROM BranchEntity b WHERE (b.repository.iid = :repoIid) """, ) fun findAllLeafCommits( - @Param("repoId") repoId: Long, + @Param("repoIid") repoIid: Uuid, ): Iterable + @OptIn(ExperimentalUuidApi::class) @Suppress("ktlint:standard:function-naming") - fun findByRepository_IdAndSha( - repoId: Long, + fun findByRepository_IidAndSha( + iid: Uuid, sha: String, ): CommitEntity? - @Query(""" + @OptIn(ExperimentalUuidApi::class) + fun findByIid(iid: Uuid): CommitEntity? + + @Query( + """ select ch from CommitEntity c join c.children ch where c.sha = :sha - """) - fun findChildrenBySha(@Param("sha") sha: String): Set + """ + ) + fun findChildrenBySha(@Param("sha") sha: String): Set - @Query(""" + @Query( + """ select p from CommitEntity c join c.parents p where c.sha = :sha - """) - fun findParentsBySha(@Param("sha") sha: String): Set + """, + ) + fun findParentsBySha(@Param("sha") sha: String): Set } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/UserRepository.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/DeveloperRepository.kt similarity index 57% rename from binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/UserRepository.kt rename to binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/DeveloperRepository.kt index 33dadd2d7..f81801ebe 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/UserRepository.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/DeveloperRepository.kt @@ -1,6 +1,6 @@ package com.inso_world.binocular.infrastructure.sql.persistence.repository -import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity import jakarta.persistence.QueryHint import org.hibernate.jpa.AvailableHints import org.springframework.data.jpa.repository.JpaRepository @@ -10,7 +10,11 @@ import org.springframework.stereotype.Repository import java.util.stream.Stream @Repository -internal interface UserRepository : JpaRepository, JpaSpecificationExecutor { - fun findAllByEmailIn(emails: Collection): Stream - fun findAllByRepository_Id(id: Long): Stream +internal interface DeveloperRepository : + JpaRepository, + JpaSpecificationExecutor { + @QueryHints(QueryHint(name = AvailableHints.HINT_FETCH_SIZE, value = "256")) + fun findAllByEmailIn(emails: Collection): Stream + + fun findAllByRepository_Id(id: Long): Stream } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/ProjectRepository.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/ProjectRepository.kt index 7b15ae7f2..21bf111cd 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/ProjectRepository.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/ProjectRepository.kt @@ -1,10 +1,16 @@ package com.inso_world.binocular.infrastructure.sql.persistence.repository import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Repository internal interface ProjectRepository : JpaRepository { fun findByName(name: String): ProjectEntity? + + @OptIn(ExperimentalUuidApi::class) + fun findByIid(iid: Uuid): ProjectEntity? } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/RepositoryRepository.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/RepositoryRepository.kt index 0008f884c..b96d5aec6 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/RepositoryRepository.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/persistence/repository/RepositoryRepository.kt @@ -6,6 +6,8 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Repository internal interface RepositoryRepository : JpaRepository, JpaSpecificationExecutor { @@ -22,4 +24,8 @@ internal interface RepositoryRepository : JpaRepository, // ) @Query("select r from RepositoryEntity r where r.id = :id") fun findByIdWithAllRelations(@Param("id") id: Long): RepositoryEntity? + + + @OptIn(ExperimentalUuidApi::class) + fun findByIid(iid: Uuid): RepositoryEntity? } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/AbstractInfrastructurePort.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/AbstractInfrastructurePort.kt index 5e6007d07..a257d8f49 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/AbstractInfrastructurePort.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/AbstractInfrastructurePort.kt @@ -22,7 +22,8 @@ internal abstract class AbstractInfrastructurePort @Transactional(readOnly = true) - internal fun findById(id: I): E? = dao.findById(id) + internal fun findById(id: I): E? = throw UnsupportedOperationException("use findByIid instead of findById") +// dao.findById(id) @Transactional(readOnly = true) internal fun findAllEntities(): Iterable = this.dao.findAll() @@ -43,25 +44,25 @@ internal abstract class AbstractInfrastructurePort = projectDao.findAll() + + fun loadRepositoryEntities(projectDao: IProjectDao): List = + loadProjectEntities(projectDao).mapNotNull(ProjectEntity::repo) +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/BranchInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/BranchInfrastructurePortImpl.kt index 9524e0b30..ce0b6b8a2 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/BranchInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/BranchInfrastructurePortImpl.kt @@ -5,13 +5,16 @@ import com.inso_world.binocular.core.service.BranchInfrastructurePort import com.inso_world.binocular.infrastructure.sql.mapper.BranchMapper import com.inso_world.binocular.infrastructure.sql.mapper.ProjectMapper import com.inso_world.binocular.infrastructure.sql.mapper.RepositoryMapper -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingSession +import com.inso_world.binocular.core.persistence.mapper.context.MappingSession +import com.inso_world.binocular.infrastructure.sql.assembler.RepositoryAssembler import com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces.IBranchDao import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.File +import com.inso_world.binocular.model.Reference import com.inso_world.binocular.model.Repository import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.data.domain.Pageable @@ -25,6 +28,9 @@ internal class BranchInfrastructurePortImpl( @Autowired private val branchMapper: BranchMapper, ) : AbstractInfrastructurePort(Long::class), BranchInfrastructurePort { + @Autowired + private lateinit var repositoryAssembler: RepositoryAssembler + @Autowired private lateinit var branchDao: IBranchDao @@ -49,15 +55,15 @@ internal class BranchInfrastructurePortImpl( TODO("Not yet implemented") } - override fun update(value: Branch): Branch { + override fun findByIid(iid: Reference.Id): @Valid Branch? { TODO("Not yet implemented") } - override fun delete(value: Branch) { + override fun update(value: Branch): Branch { TODO("Not yet implemented") } - override fun updateAndFlush(value: Branch): Branch { + override fun delete(value: Branch) { TODO("Not yet implemented") } @@ -72,28 +78,21 @@ internal class BranchInfrastructurePortImpl( @MappingSession @Transactional(readOnly = true) override fun findAll(): Iterable { - val context: MutableMap = mutableMapOf() - - return super.findAllEntities().map { b -> - val repository = - context.getOrPut(b.repository?.id!!) { - val repo = b.repository ?: throw IllegalStateException("Repository of a Branch cannot be null") - val project = - projectMapper.toDomain( - repo.project, - ) - - this.repositoryMapper.toDomain(repo, project) - } - branchMapper.toDomainFull(b, repository) - } + val branches = super.findAllEntities() + + // Group branches by repository to process related branches together + return branches + .groupBy { it.repository } + .flatMap { (repoEntity, _) -> + repositoryAssembler.toDomain(repoEntity).branches + } } @MappingSession @Transactional(readOnly = true) override fun findAll(repository: Repository): Iterable = branchDao.findAll(repository).map { b -> - branchMapper.toDomainFull(b, repository) + branchMapper.toDomain(b) } override fun findAll(pageable: Pageable): Page { @@ -103,9 +102,5 @@ internal class BranchInfrastructurePortImpl( override fun deleteById(id: String) { TODO("Not yet implemented") } - - @Transactional - override fun deleteAll() { - super.deleteAllEntities() - } + } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/CommitInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/CommitInfrastructurePortImpl.kt index e0ff0264d..00748aa79 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/CommitInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/CommitInfrastructurePortImpl.kt @@ -2,31 +2,31 @@ package com.inso_world.binocular.infrastructure.sql.service import com.inso_world.binocular.core.exception.BinocularInfrastructureException import com.inso_world.binocular.core.persistence.exception.PersistenceException +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.core.persistence.mapper.context.MappingSession import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.core.service.CommitInfrastructurePort import com.inso_world.binocular.core.service.exception.NotFoundException -import com.inso_world.binocular.infrastructure.sql.mapper.BranchMapper +import com.inso_world.binocular.infrastructure.sql.assembler.RepositoryAssembler import com.inso_world.binocular.infrastructure.sql.mapper.CommitMapper +import com.inso_world.binocular.infrastructure.sql.mapper.DeveloperMapper import com.inso_world.binocular.infrastructure.sql.mapper.ProjectMapper import com.inso_world.binocular.infrastructure.sql.mapper.RepositoryMapper -import com.inso_world.binocular.infrastructure.sql.mapper.UserMapper -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingContext -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingSession -import com.inso_world.binocular.infrastructure.sql.persistence.dao.BranchDao import com.inso_world.binocular.infrastructure.sql.persistence.dao.CommitDao +import com.inso_world.binocular.infrastructure.sql.persistence.dao.DeveloperDao import com.inso_world.binocular.infrastructure.sql.persistence.dao.RepositoryDao -import com.inso_world.binocular.infrastructure.sql.persistence.dao.UserDao -import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity import com.inso_world.binocular.model.Build import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer import com.inso_world.binocular.model.File import com.inso_world.binocular.model.Issue import com.inso_world.binocular.model.Module import com.inso_world.binocular.model.Repository import com.inso_world.binocular.model.User import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -36,386 +36,225 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.validation.annotation.Validated import java.util.stream.Collectors +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Service @Validated +@Deprecated("Use domain aggregate `Repository`") internal class CommitInfrastructurePortImpl +@Autowired constructor( + private val commitMapper: CommitMapper, + private val commitDao: CommitDao, + @Lazy private val developerDao: DeveloperDao, + @Lazy private val repositoryDao: RepositoryDao, +) : AbstractInfrastructurePort(Long::class), CommitInfrastructurePort { @Autowired - constructor( - private val commitMapper: CommitMapper, - private val commitDao: CommitDao, - @Lazy private val branchDao: BranchDao, - @Lazy private val userDao: UserDao, - @Lazy private val repositoryDao: RepositoryDao, - ) : AbstractInfrastructurePort(Long::class), - CommitInfrastructurePort { - var logger: Logger = LoggerFactory.getLogger(CommitInfrastructurePort::class.java) - - @Autowired - @Lazy - private lateinit var projectMapper: ProjectMapper - - @Autowired - @Lazy - private lateinit var branchMapper: BranchMapper - - @Autowired - @Lazy - private lateinit var repositoryMapper: RepositoryMapper - - @Autowired - private lateinit var ctx: MappingContext - - @Autowired - @Lazy - private lateinit var userMapper: UserMapper - - @PostConstruct - fun init() { - super.dao = commitDao - } + private lateinit var repositoryAssembler: RepositoryAssembler + var logger: Logger = LoggerFactory.getLogger(CommitInfrastructurePort::class.java) + + @Autowired + @Lazy + private lateinit var projectMapper: ProjectMapper + + @Autowired + @Lazy + private lateinit var repositoryMapper: RepositoryMapper + + @Autowired + private lateinit var ctx: MappingContext - @MappingSession - @Transactional - override fun create(value: Commit): Commit { - val mapped = - run { - val repositoryId = - value.repository?.id?.toLong() - ?: throw IllegalArgumentException("repository.id of Commit must not be null") - val repository = - repositoryDao.findById(repositoryId) - ?: throw NotFoundException("Repository id=${value.repository?.id} not found") - -// TODO N+1 here - ctx.entity.commit.putAll(repository.commits.associateBy(CommitEntity::uniqueKey)) - ctx.entity.branch.putAll(repository.branches.associateBy(BranchEntity::uniqueKey)) - ctx.entity.user.putAll(repository.user.associateBy(UserEntity::uniqueKey)) - - val mappedCommit = commitMapper.toEntityFull(value, repository) - return@run mappedCommit - } - return this.commitDao - .create(mapped) - .also { commitDao.flush() } - .let { commitEntity -> - val project = - projectMapper.toDomain( - mapped.repository?.project - ?: throw IllegalStateException("Project disappeared after creating commit"), - ) - - val repoModel = - project.repo ?: throw IllegalStateException("Repository disappeared after creating commit") - repoModel.commits.find { it.sha == commitEntity.sha } - ?: throw IllegalStateException("Commit disappeared from repository after creating") - } + @Autowired + @Lazy + private lateinit var developerMapper: DeveloperMapper + + @PostConstruct + fun init() { + super.dao = commitDao + } + + @MappingSession + @Transactional + override fun create(value: Commit): Commit { + val repositoryEntity = repositoryDao.findByIid(value.repository.iid) + ?: throw NotFoundException("Repository ${value.repository.iid} not found") + + ctx.remember(value.repository, repositoryEntity) + val mapped = commitMapper.toEntity(value) + return this.commitDao.create(mapped).let { commitEntity -> + commitMapper.refreshDomain(value, commitEntity) } + } + + @MappingSession + @Transactional(readOnly = true) + override fun findAll(): Iterable { + val commits = this.commitDao.findAll() - @MappingSession - @Transactional(readOnly = true) - override fun findAll(): Iterable { - val values = this.commitDao.findAll() - return values - .associateBy { it.repository } - .flatMap { (k, _) -> - if (k == null) throw IllegalStateException("Cannot map project without repository") - val project = - projectMapper.toDomain( - k.project, - ) - return@flatMap project.repo?.commits ?: emptySet() - } + // Group commits by repository to process related commits together + return commits.groupBy { it.repository }.flatMap { (repoEntity, _) -> + repositoryAssembler.toDomain(repoEntity).commits } + } + + override fun findAll(pageable: Pageable): Page { + TODO("Not yet implemented") + } - override fun findAll(pageable: Pageable): Page { - TODO("Not yet implemented") + @OptIn(ExperimentalUuidApi::class) + @MappingSession + @Transactional(readOnly = true) + override fun findById(id: String): Commit? = this.commitDao.findByIid(Commit.Id(Uuid.parse(id)))?.let { + val repository = it.repository.let { r -> + val project = projectMapper.toDomain( + r.project, + ) + + repositoryMapper.toDomain(r) } - @MappingSession - @Transactional(readOnly = true) - override fun findById(id: String): Commit? = - this.commitDao.findById(id.toLong())?.let { - val repository = - it.repository?.let { r -> - val project = - projectMapper.toDomain( - r.project, - ) - - repositoryMapper.toDomain(r, project) - } - if (repository == null) { - throw IllegalStateException("Repository cannot be null when finding commit by ID") - } - - commitMapper.toDomain(it).also { c -> repository.commits.add(c) } - } + commitMapper.toDomain(it) + } - @MappingSession - @Transactional - override fun update(value: Commit): Commit { - val repositoryId = - value.repository?.id?.toLong() ?: throw IllegalArgumentException("projectId of Repository must not be null") - val repository = - repositoryDao.findById(repositoryId) - ?: throw NotFoundException("Repository ${value.repository?.id} not found") + override fun findByIid(iid: Commit.Id): @Valid Commit? { + TODO("Not yet implemented") + } + + @MappingSession + @Transactional + override fun update(value: Commit): Commit { + val repositoryEntity = repositoryDao.findByIid(value.repository.iid) + ?: throw NotFoundException("Repository ${value.repository} not found") + + ctx.remember(value.repository, repositoryEntity) - val entity = - this.commitDao.findBySha(repository, value.sha) - ?: throw NotFoundException("Commit ${value.sha} not found, required for update") + val entity = this.commitDao.findBySha(repositoryEntity, value.sha) + ?: throw NotFoundException("Commit ${value.sha} not found, required for update") // entity.authorDateTime = value.authorDateTime // entity.commitDateTime = value.commitDateTime - entity.message = value.message - entity.webUrl = value.webUrl - - repository.branches.size // Force initialization - -// TODO N+1 here - ctx.entity.commit.putAll(repository.commits.associateBy(CommitEntity::uniqueKey)) - ctx.entity.branch.putAll(repository.branches.associateBy(BranchEntity::uniqueKey)) - ctx.entity.user.putAll(repository.user.associateBy(UserEntity::uniqueKey)) - - entity.branches.addAll( - value.branches.map { - val existing = - ctx.entity.branch["${repository.localPath},${it.name}"] - if (existing == null) { -// create new branch if not exists - val newBranch = - branchMapper - .toEntity( - it, - ).also { branch -> - repository.addBranch(branch) - } - return@map branchDao.create(newBranch) - } - return@map existing - }, - ) - entity.committer = - value.committer?.let { user -> - val existing = ctx.entity.user["${repository.localPath},${user.name}"] - if (existing == null) { - val newUser = - userMapper - .toEntity( - user, - ).also { - repository.addUser(it) - } - return@let userDao.create(newUser) - } - return@let existing - } - entity.author = - value.author?.let { user -> - val existing = ctx.entity.user["${repository.localPath},${user.name}"] - if (existing == null) { - val newUser = - userMapper - .toEntity( - user, - ).also { - repository.addUser(it) - } - return@let userDao.create(newUser) - } - return@let existing - } - - entity.branches.removeAll { !value.branches.map { b -> b.name }.contains(it.name) } - repository.branches.removeAll { !value.branches.map { b -> b.name }.contains(it.name) } - - return this.commitDao - .update(entity) - .also { commitDao.flush() } - .let { - val project = - projectMapper.toDomain( - repository.project, - ) - - val repository = - repositoryMapper.toDomain(repository, project) - ctx.domain.commit.putAll(repository.commits.associateBy { c -> c.sha }) - ctx.domain.branch.putAll( - repository.branches.associateBy { b -> "${repository.localPath},${b.name}" }, - ) - ctx.domain.user.putAll(repository.user.associateBy { u -> "${repository.localPath},${u.name}" }) -// commitMapper.toDomain(it).also { c -> repository.addCommit(c) } - commitMapper.toDomainFull(it, repository) - } + entity.apply { + this.message = value.message + this.webUrl = value.webUrl + this.authorDateTime = value.authorSignature.timestamp + this.commitDateTime = (value.committerSignature ?: value.authorSignature).timestamp + this.committer = resolveDeveloperEntity(value.committer) + this.author = resolveDeveloperEntity(value.author) } - // @MappingSession - override fun updateAndFlush(value: Commit): Commit { - val updated = update(value) - this.commitDao.flush() - return updated + return this.commitDao.update(entity).let { + commitMapper.refreshDomain(value, it) } + } - override fun saveAll(values: Collection): Iterable { - TODO("Not yet implemented") - } + private fun resolveDeveloperEntity(developer: Developer): DeveloperEntity { + ctx.findEntity(developer)?.let { return it } - override fun delete(value: Commit) { - val repositoryId = - value.repository?.id?.toLong() ?: throw IllegalArgumentException("projectId of Repository must not be null") - val repository = - repositoryDao.findById(repositoryId) - ?: throw NotFoundException("Repository ${value.repository?.id} not found") - - val mapped = - commitMapper.toEntity(value).also { - repository.addCommit(it) - } - this.commitDao.delete(mapped) + developer.id?.trim()?.toLongOrNull()?.let { existingId -> + developerDao.findById(existingId)?.let { found -> + ctx.remember(developer, found) + return found + } } - override fun deleteById(id: String) { - TODO("Not yet implemented") - } + val entity = developerMapper.toEntity(developer) + return developerDao.create(entity) + } - override fun deleteAll() { - this.commitDao.deleteAll() - } + override fun saveAll(values: Collection): Iterable { + TODO("Not yet implemented") + } - override fun findAll( - pageable: Pageable, - since: Long?, - until: Long?, - ): Page { - TODO("Not yet implemented") - } + override fun findAll( + pageable: Pageable, + since: Long?, + until: Long?, + ): Page { + TODO("Not yet implemented") + } - override fun findBuildsByCommitId(commitId: String): List { - TODO("Not yet implemented") - } + override fun findBuildsByCommitId(commitId: String): List { + TODO("Not yet implemented") + } - override fun findFilesByCommitId(commitId: String): List { - TODO("Not yet implemented") - } + override fun findFilesByCommitId(commitId: String): List { + TODO("Not yet implemented") + } - override fun findModulesByCommitId(commitId: String): List { - TODO("Not yet implemented") - } + override fun findModulesByCommitId(commitId: String): List { + TODO("Not yet implemented") + } - override fun findUsersByCommitId(commitId: String): List { - TODO("Not yet implemented") - } + override fun findUsersByCommitId(commitId: String): List = emptyList() - override fun findIssuesByCommitId(commitId: String): List { - TODO("Not yet implemented") - } + override fun findIssuesByCommitId(commitId: String): List { + TODO("Not yet implemented") + } - override fun findParentCommitsByChildCommitId(childCommitId: String): List { - TODO("Not yet implemented") - } + override fun findParentCommitsByChildCommitId(childCommitId: String): List { + TODO("Not yet implemented") + } - override fun findChildCommitsByParentCommitId(parentCommitId: String): List { - TODO("Not yet implemented") - } + override fun findChildCommitsByParentCommitId(parentCommitId: String): List { + TODO("Not yet implemented") + } - @Transactional(readOnly = true) - @MappingSession - override fun findExistingSha( - repo: Repository, - shas: List, - ): Iterable { - val repoEntity = - repositoryDao.findByName(repo.localPath) ?: throw NotFoundException("Repository ${repo.localPath} not found") - - val project = - projectMapper.toDomain( - repoEntity.project, - ) - - val repoModel = repositoryMapper.toDomain(repoEntity, project) - - return this.commitDao - .findExistingSha(repo, shas) - .map { - this.commitMapper.toDomain(it).also { c -> repoModel.commits.add(c) } - } + @Transactional(readOnly = true) + @MappingSession + override fun findExistingSha( + repo: Repository, + shas: List, + ): Iterable { + return this.commitDao.findExistingSha(repo, shas).map { + this.commitMapper.toDomain(it) } + } - @MappingSession - @Transactional(readOnly = true) - override fun findAll( - repo: Repository, - pageable: Pageable, - ): Iterable { - val repoEntity = - repositoryDao.findByName(repo.localPath) ?: throw NotFoundException("Repository ${repo.localPath} not found") - - val project = - projectMapper.toDomain( - repoEntity.project, - ) - - val repoModel = repositoryMapper.toDomain(repoEntity, project) - return try { - this.commitDao - .findAll( - repo, -// pageable, - ).map { this.commitMapper.toDomain(it).also { c -> repoModel.commits.add(c) } } - .collect(Collectors.toSet()) - } catch (e: PersistenceException) { - throw BinocularInfrastructureException(e) - } + @MappingSession + @Transactional(readOnly = true) + override fun findAll( + repo: Repository, + pageable: Pageable, + ): Iterable { + return try { + this.repositoryDao.findByIid(repo.iid)?.let { + ctx.remember(repo, it) + it.commits + }?.map { this.commitMapper.toDomain(it) } ?: emptyList() + } catch (e: PersistenceException) { + throw BinocularInfrastructureException(e) } + } - @MappingSession - @Transactional(readOnly = true) - override fun findAll(repository: Repository): Iterable = - this.commitDao - .findAll(repository) - .collect(Collectors.toSet()) - .let { - return@let this.commitMapper.toDomainFull(it, repository) - } - - @MappingSession - @Transactional(readOnly = true) - override fun findHeadForBranch( - repo: Repository, - branch: String, - ): Commit? { - val repoEntity = - repositoryDao.findByName(repo.localPath) ?: throw NotFoundException("Repository ${repo.localPath} not found") - - val project = - projectMapper.toDomain( - repoEntity.project, - ) - - val repoModel = repositoryMapper.toDomain(repoEntity, project) - return this.commitDao - .findHeadForBranch( - repo, - branch, - )?.let { this.commitMapper.toDomain(it).also { c -> repoModel.commits.add(c) } } + @MappingSession + @Transactional(readOnly = true) + override fun findAll(repository: Repository): Iterable = + this.commitDao.findAll(repository).collect(Collectors.toSet()).map { + return@map this.commitMapper.toDomain(it) } - @MappingSession - @Transactional(readOnly = true) - override fun findAllLeafCommits(repo: Repository): Iterable { - val repoEntity = - repositoryDao.findByName(repo.localPath) ?: throw NotFoundException("Repository ${repo.localPath} not found") - - val project = - projectMapper.toDomain( - repoEntity.project, - ) - - val repoModel = repositoryMapper.toDomain(repoEntity, project) - return this.commitDao - .findAllLeafCommits( - repo, - ).map { this.commitMapper.toDomain(it).also { c -> repoModel.commits.add(c) } } - } + @MappingSession + @Transactional(readOnly = true) + override fun findHeadForBranch( + repo: Repository, + branch: String, + ): Commit? { + return this.repositoryDao.findByIid( + repo.iid + )?.let { + ctx.remember(repo, it) + it + }?.let { + this.commitDao.findHeadForBranch(it, branch) + }?.let { head -> return@let repo.commits.associateBy(Commit::iid).getValue(head.iid) } + } + + @MappingSession + @Transactional(readOnly = true) + override fun findAllLeafCommits(repo: Repository): Iterable { + return this.repositoryDao.findByIid(repo.iid)?.let { + ctx.remember(repo, it) + this.commitDao.findAllLeafCommits(it) + }?.map { this.commitMapper.toDomain(it) } ?: emptyList() } +} diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/IssueInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/IssueInfrastructurePortImpl.kt index 950b05e03..750addd3a 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/IssueInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/IssueInfrastructurePortImpl.kt @@ -9,6 +9,7 @@ import com.inso_world.binocular.model.Issue import com.inso_world.binocular.model.Milestone import com.inso_world.binocular.model.Note import com.inso_world.binocular.model.User +import jakarta.validation.Valid import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.validation.annotation.Validated @@ -42,15 +43,11 @@ internal class IssueInfrastructurePortImpl : TODO("Not yet implemented") } - override fun update(value: Issue): Issue { - TODO("Not yet implemented") - } - - override fun delete(value: Issue) { + override fun findByIid(iid: Issue.Id): @Valid Issue? { TODO("Not yet implemented") } - override fun updateAndFlush(value: Issue): Issue { + override fun update(value: Issue): Issue { TODO("Not yet implemented") } @@ -69,12 +66,4 @@ internal class IssueInfrastructurePortImpl : override fun findAll(pageable: Pageable): Page { TODO("Not yet implemented") } - - override fun deleteById(id: String) { - TODO("Not yet implemented") - } - - override fun deleteAll() { - TODO("Not yet implemented") - } } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/MergeRequestInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/MergeRequestInfrastructurePortImpl.kt index 0a9d156f5..8b7f5ef75 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/MergeRequestInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/MergeRequestInfrastructurePortImpl.kt @@ -7,6 +7,7 @@ import com.inso_world.binocular.model.Account import com.inso_world.binocular.model.MergeRequest import com.inso_world.binocular.model.Milestone import com.inso_world.binocular.model.Note +import jakarta.validation.Valid import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.validation.annotation.Validated @@ -20,14 +21,6 @@ internal class MergeRequestInfrastructurePortImpl : TODO("Not yet implemented") } - override fun delete(value: MergeRequest) { - TODO("Not yet implemented") - } - - override fun updateAndFlush(value: MergeRequest): MergeRequest { - TODO("Not yet implemented") - } - override fun create(value: MergeRequest): MergeRequest { TODO("Not yet implemented") } @@ -52,19 +45,19 @@ internal class MergeRequestInfrastructurePortImpl : TODO("Not yet implemented") } - override fun findAll(): Iterable { + override fun findByIid(iid: MergeRequest.Id): @Valid MergeRequest? { TODO("Not yet implemented") } - override fun findAll(pageable: Pageable): Page { + override fun findAll(): Iterable { TODO("Not yet implemented") } - override fun deleteById(id: String) { + override fun findAll(pageable: Pageable): Page { TODO("Not yet implemented") } - override fun deleteAll() { + override fun deleteById(id: String) { TODO("Not yet implemented") } } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/ProjectInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/ProjectInfrastructurePortImpl.kt index 514ee723e..3f8b1d544 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/ProjectInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/ProjectInfrastructurePortImpl.kt @@ -1,13 +1,15 @@ package com.inso_world.binocular.infrastructure.sql.service import com.inso_world.binocular.core.persistence.exception.NotFoundException +import com.inso_world.binocular.core.persistence.mapper.context.MappingSession import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.core.service.ProjectInfrastructurePort +import com.inso_world.binocular.infrastructure.sql.assembler.ProjectAssembler +import com.inso_world.binocular.infrastructure.sql.assembler.RepositoryAssembler import com.inso_world.binocular.infrastructure.sql.mapper.ProjectMapper -import com.inso_world.binocular.infrastructure.sql.mapper.RepositoryMapper -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingSession import com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces.IProjectDao import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.sql.service.AggregateFetchSupport.loadProjectEntities import com.inso_world.binocular.model.Project import jakarta.annotation.PostConstruct import org.springframework.beans.factory.annotation.Autowired @@ -16,6 +18,7 @@ import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.validation.annotation.Validated +import kotlin.uuid.ExperimentalUuidApi @Service @Validated @@ -26,38 +29,103 @@ internal class ProjectInfrastructurePortImpl( ProjectInfrastructurePort { @Lazy @Autowired - private lateinit var repositoryMapper: RepositoryMapper + private lateinit var projectAssembler: ProjectAssembler @Lazy @Autowired private lateinit var repositoryPort: RepositoryInfrastructurePortImpl + @Lazy + @Autowired + private lateinit var repositoryAssembler: RepositoryAssembler + + /** + * Self-reference to this bean's proxy instance. + * + * **Workaround for Spring AOP + Kotlin Value Class Issue** + * + * This self-injection is required to work around a limitation where Spring AOP's aspect pointcut + * matching fails for methods with Kotlin value class parameters (inline classes) that require + * name mangling via `@JvmName`. + * + * **Problem**: When a method like `findByIid(iid: Project.Id)` overrides an interface method and + * uses a value class parameter, Kotlin mangles the JVM method name (e.g., `findByIid-pip`). + * Spring AOP's `@annotation` pointcut cannot properly match `@MappingSession` on mangled methods, + * causing the `MappingSessionAspect` to not be triggered. + * + * **Solution**: Internal method calls bypass Spring's proxy. By injecting `self` and calling + * `self.findByIidInternal()`, we ensure the call goes through the Spring AOP proxy, allowing + * the aspect to intercept and establish the required mapping session scope. + * + * @see findByIid + * @see findByIidInternal + */ + @Autowired + @Lazy + private lateinit var self: ProjectInfrastructurePortImpl + @PostConstruct fun init() { super.dao = projectDao -// super.mapper = projectMapper } @MappingSession @Transactional(readOnly = true) override fun findByName(name: String): Project? = this.projectDao.findByName(name)?.let { - this.projectMapper.toDomain(it) + this.projectAssembler.toDomain(it) } - @Transactional - override fun delete(value: Project) { - val managedEntity = - this.projectDao.findByName(value.name) ?: throw NotFoundException("Project ${value.name} not found") + /** + * Finds a project by its internal identifier (iid). + * + * **Implementation Note - Value Class Workaround**: + * This method delegates to [findByIidInternal] via [self] (the proxy instance) to ensure + * Spring AOP aspects are triggered. Direct implementation here would bypass the proxy due to + * Kotlin's value class name mangling preventing proper aspect pointcut matching. + * + * @param iid The project's technical identifier + * @return The project if found, null otherwise + * @see self + * @see findByIidInternal + */ + override fun findByIid(iid: Project.Id): Project? { + return self.findByIidInternal(iid) + } - this.projectDao.delete(managedEntity) + /** + * Internal implementation of project lookup by iid. + * + * **Why this method exists**: + * This separate method is required because Spring AOP cannot intercept methods with + * mangled signatures (caused by Kotlin value class parameters). By extracting + * the logic here with a normal method name, Spring AOP can properly intercept the call when + * invoked via [self], establishing the `@MappingSession` scope needed by [projectAssembler]. + * + * **Visibility**: Must not be `private` to allow Spring CGLIB to create + * a proxy subclass that can override this method for aspect interception. + * + * @param iid The project's technical identifier + * @return The project if found, null otherwise + * @see findByIid + * @see MappingSession + */ + @MappingSession + @Transactional(readOnly = true) + protected fun findByIidInternal(iid: Project.Id): Project? { + return this.projectDao.findByIid(iid)?.let { + projectAssembler.toDomain(it) + } } @MappingSession @Transactional override fun update(value: Project): Project { val managedEntity = - this.projectDao.findByName(value.name) ?: throw NotFoundException("Project ${value.name} not found") + this.projectDao.findByIid(value.iid) ?: throw NotFoundException("Project ${value.iid} not found") + + // update project properties + managedEntity.description = value.description run { val domainRepo = value.repo @@ -68,7 +136,7 @@ internal class ProjectInfrastructurePortImpl( domainRepo != null && entityRepo != null -> { if (domainRepo.localPath != entityRepo.localPath) { throw IllegalArgumentException( - "Cannot update project with a different repository. Project '${managedEntity.uniqueKey()}' already has repository '${entityRepo.localPath}'", + "Cannot update project with a different repository. Project '${managedEntity.uniqueKey}' already has repository '${entityRepo.localPath}'", ) } // Don't create a new entity, just update the existing one's fields @@ -78,89 +146,83 @@ internal class ProjectInfrastructurePortImpl( // Case 2: Adding a new repo where none existed domainRepo != null && entityRepo == null -> { - managedEntity.repo = - repositoryMapper.toEntity( - domain = domainRepo, - project = managedEntity, - ) + managedEntity.repo = repositoryAssembler.toEntity(domainRepo) } // Case 3: Removing existing repo -// domainRepo == null && entityRepo != null -> { -// managedEntity.repo = null -// } + domainRepo == null && entityRepo != null -> { + throw UnsupportedOperationException("Deleting repository from project is not yet allowed") + } // Case 4: No repo in either - nothing to do else -> {} } } - return super.updateEntity(managedEntity).let { - projectDao.flush() - this.projectMapper.toDomain(it) - } + val updated = super.updateEntity(managedEntity) + + // Refresh the input domain object with persisted values and return it + this.projectMapper.refreshDomain(value, updated) + return value } @MappingSession @Transactional - override fun updateAndFlush(value: Project): Project { - val managedEntity = - this.projectDao.findByName(value.name) ?: throw NotFoundException("Project ${value.name} not found") + override fun create(value: Project): Project { + ensureProjectUniqueKeyAvailable(value) + val toPersist = this.projectAssembler.toEntity(value) + val persisted = super.create(toPersist) - return super.updateAndFlush(managedEntity).let { - this.projectMapper.toDomain(it) - } + this.projectAssembler.refresh(value, persisted) + return value } - @MappingSession - @Transactional - override fun create(value: Project): Project = - super - .create( - this.projectMapper.toEntity(value), - ).let { - this.projectDao.flush() - this.projectMapper.toDomain(it) - } - @MappingSession @Transactional override fun saveAll(values: Collection): Iterable { - return values.map { this.create(it) } -// return values.map { -// this.projectMapper.toEntity(it) -// }.let { entities -> -// return@let super.saveAll(entities) -// }.map { -// this.projectMapper.toDomain(it) -// } + // Create entity-domain pairs to maintain association + val pairs = values.map { domain -> domain to this.projectAssembler.toEntity(domain) } + + // Save all entities + val savedEntities = this.projectDao.saveAll(pairs.map { it.second }) + + // Refresh each domain object with its persisted entity and return the original collection + pairs.zip(savedEntities).forEach { (pair, savedEntity) -> + this.projectMapper.refreshDomain(pair.first, savedEntity) + } + + return values } @MappingSession @Transactional(readOnly = true) override fun findAll(): Iterable = - super.findAllEntities().map { - this.projectMapper.toDomain(it) - } + loadProjectEntities(projectDao).map(projectAssembler::toDomain) + @MappingSession + @Transactional(readOnly = true) override fun findAll(pageable: Pageable): Page { - TODO("Not yet implemented") + val page = this.projectDao.findAll(pageable) + val projects = page.content.map { this.projectAssembler.toDomain(it) } + + return Page( + content = projects, + totalElements = page.totalElements, + pageable = pageable + ) } + @OptIn(ExperimentalUuidApi::class) @MappingSession @Transactional(readOnly = true) - override fun findById(id: String): Project? = - super.findById(id.toLong())?.let { - this.projectMapper.toDomain(it) - } - - @Transactional - override fun deleteById(id: String) { - super.deleteByEntityId(id) + override fun findById(id: String): Project? { + TODO() } - @Transactional - override fun deleteAll() { - super.deleteAllEntities() + private fun ensureProjectUniqueKeyAvailable(project: Project) { + val candidate = project.uniqueKey + projectDao.findByName(candidate.name)?.let { + throw IllegalArgumentException("Project with unique key '${candidate.name}' already exists") + } } } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/RepositoryInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/RepositoryInfrastructurePortImpl.kt index 77194ac6a..df01f3f6b 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/RepositoryInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/RepositoryInfrastructurePortImpl.kt @@ -2,27 +2,25 @@ package com.inso_world.binocular.infrastructure.sql.service import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.persistence.exception.NotFoundException +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.core.persistence.mapper.context.MappingSession import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.core.service.RepositoryInfrastructurePort -import com.inso_world.binocular.infrastructure.sql.mapper.BranchMapper +import com.inso_world.binocular.infrastructure.sql.assembler.RepositoryAssembler import com.inso_world.binocular.infrastructure.sql.mapper.CommitMapper -import com.inso_world.binocular.infrastructure.sql.mapper.ProjectMapper import com.inso_world.binocular.infrastructure.sql.mapper.RepositoryMapper -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingContext -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingSession +import com.inso_world.binocular.infrastructure.sql.persistence.dao.BranchDao import com.inso_world.binocular.infrastructure.sql.persistence.dao.CommitDao import com.inso_world.binocular.infrastructure.sql.persistence.dao.ProjectDao import com.inso_world.binocular.infrastructure.sql.persistence.dao.RepositoryDao -import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity -import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit import com.inso_world.binocular.model.Repository import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy -import org.springframework.dao.DataIntegrityViolationException import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -37,24 +35,46 @@ internal class RepositoryInfrastructurePortImpl : private val logger by logger() } + /** + * Self-reference to this bean's proxy instance. + * + * **Workaround for Spring AOP + Kotlin Value Class Issue** + * + * This self-injection is required to work around a limitation where Spring AOP's aspect pointcut + * matching fails for methods with Kotlin value class parameters (inline classes) that require + * name mangling via `@JvmName`. + * + * **Problem**: When a method like `findByIid(iid: Repository.Id)` overrides an interface method and + * uses a value class parameter, Kotlin mangles the JVM method name (e.g., `findByIid-pip`). + * Spring AOP's `@annotation` pointcut cannot properly match `@MappingSession` on mangled methods, + * causing the `MappingSessionAspect` to not be triggered. + * + * **Solution**: Internal method calls bypass Spring's proxy. By injecting `self` and calling + * `self.findByIidInternal()`, we ensure the call goes through the Spring AOP proxy, allowing + * the aspect to intercept and establish the required mapping session scope. + * + * @see findByIid + * @see findByIidInternal + */ + @Autowired + @Lazy + private lateinit var self: RepositoryInfrastructurePortImpl @Autowired - lateinit var repositoryMapper: RepositoryMapper + private lateinit var ctx: MappingContext @Autowired - @Lazy - lateinit var commitMapper: CommitMapper + private lateinit var branchDao: BranchDao @Autowired @Lazy - lateinit var projectMapper: ProjectMapper + lateinit var commitMapper: CommitMapper @Autowired - @Lazy - private lateinit var branchMapper: BranchMapper + private lateinit var repositoryAssembler: RepositoryAssembler @Autowired - private lateinit var ctx: MappingContext + private lateinit var repositoryMapper: RepositoryMapper @Autowired private lateinit var repositoryDao: RepositoryDao @@ -66,215 +86,195 @@ internal class RepositoryInfrastructurePortImpl : @Autowired private lateinit var projectDao: ProjectDao - @PostConstruct fun init() { super.dao = repositoryDao -// super.mapper = repositoryMapper } @MappingSession @Transactional(readOnly = true) override fun findByName(name: String): Repository? = this.repositoryDao.findByName(name)?.let { - val project = - projectMapper.toDomain( - it.project, - ) - - this.repositoryMapper.toDomain(it, project) + this.repositoryAssembler.toDomain(it) } @MappingSession @Transactional(readOnly = true) override fun findAll(): Iterable { - val projectContext = mutableMapOf() - - return findAllEntities().map { - val project = - projectContext.getOrPut(it.project.uniqueKey()) { - projectMapper.toDomain( - it.project, - ) - } - - this.repositoryMapper.toDomain(it, project) - } + return this.repositoryDao.findAll().map(repositoryAssembler::toDomain) } + @MappingSession + @Transactional(readOnly = true) override fun findAll(pageable: Pageable): Page { - TODO("Not yet implemented") -// return this.projectDao.findAll(pageable).map { -// this.projectMapper.toDomain(it) -// } + val page = this.repositoryDao.findAll(pageable) + val repositories = page.content.map { this.repositoryAssembler.toDomain(it) } + + return Page( + content = repositories, + totalElements = page.totalElements, + pageable = pageable + ) } + /** + * Finds a repository by its internal identifier (iid). + * + * **Implementation Note - Value Class Workaround**: + * This method delegates to [findByIidInternal] via [self] (the proxy instance) to ensure + * Spring AOP aspects are triggered. Direct implementation here would bypass the proxy due to + * Kotlin's value class name mangling preventing proper aspect pointcut matching. + * + * @param iid The repository's technical identifier + * @return The repository if found, null otherwise + * @see self + * @see findByIidInternal + */ + override fun findByIid(iid: Repository.Id): Repository? { + return self.findByIidInternal(iid) + } + + /** + * Internal implementation of repository lookup by iid. + * + * **Why this method exists**: + * This separate method is required because Spring AOP cannot intercept methods with + * mangled signatures (caused by Kotlin value class parameters). By extracting + * the logic here with a normal method name, Spring AOP can properly intercept the call when + * invoked via [self], establishing the `@MappingSession` scope needed by [repositoryAssembler]. + * + * **Visibility**: Must not be `private` to allow Spring CGLIB to create + * a proxy subclass that can override this method for aspect interception. + * + * @param iid The repository's technical identifier + * @return The repository if found, null otherwise + * @see findByIid + * @see MappingSession + */ @MappingSession @Transactional(readOnly = true) - override fun findById(id: String): Repository? = - this.repositoryDao.findById(id.toLong())?.let { - val project = - projectMapper.toDomain( - it.project, - ) - - this.repositoryMapper.toDomain(it, project) + protected fun findByIidInternal(iid: Repository.Id): Repository? { + return this.repositoryDao.findByIid(iid)?.let { + repositoryAssembler.toDomain(it) } + } @MappingSession @Transactional - override fun create(value: Repository): Repository { - val projectId = - value.project?.id?.toLong() ?: throw IllegalArgumentException("project.id of Repository must not be null") - val project = projectDao.findById(projectId) ?: throw NotFoundException("Project ${value.project} not found") + override fun create(@Valid value: Repository): Repository { + val projectEntity = + projectDao.findByIid(value.project.iid) ?: throw NotFoundException("Project ${value.project} not found") - if (project.repo != null) { - throw IllegalArgumentException("Selected project $project has already a Repository set") + if (projectEntity.repo != null) { + throw IllegalArgumentException("Selected project $projectEntity has already a Repository set") } - val mapped = - this.repositoryMapper.toEntity( - value, - project, - ) -// val newEntity = - return try { - val newEntity = super.create(mapped) - repositoryDao.flush() - newEntity - } catch (e: DataIntegrityViolationException) { - entityManager.flush() - entityManager.clear() - logger.error(e.message) - throw e - }.let { newEntity -> - val project = - projectMapper.toDomain( - newEntity.project, - ) - - return@let repositoryMapper.toDomain(newEntity, project) - } + ctx.remember(value.project, projectEntity) + + val toPersist = this.repositoryAssembler.toEntity(value) + val persisted = super.create(toPersist) + + // Refresh the input domain object with persisted values and return it + this.repositoryMapper.refreshDomain(value, persisted) + return value } + @MappingSession @Transactional - override fun update(value: Repository): Repository { + override fun update(@Valid value: Repository): Repository { val entity = - run { - value.id?.let { - repositoryDao.findById(it.toLong()) - } ?: run { - val project = - value.project?.id?.let { id -> - this.projectDao.findById(id.toLong()) - } ?: throw NotFoundException("Project ${value.project} not found") - - this.repositoryMapper.toEntity(value, project) - } - } - - logger.debug("Repository Entity found") - - run { - // Synchronize commits: remove those not in value.commits - val valueCommitShas = value.commits.map { it.sha }.toSet() - entity.commits.removeIf { it.sha !in valueCommitShas } - } - logger.trace("Commits synchronized") - run { - // Synchronize branches: remove those not in value.branches - val valueBranchKeys = value.branches.map { "${entity.localPath},${it.name}" }.toSet() - entity.branches.removeIf { it.uniqueKey() !in valueBranchKeys } - } - logger.trace("Branches synchronized") - run { - // Synchronize user: remove those not in value.user - val valueUserKeys = value.user.map { it.uniqueKey() }.toSet() - entity.user.removeIf { it.uniqueKey() !in valueUserKeys } + projectDao.findByIid(value.project.iid) + ?: throw NotFoundException("Project ${value.project.uniqueKey} not found") + logger.debug("Project Entity found") + ctx.remember(value.project, entity) + + val mapped = repositoryAssembler.toEntity(value) + + // Phase 1: Save commits first (without branches and without parent/child relationships) + // This ensures commits have database IDs before branches reference them + val branches = mapped.branches.toMutableSet() + mapped.branches.clear() + + // Store and clear parent/child relationships + val commitParentMap = mapped.commits.associateWith { it.parents.toSet() } + mapped.commits.forEach { + it.parents.clear() + it.children.clear() } - logger.trace("User synchronized") - - // build context after changes are synced - // TODO N+1 here - ctx.entity.commit.putAll(entity.commits.associateBy(CommitEntity::uniqueKey)) - ctx.entity.branch.putAll(entity.branches.associateBy(BranchEntity::uniqueKey)) - ctx.entity.user.putAll(entity.user.associateBy(UserEntity::uniqueKey)) - logger.trace("Entity context built") - -// wireup is done internally - commitMapper.toEntityFull( - (value.commits + value.commits.flatMap { it.parents } + value.commits.flatMap { it.children }), - entity, - ) - logger.trace("Commits updated") - // Add or update branches - value.branches.forEach { - val key = "${entity.localPath},${it.name}" - if (!ctx.entity.branch.containsKey(key)) { - val newBranch = branchMapper.toEntity(it).also { b -> entity.addBranch(b) } - entity.branches.add(newBranch) - ctx.entity.branch.computeIfAbsent(key) { newBranch } + // Persist commits + val intermediateRepo = repositoryDao.update(mapped) + entityManager.flush() + logger.trace("Phase 1: Commits persisted") + + // Phase 2: Wire parent/child relationships (commits now have IDs) + val commitsBySha = intermediateRepo.commits.associateBy { it.sha } + intermediateRepo.commits.forEach { commitEntity -> + // Find the original entity in the map to get its parents + val originalEntity = mapped.commits.find { it.sha == commitEntity.sha } + val originalParents = originalEntity?.let { commitParentMap[it] } ?: emptySet() + + originalParents.forEach { parentEntity -> + val persistedParent = commitsBySha[parentEntity.sha] + if (persistedParent != null && !commitEntity.parents.contains(persistedParent)) { + commitEntity.parents.add(persistedParent) + persistedParent.children.add(commitEntity) + } } } - logger.trace("Branches updated") + entityManager.flush() + logger.trace("Phase 2: Parent/child relationships wired") - entity.commits.filter { it.id == null }.map { commit -> - commitDao.create(commit) - } + // Phase 3: Add branches (commits are now persisted with IDs) + branches.forEach { branch -> + val persistedHead = commitsBySha[branch.head.sha] + ?: throw IllegalStateException("Head commit ${branch.head.sha} not found for branch ${branch.name}") - val updated = repositoryDao.update(entity).also { repositoryDao.flush() } - - logger.trace("Update executed") - - return run { - // Instead of refresh + lazy‐walk, grab a fully fetched instance: - val fullyLoaded = - repositoryDao - .findByIdWithAllRelations(updated.id!!) - ?: throw NotFoundException("Repository ${updated.id} disappeared") - - logger.trace("Entity refreshed") - logger.trace("Domain context built") - - val project = projectMapper.toDomain(fullyLoaded.project) - logger.trace("Domain project built") - - val domain = repositoryMapper.toDomain(fullyLoaded, project) - logger.trace("Domain object built") - return@run domain + // Create new branch entity pointing to persisted commit + val newBranch = branch.copy(head = persistedHead) + intermediateRepo.branches.add(newBranch) } - } - @Transactional - override fun updateAndFlush(value: Repository): Repository { - val updated = update(value) - repositoryDao.flush() - return updated + val updated = repositoryDao.update(intermediateRepo) + entityManager.flush() + logger.trace("Phase 3: Branches persisted") + + return repositoryAssembler.refresh(value, updated) } @Transactional override fun saveAll(values: Collection): Iterable { - return values.map { this.create(it) } + // Create each repository (which modifies them in place) + values.forEach { this.create(it) } + // Return the original collection with updated values + return values } - @Transactional - override fun delete(value: Repository) { - val mapped = - this.repositoryDao.findByName(name = value.localPath) - ?: throw NotFoundException("Repository ${value.localPath} not found") - this.repositoryDao.delete(mapped) - } + @Transactional(readOnly = true) + @MappingSession + override fun findExistingCommits(repo: Repository, shas: Set): Sequence { + val entity = + repositoryDao.findByIid(repo.iid) + ?: throw NotFoundException("Repository ${repo.uniqueKey} not found") + logger.debug("Repository Entity found") + ctx.remember(repo, entity) - @Transactional - override fun deleteById(id: String) { - this.repositoryDao.deleteById(id.toLong()) + return this.commitDao.findExistingSha(repo, shas).map { commitEntity -> + commitMapper.toDomain(commitEntity) + }.asSequence() } - @Transactional - override fun deleteAll() { - this.repositoryDao.deleteAll() + @Transactional(readOnly = true) + @MappingSession + override fun findBranch( + repository: Repository, + name: String + ): Branch? { + return this.repositoryDao.findByIid(repository.iid)?.let { + this.branchDao.findByName(it, name) + repositoryAssembler.toDomain(it).branches.find { branch -> branch.name == name } + } } } diff --git a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/UserInfrastructurePortImpl.kt b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/UserInfrastructurePortImpl.kt index 6de57a350..790152947 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/UserInfrastructurePortImpl.kt +++ b/binocular-backend-new/infrastructure-sql/src/main/kotlin/com/inso_world/binocular/infrastructure/sql/service/UserInfrastructurePortImpl.kt @@ -1,23 +1,23 @@ package com.inso_world.binocular.infrastructure.sql.service +import com.inso_world.binocular.core.delegates.logger +import com.inso_world.binocular.core.persistence.mapper.context.MappingSession import com.inso_world.binocular.core.persistence.model.Page import com.inso_world.binocular.core.service.UserInfrastructurePort -import com.inso_world.binocular.core.service.exception.NotFoundException -import com.inso_world.binocular.infrastructure.sql.mapper.CommitMapper +import com.inso_world.binocular.infrastructure.sql.assembler.RepositoryAssembler import com.inso_world.binocular.infrastructure.sql.mapper.ProjectMapper import com.inso_world.binocular.infrastructure.sql.mapper.RepositoryMapper -import com.inso_world.binocular.infrastructure.sql.mapper.UserMapper -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingSession import com.inso_world.binocular.infrastructure.sql.persistence.dao.RepositoryDao -import com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces.IUserDao -import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity +import com.inso_world.binocular.infrastructure.sql.persistence.dao.interfaces.IDeveloperDao +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer import com.inso_world.binocular.model.File import com.inso_world.binocular.model.Issue -import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository import com.inso_world.binocular.model.User import jakarta.annotation.PostConstruct +import jakarta.validation.Valid import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -34,28 +34,30 @@ import org.springframework.validation.annotation.Validated @Service @Validated internal class UserInfrastructurePortImpl( - @Autowired private val userDao: IUserDao, + @Autowired private val developerDao: IDeveloperDao, @Autowired private val repositoryDao: RepositoryDao, - @Autowired private var userMapper: UserMapper, - @Autowired private var commitMapper: CommitMapper, -) : AbstractInfrastructurePort(Long::class), + @Autowired private var repositoryMapper: RepositoryMapper, + @Autowired private var projectMapper: ProjectMapper, + @Autowired private var repositoryAssembler: RepositoryAssembler, +) : AbstractInfrastructurePort(Long::class), UserInfrastructurePort { - var logger: Logger = LoggerFactory.getLogger(UserInfrastructurePortImpl::class.java) - - @Autowired - @Lazy - private lateinit var repositoryMapper: RepositoryMapper - - @Autowired - @Lazy - private lateinit var projectMapper: ProjectMapper + companion object { + val logger by logger() + } @PostConstruct fun init() { - super.dao = userDao + super.dao = developerDao } + @MappingSession + @Transactional(readOnly = true) override fun findById(id: String): User? { + logger.trace("Finding user with id: $id") + return this.developerDao.findById(id.toLong())?.toLegacyUser() + } + + override fun findByIid(iid: User.Id): @Valid User? { TODO("Not yet implemented") } @@ -72,78 +74,39 @@ internal class UserInfrastructurePortImpl( } override fun update(value: User): User { - TODO("Not yet implemented") - } - - override fun delete(value: User) { - TODO("Not yet implemented") + throw UnsupportedOperationException("User API is deprecated; use Repository aggregate") } - override fun updateAndFlush(value: User): User { - TODO("Not yet implemented") - } override fun create(value: User): User { - TODO("Not yet implemented") + throw UnsupportedOperationException("User API is deprecated; use Repository aggregate") } override fun saveAll(values: Collection): Iterable { - TODO("Not yet implemented") + throw UnsupportedOperationException("User API is deprecated; use Repository aggregate") } @MappingSession @Transactional(readOnly = true) override fun findAll(): Iterable { - val context: MutableMap = mutableMapOf() - val projectContext = mutableMapOf() - - return super.findAllEntities().map { u -> - val repoEntity = u.repository - if (repoEntity == null) { - throw IllegalStateException("Repository cannot be null") - } - val project = - projectContext.getOrPut(repoEntity.project.uniqueKey()) { - projectMapper.toDomain( - repoEntity.project, - ) - } - - if (repoEntity.id == null) { - throw IllegalStateException("Id of repository cannot be null") - } - - val repository = - context.getOrPut(repoEntity.id) { - this.repositoryMapper.toDomain(repoEntity, project) - } - userMapper.toDomainFull(u, repository) - } + val developers = super.findAllEntities() + return developers.mapNotNull { it.toLegacyUser() } } @MappingSession override fun findAll(repository: Repository): Iterable { - TODO() -// val repoEntity = -// this.repositoryDao.findByName(repository.localPath) ?: throw NotFoundException("Could not find repository $repository") -// -// this.userDao -// .findAll(repoEntity) -// .map { -// userMapper.toDomain(it) -// } + return emptyList() } override fun findAll(pageable: Pageable): Page { TODO("Not yet implemented") } - override fun deleteById(id: String) { - TODO("Not yet implemented") - } - - @Transactional - override fun deleteAll() { - super.deleteAllEntities() + private fun DeveloperEntity.toLegacyUser(): User? { + val repositoryDomain = repositoryAssembler.toDomain(this.repository) + return User(name = this.name, repository = repositoryDomain).apply { + this.id = this@toLegacyUser.id?.toString() + this.email = this@toLegacyUser.email + } } } diff --git a/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/09/18-01-changelog.yaml b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/09/18-01-changelog.yaml index 9ba1b9233..0fc576fcd 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/09/18-01-changelog.yaml +++ b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/09/18-01-changelog.yaml @@ -331,7 +331,7 @@ databaseChangeLog: type: VARCHAR(255) - column: name: description - type: VARCHAR(255) + type: TEXT tableName: projects - changeSet: id: 1758206772030-18 @@ -355,7 +355,7 @@ databaseChangeLog: - column: constraints: nullable: false - name: fk_project + name: fk_project_id type: BIGINT tableName: repositories - changeSet: @@ -483,8 +483,8 @@ databaseChangeLog: objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS changes: - addUniqueConstraint: - columnNames: fk_project - constraintName: uc_repositories_fk_project + columnNames: fk_project_id + constraintName: uc_repositories_fk_project_id tableName: repositories - changeSet: id: 1758206772030-28 @@ -640,9 +640,9 @@ databaseChangeLog: objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS changes: - addForeignKeyConstraint: - baseColumnNames: fk_project + baseColumnNames: fk_project_id baseTableName: repositories - constraintName: FK_REPOSITORIES_ON_FK_PROJECT + constraintName: FK_REPOSITORIES_ON_FK_PROJECT_ID referencedColumnNames: id referencedTableName: projects - changeSet: diff --git a/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/11/19-01-changelog.yaml b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/11/19-01-changelog.yaml new file mode 100644 index 000000000..e62a0bad5 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/11/19-01-changelog.yaml @@ -0,0 +1,126 @@ +databaseChangeLog: + - changeSet: + id: 1763550865 + author: manuel + objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS + changes: + - createSequence: + incrementBy: 50 + sequenceName: remotes_seq + startValue: 1 + - createTable: + tableName: remotes + columns: + - column: + constraints: + nullable: false + primaryKey: true + primaryKeyName: pk_remotes + name: id + type: BIGINT + - column: + name: "name" + type: VARCHAR(255) + constraints: + nullable: false + unique: true + - column: + name: "url" + type: TEXT + constraints: + nullable: false + unique: true + - column: + name: iid + type: UUID + constraints: + unique: true + nullable: false + - column: + constraints: + nullable: false + name: repository_id + type: BIGINT + - addForeignKeyConstraint: + baseColumnNames: repository_id + baseTableName: remotes + constraintName: FK_REMOTES_ON_REPOSITORY + referencedColumnNames: id + referencedTableName: repositories + + - changeSet: + id: 1763551316 + author: manuel + objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS + changes: + - addColumn: + tableName: "branches" + columns: + - column: + name: iid + type: UUID + constraints: + unique: true + nullable: false + - column: + name: full_name + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: category + type: VARCHAR(64) + constraints: + nullable: false + - column: + name: fk_commit_id + type: bigint + constraints: + nullable: false + - addForeignKeyConstraint: + baseColumnNames: fk_commit_id + baseTableName: "branches" + constraintName: FK_COMMIT_HEAD_BRANCH + referencedColumnNames: id + referencedTableName: commits + + - changeSet: + id: 1763551471 + author: manuel + changes: + - addColumn: + tableName: "projects" + columns: + - column: + name: iid + type: UUID + constraints: + unique: true + nullable: false + - addColumn: + tableName: "repositories" + columns: + - column: + name: iid + type: UUID + constraints: + unique: true + nullable: false + - addColumn: + tableName: "commits" + columns: + - column: + name: iid + type: UUID + constraints: + unique: true + nullable: false + - addColumn: + tableName: "users" + columns: + - column: + name: iid + type: UUID + constraints: + unique: true + nullable: false diff --git a/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/12/03-01-changelog.yaml b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/12/03-01-changelog.yaml new file mode 100644 index 000000000..b089b057c --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/12/03-01-changelog.yaml @@ -0,0 +1,18 @@ +databaseChangeLog: + - changeSet: + id: 1764772875 + author: manuel + objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS + changes: + - dropUniqueConstraint: + tableName: users + constraintName: uc_4ef74b80804a037e4472a7d34 + - addUniqueConstraint: + columnNames: iid + constraintName: uc_aWlkCg== + tableName: users + - addUniqueConstraint: + columnNames: repository_id, email, name + constraintName: uc_cmVwb3NpdG9yeV9pZCwgZW1haWwsIG5hbWUK + tableName: users + diff --git a/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/12/08-02-changelog.yaml b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/12/08-02-changelog.yaml new file mode 100644 index 000000000..ebfeedf62 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/2025/12/08-02-changelog.yaml @@ -0,0 +1,60 @@ +databaseChangeLog: + - changeSet: + id: 2025-12-08-add-cascade-delete-commits + author: claude + comment: "Add ON DELETE CASCADE to commits FK constraints to fix deletion order issues" + changes: + # Drop existing FK constraints on commits + - dropForeignKeyConstraint: + baseTableName: commits + constraintName: FK_COMMITS_ON_AUTHOR + - dropForeignKeyConstraint: + baseTableName: commits + constraintName: FK_COMMITS_ON_COMMITTER + - dropForeignKeyConstraint: + baseTableName: commits + constraintName: FK_COMMITS_ON_REPOSITORY + # Drop existing FK constraints on commit_parents + - dropForeignKeyConstraint: + baseTableName: commit_parents + constraintName: FK_COMMIT_PARENTS_ON_CHILD + - dropForeignKeyConstraint: + baseTableName: commit_parents + constraintName: FK_COMMIT_PARENTS_ON_PARENT + # Re-create commits FKs with ON DELETE CASCADE + - addForeignKeyConstraint: + baseColumnNames: author_id + baseTableName: commits + constraintName: FK_COMMITS_ON_AUTHOR + referencedColumnNames: id + referencedTableName: users + onDelete: CASCADE + - addForeignKeyConstraint: + baseColumnNames: committer_id + baseTableName: commits + constraintName: FK_COMMITS_ON_COMMITTER + referencedColumnNames: id + referencedTableName: users + onDelete: CASCADE + - addForeignKeyConstraint: + baseColumnNames: repository_id + baseTableName: commits + constraintName: FK_COMMITS_ON_REPOSITORY + referencedColumnNames: id + referencedTableName: repositories + onDelete: CASCADE + # Re-create commit_parents FKs with ON DELETE CASCADE + - addForeignKeyConstraint: + baseColumnNames: child_id + baseTableName: commit_parents + constraintName: FK_COMMIT_PARENTS_ON_CHILD + referencedColumnNames: id + referencedTableName: commits + onDelete: CASCADE + - addForeignKeyConstraint: + baseColumnNames: parent_id + baseTableName: commit_parents + constraintName: FK_COMMIT_PARENTS_ON_PARENT + referencedColumnNames: id + referencedTableName: commits + onDelete: CASCADE \ No newline at end of file diff --git a/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/db.changelog-master.yaml b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/db.changelog-master.yaml index 5d7ff131d..3965dbeba 100644 --- a/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/binocular-backend-new/infrastructure-sql/src/main/resources/db/changelog/db.changelog-master.yaml @@ -1,27 +1,7 @@ databaseChangeLog: - - include: - file: db/changelog/2025/09/18-01-changelog.yaml - - include: - file: db/changelog/2025/09/19-01-changelog.yaml - - include: - file: db/changelog/2025/09/19-02-changelog.yaml - - include: - file: db/changelog/2025/09/19-03-changelog.yaml - - include: - file: db/changelog/2025/09/19-04-changelog.yaml - - include: - file: db/changelog/2025/09/22-01-changelog.yaml - - include: - file: db/changelog/2025/09/23-01-changelog.yaml - - include: - file: db/changelog/2025/09/25-01-changelog.yaml -# - changeSet: -# id: prevent-cycles-trigger -# author: manuel -# dbms: postgresql -# changes: -# - sqlFile: -# path: ./trigger/prevent_cycles_trigger.sql -# relativeToChangelogFile: true -# splitStatements: false -# stripComments: true + - includeAll: + path: db/changelog/2025/09 + - includeAll: + path: db/changelog/2025/11 + - includeAll: + path: db/changelog/2025/12 diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/SqlInfrastructureDataSetup.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/SqlInfrastructureDataSetup.kt index 2d3fc0d4d..bdb348f3f 100644 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/SqlInfrastructureDataSetup.kt +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/SqlInfrastructureDataSetup.kt @@ -3,15 +3,20 @@ package com.inso_world.binocular.infrastructure.sql import com.inso_world.binocular.core.data.MockTestDataProvider import com.inso_world.binocular.core.delegates.logger import com.inso_world.binocular.core.integration.base.InfrastructureDataSetup +import com.inso_world.binocular.infrastructure.sql.integration.service.base.deleteAllEntities import com.inso_world.binocular.infrastructure.sql.service.BranchInfrastructurePortImpl import com.inso_world.binocular.infrastructure.sql.service.CommitInfrastructurePortImpl import com.inso_world.binocular.infrastructure.sql.service.ProjectInfrastructurePortImpl import com.inso_world.binocular.infrastructure.sql.service.RepositoryInfrastructurePortImpl import com.inso_world.binocular.infrastructure.sql.service.UserInfrastructurePortImpl +import jakarta.persistence.EntityManager +import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component +import org.springframework.transaction.support.TransactionTemplate @Component internal class SqlInfrastructureDataSetup( + private val entityManager: EntityManager, private val projectInfrastructurePort: ProjectInfrastructurePortImpl, private val commitInfrastructurePort: CommitInfrastructurePortImpl, private val repositoryInfrastructurePort: RepositoryInfrastructurePortImpl, @@ -27,7 +32,11 @@ internal class SqlInfrastructureDataSetup( // private val milestoneRepository: MilestoneInfrastructurePort, ) : InfrastructureDataSetup { + @Autowired + private lateinit var transactionTemplate: TransactionTemplate + private lateinit var mockTestData: MockTestDataProvider + companion object { private val logger by logger() } @@ -47,11 +56,15 @@ internal class SqlInfrastructureDataSetup( override fun teardown() { logger.info(">>> SqlInfrastructureDataSetup teardown") - projectInfrastructurePort.deleteAll() - repositoryInfrastructurePort.deleteAll() - branchInfrastructurePort.deleteAll() - commitInfrastructurePort.deleteAll() - userPort.deleteAll() + transactionTemplate.execute { + projectInfrastructurePort.deleteAllEntities() + repositoryInfrastructurePort.deleteAllEntities() +// branchInfrastructurePort.deleteAll() +// commitInfrastructurePort.deleteAll() +// userPort.deleteAll() + entityManager.flush() + } + logger.info("<<< SqlInfrastructureDataSetup teardown") } diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/SqlTestConfig.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/SqlTestConfig.kt index c7584a9bd..41af876cc 100644 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/SqlTestConfig.kt +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/SqlTestConfig.kt @@ -7,13 +7,14 @@ import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import import org.springframework.core.env.Profiles import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName @Configuration @Import(SqlAppConfig::class) class SqlTestConfig { - companion object Companion { - val pg: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:18-alpine") + companion object { + val pg: PostgreSQLContainer<*> = PostgreSQLContainer(DockerImageName.parse("postgres:18-alpine")) .apply { withDatabaseName("binocular_it") } .apply { withUsername("postgres") } .apply { withPassword("postgres") } diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/TestData.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/TestData.kt new file mode 100644 index 000000000..fc238433d --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/TestData.kt @@ -0,0 +1,355 @@ +package com.inso_world.binocular.infrastructure.sql + +import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.DeveloperEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RemoteEntity +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Reference +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.User +import com.inso_world.binocular.model.vcs.Remote +import com.inso_world.binocular.model.vcs.ReferenceCategory +import java.time.LocalDateTime +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +internal object TestData { + object Entity { + /** + * Creates a fresh ProjectEntity with standard test data. + * + * @param name Entity name (default: "test project") + * @param id Database-specific id (default: 1L) + * @param description Entity description (default: "this is a description") + * @param iid Immutable identity (default: random UUID) + * @return A new ProjectEntity instance + */ + fun testProjectEntity( + name: String = "test project", + id: Long? = 1L, + description: String? = "this is a description", + iid: Project.Id = Project.Id(Uuid.random()) + ): ProjectEntity = ProjectEntity( + name = name, + iid = iid + ).apply { + this.description = description + this.id = id + } + + /** + * Creates a test CommitEntity persistence entity with customizable parameters. + * + * @param sha The SHA hash of the commit. Must be 40 characters long. + * @param authorDateTime The timestamp when the commit was authored. + * @param commitDateTime The timestamp when the commit was committed. + * @param message The commit message. + * @param repository The RepositoryEntity this commit belongs to. + * @param iid The internal immutable identifier. + * @param id The Long database identifier, or null. + * @return A CommitEntity configured with the specified values. + */ + fun testCommitEntity( + sha: String, + authorDateTime: LocalDateTime, + commitDateTime: LocalDateTime = authorDateTime, + message: String?, + repository: RepositoryEntity, + author: DeveloperEntity = testDeveloperEntity( + name = "Author-${sha.take(6)}", + email = "author-${sha.take(6)}@example.com", + repository = repository, + ), + committer: DeveloperEntity? = testDeveloperEntity( + name = "Committer-${sha.take(6)}", + email = "${sha.take(6)}@example.com", + repository = repository, + ), + iid: Commit.Id = Commit.Id(Uuid.random()), + id: Long? = null, + ): CommitEntity = CommitEntity( + sha = sha, + authorDateTime = authorDateTime, + commitDateTime = commitDateTime, + message = message, + repository = repository, + iid = iid, + author = author, + committer = committer ?: author + ).apply { + this.id = id + } + + /** + * Creates a test DeveloperEntity persistence entity with customizable parameters. + * + * @param name The name of the developer. + * @param email The email address of the developer. + * @param repository The RepositoryEntity this developer belongs to. + * @param iid The internal immutable identifier. + * @param id The Long database identifier, or null. + * @return A DeveloperEntity configured with the specified values. + */ + fun testDeveloperEntity( + name: String, + email: String, + repository: RepositoryEntity, + iid: Developer.Id = Developer.Id(Uuid.random()), + id: Long? = null + ): DeveloperEntity = DeveloperEntity( + name = name, + email = email, + repository = repository, + iid = iid + ).apply { + this.id = id + } + + @Deprecated("Use testDeveloperEntity", ReplaceWith("testDeveloperEntity(name,email,repository,iid,id)")) + fun testUserEntity( + name: String, + email: String, + repository: RepositoryEntity, + iid: Developer.Id = Developer.Id(Uuid.random()), + id: Long? = null + ): DeveloperEntity = testDeveloperEntity(name, email, repository, iid, id) + + /** + * Creates a test RepositoryEntity persistence entity with default or customizable parameters. + * + * This factory method provides a convenient way to create RepositoryEntity instances for testing, + * with sensible defaults that can be overridden for specific test cases. + * + * @param localPath The local file system path to the repository. Defaults to "TestRepository". + * @param id The Long database identifier, or null. Defaults to 1L. + * @param iid The internal immutable identifier. Defaults to a new random Repository.Id. + * @param project The ProjectEntity that owns this repository. Defaults to a minimal test project entity. + * @return A RepositoryEntity configured with the specified or default values. + * + */ + fun testRepositoryEntity( + localPath: String = "TestRepository", + id: Long? = 1L, + iid: Repository.Id = Repository.Id(Uuid.random()), + project: ProjectEntity = testProjectEntity( + name = "TestProject", + id = 1L, + description = "A test project" + ) + ): RepositoryEntity = RepositoryEntity( + iid = iid, + localPath = localPath, + project = project + ).apply { + this.id = id + } + + /** + * Creates a test BranchEntity persistence entity with customizable parameters. + * + * @param name The name of the branch. + * @param repository The RepositoryEntity this branch belongs to. + * @param head The CommitEntity that is the head of this branch. + * @param iid The internal immutable identifier. + * @param id The Long database identifier, or null. + * @return A BranchEntity configured with the specified values. + */ + fun testBranchEntity( + name: String, + repository: RepositoryEntity, + head: CommitEntity, + fullName: String = name, + category: ReferenceCategory = ReferenceCategory.LOCAL_BRANCH, + iid: Reference.Id = Reference.Id(Uuid.random()), + id: Long? = null + ): BranchEntity = BranchEntity( + name = name, + fullName = fullName, + category = category, + repository = repository, + head = head, + iid = iid + ).apply { + this.id = id + } + + fun testRemoteEntity( + name: String, + url: String, + repository: RepositoryEntity, + iid: Remote.Id = Remote.Id(Uuid.random()), + id: Long? = null + ): RemoteEntity = RemoteEntity( + name = name, + url = url, + repository = repository, + iid = iid + ).apply { + this.id = id + } + } + + object Domain { + /** + * Creates a fresh Project domain object with standard test data. + * + * @param name Project name (default: "test project") + * @param id Database-specific id (default: null) + * @param description Project description (default: "this is a description") + * @return A new Project instance + */ + fun testProject( + name: String = "test project", + id: String? = null, + description: String? = "this is a description" + ): Project = Project(name = name).apply { + this.id = id + this.description = description + } + + /** + * Creates a test Commit domain object with customizable parameters. + * + * @param sha The SHA hash of the commit. Must be 40 characters long. + * @param authorDateTime The timestamp when the commit was authored. + * @param commitDateTime The timestamp when the commit was committed. + * @param message The commit message. + * @param repository The Repository this commit belongs to. + * @param committer The User who committed this change. + * @param id The string identifier for the commit, or null. + * @return A Commit domain object configured with the specified values. + */ + fun testCommit( + sha: String, + authorDateTime: LocalDateTime, + commitDateTime: LocalDateTime?, + message: String?, + repository: Repository, + author: Developer = testDeveloper( + name = "Author-${sha.take(6)}", + email = "author-${sha.take(6)}@example.com", + repository = repository + ), + committer: Developer = author, + id: String? = null, + ): Commit { + val authorSignature = Signature(developer = author, timestamp = authorDateTime) + val committerSignature = commitDateTime?.let { Signature(developer = committer, timestamp = it) } ?: authorSignature + + return Commit( + sha = sha, + authorSignature = authorSignature, + committerSignature = committerSignature, + message = message, + repository = repository, + ).apply { + this.id = id + } + } + + /** + * Creates a test Repository domain object with default or customizable parameters. + * + * This factory method provides a convenient way to create Repository instances for testing, + * with sensible defaults that can be overridden for specific test cases. + * + * @param localPath The local file system path to the repository. Defaults to "TestRepo". + * @param id The string identifier for the repository, or null. Defaults to "10". + * @param project The Project that owns this repository. Defaults to a minimal test project. + * @return A Repository domain object configured with the specified or default values. + * + */ + fun testRepository( + localPath: String = "TestRepo", + id: String? = "10", + project: Project = testProject( + name = "TestProject", + id = "1", + description = "A test project" + ) + ): Repository = Repository( + localPath = localPath, + project = project + ).apply { + this.id = id + } + + /** + * Creates a test Developer domain object with customizable parameters. + * + * @param name The name of the developer. + * @param email The email address of the developer. + * @param repository The Repository this developer belongs to. + * @param id The string identifier for the developer, or null. + * @return A Developer domain object configured with the specified values. + */ + fun testDeveloper( + name: String, + email: String, + repository: Repository, + id: String? = null + ): Developer = + Developer( + name = name, + email = email, + repository = repository + ).apply { + this.id = id + } + + @Deprecated("Use testDeveloper", ReplaceWith("testDeveloper(name,email,repository,id)")) + fun testUser( + name: String, + email: String, + repository: Repository, + id: String? = null + ): Developer = testDeveloper(name, email, repository, id) + + /** + * Creates a test Branch domain object with customizable parameters. + * + * @param name The name of the branch. + * @param repository The Repository this branch belongs to. + * @param head The Commit that is the head of this branch. + * @param id The string identifier for the branch, or null. + * @return A Branch domain object configured with the specified values. + */ + fun testBranch( + name: String, + repository: Repository, + head: Commit, + fullName: String = name, + category: ReferenceCategory = ReferenceCategory.LOCAL_BRANCH, + id: String? = null + ): Branch = Branch( + name = name, + fullName = fullName, + category = category, + repository = repository, + head = head + ).apply { + this.id = id + } + + fun testRemote( + name: String = "origin", + url: String = "https://example.com/repo.git", + repository: Repository, + id: String? = null + ): Remote = Remote( + name = name, + url = url, + repository = repository + ).apply { + this.id = id + } + } +} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/BranchMapperTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/BranchMapperTest.kt deleted file mode 100644 index cc4b69865..000000000 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/BranchMapperTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.integration.persistence.mapper - -import com.inso_world.binocular.infrastructure.sql.integration.persistence.mapper.base.BaseMapperTest -import com.inso_world.binocular.infrastructure.sql.mapper.BranchMapper -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingContext -import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import io.mockk.mockk -import jakarta.persistence.EntityManager -import jakarta.validation.Validation -import jakarta.validation.Validator -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertAll -import org.junit.jupiter.api.assertDoesNotThrow -import org.springframework.beans.factory.annotation.Autowired -import java.time.LocalDateTime - -internal class BranchMapperTest : BaseMapperTest() { - @Autowired - private lateinit var ctx: MappingContext - - @Autowired - private lateinit var branchMapper: BranchMapper - - lateinit var validator: Validator - - private lateinit var repositoryEntity: RepositoryEntity - private lateinit var repositoryModel: Repository - - @BeforeEach - fun setup() { - val projectEntity = - ProjectEntity( - id = 1L, - name = "TestProject", - description = "A test project", - ) - val projectModel = Project( - id = projectEntity.id?.toString(), - name = projectEntity.name, - description = projectEntity.description - ) - - this.repositoryEntity = - RepositoryEntity( - id = 1L, - localPath = "TestRepository", - project = projectEntity, - ) - - this.repositoryModel = - Repository( - id = this.repositoryEntity.id.toString(), - localPath = this.repositoryEntity.localPath, - project = projectModel, - ) - - this.validator = Validation.buildDefaultValidatorFactory().validator - } - - @Test - fun `branchMapper toEntity, minimal valid example`() { - val branch = - Branch( - name = "testBranch", - repository = this.repositoryEntity.toDomain(null), - ) - - val entity = this.branchMapper.toEntity(branch).also { b -> this.repositoryEntity.addBranch(b) } - - assertThat(entity.repository).isNotNull() - assertAll( - { assertThat(entity.repository?.id).isEqualTo(this.repositoryEntity.id) }, - { assertThat(entity.commits).isEmpty() }, - ) - } - - @Test - fun `branchMapper toEntity, with commit`() { - val repository = this.repositoryEntity.toDomain(null) - val commit = - Commit( - id = "1", - sha = "a".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 1, 1, 0, 0, 0), - message = "Valid commit 1", - ) - repository.commits.add(commit) - val branch = - Branch( - name = "testBranch", - repository = this.repositoryEntity.toDomain(null), - ) - repository.branches.add(branch) - branch.commits.add(commit) - // needed as branchMapper.toEntity requires the commit to be part of the repository already - val commitEntity = - CommitEntity( - sha = commit.sha, - authorDateTime = commit.authorDateTime, - commitDateTime = commit.commitDateTime, - message = commit.message, - repository = this.repositoryEntity, - branches = mutableSetOf(), - ) - this.repositoryEntity.commits.add(commitEntity) - -// setup ctx - ctx.entity.commit[commit.sha] = commitEntity - - val entity = - assertDoesNotThrow { - this.branchMapper.toEntity(branch).also { b -> - this.repositoryEntity.addBranch(b) - } - } - - assertAll( - "entity.repository", - { assertThat(entity.repository).isNotNull() }, - { assertThat(entity.repository?.id).isEqualTo(this.repositoryEntity.id) }, - { assertThat(entity.repository?.commits).hasSize(1) }, - { assertThat(entity.repository?.commits?.toList()[0]).isSameAs(entity.commits.toList()[0]) }, - ) - assertAll( - "entity.commits", - { assertThat(entity.commits).hasSize(1) }, - { assertThat(entity.commits.toList()[0]).isSameAs(commitEntity) }, - ) - assertAll( - "entity.commits.branches", - { assertThat(entity.commits.flatMap { it.branches }).hasSize(1) }, - { assertThat(entity.commits.flatMap { it.branches }.toList()[0]).isSameAs(entity) }, - ) - } - - @Nested - inner class ToDomain { - private lateinit var entityManagerMock: EntityManager - - @BeforeEach - fun setup() { - // Create a mock EntityManager - entityManagerMock = mockk() - - // Use reflection to set the private field - run { - val field = BranchMapper::class.java.getDeclaredField("entityManager") - field.isAccessible = true - field.set(branchMapper, entityManagerMock) - } - } - - @Test - fun `branchMapper toDomain, minimal valid example`() { - val commitEntity = - CommitEntity( - sha = "d".repeat(40), - branches = mutableSetOf(), - ) - val branchEntity = - BranchEntity( - id = 1, - name = "testBranch", - repository = repositoryEntity, - commits = - mutableSetOf(commitEntity), - ) - commitEntity.addBranch(branchEntity) - repositoryEntity.addBranch(branchEntity) - repositoryEntity.addCommit(commitEntity) - -// every { entityManagerMock.find(BranchEntity::class.java, branchEntity.id) } returns branchEntity - - val domain = branchMapper.toDomain(branchEntity) - .also { - repositoryModel.branches.add(it) - it.commits.forEach { c -> - repositoryModel.commits.add(c) - } - } - - assertAll( - { assertThat(domain.id).isEqualTo(branchEntity.id.toString()) }, - { assertThat(domain.repository?.id).isNotNull() }, - { assertThat(domain.repository?.id).isEqualTo(repositoryModel.id) }, - { assertThat(domain.repository?.id).isEqualTo(repositoryEntity.id.toString()) }, - { assertThat(domain.repository?.id).isEqualTo(branchEntity.repository?.id?.toString()) }, - { assertThat(domain.commits.map { it.sha }).containsExactlyInAnyOrderElementsOf(listOf("d".repeat(40))) }, - { - assertThat(domain) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes( - ".*logger", - ".*id", - ".*commits", - ".*project", - ".*latestCommit", - ".*active", - ".*tracksFileRenames", - ".*files", - ".*branch", - ".*_*" - ) - .isEqualTo(branchEntity) - }, - ) - } - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/CommitMapperTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/CommitMapperTest.kt deleted file mode 100644 index df0656e14..000000000 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/CommitMapperTest.kt +++ /dev/null @@ -1,381 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.integration.persistence.mapper - -import com.inso_world.binocular.infrastructure.sql.integration.persistence.mapper.base.BaseMapperTest -import com.inso_world.binocular.infrastructure.sql.mapper.BranchMapper -import com.inso_world.binocular.infrastructure.sql.mapper.CommitMapper -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingContext -import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import io.mockk.every -import io.mockk.mockk -import jakarta.persistence.EntityManager -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertAll -import org.junit.jupiter.api.assertDoesNotThrow -import org.springframework.beans.factory.annotation.Autowired -import java.time.LocalDateTime - -internal class CommitMapperTest : BaseMapperTest() { - @Autowired - private lateinit var commitMapper: CommitMapper - - @Autowired - private lateinit var ctx: MappingContext - - private lateinit var repositoryEntity: RepositoryEntity - private lateinit var repositoryDomain: Repository - private lateinit var branchEntity: BranchEntity - - private lateinit var commitEntityA: CommitEntity - private lateinit var commitEntityB: CommitEntity - private lateinit var commitDomainA: Commit - private lateinit var commitDomainB: Commit - - @BeforeEach - fun setup() { - this.repositoryEntity = - RepositoryEntity( - id = 1L, - localPath = "TestRepository", - project = - ProjectEntity( - id = 1L, - name = "TestProject", - description = "A test project", - ), - ) - this.repositoryDomain = - Repository( - id = this.repositoryEntity.id?.toString(), - localPath = this.repositoryEntity.localPath, - project = Project( - id = this.repositoryEntity.project.id?.toString(), - name = this.repositoryEntity.project.name, - description = this.repositoryEntity.project.description, - ), - ) - - this.commitEntityA = - CommitEntity( - id = 1, - sha = "A".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 1, 1, 0, 0, 0), - message = "Valid commit 1", - repository = this.repositoryEntity, - branches = mutableSetOf(), - ) - - this.commitEntityB = - CommitEntity( - id = 2, - sha = "B".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - message = "Valid commit 2", - repository = this.repositoryEntity, - branches = mutableSetOf(), - ) - - this.branchEntity = - BranchEntity( - name = "testBranch", - repository = this.repositoryEntity, - commits = mutableSetOf(), - ) -// commitEntityA -// this.commitEntityA.addBranch(this.branchEntity) -// commitEntityB -// this.commitEntityB.addBranch(this.branchEntity) - - this.commitDomainA = - Commit( - id = this.commitEntityA.id?.toString(), - sha = this.commitEntityA.sha, - authorDateTime = this.commitEntityA.authorDateTime, - commitDateTime = this.commitEntityA.commitDateTime, - message = this.commitEntityA.message, -// repositoryId = -// this.commitEntityA.repository -// ?.id -// .toString(), - ) - repositoryDomain.commits.add(commitDomainA) - - this.commitDomainB = - Commit( - id = this.commitEntityB.id?.toString(), - sha = this.commitEntityB.sha, - authorDateTime = this.commitEntityB.authorDateTime, - commitDateTime = this.commitEntityB.commitDateTime, - message = this.commitEntityB.message, -// repositoryId = -// this.commitEntityB.repository -// ?.id -// .toString(), - ) - repositoryDomain.commits.add(commitDomainB) - } - - @Nested - inner class ToEntity : BaseMapperTest() { - @Test - fun `commitMapper toEntity, minimal valid example`() { - val branch = - Branch( - id = branchEntity.id?.toString(), - name = branchEntity.name, - repository = - branchEntity.repository?.toDomain(null) -// ?.id -// .toString(), - ) - commitDomainA.branches.add(branch) - branchEntity.addCommit(commitEntityA) - repositoryEntity.addBranch(branchEntity) - - val entity = - assertDoesNotThrow { - commitMapper.toEntity(commitDomainA) - .also { c -> repositoryEntity.addCommit(c) } - .also { branchEntity.addCommit(it) } - } - - assertAll( - "entity", - { assertThat(entity.id).isEqualTo(commitEntityA.id) }, - { assertThat(entity.parents).isEmpty() }, - { assertThat(entity.repository).isNotNull() }, - { assertThat(entity.repository).isSameAs(repositoryEntity) }, - { assertThat(entity.branches).hasSize(1) }, - { - assertThat(entity.branches.toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(branchEntity) - }, - { - val actualCommitUnwrapped = - entity.copy( - parents = entity.parents.toMutableSet(), - ) - val expectedCommitUnwrapped = - commitEntityA.copy( - parents = commitEntityA.parents.toMutableSet(), - ) - assertThat(actualCommitUnwrapped) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(expectedCommitUnwrapped) - }, - ) - assertAll( - "repositoryEntity", - { assertThat(repositoryEntity.branches).hasSize(1) }, - { assertThat(repositoryEntity.commits).hasSize(1) }, - { assertThat(repositoryEntity.user).hasSize(0) }, - ) - } - - @Test - fun `commitMapper toEntity, commit with parent`() { - val branch = - Branch( - id = branchEntity.id?.toString(), - name = branchEntity.name, - repository = - branchEntity.repository?.toDomain(null) -// ?.id -// .toString(), - ) -// Commit A - commitDomainA.branches.add(branch) - branchEntity.addCommit(commitEntityA) -// Commit B - commitDomainB.branches.add(branch) - branchEntity.addCommit(commitEntityB) -// Entity setup - commitEntityA.addParent(commitEntityB) -// commitEntityB.children.add(commitEntityA) - - val commitWithParent = commitDomainA - commitWithParent.parents.add(commitDomainB) -// commitDomainB.children.add(commitWithParent) - - assertThat(branch.commits).hasSize(2) - assertThat(commitEntityA.parents).hasSize(1) - assertThat(commitEntityB.parents).hasSize(0) - assertThat(commitEntityB.children).hasSize(1) - assertThat(branchEntity.commits).hasSize(2) - assertThat(repositoryEntity.commits).hasSize(0) - - repositoryEntity.addBranch(branchEntity) - - val entity = - assertDoesNotThrow { - commitMapper.toEntity(commitWithParent) - } - - assertThat(entity.id).isEqualTo(commitEntityA.id) - assertThat(entity.parents).hasSize(1) - assertThat(entity.children).hasSize(0) - run { -// BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB - val cmtB = entity.parents.find { it.sha == "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" } - ?: throw IllegalStateException("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB not found") - assertThat(cmtB) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*branches", ".*repository") - .isEqualTo(commitEntityB) - } - assertThat(entity.repository).isNull() - assertThat(entity.branches).hasSize(0) -// check bidirectional relationship -// assertThat(entity.branches.toList()[0].commits).hasSize(2) -// assertThat(entity.branches.toList()[0].commits) -// .usingRecursiveComparison() -// .ignoringCollectionOrder() -// .isEqualTo(listOf(commitEntityA, commitEntityB)) -// assertThat(entity.branches.toList()[0]) -// .usingRecursiveComparison() -// .ignoringCollectionOrder() -// .isEqualTo(branchEntity) - assertThat(entity) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*branches", ".*repository", ".*diffs") - .isEqualTo(commitEntityA) -// assertThat(repositoryEntity.branches).hasSize(1) -// assertThat(repositoryEntity.branches.toList()[0]).isSameAs(entity.branches.toList()[0]) -// assertThat(repositoryEntity.branches.toList()[0]) -// .usingRecursiveComparison() -// .ignoringCollectionOrder() -// .isEqualTo(branchEntity) -// assertThat(repositoryEntity.commits).hasSize(2) -// assertThat(repositoryEntity.commits).containsExactly(commitEntityA, commitEntityB) -// assertThat(repositoryEntity.user).hasSize(0) - } - } - - @Nested - inner class ToDomain : BaseMapperTest() { - @Autowired - private lateinit var commitMapper: CommitMapper - - @Autowired - private lateinit var branchMapper: BranchMapper - - @BeforeEach - fun setup() { - // Create a mock EntityManager - val entityManagerMock = mockk() - - // Use reflection to set the private field - run { - val field = BranchMapper::class.java.getDeclaredField("entityManager") - field.isAccessible = true - field.set(branchMapper, entityManagerMock) - } - - // Now you can stub methods as before - every { entityManagerMock.find(CommitEntity::class.java, commitEntityA.id) } returns commitEntityA - every { entityManagerMock.find(CommitEntity::class.java, commitEntityB.id) } returns commitEntityB - every { entityManagerMock.find(BranchEntity::class.java, branchEntity.id) } returns branchEntity - } - - @Test - fun `commitMapper toDomain, one commits, one branch`() { -// val branchDomain = branchEntity.toDomain() - commitEntityA.addBranch(branchEntity) - branchEntity.addCommit(commitEntityA) - - assertThat(commitEntityA.branches).hasSize(1) - assertThat(branchEntity.commits).hasSize(1) - - val domain = - assertDoesNotThrow { - commitMapper.toDomain(commitEntityA) - } - - assertThat(domain.repository).isNull() - assertThat(domain) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes( - ".*id",".*branches", ".*committer", ".*author", ".*repository", ".*project", ".*_*", - ".*issues", ".*modules", ".*stats", ".*repositoryId", ".*builds", ".*files", ".*diffs" - ).isEqualTo(commitEntityA) - assertThat(domain.branches).isEmpty() - } - - @Test - fun `commitMapper toDomain, commit with parent, one branch`() { - commitEntityA.addParent(commitEntityB) - commitEntityA.addBranch(branchEntity) - branchEntity.addCommit(commitEntityA) - branchEntity.addCommit(commitEntityB) - // wire up ctx - ctx.domain.commit[commitDomainB.sha] = commitDomainB - - assertThat(commitEntityA.branches).hasSize(1) - assertThat(branchEntity.commits).hasSize(2) - - val domain = - assertDoesNotThrow { - commitMapper.toDomain(commitEntityA, options = CommitMapper.Options.FULL) - } - - assertThat(domain.parents).hasSize(1) - assertThat(domain.parents.toList()[0].children).hasSize(1) - assertThat(domain.branches).hasSize(0) - assertThat(setOf(domain) + domain.parents + domain.children) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes( - ".*id",".*branches", ".*committer", ".*author", ".*repository", ".*project", ".*_*", - ".*issues", ".*modules", ".*stats", ".*repositoryId", ".*builds", ".*files", ".*diffs" - ) - .isEqualTo(listOf(commitEntityA, commitEntityB)) - } - - @Test - fun `commitMapper toDomain, add branch multiple times`() { - val branchDomain = branchEntity.toDomain() - commitEntityA.addBranch(branchEntity) - branchEntity.addCommit(commitEntityA) - - val domainA = - assertDoesNotThrow { - commitMapper.toDomain(commitEntityA) - .also { repositoryDomain.commits.add(it) } - .also { branchDomain.commits.add(it) } - } - - assertThat(domainA.branches).hasSize(1) - -// val domainBranch = domain.branches.toList()[0] -// assertFalse(domain.addBranch(domainBranch)) - commitEntityB.addBranch(branchEntity) - branchEntity.addCommit(commitEntityB) - - val domainB = - assertDoesNotThrow { - commitMapper.toDomain(commitEntityB) - .also { repositoryDomain.commits.add(it) } - .also { branchDomain.commits.add(it) } - } - - assertThat(domainB.branches).hasSize(1) - } - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/RepositoryMapperTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/RepositoryMapperTest.kt deleted file mode 100644 index 6948ba6ca..000000000 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/RepositoryMapperTest.kt +++ /dev/null @@ -1,585 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.integration.persistence.mapper - -import com.inso_world.binocular.infrastructure.sql.integration.persistence.mapper.base.BaseMapperTest -import com.inso_world.binocular.infrastructure.sql.mapper.RepositoryMapper -import com.inso_world.binocular.infrastructure.sql.persistence.entity.BranchEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import io.mockk.every -import io.mockk.mockk -import jakarta.persistence.EntityManager -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertAll -import org.junit.jupiter.api.assertNotNull -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.Arguments -import org.junit.jupiter.params.provider.MethodSource -import org.springframework.beans.factory.annotation.Autowired -import java.time.LocalDateTime -import java.util.stream.Stream -import kotlin.test.assertEquals - -internal class RepositoryMapperTest : BaseMapperTest() { - @Autowired - private lateinit var repositoryMapper: RepositoryMapper - - private lateinit var projectEntity: ProjectEntity - - @BeforeEach - fun setup() { - this.projectEntity = - ProjectEntity( - id = 1L, - name = "TestProject", - description = "A test project", - ) - } - - companion object { - private fun branchModel() = - Branch( - name = "testBranch", - ) - - @JvmStatic - fun commitList(): Stream = - Stream.of( - Arguments.of( - with(branchModel()) { - listOf( - run { - val cmt = Commit( - id = "1", - sha = "a".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 1, 1, 0, 0, 0), - message = "Valid commit 1", - ) - this.commits.add(cmt) - cmt.parents.add( - Commit( - id = "2", - sha = "b".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - message = "Valid commit 2", - ) - ) - cmt - }, - run { - val cmt = Commit( - id = "2", - sha = "b".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - message = "Valid commit 2", - ) - this.commits.add(cmt) - cmt - }, - ) - }, - 2, - ), - Arguments.of( - with(branchModel()) { - listOf( - run { - val cmt = Commit( - id = "3", - sha = "a".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 1, 1, 0, 0, 0), - message = "Valid commit 1", - ) - this.commits.add(cmt) - cmt.parents.add( - Commit( - id = "4", - sha = "b".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - message = "Valid commit 2", - ) - ) - cmt - }, - run { - val cmt = Commit( - id = "5", - sha = "c".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 4, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - message = "Valid commit 3", - ) - this.commits.add(cmt) - cmt - }, - ) - }, - 3, - ), - Arguments.of( - with(branchModel()) { - listOf( - run { - val cmt = Commit( - id = "6", - sha = "a".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 1, 1, 0, 0, 0), - message = "Valid commit 1", - ) - this.commits.add(cmt) - cmt.parents.add( - Commit( - id = "7", - sha = "b".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - message = "Valid commit 2", - ) - ) - cmt - }, - run { - val cmt = Commit( - id = "8", - sha = "c".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 4, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - message = "Valid commit 3", - ) - this.commits.add(cmt) - cmt.parents.add( - Commit( - id = "7", - sha = "b".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - message = "Valid commit 2", - ) - ) - cmt - }, - ) - }, - 3, - ), - Arguments.of( - with(branchModel()) { - listOf( - run { - val cmt = Commit( - id = "9", - sha = "a".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 1, 1, 0, 0, 0), - message = "Valid commit 1", - ) - this.commits.add(cmt) - cmt.parents.add( - Commit( - id = "10", - sha = "b".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - message = "Valid commit 2", - ) - ) - cmt - }, - run { - val cmt = Commit( - id = "11", - sha = "c".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 4, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - message = "Valid commit 3", - ) - this.commits.add(cmt) - cmt.parents.add( - Commit( - id = "12", - sha = "d".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 5, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 4, 1, 0, 0, 0), - message = "Valid commit 4", - ) - ) - cmt - }, - ) - }, - 4, - ), - ) - } - - @Test - fun `repositoryMapper toEntity, minimal valid repository`() { - // Create a minimal valid Repository domain object - val repositoryDomain = - Repository( - id = "10", - localPath = "TestRepo", - project = Project( - id = projectEntity.id?.toString(), - name = projectEntity.name, - description = projectEntity.description, - ) - ) - - // Map to entity - val repositoryEntity = repositoryMapper.toEntity(repositoryDomain, projectEntity) - - // Assert mapping - assertAll( - { assertNotNull(repositoryEntity) }, - { assertEquals(repositoryEntity.localPath, repositoryDomain.localPath) }, - { assertThat(repositoryEntity.project).isSameAs(projectEntity) }, - { assertThat(projectEntity.repo).isSameAs(repositoryEntity) }, - { assertThat(projectEntity.repo).usingRecursiveComparison().isEqualTo(repositoryEntity) }, - ) - } - - @Test - fun `repositoryMapper toEntity, with commits no parents`() { - // Create a minimal valid Repository domain object - val domain = - Repository( - id = "10", - localPath = "TestRepo", - project = Project( - id = projectEntity.id?.toString(), - name = projectEntity.name, - description = projectEntity.description, - ) - ) - val commitList = - listOf( - Commit( - id = "1", - sha = "a".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 1, 1, 0, 0, 0), - message = "Valid commit 1", - ), - Commit( - id = "2", - sha = "b".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - message = "Valid commit 2", - ), - ) - domain.commits.addAll(commitList) - - val branch = - Branch( - name = "test", - repository = domain, - ) - branch.commits.addAll(commitList) - commitList.forEach { it.branches.add(branch) } - domain.branches.add(branch) - - // Map to entity - val entity = repositoryMapper.toEntity(domain, projectEntity) - - // Assert mapping - assertAll( - { assertEquals(entity.localPath, domain.localPath) }, - { assertThat(entity.commits).hasSize(domain.commits.size) }, -// { -// assertThat(entity.commits) -// .usingRecursiveComparison() -// .ignoringCollectionOrder() -// .ignoringFields("id", "repository", "repositoryId", "branches") -// .isEqualTo(domain.commits) -// }, -// { -// assertThat(entity.commits.flatMap { it.branches }.distinct()) -// .usingRecursiveComparison() -// .ignoringCollectionOrder() -// .isEqualTo(entity.branches) -// }, -// { -// assertThat(entity.commits.flatMap { it.branches }.distinct()) -// .usingRecursiveComparison() -// .ignoringCollectionOrder() -// .comparingOnlyFields("id", "name") -// .isEqualTo(domain.commits.flatMap { it.branches }.distinct()) -// }, -// { -// assertThat(entity.commits.map { it.repository?.id }).isEqualTo( -// listOf( -// domain.id?.toLong(), -// domain.id?.toLong(), -// ), -// ) -// }, -// { assertThat(entity.project).isSameAs(projectEntity) }, -// { assertThat(projectEntity.repo).isSameAs(entity) }, -// { assertThat(projectEntity.repo).usingRecursiveComparison().isEqualTo(entity) }, - ) - } - - @ParameterizedTest - @MethodSource("commitList") - fun `repositoryMapper toEntity, with commits with parents`( - commitList: List, - noOfUniqueCommits: Int, - ) { - // Create a minimal valid Repository domain object - val domain = - Repository( - id = "10", - localPath = "TestRepo", - project = Project( - id = projectEntity.id?.toString(), - name = projectEntity.name, - description = projectEntity.description, - ) - ) - commitList.forEach { - // wire up author, committer - it.author?.authoredCommits?.add(it) - it.committer?.committedCommits?.add(it) - it.repository = domain - it.branches.forEach { b -> domain.branches.add(b) } - it.parents.forEach { parent -> - parent.repository = domain - it.branches.forEach { b -> - parent.branches.add(b) - domain.branches.add(b) - } - parent.children.add(it) - } - } - - commitList.forEach { domain.commits.add(it) } - - assertThat(domain.branches).hasSize(1) - - // Map to entity - val entity = repositoryMapper.toEntity(domain, projectEntity) - - // Assert mapping - assertAll( - { assertThat(entity.commits).hasSize(noOfUniqueCommits) }, - { assertThat(entity.branches).hasSize(1) }, - { - assertThat(entity.commits.map { it.repository?.id }).containsOnly(domain.id?.toLong()) - }, - ) - (domain.commits + domain.commits.flatMap { it.parents } + domain.commits.flatMap { it.children }).forEach { domainCmt -> - val entityCmt = - entity.commits.find { it.sha == domainCmt.sha } ?: throw IllegalStateException("must find commit here") - assertThat(entityCmt) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes( - ".*id", - ".*repositoryId", - ".*repository", - ".*children", - ".*commits", - ".*commitShas", - ".*diffs" - ) - .isEqualTo(domainCmt) - assertThat(entityCmt.branches.flatMap { b -> b.commits.map { c -> c.sha } }) - .containsAll(domainCmt.branches.flatMap { b -> b.commits.map { c -> c.sha } }) - } - } - - @Test - fun `repositoryMapper toEntity, with commits with branch, add via commit`() { - // Create a minimal valid Repository domain object - val domain = - Repository( - id = "10", - localPath = "TestRepo", - project = Project( - id = projectEntity.id?.toString(), - name = projectEntity.name, - description = projectEntity.description, - ) - ) - val commit = - Commit( - id = "1", - sha = "a".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 1, 1, 0, 0, 0), - message = "Valid commit 1", - ) - val branch = - Branch( - name = "test", - repository = domain, - ) - branch.commits.add(commit) - commit.branches.add(branch) - domain.branches.add(branch) - - domain.commits.add(commit) - - // Map to entity - val entity = repositoryMapper.toEntity(domain, projectEntity) - - assertThat(entity.commits).hasSize(domain.commits.size) - assertThat(entity.branches).hasSize(domain.branches.size) - assertThat(entity.commits).hasSize(1) - assertThat(entity.commits.map { it.branches }).hasSize(1) - assertThat(entity.branches).hasSize(1) - assertThat(entity.commits.flatMap { it.branches }.toList()[0]).isSameAs(entity.branches.toList()[0]) - } - - @Nested - inner class ToDomain : BaseMapperTest() { - private lateinit var repositoryEntity: RepositoryEntity - private lateinit var repositoryDomain: Repository - private lateinit var branchEntity: BranchEntity - - private lateinit var commitEntityA: CommitEntity - private lateinit var commitEntityB: CommitEntity - private lateinit var commitDomainA: Commit - private lateinit var commitDomainB: Commit - - val entityManagerMock = mockk() - - @BeforeEach - fun setup() { - run { - val field = RepositoryMapper::class.java.getDeclaredField("entityManager") - field.isAccessible = true - field.set(repositoryMapper, entityManagerMock) - } - - this.repositoryEntity = - RepositoryEntity( - id = 1L, - localPath = "TestRepository", - project = - ProjectEntity( - id = 1L, - name = "TestProject", - description = "A test project", - ), - ) - this.repositoryDomain = - Repository( - id = this.repositoryEntity.id?.toString(), - localPath = this.repositoryEntity.localPath, - project = Project( - id = this.repositoryEntity.project.id?.toString(), - name = this.repositoryEntity.project.name, - description = this.repositoryEntity.project.description, - ), - ) - - this.commitEntityA = - CommitEntity( - id = 1, - sha = "A".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 1, 1, 0, 0, 0), - message = "Valid commit 1", - repository = this.repositoryEntity, - branches = mutableSetOf(), - ) - - this.commitEntityB = - CommitEntity( - id = 2, - sha = "B".repeat(40), - authorDateTime = LocalDateTime.of(2020, 1, 3, 1, 0, 0, 0), - commitDateTime = LocalDateTime.of(2020, 1, 2, 1, 0, 0, 0), - message = "Valid commit 2", - repository = this.repositoryEntity, - branches = mutableSetOf(), - ) - - this.branchEntity = - BranchEntity( - name = "testBranch", - repository = this.repositoryEntity, - commits = mutableSetOf(), - ) -// commitEntityA -// this.commitEntityA.addBranch(this.branchEntity) -// commitEntityB -// this.commitEntityB.addBranch(this.branchEntity) - - this.commitDomainA = - Commit( - id = this.commitEntityA.id?.toString(), - sha = this.commitEntityA.sha, - authorDateTime = this.commitEntityA.authorDateTime, - commitDateTime = this.commitEntityA.commitDateTime, - message = this.commitEntityA.message, -// repositoryId = -// this.commitEntityA.repository -// ?.id -// .toString(), - ) - repositoryDomain.commits.add(commitDomainA) - - this.commitDomainB = - Commit( - id = this.commitEntityB.id?.toString(), - sha = this.commitEntityB.sha, - authorDateTime = this.commitEntityB.authorDateTime, - commitDateTime = this.commitEntityB.commitDateTime, - message = this.commitEntityB.message, -// repositoryId = -// this.commitEntityB.repository -// ?.id -// .toString(), - ) - repositoryDomain.commits.add(commitDomainB) - - every { entityManagerMock.find(RepositoryEntity::class.java, repositoryEntity.id) } returns repositoryEntity - } - - @Test - @Disabled("Probably the mapper needs a refactoring as it uses transactions internally") - fun `repositoryMapper toDomain, with commit and parent`() { - commitEntityA.addParent(commitEntityB) - commitEntityA.addBranch(branchEntity) - branchEntity.addCommit(commitEntityA) - branchEntity.addCommit(commitEntityB) - - repositoryEntity.addCommit(commitEntityA) - repositoryEntity.addCommit(commitEntityB) - repositoryEntity.addBranch(branchEntity) - - val domain = repositoryMapper.toDomain(repositoryEntity, null) - - assertThat(domain.commits).hasSize(2) -// assertThat(domain.branches).hasSize(1) - val commitA = domain.commits.find { it.sha == commitEntityA.sha } - ?: throw java.lang.IllegalStateException("Could not find commitEntityA") - val commitB = domain.commits.find { it.sha == commitEntityB.sha } - ?: throw java.lang.IllegalStateException("Could not find commitEntityB") - - assertThat(commitA.parents).hasSize(1) - assertThat(commitA.children).hasSize(0) - - assertThat(commitB.parents).hasSize(0) - assertThat(commitB.children).hasSize(1) - } - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/base/BaseMapperTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/base/BaseMapperTest.kt deleted file mode 100644 index c268dda71..000000000 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/mapper/base/BaseMapperTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.integration.persistence.mapper.base - -import com.inso_world.binocular.core.integration.base.BaseIntegrationTest -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingScope -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.annotation.DirtiesContext - -@SpringBootApplication( - scanBasePackages = ["com.inso_world.binocular.infrastructure.sql.mapper", "com.inso_world.binocular.core"], -) -private class MapperTestApplication - -@SpringBootTest( - classes = [MapperTestApplication::class], - properties = [ - "spring.autoconfigure.exclude=" + - "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," + - "org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration," + - "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration," + - "org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration," + - "org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration" - ] -) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) -internal class BaseMapperTest : BaseIntegrationTest() { - @Autowired - private lateinit var mappingScope: MappingScope - - @BeforeEach - fun openSession() { - mappingScope.startSession() - } - - @AfterEach - fun closeSession() { - mappingScope.endSession() - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/repository/RepositoryRepositoryTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/repository/RepositoryRepositoryTest.kt new file mode 100644 index 000000000..64929ce4c --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/repository/RepositoryRepositoryTest.kt @@ -0,0 +1,313 @@ +//package com.inso_world.binocular.infrastructure.sql.integration.persistence.repository +// +//import com.inso_world.binocular.infrastructure.sql.integration.persistence.repository.base.BaseRepositoryTest +//import com.inso_world.binocular.infrastructure.sql.persistence.entity.CommitEntity +//import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity +//import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity +//import com.inso_world.binocular.infrastructure.sql.persistence.entity.UserEntity +//import com.inso_world.binocular.infrastructure.sql.persistence.repository.ProjectRepository +//import com.inso_world.binocular.infrastructure.sql.persistence.repository.RepositoryRepository +//import com.inso_world.binocular.model.Commit +//import com.inso_world.binocular.model.Project +//import com.inso_world.binocular.model.Repository +//import com.inso_world.binocular.model.User +//import org.assertj.core.api.Assertions.assertThat +//import org.junit.jupiter.api.AfterEach +//import org.junit.jupiter.api.BeforeEach +//import org.junit.jupiter.api.DisplayName +//import org.junit.jupiter.api.Nested +//import org.junit.jupiter.api.Test +//import org.junit.jupiter.api.assertAll +//import org.springframework.beans.factory.annotation.Autowired +//import java.time.LocalDateTime +//import kotlin.uuid.ExperimentalUuidApi +//import kotlin.uuid.Uuid +// +//@OptIn(ExperimentalUuidApi::class) +//internal class RepositoryRepositoryTest : BaseRepositoryTest() { +// @Autowired +// private lateinit var repositoryRepository: RepositoryRepository +// +// @Autowired +// private lateinit var projectRepository: ProjectRepository +// +//// @Autowired +//// private lateinit var commitRepository: CommitRepository +// +// private lateinit var testProject: ProjectEntity +// +// @BeforeEach +// fun setup() { +// testProject = projectRepository.save( +// ProjectEntity( +// name = "Test Project", +// iid = Project.Id(Uuid.random()), +// ).apply { description = "Test Description" } +// ) +// } +// +// @AfterEach +// fun cleanup() { +//// tearDown() +// } +// +// private fun createRepositoryEntity(localPath: String): RepositoryEntity { +// return RepositoryEntity( +// iid = Repository.Id(Uuid.random()), +// localPath = localPath, +// project = testProject +// ) +// } +// +// private fun createCommitEntity( +// sha: String, +// repository: RepositoryEntity, +// commitDateTime: LocalDateTime = LocalDateTime.of(2024, 1, 1, 12, 0) +// ): CommitEntity { +// val user = UserEntity( +// name = "Test User", +// email = "test@example.com", +// repository = repository, +// iid = User.Id(Uuid.random()) +// ) +// repository.user.add(user) +// +// return CommitEntity( +// sha = sha, +// commitDateTime = commitDateTime, +// repository = repository, +// iid = Commit.Id(Uuid.random()) +// ).apply { committer = user } +// .also { +// repository.commits.add(it) +// } +// } +// +// @Nested +// @DisplayName("findByIidAndCommits_ShaIn Tests") +// inner class FindByIidAndCommitsShaInTests { +// +// @Test +// fun `returns repository when iid exists and commits contain one of the SHAs`() { +// val repository = createRepositoryEntity("/test/repo1") +// val commit1 = createCommitEntity("a".repeat(40), repository) +// val commit2 = createCommitEntity("b".repeat(40), repository) +// val commit3 = createCommitEntity("c".repeat(40), repository) +// +// val saved = repositoryRepository.save(repository) +// +// val found = repositoryRepository.findByIidAndCommits_ShaIn( +// saved.iid.value, +// listOf("a".repeat(40), "z".repeat(40)) +// ) +// +// assertAll( +// "Verify repository found", +// { assertThat(found).isNotNull }, +// { assertThat(found?.iid).isEqualTo(saved.iid) }, +// { assertThat(found?.localPath).isEqualTo("/test/repo1") }, +// { assertThat(found?.commits).hasSize(3) } +// ) +// } +// +// @Test +// fun `returns repository when iid exists and commits contain all provided SHAs`() { +// val repository = createRepositoryEntity("/test/repo2") +// val commit1 = createCommitEntity("d".repeat(40), repository) +// val commit2 = createCommitEntity("e".repeat(40), repository) +// +// val saved = repositoryRepository.save(repository) +// +// val found = repositoryRepository.findByIidAndCommits_ShaIn( +// saved.iid.value, +// listOf("d".repeat(40), "e".repeat(40)) +// ) +// +// assertAll( +// "Verify repository found with all matching SHAs", +// { assertThat(found).isNotNull }, +// { assertThat(found?.iid).isEqualTo(saved.iid) }, +// { assertThat(found?.commits).hasSize(2) } +// ) +// } +// +// @Test +// fun `returns null when iid exists but no commits match the SHAs`() { +// val repository = createRepositoryEntity("/test/repo3") +//// createCommitEntity("f".repeat(40), repository) +//// createCommitEntity("1".repeat(40), repository) +// +// val saved = repositoryRepository.save(repository) +// +// val found = repositoryRepository.findByIidAndCommits_ShaIn( +// saved.iid.value, +// listOf("x".repeat(40), "y".repeat(40)) +// ) +// +// assertThat(found).isNull() +// } +// +// @Test +// fun `returns null when iid does not exist`() { +// val repository = createRepositoryEntity("/test/repo4") +//// createCommitEntity("2".repeat(40), repository) +//// +// repositoryRepository.save(repository) +// +// val nonExistentIid = kotlin.uuid.Uuid.random() +// val found = repositoryRepository.findByIidAndCommits_ShaIn( +// nonExistentIid, +// listOf("2".repeat(40)) +// ) +// +// assertThat(found).isNull() +// } +// +// @Test +// fun `returns null when repository has no commits`() { +// val repository = createRepositoryEntity("/test/repo5") +// val saved = repositoryRepository.save(repository) +// +// val found = repositoryRepository.findByIidAndCommits_ShaIn( +// saved.iid.value, +// listOf("3".repeat(40)) +// ) +// +// assertThat(found).isNull() +// } +// +// @Test +// fun `returns repository when one of multiple SHAs matches`() { +// val repository = createRepositoryEntity("/test/repo6") +//// createCommitEntity("4".repeat(40), repository) +//// createCommitEntity("5".repeat(40), repository) +//// +// val saved = repositoryRepository.save(repository) +// +// val found = repositoryRepository.findByIidAndCommits_ShaIn( +// saved.iid.value, +// listOf("4".repeat(40), "nonexistent1", "nonexistent2") +// ) +// +// assertAll( +// "Verify repository found when at least one SHA matches", +// { assertThat(found).isNotNull }, +// { assertThat(found?.iid).isEqualTo(saved.iid) } +// ) +// } +// +// @Test +// fun `handles empty SHA collection by returning null`() { +// val repository = createRepositoryEntity("/test/repo7") +// createCommitEntity("6".repeat(40), repository) +// +// val saved = repositoryRepository.save(repository) +// +// val found = repositoryRepository.findByIidAndCommits_ShaIn( +// saved.iid.value, +// emptyList() +// ) +// +// assertThat(found).isNull() +// } +// +// @Test +// fun `case sensitivity test - exact SHA match required`() { +// val repository = createRepositoryEntity("/test/repo8") +// createCommitEntity("abcdef1234567890abcdef1234567890abcdef12", repository) +// +// val saved = repositoryRepository.save(repository) +// +// val foundLowercase = repositoryRepository.findByIidAndCommits_ShaIn( +// saved.iid.value, +// listOf("abcdef1234567890abcdef1234567890abcdef12") +// ) +// +// val foundUppercase = repositoryRepository.findByIidAndCommits_ShaIn( +// saved.iid.value, +// listOf("ABCDEF1234567890ABCDEF1234567890ABCDEF12") +// ) +// +// assertAll( +// "Verify SHA matching is case-sensitive", +// { assertThat(foundLowercase).isNotNull }, +// { assertThat(foundUppercase).isNull() } +// ) +// } +// +// @Test +// fun `works correctly with large number of SHAs`() { +// val repository = createRepositoryEntity("/test/repo9") +// +// // Create 50 commits +// repeat(50) { index -> +// createCommitEntity(index.toString().padStart(40, '0'), repository) +// } +// +// val saved = repositoryRepository.save(repository) +// +// // Search with 100 SHAs (50 existing + 50 non-existing) +// val shas = (0 until 100).map { it.toString().padStart(40, '0') } +// +// val found = repositoryRepository.findByIidAndCommits_ShaIn( +// saved.iid.value, +// shas +// ) +// +// assertAll( +// "Verify repository found with large SHA collection", +// { assertThat(found).isNotNull }, +// { assertThat(found?.commits).hasSize(50) } +// ) +// } +// +// @Test +// fun `distinguishes between different repositories with same commit SHAs`() { +// val repo1 = createRepositoryEntity("/test/repo10") +// createCommitEntity("7".repeat(40), repo1) +// val saved1 = repositoryRepository.save(repo1) +// +// val repo2 = createRepositoryEntity("/test/repo11") +// createCommitEntity("7".repeat(40), repo2) +// val saved2 = repositoryRepository.save(repo2) +// +// val foundRepo1 = repositoryRepository.findByIidAndCommits_ShaIn( +// saved1.iid.value, +// listOf("7".repeat(40)) +// ) +// +// val foundRepo2 = repositoryRepository.findByIidAndCommits_ShaIn( +// saved2.iid.value, +// listOf("7".repeat(40)) +// ) +// +// assertAll( +// "Verify correct repositories are returned", +// { assertThat(foundRepo1).isNotNull }, +// { assertThat(foundRepo2).isNotNull }, +// { assertThat(foundRepo1?.iid).isEqualTo(saved1.iid) }, +// { assertThat(foundRepo2?.iid).isEqualTo(saved2.iid) }, +// { assertThat(foundRepo1?.localPath).isEqualTo("/test/repo10") }, +// { assertThat(foundRepo2?.localPath).isEqualTo("/test/repo11") } +// ) +// } +// +// @Test +// fun `returns null when searching wrong repository with correct SHA`() { +// val repo1 = createRepositoryEntity("/test/repo12") +// createCommitEntity("8".repeat(40), repo1) +// repositoryRepository.save(repo1) +// +// val repo2 = createRepositoryEntity("/test/repo13") +// createCommitEntity("9".repeat(40), repo2) +// val saved2 = repositoryRepository.save(repo2) +// +// val found = repositoryRepository.findByIidAndCommits_ShaIn( +// saved2.iid.value, +// listOf("8".repeat(40)) // SHA from repo1 +// ) +// +// assertThat(found).isNull() +// } +// } +//} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/repository/base/BaseRepositoryTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/repository/base/BaseRepositoryTest.kt new file mode 100644 index 000000000..6c92db65c --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/persistence/repository/base/BaseRepositoryTest.kt @@ -0,0 +1,52 @@ +package com.inso_world.binocular.infrastructure.sql.integration.persistence.repository.base + +import com.inso_world.binocular.core.integration.base.BaseIntegrationTest +import com.inso_world.binocular.infrastructure.sql.SqlTestConfig +import com.inso_world.binocular.infrastructure.sql.SqlInfrastructureDataSetup +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.transaction.support.TransactionTemplate + +@SpringBootTest +@EnableAutoConfiguration +@ContextConfiguration( + classes = [SqlTestConfig::class], + initializers = [ + SqlTestConfig.Initializer::class, + ] +) +@ExtendWith(SpringExtension::class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) +internal class BaseRepositoryTest : BaseIntegrationTest() { + @Autowired + private lateinit var transactionTemplate: TransactionTemplate + + @PersistenceContext + private lateinit var entityManager: EntityManager + + @Autowired + private lateinit var testDataSetupService: SqlInfrastructureDataSetup +// protected lateinit var project: Project +// protected lateinit var repository: Repository + + internal fun setUp() { + testDataSetupService.setup() + } + + @AfterEach + internal fun tearDown() { + transactionTemplate.execute { + entityManager.flush() + entityManager.clear() + testDataSetupService.teardown() + } + } +} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/CommitInfrastructurePortImplTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/CommitInfrastructurePortImplTest.kt deleted file mode 100644 index 613e08f3b..000000000 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/CommitInfrastructurePortImplTest.kt +++ /dev/null @@ -1,1130 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.integration.service - -import com.inso_world.binocular.infrastructure.sql.integration.service.base.BaseServiceTest -import com.inso_world.binocular.infrastructure.sql.mapper.context.MappingContext -import com.inso_world.binocular.infrastructure.sql.persistence.dao.RepositoryDao -import com.inso_world.binocular.infrastructure.sql.service.BranchInfrastructurePortImpl -import com.inso_world.binocular.infrastructure.sql.service.CommitInfrastructurePortImpl -import com.inso_world.binocular.infrastructure.sql.service.ProjectInfrastructurePortImpl -import com.inso_world.binocular.infrastructure.sql.service.RepositoryInfrastructurePortImpl -import com.inso_world.binocular.infrastructure.sql.service.UserInfrastructurePortImpl -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User -import io.mockk.mockk -import io.mockk.verify -import jakarta.validation.ConstraintViolationException -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Assertions.assertSame -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertAll -import org.junit.jupiter.api.assertDoesNotThrow -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.Arguments -import org.junit.jupiter.params.provider.MethodSource -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.dao.DataAccessException -import java.time.LocalDateTime -import java.util.stream.Stream - -internal class CommitInfrastructurePortImplTest : BaseServiceTest() { - @Autowired - private lateinit var branchPort: BranchInfrastructurePortImpl - - @Autowired - private lateinit var userPort: UserInfrastructurePortImpl - - @Autowired - private lateinit var projectPort: ProjectInfrastructurePortImpl - - @Autowired - private lateinit var repositoryPort: RepositoryInfrastructurePortImpl - - @Autowired - private lateinit var commitPort: CommitInfrastructurePortImpl - - private var repository = - Repository( - localPath = "test repository", - ) - private lateinit var branchDomain: Branch - private lateinit var project: Project - - @BeforeEach - fun setup() { - this.project = - projectPort.create( - Project( - name = "test project", - repo = repository, - ), - ) - this.repository = this.project.repo ?: throw IllegalStateException("test repository can not be null") - this.branchDomain = - Branch( - name = "test branch", - repository = repository, - ) - } - - companion object { - @JvmStatic - fun provideCyclicCommits(): Stream { - fun user() = - User( - name = "test", - email = "test@example.com", - ) - - fun commit1() = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2020, 1, 1, 0, 0, 0), - ) - - fun commit2() = - Commit( - sha = "fedcbafedcbafedcbafedcbafedcbafedcbafedc", - message = "yet another commit", - commitDateTime = LocalDateTime.of(2021, 1, 1, 0, 0, 0), - ) - - fun commit3() = - Commit( - sha = "0987654321098765432109876543210987654321", - message = "commit number three", - commitDateTime = LocalDateTime.of(2022, 1, 1, 0, 0, 0), - ) - - return Stream.of( - // 1, one commit, self referencing - Arguments.of( - run { - val c1 = commit1() - c1.parents.add(c1) - listOf( - c1 - ) - } - ), -// 2 - Arguments.of( - run { - val c1 = commit1() - val c2 = commit2() - c1.parents.add(c2) - c2.parents.add(c2) - - listOf(c1) - } - ), -// 3 - Arguments.of( - run { - val c1 = commit1() - val c2 = commit2() - - c1.parents.add(c2) - c2.parents.add(c1) - - listOf(c1) - } - ), -// 4 - Arguments.of( - run { - val c1 = commit1() - val c2 = commit2() - val c3 = commit3() - - c1.parents.add(c2) - c2.parents.add(c3) - c2.parents.add(c1) - - listOf(c1) - } - ), -// 5 - Arguments.of( - run { - val c1 = commit1() - val c2 = commit2() - val c3 = commit3() - - c1.parents.add(c2) - c2.parents.add(c2) - - listOf(c1, c3) - } - ), -// 6, same as 5 but reversed order - Arguments.of( - run { - val c1 = commit1() - val c2 = commit2() - val c3 = commit3() - - c1.parents.add(c2) - c2.parents.add(c2) - - listOf(c3, c1) - } - ), -// 7, just save middle commit c2 - Arguments.of( - run { - val c1 = commit1() - val c2 = commit2() - val c3 = commit3() - - c1.parents.add(c2) - c2.parents.add(c3) - c3.parents.add(c1) - - listOf(c2) - } - ), -// 8, just save first commit c1 - Arguments.of( - run { - val c1 = commit1() - val c2 = commit2() - val c3 = commit3() - - c1.parents.add(c2) - c2.parents.add(c3) - c3.parents.add(c1) - - listOf(c1) - } - ), -// 9, just save last commit c1 - Arguments.of( - run { - val c1 = commit1() - val c2 = commit2() - val c3 = commit3() - - c1.parents.add(c2) - c2.parents.add(c3) - c3.parents.add(c1) - - listOf(c3) - } - ), - ) - } - - @JvmStatic - fun provideCommitsAndLists(): Stream { - fun user() = - User( - name = "user 1", - email = "user@example.com", - ) - - fun commit1_pc(): Commit { - val cmt = - Commit( - sha = "1".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2020, 1, 1, 0, 0, 0), - ) - val user = user() - user.committedCommits.add(cmt) - return cmt - } - - fun commit2_pc(): Commit { - val cmt = - Commit( - sha = "2".repeat(40), - message = "yet another commit", - commitDateTime = LocalDateTime.of(2021, 1, 1, 0, 0, 0), - ) - val user = user() - user.committedCommits.add(cmt) - return cmt - } - - fun commit3_pc(): Commit { - val cmt = - Commit( - sha = "3".repeat(40), - message = "commit number three", - commitDateTime = LocalDateTime.of(2022, 1, 1, 0, 0, 0), - ) - val user = user() - user.committedCommits.add(cmt) - return cmt - } - - return Stream.of( -// 1 - Arguments.of( - listOf( - commit1_pc(), - ), - ), -// 2 - Arguments.of( - run { - val c1 = commit1_pc() - val c2 = commit2_pc() - listOf( - c1, c2 - ) - } - ), -// 3 - Arguments.of( - run { - val c1 = commit1_pc() - val c2 = commit2_pc() - val c3 = commit3_pc() - - listOf( - c1, c2, c3 - ) - } - ), - // 4, two commits, with relationship c1->c2 - Arguments.of( - run { - val c1 = commit1_pc() - val c2 = commit2_pc() - - c1.parents.add(c2) - - listOf( -// intentionally missing c2 here - c1 - ) - } - ), - // 4.2, two commits, with relationship c1<-c2 - Arguments.of( - run { - val c1 = commit1_pc() - val c2 = commit2_pc() - - c1.children.add(c2) - - listOf( -// intentionally missing c2 here - c1 - ) - } - ), - // 5, second commit without extra - Arguments.of( - run { - val c1 = commit1_pc() - val c2 = commit2_pc() - val c3 = commit3_pc() - - c1.parents.add(c2) - - listOf( -// intentionally missing c2 here - c1, c3 - ) - } - ), - // 6, two commits, with relationship, with extra - Arguments.of( - run { - val c1 = commit1_pc() - val c2 = commit2_pc() - c1.parents.add(c2) - - listOf( - c1, c2 - ) - }, - ), -// 7, octopus merge - Arguments.of( - run { - val c1 = commit1_pc() - c1.parents.add(commit2_pc()) - c1.parents.add(commit3_pc()) - - listOf(c1) - } - ), -// 8, octopus merge - Arguments.of( - run { - val c1 = commit1_pc() - val c2 = commit2_pc() - val c3 = commit3_pc() - - c1.parents.add(c3) - c1.parents.add(c2) - - c3.parents.add(c2) - listOf( - c1, c2, c3 - ) - } - ), -// 9, octopus merge - Arguments.of( - run { - val c1 = commit1_pc() - val c2 = commit2_pc() - val c3 = commit3_pc() - - c1.parents.add(c2) - c1.parents.add(c3) - -// vice versa to 7 - c2.parents.add(c3) - - listOf( - c1, c2, c3 - ) - } - ), - ) - } - } - - @Nested - inner class UpdateOperation : BaseServiceTest() { - private lateinit var savedCommit: Commit - - @BeforeEach - fun setup() { - val user = - User( - name = "user 1", - email = "user@example.com", - ) - val baseBranch = - Branch( - name = "fixed branch", - ) - val baseCommit = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2020, 1, 1, 0, 0, 0), - ) - repository.commits.add(baseCommit) - baseBranch.commits.add(baseCommit) - user.committedCommits.add(baseCommit) - baseBranch.commits.add(baseCommit) - repository.branches.add(baseBranch) - repository.user.add(user) - - this.savedCommit = commitPort.create(baseCommit) - } - - @Test - fun `update commit unchanged, should not fail`() { - assertDoesNotThrow { - commitPort.update(savedCommit) - } - assertAll( - "check database numbers", - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - } - - @Test - fun `update commit, add new branch`() { - assertThat(savedCommit.branches).hasSize(1) - val newBranch = - Branch( - name = "new branch", - ) - - savedCommit.branches.add(newBranch) - newBranch.commits.add(savedCommit) - repository.branches.add(newBranch) - - assertAll( - "check model", - { assertThat(savedCommit.branches).hasSize(2) }, - { assertThat(newBranch.commits).hasSize(1) }, - ) - - val updatedEntity = - assertDoesNotThrow { - commitPort.update(savedCommit) - } - - assertThat(updatedEntity.branches).hasSize(2) - } - - @Test - fun `update commit, remove branch`() { - // cleanup - super.tearDown() - - assertAll( - "check database numbers", - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(userPort.findAll()).hasSize(0) }, - { assertThat(repositoryPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - ) - - val branchA = - Branch( - name = "A", - ) - val commit = - run { -// just the setup part here - val user = - User( - name = "test", - email = "test@example.com", - ) - val branchB = - Branch( - name = "B", - ) - val project = - projectPort.create( - Project( - name = "test project", - repo = - Repository( - localPath = "test repository", - ), - ), - ) - val repository = project.repo!! - val cmt = - Commit( - sha = "C".repeat(40), - message = "msg", - commitDateTime = LocalDateTime.now(), - ) - repository.commits.add(cmt) - cmt.branches.addAll(mutableSetOf(branchA, branchB)) - - user.committedCommits.add(cmt) - repository.user.add(user) - - branchA.commits.add(cmt) - branchB.commits.add(cmt) - repository.branches.add(branchA) - repository.branches.add(branchB) - - val savedCommit = - assertDoesNotThrow { - commitPort.create(cmt) - } - - assertAll( - "check database numbers", - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(2) }, - ) -// setup done here - return@run savedCommit - } - - run { - assertTrue(commit.branches.removeIf { it.name == "A" }) - assertThat(commit.branches).hasSize(1) - - val updatedEntity = - assertDoesNotThrow { - commitPort.update(commit) - } - - assertThat(updatedEntity.branches).hasSize(1) - - assertAll( - "check database numbers", - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - ) - } - } - } - - @Nested - inner class SaveOperation : BaseServiceTest() { - @Autowired - private lateinit var ctx: MappingContext -// -// @BeforeEach -// fun setup() { -// mappingScope.clear() -// } - - @ParameterizedTest - @MethodSource( - "com.inso_world.binocular.infrastructure.sql.integration.service.CommitInfrastructurePortImplTest#provideCyclicCommits", - ) - @Disabled("until something clever is implemented for cycle detection") - fun `save multiple commits with cycle, expect ValidationException`(commitList: List) { - var branch = Branch( - name = "test branch" - ) - - val repositoryDao = mockk() - - val ex = assertThrows { - commitList.forEach { cmt -> - cmt.repository = repository - cmt.parents.forEach { c -> - c.repository = repository - - branch.commits.add(c) - c.branches.add(branch) - - c.committer?.committedCommits?.add(c) - } - - branch.commits.add(cmt) - cmt.branches.add(branch) - - cmt.committer?.committedCommits?.add(cmt) - - repository.commits.add(cmt) - branch = commitPort.create(cmt).branches.toList()[0] - } - } - assertThat(ex.message).contains("Cyclic dependency detected") - verify(exactly = 0) { repositoryDao.create(any()) } - } - - @ParameterizedTest - @MethodSource( - "com.inso_world.binocular.infrastructure.sql.integration.service.CommitInfrastructurePortImplTest#provideCommitsAndLists", - ) - fun `save multiple commits with repository, expecting in database`(commitList: List) { - val savedCommits = - commitList - .map { cmt -> - cmt.branches.add(branchDomain) - branchDomain.commits.add(cmt) - cmt.repository = repository - cmt.committer?.repository = repository - cmt.author?.repository = repository - (cmt.parents + cmt.children).toSet().forEach { c -> - c.repository = repository - c.committer?.repository = repository - c.committer?.let { - repository.user.add(it) - it.committedCommits.add(c) - } - c.author?.repository = repository - c.author?.let { - repository.user.add(it) - it.authoredCommits.add(c) - } - c.branches.add(branchDomain) - branchDomain.commits.add(c) -// repository.commits.add(c) - } -// repository.commits.add(cmt) - cmt.committer?.let { - repository.user.add(it) - it.committedCommits.add(cmt) - } - cmt.author?.let { - repository.user.add(it) - it.authoredCommits.add(cmt) - } - repository.branches.add(branchDomain) - - assertDoesNotThrow { - commitPort.create(cmt) - } - }.map { - commitPort.findById(it.id!!) ?: throw IllegalStateException("must find commit here") - } - repository.commits.addAll(savedCommits) - - assertAll( - "check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - assertThat(commitPort.findAll()).hasSize( - commitList - .map { it.sha } - .union( - commitList.flatMap { it.parents.map { parent -> parent.sha } }, - ).union( - commitList.flatMap { it.children.map { parent -> parent.sha } }, - ).distinct() - .size, - ) - run { -// check that branch with same identity map onto same object after mapping - val allBranches = commitPort.findAll().flatMap { it.branches } - if (allBranches.isNotEmpty()) { - val first = allBranches.first() - assertAll( - allBranches.map { branch -> - { assertSame(first, branch, "Branch is not the same instance as the first") } - }, - ) - } - } - run { - val elements = commitPort.findExistingSha(repository, commitList.map { it.sha }) - assertThat(elements) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") // This ignores only fields starting with _ - .isEqualTo(savedCommits) - } - run { - val elements = commitPort.findExistingSha(repository, commitList.map { it.sha }) - assertThat(elements) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .ignoringFields( - "users", // deprecated field - ).isEqualTo( - project.repo - ?.commits, - ) - } - run { - val elements = commitPort.findExistingSha(repository, commitList.map { it.sha }) - assertThat(elements) - .usingRecursiveComparison() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .ignoringFields( - "users", // deprecated field - ).ignoringCollectionOrder() - .isEqualTo( - repository - .commits, - ) - } - } - - @ParameterizedTest - @MethodSource( - "com.inso_world.binocular.infrastructure.sql.integration.service.CommitInfrastructurePortImplTest#provideCommitsAndLists", - ) - fun `save multiple commits with repository, verify relationship to repository`(commitList: List) { - val savedEntities = - commitList - .map { cmt -> - (listOf(cmt) + cmt.parents + cmt.children).forEach { elem -> - repository.commits.add(elem) - branchDomain.commits.add(elem) - elem.committer?.let { - repository.user.add(it) - elem.committer = it - } - elem.author?.let { - repository.user.add(it) - elem.author = it - } - } - assertDoesNotThrow { - return@map commitPort.create(cmt) - } - }.map { - commitPort.findById(it.id!!) ?: throw IllegalStateException("must find commit here") - } - repository.commits.clear() - repository.commits.addAll(savedEntities) - - val expectedCommits = - (savedEntities + savedEntities.flatMap { it.parents } + savedEntities.flatMap { it.children }) - .distinctBy { it.sha } - - assertAll( - "Check database numbers", - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(expectedCommits.size) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - run { - val elem = repositoryPort.findAll().toList()[0] - assertThat(elem.commits).hasSize(expectedCommits.size) - } - run { - val elem = repositoryPort.findAll().toList()[0] - assertThat(elem.commits) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .isEqualTo(expectedCommits) - } - - run { - val elem = commitPort.findAll() - assertThat(elem) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .isEqualTo(expectedCommits) - } - // do not continue here as it fails anyway then - run { - assertThat( - repositoryPort - .findAll() - .toList()[0] - .commits, - ).usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .isEqualTo(expectedCommits) - } - run { - val elements = commitPort.findExistingSha(repository, expectedCommits.map { it.sha }) - assertThat( - repositoryPort - .findAll() - .toList()[0] - .commits, - ).usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .isEqualTo(elements) - } - } - - @ParameterizedTest - @MethodSource( - "com.inso_world.binocular.infrastructure.sql.integration.service.CommitInfrastructurePortImplTest#provideCommitsAndLists", - ) - fun `save multiple commits with repository, verify relationship to project`(commitList: List) { - val savedEntities = - run { - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - repository.user.add(user) - val savedCommits = - commitList - .map { cmt -> - (listOf(cmt) + cmt.parents + cmt.children).forEach { elem -> - repository.commits.add(elem) - branchDomain.commits.add(elem) - elem.committer?.let { - repository.user.add(it) - elem.committer = it - } - elem.author?.let { - repository.user.add(it) - elem.author = it - } - } - assertDoesNotThrow { - commitPort.create(cmt) - } - }.map { - commitPort.findById(it.id!!) ?: throw IllegalStateException("must find commit here") - } - repository.commits.clear() - repository.commits.addAll(savedCommits) - - return@run savedCommits - } - - val expectedCommits = - (savedEntities + savedEntities.flatMap { it.parents }+ savedEntities.flatMap { it.children }) - .distinctBy { it.sha } - assertAll( - "check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(expectedCommits.size) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - // do not continue here as it fails anyway then - run { - val elem = projectPort.findAll().toList()[0] - assertThat(elem.repo?.commits).hasSize(expectedCommits.size) - } - run { - assertThat( - projectPort - .findAll() - .toList()[0] - .repo - ?.commits, - ).usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .isEqualTo(expectedCommits) - } - run { - val elements = commitPort.findExistingSha(repository, expectedCommits.map { it.sha }) - assertThat(elements) - .usingRecursiveComparison() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .ignoringCollectionOrder() - .isEqualTo( - projectPort - .findAll() - .toList()[0] - .repo - ?.commits, - ) - } - } - - @Test - fun `save 1 commit with repository, expecting in database`() { - val savedCommit = - run { - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - user.committedCommits.add(cmt) - cmt.branches.add(branchDomain) - branchDomain.commits.add(cmt) - cmt.repository = repository - - repository.commits.add(cmt) - repository.user.add(user) - repository.branches.add(branchDomain) - - assertAll( - "check model", - { assertThat(cmt.branches).hasSize(1) }, - { assertThat(cmt.committer).isNotNull() }, - { assertThat(branchDomain.commits).hasSize(1) }, - { assertThat(cmt.repository).isNotNull() }, - { assertThat(cmt.repository?.id).isNotNull() }, - { assertThat(user.repository).isNotNull() }, - { assertThat(cmt.repository?.id).isEqualTo(repository.id) }, - { assertThat(repository.commits).hasSize(1) }, - { assertThat(repository.user).hasSize(1) }, - { assertThat(user.committedCommits).hasSize(1) }, - ) - val saved = - assertDoesNotThrow { - commitPort.create(cmt) - } - - assertAll( - "check saved entity", - { assertThat(saved.branches).hasSize(1) }, - { assertThat(saved.branches.map { it.id }).doesNotContainNull() }, - { assertThat(saved.committer).isNotNull() }, - { assertThat(saved.author).isNull() }, - { assertThat(saved.repository).isNotNull() }, - { assertThat(saved.repository?.id).isNotNull() }, - { assertThat(saved.repository?.id).isEqualTo(repository.id) }, - ) - - return@run saved - } - - assertAll( - "check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - assertThat(commitPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .isEqualTo(savedCommit) - - assertThat( - commitPort.findAll().toList()[0], - ).usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .ignoringFields( - "users", // deprecated field - ).isEqualTo( - project.repo - ?.commits - ?.toList() - ?.get(0), - ) - assertThat( - commitPort.findAll().toList()[0], - ).usingRecursiveComparison() - .ignoringCollectionOrder() - .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") - .ignoringFields( - "users", // deprecated field - ).isEqualTo(repository.commits.toList()[0]) - assertThat( - commitPort.findAll().toList()[0], - ).usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedCommit) - - assertAll( - "check ids", - { assertThat(commitPort.findAll().toList()[0].id).isNotNull() }, - { assertThat(commitPort.findAll().toList()[0].repository).isNotNull() }, - { assertThat(commitPort.findAll().toList()[0].repository?.id).isNotNull() }, - { assertThat(commitPort.findAll().toList()[0].repository?.id).isEqualTo(project.repo?.id) }, - { assertThat(commitPort.findAll().toList()[0].repository?.id).isEqualTo(repository.id) }, - ) - } - - @Test - fun `save 1 commit with repository, verify relationship to repository`() { - val savedCommit = - run { - val user = - User( - name = "test", - email = "test", - ) - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - repository.commits.add(cmt) - branchDomain.commits.add(cmt) - user.committedCommits.add(cmt) - branchDomain.commits.add(cmt) - repository.user.add(user) - - assertDoesNotThrow { - return@run commitPort.create(cmt) - } - } - - assertAll( - "check database numbers", - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - // do not continue with assertAll as list access will be wrong - assertAll( - { - val elem = repositoryPort.findAll().toList()[0] - assertThat(elem.commits).hasSize(1) - }, - { - val elem = - repositoryPort - .findAll() - .toList()[0] - .commits - .toList()[0] - assertThat(elem) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedCommit) - }, - ) - } - - @Test - fun `save 1 commit with repository, verify relationship to project`() { - val savedCommit = - run { - val user = - User( - name = "test", - email = "test@example.com", - ) - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - repository.commits.add(cmt) - branchDomain.commits.add(cmt) - user.committedCommits.add(cmt) - branchDomain.commits.add(cmt) - repository.commits.add(cmt) - repository.user.add(user) - - assertDoesNotThrow { - return@run commitPort.create(cmt) - } - } - - assertAll( - "projectport", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(projectPort.findAll().toList()[0]).isNotNull() }, - { assertThat(projectPort.findAll().toList()[0].repo).isNotNull() }, - ) - // do not continue with assertAll as list access will be wrong - assertAll( - { - val elem = projectPort.findAll().toList()[0] - assertThat(elem.repo?.commits).hasSize(1) - }, - { - val elem = - projectPort - .findAll() - .toList()[0] - .repo - ?.commits - ?.toList()[0] - assertThat(elem) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedCommit) - }, - ) - } - } - - @Nested - inner class Validation : BaseServiceTest() { - @Test - fun `save 1 commit without branch, expect Constraint`() { - assertThrows { - commitPort.create( - Commit( - sha = "B".repeat(40), - ), - ) - } - } - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/ProjectInfrastructurePortImplTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/ProjectInfrastructurePortImplTest.kt new file mode 100644 index 000000000..c20c10e8b --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/ProjectInfrastructurePortImplTest.kt @@ -0,0 +1,1132 @@ +package com.inso_world.binocular.infrastructure.sql.integration.service + +import com.inso_world.binocular.core.service.ProjectInfrastructurePort +import com.inso_world.binocular.core.service.RepositoryInfrastructurePort +import com.inso_world.binocular.infrastructure.sql.integration.service.base.BaseServiceTest +import com.inso_world.binocular.model.Issue +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.domain.PageRequest +import java.time.LocalDateTime +import kotlin.uuid.ExperimentalUuidApi + +internal class ProjectInfrastructurePortImplTest : BaseServiceTest() { + @Autowired + private lateinit var projectPort: ProjectInfrastructurePort + + @Autowired + private lateinit var repositoryPort: RepositoryInfrastructurePort + +// @BeforeEach +// fun setup() { +// setUp() +// } + + @AfterEach + fun cleanup() { + tearDown() + } + + @Nested + @DisplayName("Create Operations") + inner class CreateOperations { + @Test + fun `create project with minimal required fields`() { + // Given: A project with only required name field + val project = Project(name = "MinimalProject") + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Project is created with all expected fields + assertAll( + "Verify minimal project creation", + { assertThat(created.name).isEqualTo("MinimalProject") }, + { assertThat(created.id).isNotNull() }, + { assertThat(created.iid).isNotNull() }, + { assertThat(created.description).isNull() }, + { assertThat(created.repo).isNull() }, + { assertThat(created.issues).isEmpty() } + ) + + // And: Project can be retrieved + assertThat(projectPort.findAll()).hasSize(1) + } + + @Test + fun `create project with minimal required fields, verify that domain object is returned again`() { + // Given: A project with only required name field + val project = Project(name = "MinimalProject") + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Project is created with all expected fields + assertAll( + { assertThat(project).isSameAs(created) }, + ) + } + + @Test + fun `create project with name and description`() { + // Given: A project with name and description + val project = Project(name = "DescribedProject").apply { + description = "This is a test project with a description" + } + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Project is created with description + assertAll( + "Verify project with description", + { assertThat(created.name).isEqualTo("DescribedProject") }, + { assertThat(created.description).isEqualTo("This is a test project with a description") }, + { assertThat(created.id).isNotNull() } + ) + } + + @Test + fun `create project with 254 character long description, should succeed`() { + val longDescription = "a".repeat(254) + val project = Project(name = "LongDescProject").apply { + description = longDescription + } + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Long description is stored correctly + assertThat(created.description).isEqualTo(longDescription) + assertThat(created.description!!.length).isEqualTo(254) + } + + @Test + fun `create project with 255 character long description, should succeed`() { + val longDescription = "a".repeat(255) + val project = Project(name = "LongDescProject").apply { + description = longDescription + } + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Long description is stored correctly + assertThat(created.description).isEqualTo(longDescription) + assertThat(created.description!!.length).isEqualTo(255) + } + + @Test + fun `create project with 256 character long description, should fail`() { + // Given: A project with a very long description (255+ characters) + val longDescription = "a".repeat(256) + val project = Project(name = "LongDescProject").apply { + description = longDescription + } + + // When: Creating the project + val ex = assertThrows { projectPort.create(project) } + + // Then: Long description is stored correctly + assertThat(ex.message).isEqualTo("Description must not exceed 255 characters.") + } + + @Test + fun `create project with special characters in name`() { + // Given: A project with special characters in name + val specialName = "Project-2024_v1.0 (Beta) #123" + val project = Project(name = specialName) + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Special characters are preserved + assertThat(created.name).isEqualTo(specialName) + } + + @Test + fun `create project with unicode characters in name`() { + // Given: A project with unicode/international characters + val unicodeName = "项目 Проект プロジェクト 🚀" + val project = Project(name = unicodeName) + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Unicode characters are preserved + assertThat(created.name).isEqualTo(unicodeName) + } + + @Test + fun `create multiple projects with different names`() { + // Given: Multiple projects with unique names + val project1 = Project(name = "Project Alpha") + val project2 = Project(name = "Project Beta") + val project3 = Project(name = "Project Gamma") + + // When: Creating all projects + val created1 = projectPort.create(project1) + val created2 = projectPort.create(project2) + val created3 = projectPort.create(project3) + + // Then: All projects are created with unique IDs + assertAll( + "Verify multiple project creation", + { assertThat(projectPort.findAll()).hasSize(3) }, + { assertThat(created1.id).isNotEqualTo(created2.id) }, + { assertThat(created2.id).isNotEqualTo(created3.id) }, + { assertThat(created1.id).isNotEqualTo(created3.id) } + ) + } + + @Test + fun `creating project with duplicate name fails fast`() { + // Given: an existing project with a name + projectPort.create(Project(name = "Duplicate Project")) + + // When: creating another project with the same name + val exception = assertThrows { + projectPort.create(Project(name = "Duplicate Project")) + } + + // Then: creation fails before hitting the database unique constraint + assertThat(exception.message).isEqualTo("Project with unique key 'Duplicate Project' already exists") + assertThat(projectPort.findAll()).hasSize(1) + } + + @Test + fun `create project with repository association`() { + // Given: A project and a repository + val project = Project(name = "ProjectWithRepo") + val repository = Repository( + localPath = "/path/to/repo", + project = project + ) + + // When: Creating the project (repository is auto-linked in constructor) + val created = projectPort.create(project) + + // Then: Project has repository associated + assertAll( + "Verify project-repository association", + { assertThat(created.repo).isNotNull() }, + { assertThat(created.repo?.localPath).isEqualTo("/path/to/repo") } + ) + } + + @Test + fun `create project with issues`() { + // Given: A project with issues + val project = Project(name = "ProjectWithIssues") + val issue1 = Issue( + title = "Bug: Login fails", + description = "Users cannot login", + state = "open", + createdAt = LocalDateTime.of(2024, 1, 1, 10, 0) + ) + val issue2 = Issue( + title = "Feature: Dark mode", + description = "Add dark mode support", + state = "open", + createdAt = LocalDateTime.of(2024, 1, 2, 10, 0) + ) + project.issues.add(issue1) + project.issues.add(issue2) + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Issues are associated with project + assertAll( + "Verify project with issues", + { assertThat(created.issues).hasSize(2) }, + { + val titles = created.issues.map { it.title } + assertThat(titles).containsExactlyInAnyOrder("Bug: Login fails", "Feature: Dark mode") + } + ) + } + + @Test + fun `create project with empty description`() { + // Given: A project with empty string description + val project = Project(name = "EmptyDescProject").apply { + description = "" + } + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Empty description is stored + assertThat(created.description).isEqualTo("") + } + + @Test + fun `create project with null description is allowed`() { + // Given: A project with null description (default) + val project = Project(name = "NullDescProject") + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Description remains null + assertThat(created.description).isNull() + } + } + + @Nested + @DisplayName("Read Operations") + @OptIn(ExperimentalUuidApi::class) + inner class ReadOperations { + @Test + fun `findAll returns all created projects`() { + // Given: Multiple projects exist in database + projectPort.create(Project(name = "Project 1")) + projectPort.create(Project(name = "Project 2")) + projectPort.create(Project(name = "Project 3")) + + // When: Finding all projects + val allProjects = projectPort.findAll() + + // Then: All projects are returned + assertAll( + "Verify all projects retrieved", + { assertThat(allProjects).hasSize(3) }, + { + val names = allProjects.map { it.name } + assertThat(names).containsExactlyInAnyOrder("Project 1", "Project 2", "Project 3") + } + ) + } + + @Test + fun `findAll returns empty collection when no projects exist`() { + // Given: No projects in database + + // When: Finding all projects + val allProjects = projectPort.findAll() + + // Then: Empty collection is returned + assertThat(allProjects).isEmpty() + } + + @Test + fun `findAll with pagination returns correct page`() { + // Given: 10 projects in database + repeat(10) { index -> + projectPort.create(Project(name = "Project ${index + 1}")) + } + + // When: Finding projects with pagination (page 0, size 3) + val page = projectPort.findAll(PageRequest.of(0, 3)) + + // Then: Correct page is returned + assertAll( + "Verify paginated results", + { assertThat(page.content).hasSize(3) }, + { assertThat(page.totalElements).isEqualTo(10) }, + { assertThat(page.totalPages).isEqualTo(4) }, + { assertThat(page.number).isEqualTo(0) } + ) + } + + @Test + fun `findAll with pagination returns second page correctly`() { + // Given: 10 projects in database + repeat(10) { index -> + projectPort.create(Project(name = "Project ${index + 1}")) + } + + // When: Finding second page (page 1, size 3) + val page = projectPort.findAll(PageRequest.of(1, 3)) + + // Then: Second page is returned + assertAll( + "Verify second page", + { assertThat(page.content).hasSize(3) }, + { assertThat(page.number).isEqualTo(1) }, + { assertThat(page.totalElements).isEqualTo(10) } + ) + } + + @Test + fun `findByIid returns correct project`() { + // Given: A created project + val created = projectPort.create(Project(name = "TargetProject").apply { + description = "Find me!" + }) + projectPort.create(Project(name = "OtherProject")) + + // When: Finding by iid + val found = projectPort.findByIid(created.iid) + + // Then: Correct project is returned + assertAll( + "Verify found project", + { assertThat(found).isNotNull }, + { assertThat(found?.name).isEqualTo("TargetProject") }, + { assertThat(found?.description).isEqualTo("Find me!") }, + { assertThat(found?.iid).isEqualTo(created.iid) } + ) + } + + @Test + fun `findByIid returns null for non-existent iid`() { + // Given: Some projects exist + projectPort.create(Project(name = "ExistingProject")) + + // When: Finding by non-existent iid + val nonExistentIid = Project.Id(kotlin.uuid.Uuid.random()) + val found = projectPort.findByIid(nonExistentIid) + + // Then: Null is returned + assertThat(found).isNull() + } + + @Test + fun `findByName returns correct project`() { + // Given: Multiple projects with different names + projectPort.create(Project(name = "Alpha Project")) + val target = projectPort.create(Project(name = "Beta Project").apply { + description = "This is the one" + }) + projectPort.create(Project(name = "Gamma Project")) + + // When: Finding by name + val found = projectPort.findByName("Beta Project") + + // Then: Correct project is returned + assertAll( + "Verify found project by name", + { assertThat(found).isNotNull }, + { assertThat(found?.name).isEqualTo("Beta Project") }, + { assertThat(found?.description).isEqualTo("This is the one") }, + { assertThat(found?.id).isEqualTo(target.id) } + ) + } + + @Test + fun `findByName returns null for non-existent name`() { + // Given: Some projects exist + projectPort.create(Project(name = "Existing Project")) + + // When: Finding by non-existent name + val found = projectPort.findByName("Non-Existent Project") + + // Then: Null is returned + assertThat(found).isNull() + } + + @Test + fun `findByName is case-sensitive`() { + // Given: A project with specific casing + projectPort.create(Project(name = "MyProject")) + + // When: Finding with different casing + val foundLowercase = projectPort.findByName("myproject") + val foundUppercase = projectPort.findByName("MYPROJECT") + val foundCorrect = projectPort.findByName("MyProject") + + // Then: Only exact case match returns project + assertAll( + "Verify case sensitivity", + { assertThat(foundLowercase).isNull() }, + { assertThat(foundUppercase).isNull() }, + { assertThat(foundCorrect).isNotNull } + ) + } + + @Test + fun `findByName with special characters works correctly`() { + // Given: A project with special characters + val specialName = "Project-2024_v1.0 (Beta)" + projectPort.create(Project(name = specialName)) + + // When: Finding by special name + val found = projectPort.findByName(specialName) + + // Then: Project is found + assertAll( + "Verify special character search", + { assertThat(found).isNotNull }, + { assertThat(found?.name).isEqualTo(specialName) } + ) + } + } + + @Nested + @DisplayName("Update Operations") + inner class UpdateOperations { + @Test + fun `update project description`() { + // Given: An existing project + val project = projectPort.create(Project(name = "UpdateTest").apply { + description = "Original description" + }) + + // When: Updating description + project.description = "Updated description" + val updated = projectPort.update(project) + + // Then: Description is updated + assertAll( + "Verify description update", + { assertThat(updated.description).isEqualTo("Updated description") }, + { assertThat(updated.name).isEqualTo("UpdateTest") }, + { assertThat(updated.id).isEqualTo(project.id) } + ) + + // And: Update is persisted + val found = projectPort.findByIid(project.iid) + assertThat(found?.description).isEqualTo("Updated description") + } + + @Test + fun `update project description to null`() { + // Given: A project with description + val project = projectPort.create(Project(name = "NullUpdateTest").apply { + description = "Will be removed" + }) + + // When: Setting description to null + project.description = null + val updated = projectPort.update(project) + + // Then: Description is null + assertThat(updated.description).isNull() + } + + @Test + fun `update project description to empty string`() { + // Given: A project with description + val project = projectPort.create(Project(name = "EmptyUpdateTest").apply { + description = "Will be empty" + }) + + // When: Setting description to empty + project.description = "" + val updated = projectPort.update(project) + + // Then: Description is empty + assertThat(updated.description).isEqualTo("") + } + + @Test + fun `update project with repository association`() { + // Given: A project without repository + val project = projectPort.create(Project(name = "RepoUpdateTest")) + assertThat(project.repo).isNull() + + // When: Creating and associating a repository + val repository = Repository( + localPath = "/new/repo/path", + project = project + ) + repositoryPort.create(repository) + val updated = projectPort.update(project) + + // Then: Repository is associated + assertAll( + "Verify repository association", + { assertThat(updated.repo).isNotNull() }, + { assertThat(updated.repo?.localPath).isEqualTo("/new/repo/path") } + ) + } + + @Test + fun `update project fails when repository is replaced`() { + val project = projectPort.create(Project(name = "RepoChangeGuard")) + repositoryPort.create( + Repository( + localPath = "/existing/repo", + project = project + ) + ) + val loaded = requireNotNull(projectPort.findByIid(project.iid)) + loaded.forceSetRepo(null) + Repository( + localPath = "/other/repo", + project = loaded + ) + + val exception = assertThrows { projectPort.update(loaded) } + + assertThat(exception).hasMessageContaining("Cannot update project with a different repository.") + } + + @Test + fun `update project fails when repository is removed`() { + val project = projectPort.create(Project(name = "RepoRemovalGuard")) + repositoryPort.create( + Repository( + localPath = "/existing/repo", + project = project + ) + ) + val loaded = requireNotNull(projectPort.findByIid(project.iid)) + loaded.forceSetRepo(null) + + val exception = assertThrows { projectPort.update(loaded) } + + assertThat(exception).hasMessage("Deleting repository from project is not yet allowed") + } + + @Test + @Disabled("Not implemented yet") + fun `update project by adding issues`() { + // Given: A project without issues + val project = projectPort.create(Project(name = "IssueUpdateTest")) + + // When: Adding issues + val issue1 = Issue(title = "Issue 1", state = "open", createdAt = LocalDateTime.now()) + val issue2 = Issue(title = "Issue 2", state = "closed", createdAt = LocalDateTime.now()) + project.issues.add(issue1) + project.issues.add(issue2) + val updated = projectPort.update(project) + + // Then: Issues are added + assertThat(updated.issues).hasSize(2) + } + + @Test + fun `update same project multiple times (idempotency)`() { + // Given: A created project + val project = projectPort.create(Project(name = "IdempotentTest").apply { + description = "Original" + }) + + // When: Updating multiple times with same data + val update1 = projectPort.update(project) + val update2 = projectPort.update(update1) + val update3 = projectPort.update(update2) + + // Then: All updates return same data + assertAll( + "Verify idempotency", + { assertThat(update1.description).isEqualTo("Original") }, + { assertThat(update2.description).isEqualTo("Original") }, + { assertThat(update3.description).isEqualTo("Original") }, + { assertThat(update1.id).isEqualTo(update2.id) }, + { assertThat(update2.id).isEqualTo(update3.id) } + ) + + // And: Only one project exists + assertThat(projectPort.findAll()).hasSize(1) + } + + @Test + fun `update project with very long description, 254 characters, should succeed`() { + // Given: A project with short description + val project = projectPort.create(Project(name = "LongUpdateTest").apply { + description = "Short" + }) + + // When: Updating to very long description + val longDesc = "a".repeat(254) + project.description = longDesc + val updated = projectPort.update(project) + + // Then: Long description is stored + assertAll( + "Verify long description update", + { assertThat(updated.description).isEqualTo(longDesc) }, + { assertThat(updated.description!!.length).isEqualTo(254) } + ) + } + + @Test + fun `update project with very long description, 255 characters, should succeed`() { + // Given: A project with short description + val project = projectPort.create(Project(name = "LongUpdateTest").apply { + description = "Short" + }) + + // When: Updating to very long description + val longDesc = "a".repeat(255) + project.description = longDesc + val updated = projectPort.update(project) + + // Then: Long description is stored + assertAll( + "Verify long description update", + { assertThat(updated.description).isEqualTo(longDesc) }, + { assertThat(updated.description!!.length).isEqualTo(255) } + ) + } + + @Test + fun `update project with very long description, 256 characters, should fail`() { + // Given: A project with short description + val project = projectPort.create(Project(name = "LongUpdateTest").apply { + description = "Short" + }) + + // When: Updating to very long description + val longDesc = "a".repeat(256) + project.description = longDesc + val ex = assertThrows { + projectPort.update(project) + } + + assertThat(ex.message).isEqualTo("Description must not exceed 255 characters.") + } + } + + @Nested + @DisplayName("Delete Operations") + inner class DeleteOperations { + @Test + fun `delete project by entity`() { + // Given: An existing project + val project = projectPort.create(Project(name = "ToDelete")) + assertThat(projectPort.findAll()).hasSize(1) + + // When: Deleting the project + assertThrows { + projectPort.delete(project) + } + + // Then: Project is removed, TODO uncomment iff delete operation is implemented +// assertAll( +// "Verify deletion", +// { assertThat(projectPort.findAll()).isEmpty() }, +// { assertThat(projectPort.findById(project.id!!)).isNull() }, +// { assertThat(projectPort.findByName("ToDelete")).isNull() } +// ) + } + + @Test + fun `delete project by ID`() { + // Given: An existing project + val project = projectPort.create(Project(name = "DeleteById")) + val projectId = project.id!! + + // When: Deleting by ID + assertThrows { + projectPort.deleteById(projectId) + } + + // Then: Project is removed +// assertAll( +// "Verify deletion by ID", +// { assertThat(projectPort.findAll()).isEmpty() }, +// { assertThat(projectPort.findById(projectId)).isNull() } +// ) + } + + @Test + fun `deleteAll removes all projects`() { + // Given: Multiple projects exist + projectPort.create(Project(name = "Project 1")) + projectPort.create(Project(name = "Project 2")) + projectPort.create(Project(name = "Project 3")) + assertThat(projectPort.findAll()).hasSize(3) + + // When: Deleting all projects + assertThrows { projectPort.deleteAll() } + + // Then: All projects are removed +// assertThat(projectPort.findAll()).isEmpty() + } + + @Test + fun `delete project with repository cascades correctly`() { + // Given: A project with associated repository + val project = projectPort.create(Project(name = "ProjectWithRepo")) + val repository = Repository( + localPath = "/repo/path", + project = project + ) + repositoryPort.create(repository) + + // When: Deleting the project + assertThrows { projectPort.delete(project) } + + // Then: Project is deleted (cascade behavior depends on configuration) +// assertThat(projectPort.findAll()).isEmpty() + } + + @Test + fun `delete project with issues removes issues`() { + // Given: A project with issues + val project = projectPort.create(Project(name = "ProjectWithIssues")) + val issue1 = Issue(title = "Issue 1", state = "open", createdAt = LocalDateTime.now()) + val issue2 = Issue(title = "Issue 2", state = "closed", createdAt = LocalDateTime.now()) + project.issues.add(issue1) + project.issues.add(issue2) + projectPort.update(project) + + // When: Deleting the project + assertThrows { projectPort.delete(project) } + + // Then: Project and issues are removed +// assertThat(projectPort.findAll()).isEmpty() + } + + @Test + fun `delete one project does not affect others`() { + // Given: Multiple projects + val project1 = projectPort.create(Project(name = "Project 1")) + val project2 = projectPort.create(Project(name = "Project 2")) + val project3 = projectPort.create(Project(name = "Project 3")) + + // When: Deleting one project + assertThrows { projectPort.delete(project2) } + + // Then: Only the deleted project is removed +// val remaining = projectPort.findAll() +// assertAll( +// "Verify selective deletion", +// { assertThat(remaining).hasSize(2) }, +// { +// val names = remaining.map { it.name } +// assertThat(names).containsExactlyInAnyOrder("Project 1", "Project 3") +// }, +// { assertThat(projectPort.findById(project2.id!!)).isNull() } +// ) + } + } + + @Nested + @DisplayName("Domain Invariant Tests") + inner class DomainInvariantTests { + @Test + fun `project name cannot be blank`() { + // Given/When/Then: Creating project with blank name throws exception + assertThrows { + Project(name = " ") + } + } + + @Test + fun `project name cannot be empty`() { + // Given/When/Then: Creating project with empty name throws exception + assertThrows { + Project(name = "") + } + } + + @Test + fun `project name must not be only whitespace`() { + // Given/When/Then: Creating project with whitespace-only name throws exception + assertThrows { + Project(name = "\t\n ") + } + } + + @Test + fun `project repository is set-once and cannot be changed`() { + // Given: A project with repository + val project = Project(name = "RepositoryTest") + val repo1 = Repository(localPath = "/repo1", project = project) + + // When/Then: Attempting to change repository throws exception + val repo2 = Repository(localPath = "/repo2", project = Project(name = "Other")) + assertThrows { + project.repo = repo2 + } + } + + @Test + fun `project repository cannot be set to null`() { + // Given: A project + val project = Project(name = "NullRepoTest") + + // When/Then: Attempting to set repository to null throws exception + assertThrows { + @Suppress("SENSELESS_COMPARISON") + project.repo = null + } + } + + @Test + fun `project repository can be set to same instance multiple times (idempotent)`() { + // Given: A project with repository + val project = Project(name = "IdempotentRepoTest") + val repository = Repository(localPath = "/repo", project = project) + + // When: Setting same repository again + project.repo = repository + project.repo = repository + + // Then: No exception is thrown and repo remains the same + assertThat(project.repo).isEqualTo(repository) + } + + @Test + fun `project uniqueKey is based on name`() { + // Given: A project + val project = Project(name = "TestProject") + + // When: Getting unique key + val key = project.uniqueKey + + // Then: Key is based on name + assertAll( + "Verify unique key", + { assertThat(key.name).isEqualTo("TestProject") }, + { assertThat(project.uniqueKey).isEqualTo(Project.Key("TestProject")) } + ) + } + + @Test + fun `project equality is identity-based not value-based`() { + // Given: Two projects with same name + val project1 = Project(name = "SameName") + val project2 = Project(name = "SameName") + + // When: Comparing projects + val areEqual = project1 == project2 + val haveSameHashCode = project1.hashCode() == project2.hashCode() + + // Then: Projects are not equal (identity-based equality) + assertAll( + "Verify identity-based equality", + { assertThat(areEqual).isFalse() }, + { assertThat(project1.iid).isNotEqualTo(project2.iid) } + ) + } + + @Test + fun `project can exist without repository`() { + // Given: A project without repository + val project = Project(name = "StandaloneProject") + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Project exists without repository + assertAll( + "Verify standalone project", + { assertThat(created.repo).isNull() }, + { assertThat(created.name).isEqualTo("StandaloneProject") } + ) + } + + @Test + fun `project issues collection is mutable`() { + // Given: A project + val project = Project(name = "MutableIssuesTest") + + // When: Adding issues + val issue1 = Issue(title = "Issue 1", state = "open", createdAt = LocalDateTime.now()) + val issue2 = Issue(title = "Issue 2", state = "open", createdAt = LocalDateTime.now()) + project.issues.add(issue1) + project.issues.add(issue2) + + // Then: Issues can be added and removed + assertAll( + "Verify mutable issues collection", + { assertThat(project.issues).hasSize(2) }, + { assertThat(project.issues.remove(issue1)).isTrue() }, + { assertThat(project.issues).hasSize(1) } + ) + } + } + + @Nested + @DisplayName("Edge Cases and Boundary Conditions") + inner class EdgeCasesAndBoundaryConditions { + @Test + fun `create project with minimum valid name (single character)`() { + // Given: A project with single character name + val project = Project(name = "A") + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Project is created successfully + assertThat(created.name).isEqualTo("A") + } + + @Test + fun `create project with name containing newlines and tabs`() { + // Given: A project with whitespace characters in name + val nameWithWhitespace = "Project\n\tWith\r\nWhitespace" + val project = Project(name = nameWithWhitespace) + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Whitespace is preserved + assertThat(created.name).isEqualTo(nameWithWhitespace) + } + + @Test + fun `create project with name that is pure numbers`() { + // Given: A project with numeric name + val project = Project(name = "1234567890") + + // When: Creating the project + val created = projectPort.create(project) + + // Then: Numeric name is accepted + assertThat(created.name).isEqualTo("1234567890") + } + + @Test + fun `create project with SQL-like characters in name (SQL injection prevention)`() { + // Given: A project with SQL-like characters + val sqlName = "Project'; DROP TABLE projects;--" + val project = Project(name = sqlName) + + // When: Creating the project + val created = projectPort.create(project) + + // Then: SQL characters are treated as literal string + assertThat(created.name).isEqualTo(sqlName) + + // And: No SQL injection occurred + assertThat(projectPort.findAll()).isNotEmpty() + } + + @Test + fun `findAll with very large page size`() { + // Given: 5 projects exist + repeat(5) { index -> + projectPort.create(Project(name = "Project ${index + 1}")) + } + + // When: Requesting page with size larger than total + val page = projectPort.findAll(PageRequest.of(0, 1000)) + + // Then: All projects are returned in single page + assertAll( + "Verify large page size", + { assertThat(page.content).hasSize(5) }, + { assertThat(page.totalElements).isEqualTo(5) }, + { assertThat(page.totalPages).isEqualTo(1) } + ) + } + + @Test + fun `findAll with page beyond available pages returns empty`() { + // Given: Only 3 projects exist + repeat(3) { index -> + projectPort.create(Project(name = "Project ${index + 1}")) + } + + // When: Requesting page far beyond available data + val page = projectPort.findAll(PageRequest.of(100, 10)) + + // Then: Empty page is returned + assertAll( + "Verify out-of-bounds page", + { assertThat(page.content).isEmpty() }, + { assertThat(page.totalElements).isEqualTo(3) }, + { assertThat(page.number).isEqualTo(100) } + ) + } + + @Test + fun `saveAll creates multiple projects atomically`() { + // Given: Multiple projects to save + val projects = listOf( + Project(name = "Batch 1"), + Project(name = "Batch 2"), + Project(name = "Batch 3"), + Project(name = "Batch 4"), + Project(name = "Batch 5") + ) + + // When: Saving all at once + val saved = projectPort.saveAll(projects) + + // Then: All projects are created + assertAll( + "Verify batch creation", + { assertThat(saved).hasSize(5) }, + { assertThat(projectPort.findAll()).hasSize(5) }, + { + val names = projectPort.findAll().map { it.name } + assertThat(names).containsExactlyInAnyOrder( + "Batch 1", "Batch 2", "Batch 3", "Batch 4", "Batch 5" + ) + } + ) + } + } + + @Nested + @DisplayName("Integration with Repository") + inner class IntegrationWithRepository { + @Test + fun `project with repository maintains bidirectional relationship`() { + // Given: A project and repository + val project = Project(name = "BidirectionalTest") + val repository = Repository( + localPath = "/test/repo", + project = project + ) + + // When: Creating the project + val createdProject = projectPort.create(project) + + // Then: Bidirectional relationship exists + assertAll( + "Verify bidirectional relationship", + { assertThat(createdProject.repo).isNotNull() }, + { assertThat(createdProject.repo).isEqualTo(repository) }, + { assertThat(repository.project).isEqualTo(createdProject) } + ) + } + + @Test + @Disabled("DELETE operations not yet permitted") + fun `deleting project with repository preserves or cascades based on configuration`() { + // Given: A project with repository + val project = projectPort.create(Project(name = "CascadeTest")) + val repository = repositoryPort.create( + Repository( + localPath = "/cascade/repo", + project = project + ) + ) + val repoId = repository.id!! + + // When: Deleting the project + projectPort.delete(project) + + // Then: Project is deleted + assertThat(projectPort.findByIid(project.iid)).isNull() + // Note: Repository cascade behavior should match configuration + // This test documents the expected behavior + } + + @Test + fun `finding project by iid includes repository data`() { + // Given: A project with repository + val project = projectPort.create(Project(name = "LoadTest")) + repositoryPort.create( + Repository( + localPath = "/load/test", + project = project + ) + ) + + // When: Finding project by iid + val found = projectPort.findByIid(project.iid) + + // Then: Repository data is loaded + assertAll( + "Verify eager/lazy loading", + { assertThat(found).isNotNull() }, + { assertThat(found?.repo).isNotNull() }, + { assertThat(found?.repo?.localPath).isEqualTo("/load/test") } + ) + } + } +} + +// Helper that bypasses the domain guard rails so failure scenarios can be exercised explicitly. +private fun Project.forceSetRepo(repository: Repository?) = + Project::class.java.getDeclaredField("repo").apply { isAccessible = true }.also { it.set(this, repository) } diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/RepositoryInfrastructurePortImplTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/RepositoryInfrastructurePortImplTest.kt deleted file mode 100644 index 6a06a6de9..000000000 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/RepositoryInfrastructurePortImplTest.kt +++ /dev/null @@ -1,1529 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.integration.service - -import com.inso_world.binocular.core.service.BranchInfrastructurePort -import com.inso_world.binocular.infrastructure.sql.integration.service.base.BaseServiceTest -import com.inso_world.binocular.infrastructure.sql.service.CommitInfrastructurePortImpl -import com.inso_world.binocular.infrastructure.sql.service.ProjectInfrastructurePortImpl -import com.inso_world.binocular.infrastructure.sql.service.RepositoryInfrastructurePortImpl -import com.inso_world.binocular.infrastructure.sql.service.UserInfrastructurePortImpl -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User -import jakarta.persistence.EntityManager -import jakarta.persistence.PersistenceContext -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertAll -import org.junit.jupiter.api.assertDoesNotThrow -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.support.TransactionTemplate -import java.time.LocalDateTime -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -internal class RepositoryInfrastructurePortImplTest : BaseServiceTest() { - @Autowired - private lateinit var transactionTemplate: TransactionTemplate - - @PersistenceContext - private lateinit var entityManager: EntityManager - - @Autowired - private lateinit var projectPort: ProjectInfrastructurePortImpl - - @Autowired - private lateinit var repositoryPort: RepositoryInfrastructurePortImpl - - @Autowired - private lateinit var commitPort: CommitInfrastructurePortImpl - - @Autowired - private lateinit var userPort: UserInfrastructurePortImpl - - @Autowired - private lateinit var branchPort: BranchInfrastructurePort - - private lateinit var repositoryProject: Project - - @BeforeEach - @Transactional - fun setup() { - repositoryProject = - projectPort.create( - Project( - name = "test project", - ), - ) - } - - @AfterEach - fun teardown() { - transactionTemplate.execute { entityManager.clear() } - } - - @Nested - inner class SaveOperation : BaseServiceTest() { - @Test - fun `save repository with one commit, expecting in database`() { - val savedRepo = - run { - val repository = - Repository( - localPath = "test repository", - project = repositoryProject, - ) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - - repository.project = repositoryProject - val branch = - Branch( - name = "test branch", - repository = repository, - ) - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - user.committedCommits.add(cmt) - - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - assertAll( - "check model", - { assertThat(branch.commits).hasSize(1) }, - { assertThat(cmt.branches).hasSize(1) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.user).hasSize(1) }, - { assertThat(repository.commits).hasSize(1) }, - ) - - return@run repositoryPort.create(repository) - } - - assertAll( - "check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - assertThat(commitPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedRepo.commits.toList()[0]) - assertAll( - "check commit relationship", - { assertThat(commitPort.findAll().toList()[0].id).isNotNull() }, - { assertThat(commitPort.findAll().toList()[0].repository).isNotNull() }, - { assertThat(commitPort.findAll().toList()[0].repository?.id).isNotNull() }, - { assertThat(commitPort.findAll().toList()[0].repository?.id).isEqualTo(savedRepo.id) }, - ) - } - - @Test - fun `save repository with one commit with one parent, expecting both in database`() { - val savedRepo = - run { - val repository = - Repository( - localPath = "test repository", - project = repositoryProject, - ) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - - repository.project = repositoryProject - val branch = - Branch( - name = "test branch", - repository = repository, - ) - val parent = Commit( - sha = "0".repeat(40), - message = "test commit 2", - commitDateTime = LocalDateTime.of(2025, 5, 13, 1, 1), - ) - branch.commits.add(parent) - val cmt = - Commit( - sha = "1".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - parent.children.add(cmt) - user.committedCommits.add(cmt) - user.committedCommits.add(parent) - - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - assertAll( - "check model", - { assertThat(branch.commits).hasSize(2) }, - { assertThat(cmt.parents).hasSize(1) }, - { assertThat(cmt.branches).hasSize(1) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.user).hasSize(1) }, - { assertThat(repository.commits).hasSize(1) }, - ) - - return@run repositoryPort.create(repository) - } - - assertAll( - "check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(2) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) -// assert parent - assertThat(commitPort.findAll().find { it.sha == "0".repeat(40) }) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedRepo.commits.find { it.sha == "0".repeat(40) }) -// assert child - assertThat(commitPort.findAll().find { it.sha == "1".repeat(40) }) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedRepo.commits.find { it.sha == "1".repeat(40) }) - assertAll( - "check commit relationship", - { assertThat(commitPort.findAll().map { it.id }).doesNotContainNull() }, - { assertThat(commitPort.findAll().map { it.repository?.id }).doesNotContainNull() }, - { assertThat(commitPort.findAll().map { it.repository?.id }).containsOnly(savedRepo.id) }, - ) - } - - @Test - fun `save repository with one commit with two parents, expecting both in database`() { - val savedRepo = - run { - val repository = - Repository( - localPath = "test repository", - project = repositoryProject, - ) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - - repository.project = repositoryProject - val branch = - Branch( - name = "test branch", - repository = repository, - ) - val parent1 = Commit( - sha = "1".repeat(40), - message = "parent1", - commitDateTime = LocalDateTime.of(2025, 5, 13, 1, 1), - ) - branch.commits.add(parent1) - val parent2 = Commit( - sha = "2".repeat(40), - message = "parent2", - commitDateTime = LocalDateTime.of(2025, 5, 13, 1, 1), - ) - branch.commits.add(parent2) - val cmt = - Commit( - sha = "c".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - cmt.parents.addAll(listOf(parent1, parent2)) - parent1.children.add(cmt) - parent2.children.add(cmt) - user.committedCommits.add(cmt) - user.committedCommits.add(parent1) - user.committedCommits.add(parent2) - - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - assertAll( - "check model", - { assertThat(branch.commits).hasSize(3) }, - { assertThat(cmt.parents).hasSize(2) }, - { assertThat(cmt.branches).hasSize(1) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.user).hasSize(1) }, - { assertThat(repository.commits).hasSize(1) }, - ) - - return@run repositoryPort.create(repository) - } - - assertAll( - "check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(3) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) -// assert parent1 - assertThat(commitPort.findAll().find { it.sha == "1".repeat(40) }) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedRepo.commits.find { it.sha == "1".repeat(40) }) -// assert parent2 - assertThat(commitPort.findAll().find { it.sha == "2".repeat(40) }) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedRepo.commits.find { it.sha == "2".repeat(40) }) -// assert child - assertThat(commitPort.findAll().find { it.sha == "c".repeat(40) }) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedRepo.commits.find { it.sha == "c".repeat(40) }) -// check relationship of child - run { - val child = savedRepo.commits.find { it.sha == "c".repeat(40) } - ?: throw IllegalStateException("child must be found here") - - assertThat(child.parents).hasSize(2) - assertThat(child.children).isEmpty() - } - assertAll( - "check commit relationship", - { assertThat(commitPort.findAll().map { it.id }).doesNotContainNull() }, - { assertThat(commitPort.findAll().map { it.repository?.id }).doesNotContainNull() }, - { assertThat(commitPort.findAll().map { it.repository?.id }).containsOnly(savedRepo.id) }, - ) - } - - @Test - fun `save plain repository, expecting in database`() { - val repository = - Repository( - localPath = "test repository", - project = repositoryProject, - ) - - repositoryProject.repo = repository - assertDoesNotThrow { - repositoryPort.create(repository) - } - - assertAll( - "check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - ) - } - - @Test - fun `save repository with two commits, expecting in database`() { - val repository = - Repository( - localPath = "test repository", - project = repositoryProject, - ) - - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - - repository.project = repositoryProject - val branch = - Branch( - name = "test branch", - repository = repository - ) - val cmtA = - Commit( - sha = "A".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmtA) - val cmtB = - Commit( - sha = "B".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2024, 8, 13, 1, 1), - ) - branch.commits.add(cmtB) - user.committedCommits.add(cmtA) - user.committedCommits.add(cmtB) - - branch.commits.add(cmtA) - branch.commits.add(cmtB) - - repository.branches.add(branch) - repository.commits.addAll(listOf(cmtA, cmtB)) - repository.user.add(user) - - assertAll( - "check model", - { assertThat(branch.commits).hasSize(2) }, - { assertThat(cmtA.branches).hasSize(1) }, - { assertThat(cmtB.branches).hasSize(1) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.commits).hasSize(2) }, - { assertThat(repository.user).hasSize(1) }, - ) - - val savedEntity = - assertDoesNotThrow { - repositoryPort.create(repository) - } - - assertAll( - "check saved entity", - { assertThat(savedEntity.branches).hasSize(1) }, - { assertThat(savedEntity.commits).hasSize(2) }, - ) - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(2) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - } - } - - @Nested - inner class UpdateOperations : BaseServiceTest() { - @Test - fun `save empty repository, update with two commits, one branch`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - ) - repository.project = repositoryProject - - val branch = - Branch( - name = "test branch", - repository = repository, - ) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - val cmtA = - Commit( - sha = "A".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmtA) - val cmtB = - Commit( - sha = "B".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2024, 8, 13, 1, 1), - ) - branch.commits.add(cmtB) - user.committedCommits.add(cmtA) - user.committedCommits.add(cmtB) - - branch.commits.addAll(listOf(cmtA, cmtB)) - - repository.branches.add(branch) - repository.commits.addAll(listOf(cmtA, cmtB)) - repository.user.add(user) - - assertAll( - "check model", - { assertThat(branch.commits).hasSize(2) }, - { assertThat(repository.commits).hasSize(2) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.user).hasSize(1) }, - ) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - assertAll( - "Check updated entity", - { assertThat(updatedEntity.commits).hasSize(2) }, - { assertThat(updatedEntity.branches).hasSize(1) }, - { assertThat(updatedEntity.user).hasSize(1) }, - ) - assertThat(updatedEntity.branches.toList()[0].commits).hasSize(2) - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(2) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(updatedEntity) - } - - @Test - fun `save empty repository, update with one commit, no parent`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - { assertThat(userPort.findAll()).hasSize(0) }, - ) - - val branch = - Branch( - name = "test branch", - repository = repository, - ) - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - user.committedCommits.add(cmt) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - assertAll( - "Check model", - { assertThat(branch.commits).hasSize(1) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.commits).hasSize(1) }, - { assertThat(repository.user).hasSize(1) }, - ) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - assertAll( - "Check updated entity", - { assertThat(updatedEntity.commits).hasSize(1) }, - { assertThat(updatedEntity.branches).hasSize(1) }, - { assertThat(updatedEntity.user).hasSize(1) }, - ) - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(updatedEntity) - } - - @Test - fun `save empty repository, update with one commit, one parent`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - { assertThat(userPort.findAll()).hasSize(0) }, - ) - - val branch = - Branch( - name = "test branch", - repository = repository, - ) - val parent = - Commit( - sha = "1".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(parent) - val cmt = - Commit( - sha = "c".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - cmt.parents.add(parent) -// parent.addChild(cmt) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - user.committedCommits.add(cmt) - user.committedCommits.add(parent) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - assertAll( - "Check model", - { assertThat(cmt.parents).hasSize(1) }, - { assertThat(branch.commits).hasSize(2) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.commits).hasSize(1) }, - { assertThat(repository.user).hasSize(1) }, - ) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - assertAll( - "Check updated entity", - { assertThat(updatedEntity.commits).hasSize(2) }, - { assertThat(updatedEntity.branches).hasSize(1) }, - { assertThat(updatedEntity.user).hasSize(1) }, - ) - assertAll( - "check child", - { assertThat(updatedEntity.commits.find { it.sha == "c".repeat(40) }?.parents).hasSize(1) }, - { assertThat(updatedEntity.commits.find { it.sha == "c".repeat(40) }?.children).isEmpty() }, - ) - assertAll( - "check parent", - { assertThat(updatedEntity.commits.find { it.sha == "1".repeat(40) }?.children).hasSize(1) }, - { assertThat(updatedEntity.commits.find { it.sha == "1".repeat(40) }?.parents).isEmpty() }, - ) - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(2) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(updatedEntity) - } - - @Test - fun `save empty repository, update with one commit, two parents`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - { assertThat(userPort.findAll()).hasSize(0) }, - ) - - val branch = - Branch( - name = "test branch", - repository = repository, - ) - val parent1 = - Commit( - sha = "1".repeat(40), - message = "parent1", - commitDateTime = LocalDateTime.of(2025, 6, 13, 1, 1), - ) - branch.commits.add(parent1) - val parent2 = - Commit( - sha = "2".repeat(40), - message = "parent2", - commitDateTime = LocalDateTime.of(2025, 6, 14, 1, 1), - ) - branch.commits.add(parent2) - val cmt = - Commit( - sha = "c".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - cmt.parents.addAll(mutableSetOf(parent1, parent2)) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - user.committedCommits.add(cmt) - user.committedCommits.add(parent1) - user.committedCommits.add(parent2) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - assertAll( - "Check model", - { assertThat(cmt.parents).hasSize(2) }, - { assertThat(branch.commits).hasSize(3) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.commits).hasSize(1) }, - { assertThat(repository.user).hasSize(1) }, - ) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - assertAll( - "Check updated entity", - { assertThat(updatedEntity.commits).hasSize(3) }, - { assertThat(updatedEntity.commits.find { it.sha == "c".repeat(40) }?.parents).hasSize(2) }, - { assertThat(updatedEntity.commits.find { it.sha == "c".repeat(40) }?.children).isEmpty() }, - { assertThat(updatedEntity.commits.find { it.sha == "1".repeat(40) }?.children).hasSize(1) }, - { assertThat(updatedEntity.commits.find { it.sha == "1".repeat(40) }?.parents).isEmpty() }, - { assertThat(updatedEntity.commits.find { it.sha == "2".repeat(40) }?.children).hasSize(1) }, - { assertThat(updatedEntity.commits.find { it.sha == "2".repeat(40) }?.parents).isEmpty() }, - { assertThat(updatedEntity.branches).hasSize(1) }, - { assertThat(updatedEntity.user).hasSize(1) }, - ) - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(3) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(updatedEntity) - } - - @Test - fun `save empty repository, update with one commit, update second time unchanged`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - { assertThat(userPort.findAll()).hasSize(0) }, - ) - - val branch = - Branch( - name = "test branch", - repository = repository, - ) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - user.committedCommits.add(cmt) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - assertDoesNotThrow { - repositoryPort.update(repository) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(updatedEntity) - } - - @Test - fun `save empty repository, update with one commit, update again with new commit`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - { assertThat(userPort.findAll()).hasSize(0) }, - ) - - val branch = - Branch( - name = "test branch", - repository = repository, - ) - - run { - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - user.committedCommits.add(cmt) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - assertDoesNotThrow { - repositoryPort.update(repository) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - } - - run { - val cmt = - Commit( - sha = "B".repeat(40), - message = "test commit 2", - commitDateTime = LocalDateTime.of(2024, 1, 26, 1, 1), - ) - branch.commits.add(cmt) - val user = - User( - name = "test2", - email = "test2@example.com", - repository = repository, - ) - user.committedCommits.add(cmt) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(2) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(2) }, - ) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(updatedEntity) - } - } - - @Test - fun `save empty repository, update with two commits, two branches`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - { assertThat(userPort.findAll()).hasSize(0) }, - ) - - run { - val branch = - Branch( - name = "test branch 1", - repository = repository, - ) - - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - user.committedCommits.add(cmt) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - assertAll( - "Check updated entity", - { assertThat(updatedEntity.commits).hasSize(1) }, - { assertThat(updatedEntity.branches).hasSize(1) }, - { assertThat(updatedEntity.user).hasSize(1) }, - ) - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - } - - run { - val branch = - Branch( - name = "test branch 2", - repository = repository, - ) - - val cmt = - Commit( - sha = "B".repeat(40), - message = "test commit 2", - commitDateTime = LocalDateTime.of(2024, 1, 26, 1, 1), - ) - branch.commits.add(cmt) - val user = // same as before - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - user.committedCommits.add(cmt) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - assertAll( - "Check updated entity", - { assertThat(updatedEntity.commits).hasSize(2) }, - { assertThat(updatedEntity.branches).hasSize(2) }, - { assertThat(updatedEntity.user).hasSize(1) }, - ) - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(2) }, - { assertThat(branchPort.findAll()).hasSize(2) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(updatedEntity) - } - } - - @Test - fun `save empty repository, update with one commits, add new branch to commit`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - "Check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - ) - - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - user.committedCommits.add(cmt) - - run { - val branch = - Branch( - name = "test branch 1", - repository = repository, - ) - - cmt.branches.add(branch) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - repository.user.add(user) - - assertAll( - "check model", - { assertThat(cmt.branches).hasSize(1) }, - { assertThat(branch.commits).hasSize(1) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.commits).hasSize(1) }, - ) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - - assertAll( - "check updated entity", - { assertThat(updatedEntity.branches).hasSize(1) }, - { assertThat(updatedEntity.commits).hasSize(1) }, - { assertThat(updatedEntity.user).hasSize(1) }, - ) - - assertAll( - "Check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - } - - run { - val branch = - Branch( - name = "test branch 2", - repository = repository, - ) - - cmt.branches.add(branch) - branch.commits.add(cmt) - repository.branches.add(branch) - repository.commits.add(cmt) - - assertAll( - "check model", - { assertThat(cmt.branches).hasSize(2) }, - { assertThat(branch.commits).hasSize(1) }, - { assertThat(repository.branches).hasSize(2) }, - { assertThat(repository.commits).hasSize(1) }, - { assertThat(repository.user).hasSize(1) }, - ) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.update(repository) - } - assertAll( - "check updated entity", - { assertThat(updatedEntity.branches).hasSize(2) }, - { assertThat(updatedEntity.commits).hasSize(1) }, - { assertThat(updatedEntity.commits.toList()[0].branches).hasSize(2) }, - ) - - assertAll( - "Check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(2) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - assertThat( - commitPort.findAll().toList()[0].branches, - ).hasSize(2) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(updatedEntity) - } - } - - @Test - fun `save empty repository, update with commit, update with same commit again`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - "Check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - { assertThat(userPort.findAll()).hasSize(0) }, - ) - - val branch = - Branch( - name = "test branch 1", - repository = repository, - ) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - run { - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - user.committedCommits.add(cmt) - assertAll( - "Adding new commit", - { assertTrue(branch.commits.add(cmt)) }, - { assertTrue(repository.branches.add(branch)) }, - { assertTrue(repository.commits.add(cmt)) }, - { assertFalse(repository.user.add(user)) }, - ) - - assertDoesNotThrow { - repositoryPort.update(repository) - } - - assertAll( - "Check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - } - - run { - val cmt = - Commit( - // same commit as above, but new object - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - user.committedCommits.add(cmt) - assertAll( - "Add same commit again, same hashCode", - { assertFalse(branch.commits.add(cmt)) }, // sha already in - { assertFalse(repository.branches.add(branch)) }, // branch already in - { assertFalse(repository.commits.add(cmt)) }, // cmt is in based on hashCode - { assertFalse(repository.user.add(user)) }, // user is in based on hashCode - ) - assertThat(repository.commits).hasSize(1) - assertThat(repository.user).hasSize(1) - - val savedRepo = - assertDoesNotThrow { - repositoryPort.update(repository) - } - - assertAll( - "Check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedRepo) - } - } - - @Test - fun `save empty repository, update with commit, update with same commit again but changed`() { - val repository = - assertDoesNotThrow { - repositoryPort.create( - Repository( - localPath = "test repository", - project = repositoryProject, - ), - ) - } - - assertAll( - "Check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(0) }, - { assertThat(branchPort.findAll()).hasSize(0) }, - { assertThat(userPort.findAll()).hasSize(0) }, - ) - - val branch = - Branch( - name = "test branch 1", - repository = repository, - ) - val savedRepo = run { - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - user.committedCommits.add(cmt) - - assertAll( - "Adding new commit", - { assertTrue(branch.commits.add(cmt)) }, - { assertTrue(repository.branches.add(branch)) }, - { assertTrue(repository.commits.add(cmt)) }, - { assertFalse(repository.user.add(user)) }, - ) - - val savedRepo = - assertDoesNotThrow { - repositoryPort.update(repository) - } - - assertAll( - "Check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(savedRepo) - - return@run savedRepo - } - - run { - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "msg changed", // message property changed! - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - assertAll( - "Add same commit again, same hashCode", - { assertFalse(branch.commits.add(cmt)) }, // sha already in - { assertFalse(repository.branches.add(branch)) }, // branch already in - { assertFalse(repository.commits.add(cmt)) }, // cmt is in based on hashCode, changed message - ) - assertThat(repository.commits).hasSize(1) - - val updatedRepo = - assertDoesNotThrow { - repositoryPort.update(savedRepo) - } - - assertAll( - "Check database numbers", - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(1) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - assertThat(repositoryPort.findAll().toList()[0]) - .usingRecursiveComparison() - .ignoringCollectionOrder() - .isEqualTo(updatedRepo) - } - } - - @Test - fun `save repository with two commits, remove commit and update`() { - val savedRepo = - run { - val repository = - Repository( - localPath = "test repository", - project = repositoryProject, - ) - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - val branch = - Branch( - name = "test branch 1", - repository = repository, - ) - - val cmtA = - Commit( - sha = "A".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmtA) - val cmtB = - Commit( - sha = "B".repeat(40), - message = "test commit", - commitDateTime = LocalDateTime.of(2024, 8, 13, 1, 1), - ) - branch.commits.add(cmtB) - user.committedCommits.add(cmtA) - user.committedCommits.add(cmtB) - - branch.commits.add(cmtA) - branch.commits.add(cmtB) - repository.branches.add(branch) - repository.commits.add(cmtA) - repository.commits.add(cmtB) - repository.user.add(user) - - assertAll( - "check model", - { assertThat(branch.commits).hasSize(2) }, - { assertThat(cmtA.branches).hasSize(1) }, - { assertThat(cmtB.branches).hasSize(1) }, - { assertThat(repository.branches).hasSize(1) }, - { assertThat(repository.commits).hasSize(2) }, - { assertThat(repository.user).hasSize(1) }, - ) - - val updatedEntity = - assertDoesNotThrow { - repositoryPort.create(repository) - } - assertAll( - "Check created entity", - { assertThat(updatedEntity.commits).hasSize(2) }, - { assertThat(updatedEntity.branches).hasSize(1) }, - ) - - assertAll( - { assertThat(projectPort.findAll()).hasSize(1) }, - { assertThat(repositoryPort.findAll()).hasSize(1) }, - { assertThat(commitPort.findAll()).hasSize(2) }, - { assertThat(branchPort.findAll()).hasSize(1) }, - { assertThat(userPort.findAll()).hasSize(1) }, - ) - - return@run updatedEntity - } - - val updatedRepo = - run { - assertDoesNotThrow { - savedRepo.removeCommitBySha("A".repeat(40)) - } - assertThat(savedRepo.commits).hasSize(1) - assertThat(savedRepo.branches).hasSize(1) - assertThat(savedRepo.user).hasSize(1) - assertThat(savedRepo.branches.toList()[0].commits).hasSize(1) - - val updatedRepo = - assertDoesNotThrow { - return@assertDoesNotThrow repositoryPort.update(savedRepo) - } - - assertThat(updatedRepo.commits).hasSize(1) - assertThat(updatedRepo.branches).hasSize(1) - assertThat(updatedRepo.user).hasSize(1) - assertThat(updatedRepo.branches.toList()[0].commits).hasSize(1) - - return@run updatedRepo - } - - assertThat(updatedRepo.commits).hasSize(1) - assertThat(updatedRepo.branches).hasSize(1) - assertThat(updatedRepo.user).hasSize(1) - assertThat(commitPort.findAll()).hasSize(1) - assertThat(userPort.findAll()).hasSize(1) - } - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/base/AbstractInfrastructurePortExtension.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/base/AbstractInfrastructurePortExtension.kt new file mode 100644 index 000000000..a448c6257 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/base/AbstractInfrastructurePortExtension.kt @@ -0,0 +1,10 @@ +package com.inso_world.binocular.infrastructure.sql.integration.service.base + +import com.inso_world.binocular.infrastructure.sql.service.AbstractInfrastructurePort +import org.springframework.transaction.annotation.Transactional +import java.io.Serializable + +@Transactional +internal fun AbstractInfrastructurePort.deleteAllEntities() { + this.dao.deleteAll() +} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/validation/CommitInfrastructurePortValidationTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/validation/CommitInfrastructurePortValidationTest.kt deleted file mode 100644 index 83aaea2eb..000000000 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/validation/CommitInfrastructurePortValidationTest.kt +++ /dev/null @@ -1,260 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.integration.service.validation - -import com.inso_world.binocular.infrastructure.sql.integration.service.base.BaseServiceTest -import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity -import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity -import com.inso_world.binocular.infrastructure.sql.service.CommitInfrastructurePortImpl -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Repository -import io.mockk.junit5.MockKExtension -import jakarta.validation.Validation -import jakarta.validation.Validator -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.Arguments -import org.junit.jupiter.params.provider.MethodSource -import org.springframework.beans.factory.annotation.Autowired -import java.time.LocalDateTime -import java.util.stream.Stream - -@ExtendWith(MockKExtension::class) -internal class CommitInfrastructurePortValidationTest : BaseServiceTest() { - @Autowired - private lateinit var commitPort: CommitInfrastructurePortImpl - - private lateinit var validator: Validator - - @BeforeEach - fun setup() { - validator = Validation.buildDefaultValidatorFactory().validator - } - - companion object { - @JvmStatic - fun invalidCommitsForEntity(): Stream = - Stream.of( - Arguments.of( - run { - val repository = Repository(id = "1", localPath = "1") - val cmt = Commit( - id = null, - sha = "", // invalid: should be 40 chars - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), - message = "Valid message", - ) - repository.commits.add(cmt) - cmt - }, - "sha", - ), - Arguments.of( - run { - val repository = Repository(id = "2", localPath = "2") - val cmt = Commit( - id = null, - sha = "a".repeat(39), // invalid: should be 40 chars - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), - message = "Valid message", - ) - repository.commits.add(cmt) - cmt - }, - "sha", - ), - Arguments.of( - run { - val repository = Repository(id = "3", localPath = "3") - val cmt = Commit( - id = null, - sha = "b".repeat(41), // invalid: should be 40 chars - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), - message = "Valid message", - ) - repository.commits.add(cmt) - cmt - }, - "sha", - ), - Arguments.of( - run { - val repository = Repository(id = "4", localPath = "4") - val cmt = Commit( - id = null, - sha = "c".repeat(40), - authorDateTime = LocalDateTime.now(), - commitDateTime = null, // invalid: NotNull - message = "Valid message", - ) - repository.commits.add(cmt) - cmt - }, - "commitDateTime", - ), - Arguments.of( - run { - val repository = Repository(id = "5", localPath = "5") - val cmt = Commit( - id = null, - sha = "c".repeat(40), - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now().plusSeconds(30), // invalid: Future - message = "Valid message", - ) - repository.commits.add(cmt) - cmt - }, - "commitDateTime", - ), -// Arguments.of( -// Commit( -// id = null, -// sha = "d".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = null, // invalid: NotBlank -// repositoryId = "1", -// ), -// "message", -// ), -// Arguments.of( -// Commit( -// id = null, -// sha = "d".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = " ", // invalid: NotBlank -// repositoryId = "1", -// ), -// "message", -// ), -// Arguments.of( -// Commit( -// id = null, -// sha = "d".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = "", // invalid: NotBlank -// repositoryId = "1", -// ), -// "message", -// ), -// Arguments.of( -// Commit( -// id = null, -// sha = "e".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = "Valid message", -// repositoryId = null, // invalid: NotNull, TODO only invalid if coming out of mapper, going in is ok e.g. on create -// ), -// "repositoryId", -// ), - ) - -// @JvmStatic -// fun invalidCommitsForDomain(): Stream = invalidCommitsForEntity() - } - - @ParameterizedTest - @MethodSource("invalidCommitsForEntity") - fun `create should produce violations for single invalid property`( - invalidCommit: Commit, - propertyPath: String, - ) { - val dummyRepo = - RepositoryEntity( - id = 1, - localPath = "test r", - project = - ProjectEntity( - id = 1, - name = "test p", - ), - ) - val dummyBranch = - Branch( - name = "branch", - repository = dummyRepo.toDomain(null), - ) - invalidCommit.branches.add(dummyBranch) - dummyBranch.commits.add(invalidCommit) - - val e = - assertThrows { - commitPort.create(invalidCommit) - } - assertThat(e.constraintViolations).hasSize(1) - assertThat(e.message).contains("create.value.$propertyPath:") - } - - @ParameterizedTest - @MethodSource("invalidCommitsForEntity") - fun `update should produce violations for single invalid property`( - invalidCommit: Commit, - propertyPath: String, - ) { - val dummyRepo = - RepositoryEntity( - id = 1, - localPath = "test r", - project = - ProjectEntity( - id = 1, - name = "test p", - ), - ) - val dummyBranch = - Branch( - name = "branch", - repository = dummyRepo.toDomain(null), - ) - invalidCommit.branches.add(dummyBranch) - dummyBranch.commits.add(invalidCommit) - - val e = - assertThrows { - commitPort.update(invalidCommit) - } - assertThat(e.constraintViolations).hasSize(1) - assertThat(e.message).contains("update.value.$propertyPath:") - } - - @ParameterizedTest - @MethodSource("invalidCommitsForEntity") - fun `delete should produce violations for single invalid property`( - invalidCommit: Commit, - propertyPath: String, - ) { - val dummyRepo = - RepositoryEntity( - id = 1, - localPath = "test r", - project = - ProjectEntity( - id = 1, - name = "test p", - ), - ) - val dummyBranch = - Branch( - name = "branch", - repository = dummyRepo.toDomain(null), - ) - invalidCommit.branches.add(dummyBranch) - dummyBranch.commits.add(invalidCommit) - - val e = - assertThrows { - commitPort.delete(invalidCommit) - } - assertThat(e.constraintViolations).hasSize(1) - assertThat(e.message).contains("delete.value.$propertyPath:") - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/validation/RepositoryInfrastructurePortValidationTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/validation/RepositoryInfrastructurePortValidationTest.kt deleted file mode 100644 index 73b873da3..000000000 --- a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/integration/service/validation/RepositoryInfrastructurePortValidationTest.kt +++ /dev/null @@ -1,269 +0,0 @@ -package com.inso_world.binocular.infrastructure.sql.integration.service.validation - -import com.inso_world.binocular.infrastructure.sql.integration.service.base.BaseServiceTest -import com.inso_world.binocular.infrastructure.sql.service.RepositoryInfrastructurePortImpl -import com.inso_world.binocular.model.Branch -import com.inso_world.binocular.model.Commit -import com.inso_world.binocular.model.Project -import com.inso_world.binocular.model.Repository -import io.mockk.junit5.MockKExtension -import jakarta.validation.ConstraintViolationException -import jakarta.validation.Validation -import jakarta.validation.Validator -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.Arguments -import org.junit.jupiter.params.provider.MethodSource -import org.springframework.beans.factory.annotation.Autowired -import java.time.LocalDateTime -import java.util.stream.Stream - -@ExtendWith(MockKExtension::class) -internal class RepositoryInfrastructurePortValidationTest : BaseServiceTest() { - @Autowired - private lateinit var repositoryPort: RepositoryInfrastructurePortImpl - - private lateinit var validator: Validator - - @BeforeEach - fun setup() { - validator = Validation.buildDefaultValidatorFactory().validator - } - - companion object { - @JvmStatic - fun invalidCommitsForEntity(): Stream = - Stream.of( - Arguments.of( - run { -// val repository = Repository(id = "1", name = "1") - val cmt = Commit( - id = null, - sha = "", // invalid: should be 40 chars - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), - message = "Valid message", - ) -// repository.commits.add(cmt) - cmt - }, - "sha", - ), - Arguments.of( - run { -// val repository = Repository(id = "1", name = "1") - val cmt = Commit( - id = null, - sha = "a".repeat(39), // invalid: should be 40 chars - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), - message = "Valid message", - ) -// repository.commits.add(cmt) - cmt - }, - "sha", - ), - Arguments.of( - run { -// val repository = Repository(id = "1", name = "1") - val cmt = Commit( - id = null, - sha = "b".repeat(41), // invalid: should be 40 chars - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), - message = "Valid message", - ) -// repository.commits.add(cmt) - cmt - }, - "sha", - ), - Arguments.of( - run { -// val repository = Repository(id = "1", name = "1") - val cmt = Commit( - id = null, - sha = "c".repeat(40), - authorDateTime = LocalDateTime.now(), - commitDateTime = null, // invalid: NotNull - message = "Valid message", - ) -// repository.commits.add(cmt) - cmt - }, - "commitDateTime", - ), - Arguments.of( - run { -// val repository = Repository(id = "1", name = "1") - val cmt = Commit( - id = null, - sha = "c".repeat(40), - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now().plusSeconds(60), // invalid: Future - message = "Valid message", - ) -// repository.commits.add(cmt) - cmt - }, - "commitDateTime", - ), -// Arguments.of( -// Commit( -// id = null, -// sha = "d".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = null, // invalid: NotBlank -// repositoryId = "1", -// ), -// "message", -// ), -// Arguments.of( -// Commit( -// id = null, -// sha = "d".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = " ", // invalid: NotBlank -// repositoryId = "1", -// ), -// "message", -// ), -// Arguments.of( -// Commit( -// id = null, -// sha = "d".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = "", // invalid: NotBlank -// repositoryId = "1", -// ), -// "message", -// ), -// Arguments.of( -// Commit( -// id = null, -// sha = "e".repeat(40), -// authorDateTime = LocalDateTime.now(), -// commitDateTime = LocalDateTime.now(), -// message = "Valid message", -// repository = null, // invalid: NotNull, TODO only invalid if coming out of mapper, going in is ok e.g. on create -// ), -// "repositoryId", -// ), - ) - } - - @ParameterizedTest - @MethodSource("invalidCommitsForEntity") - fun `create should produce violations for single invalid commit`( - invalidCommit: Commit, - propertyPath: String, - ) { - val dummyProject = - Project( - id = "1", - name = "test p", - ) - val dummyRepo = - Repository( - id = "1", - localPath = "test r", - project = dummyProject, - ) - val dummyBranch = - Branch( - name = "branch", - repository = dummyRepo, - ) - invalidCommit.branches.add(dummyBranch) - dummyBranch.commits.add(invalidCommit) - dummyRepo.commits.add(invalidCommit) - dummyRepo.branches.add(dummyBranch) - dummyProject.repo = dummyRepo - - val e = - assertThrows { - repositoryPort.create(dummyRepo) - } - assertThat(e.constraintViolations).hasSize(1) - assertThat(e.message).contains("create.value.commits[].$propertyPath:") - } - - @ParameterizedTest - @MethodSource("invalidCommitsForEntity") - fun `update should produce violations for single invalid commit`( - invalidCommit: Commit, - propertyPath: String, - ) { - val dummyProject = - Project( - id = "1", - name = "test p", - ) - val dummyRepo = - Repository( - id = "1", - localPath = "test r", - project = dummyProject, - ) - val dummyBranch = - Branch( - name = "branch", - repository = dummyRepo, - ) - invalidCommit.branches.add(dummyBranch) - dummyBranch.commits.add(invalidCommit) - dummyRepo.commits.add(invalidCommit) - dummyRepo.branches.add(dummyBranch) - dummyProject.repo = dummyRepo - - val e = - assertThrows { - repositoryPort.update(dummyRepo) - } - assertThat(e.constraintViolations).hasSize(1) - assertThat(e.message).contains("update.value.commits[].$propertyPath:") - } - - @ParameterizedTest - @MethodSource("invalidCommitsForEntity") - fun `delete should produce violations for single invalid commit`( - invalidCommit: Commit, - propertyPath: String, - ) { - val dummyProject = - Project( - id = "1", - name = "test p", - ) - val dummyRepo = - Repository( - id = "1", - localPath = "test r", - project = dummyProject, - ) - val dummyBranch = - Branch( - name = "branch", - repository = dummyRepo, - ) - invalidCommit.branches.add(dummyBranch) - dummyBranch.commits.add(invalidCommit) - dummyRepo.commits.add(invalidCommit) - dummyRepo.branches.add(dummyBranch) - dummyProject.repo = dummyRepo - - val e = - assertThrows { - repositoryPort.delete(dummyRepo) - } - assertThat(e.constraintViolations).hasSize(1) - assertThat(e.message).contains("delete.value.commits[].$propertyPath:") - } -} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/unit/mapper/ProjectMapperTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/unit/mapper/ProjectMapperTest.kt new file mode 100644 index 000000000..2167593f3 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/unit/mapper/ProjectMapperTest.kt @@ -0,0 +1,541 @@ +package com.inso_world.binocular.infrastructure.sql.unit.mapper + +import com.inso_world.binocular.domain.data.MockTestDataProvider +import com.inso_world.binocular.infrastructure.sql.TestData +import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.toEntity +import com.inso_world.binocular.infrastructure.sql.unit.mapper.ProjectMapperTest.Companion.IGNORED_FIELDS +import com.inso_world.binocular.infrastructure.sql.unit.mapper.base.BaseMapperTest +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import io.mockk.confirmVerified +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.unmockkStatic +import io.mockk.verify +import io.mockk.verifyOrder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertNull +import java.util.function.BiPredicate +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + + +@OptIn(ExperimentalUuidApi::class) +internal class ProjectMapperTest : BaseMapperTest() { + + companion object { + val IGNORED_FIELDS = listOf( + "id", "issues" + ) + + /** + * Configures recursive comparison for Project domain-entity equality checks. + * + * ### Configuration + * - Ignores collection order for flexible list/set comparisons + * - Uses custom equality for [Project.Id] value class (compares wrapped UUID values) + * - Ignores fields: [IGNORED_FIELDS] (id, issues) + * + * ### Usage + * ```kotlin + * assertThat(domain) + * .usingRecursiveComparisonForProject() + * .isEqualTo(entity) + * ``` + * + * @receiver The AssertJ ObjectAssert for a Project domain object + * @return Configured RecursiveComparisonAssert ready for `.isEqualTo(entity)` + */ + fun org.assertj.core.api.ObjectAssert.usingRecursiveComparisonForProject() = + this.usingRecursiveComparison() + .ignoringCollectionOrder() + .withEqualsForFields( + BiPredicate { actual, expected -> + when (actual) { + is Project.Id if expected is Project.Id -> actual.value == expected.value + is Project.Id if expected is Uuid -> actual.value == expected + is Uuid if expected is Project.Id -> actual == expected.value + else -> actual == expected + } + }, + "iid" + ) + .ignoringFields(*IGNORED_FIELDS.toTypedArray()) + + } + + @BeforeEach + fun setup() { + // edit fields before spying on super properties + projectMapper = spyk(super.projectMapper) + } + + @Nested + inner class ToEntity { + private lateinit var mockTestDataProvider: MockTestDataProvider + + @BeforeEach + fun setup() { + mockkStatic("com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntityKt") + mockkStatic("com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntityKt") + mockTestDataProvider = MockTestDataProvider( + Repository(localPath = "./", project = TestData.Domain.testProject()) + ) + } + + @AfterEach + fun tearDown() { + unmockkStatic("com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntityKt") + unmockkStatic("com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntityKt") + } + + @Test + fun `map with id is null, should not fail`() { + val domain = TestData.Domain.testProject(id = null) + + assertAll( + { assertNull(domain.id) }, + { assertDoesNotThrow { projectMapper.toEntity(domain) } } + ) + } + + @Test + fun `map with id containing whitespace, should trim and parse correctly`() { + val domain = TestData.Domain.testProject(id = " 42 ") + + val entity = projectMapper.toEntity(domain) + + assertThat(entity.id).isEqualTo(42L) + } + + @Test + fun `map with id as empty string, should result in null entity id`() { + val domain = TestData.Domain.testProject(id = "") + + val entity = projectMapper.toEntity(domain) + + assertThat(entity.id).isNull() + } + + @Test + fun `map with id as whitespace only, should result in null entity id`() { + val domain = TestData.Domain.testProject(id = " ") + + val entity = projectMapper.toEntity(domain) + + assertThat(entity.id).isNull() + } + + @Test + fun `map with id as non-numeric string, should result in null entity id`() { + val domain = TestData.Domain.testProject(id = "not-a-number") + + val entity = projectMapper.toEntity(domain) + + assertThat(entity.id).isNull() + } + + @Test + fun `map without description, should have null description in entity`() { + val domain = TestData.Domain.testProject(id = "1", description = null) + + val entity = projectMapper.toEntity(domain) + + assertAll( + { assertThat(entity.name).isEqualTo(domain.name) }, + { assertThat(entity.description).isNull() } + ) + } + + @Test + fun `map same domain twice, should return cached entity from context`() { + val domain = TestData.Domain.testProject(id = "1") + + val entity1 = projectMapper.toEntity(domain) + val entity2 = projectMapper.toEntity(domain) + + assertAll( + { assertThat(entity1).isSameAs(entity2) }, + { verify(exactly = 2) { ctx.findEntity(domain) } }, + { verify(exactly = 1) { ctx.remember(domain, entity1) } }, + { verify(exactly = 1) { domain.toEntity() } } + ) + } + + @Test + fun `minimal valid example, verify calls`() { + val domain = TestData.Domain.testProject(id = "1") + + val entity = projectMapper.toEntity(domain) + + assertAll( + { assertThat(entity.id).isEqualTo(domain.id?.toLong()) }, + { assertThat(entity.name).isEqualTo(domain.name) }, + { assertThat(entity.description).isEqualTo(domain.description) }, + { assertThat(entity.repo).isNull() } + ) + + verify(exactly = 1) { ctx.findEntity(domain) } + verify(exactly = 1) { ctx.remember(domain, entity) } + verify(exactly = 0) { ctx.findEntity(ofType()) } + verify(exactly = 1) { domain.toEntity() } + + confirmVerified(ctx) + } + + @Test + fun `map project with repository`() { + val domain = TestData.Domain.testProject(id = "1").apply { + repo = Repository(localPath = "./", project = this) + } + + val entity = projectMapper.toEntity(domain) + + assertThat(entity.name).isEqualTo(domain.name) + assertThat(entity.repo).isNull() + } + + @Test + fun `map project with repository, check equality`() { + val domain = TestData.Domain.testProject(id = "1").apply { + repo = Repository(localPath = "./", project = this) + } + + val entity = projectMapper.toEntity(domain) + + assertThat(entity).usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id") + .ignoringActualNullFields() + .isEqualTo(domain) + } + + @Test + fun `map project with repository, check iid is equal`() { + val domain = TestData.Domain.testProject(id = "1").apply { + repo = Repository(localPath = "./", project = this) + } + + val entity = projectMapper.toEntity(domain) + + assertAll( + { assertThat(domain.iid).isEqualTo(entity.iid) }, + { assertThat(domain.iid).isNotSameAs(entity.iid) } + ) + } + + @Test + fun `map project with repository, verify calls`() { + val domain = TestData.Domain.testProject(id = "1").apply { + repo = Repository(localPath = "./", project = this) + } + + val entity = projectMapper.toEntity(domain) + + verifyOrder { + ctx.findEntity(domain) + domain.toEntity() + ctx.remember(domain, entity) + } + + confirmVerified(ctx) + } + } + + @Nested + inner class ToDomain { + private lateinit var mockTestDataProvider: MockTestDataProvider + + @BeforeEach + fun setup() { + mockTestDataProvider = MockTestDataProvider( + Repository(localPath = "./", project = TestData.Domain.testProject()) + ) + } + + @Test + fun `map with id is null, should not fail`() { + val entity = TestData.Entity.testProjectEntity(id = null) + + assertAll( + { assertNull(entity.id) }, + { assertDoesNotThrow { projectMapper.toDomain(entity) } } + ) + } + + @Test + fun `map with id is null, domain id should be null`() { + val entity = TestData.Entity.testProjectEntity(id = null) + + val domain = projectMapper.toDomain(entity) + + assertThat(domain.id).isNull() + } + + @Test + fun `map without description, domain description should be null`() { + val entity = TestData.Entity.testProjectEntity(description = null) + + val domain = projectMapper.toDomain(entity) + + assertAll( + { assertThat(domain.name).isEqualTo(entity.name) }, + { assertThat(domain.description).isNull() } + ) + } + + @Test + fun `map same entity twice, should return cached domain from context`() { + val entity = spyk(TestData.Entity.testProjectEntity()) + + val domain1 = projectMapper.toDomain(entity) + val domain2 = projectMapper.toDomain(entity) + + assertAll( + { assertThat(domain1).isSameAs(domain2) }, + { verify(exactly = 2) { ctx.findDomain(entity) } }, + { verify(exactly = 1) { ctx.remember(domain1, entity) } }, + { verify(exactly = 1) { entity.toDomain() } } + ) + + confirmVerified(ctx) + } + + @Test + fun `map entity with id as Long MAX_VALUE, should convert correctly`() { + val entity = TestData.Entity.testProjectEntity(id = Long.MAX_VALUE) + + val domain = projectMapper.toDomain(entity) + + assertThat(domain.id).isEqualTo(Long.MAX_VALUE.toString()) + } + + @Test + fun `map entity, iid should be set correctly via reflection`() { + val expectedIid = Project.Id(Uuid.random()) + val entity = TestData.Entity.testProjectEntity(iid = expectedIid) + + val domain = projectMapper.toDomain(entity) + + assertAll( + { assertThat(domain.iid).isEqualTo(expectedIid) }, + { assertThat(domain.iid.value).isEqualTo(expectedIid.value) } + ) + } + + @Test + fun `minimal valid example without repository, check equality`() { + val entity = TestData.Entity.testProjectEntity() + + val domain = projectMapper.toDomain(entity) + + assertThat(domain) + .usingRecursiveComparisonForProject() + .isEqualTo(entity) + } + + @Test + fun `minimal valid example, verify calls`() { + val entity = spyk(TestData.Entity.testProjectEntity()) + + val domain = projectMapper.toDomain(entity) + + verify(exactly = 1) { ctx.findDomain(entity) } + verify(exactly = 1) { ctx.remember(domain, entity) } + verify(exactly = 0) { ctx.findDomain(ofType()) } + verify(exactly = 1) { entity.toDomain() } + + confirmVerified(ctx) + } + + @Test + fun `minimal valid example, check equality`() { + val entity = TestData.Entity.testProjectEntity() + + val domain = projectMapper.toDomain(entity) + + assertThat(domain) + .usingRecursiveComparisonForProject() + .isEqualTo(entity) + } + + @Test + fun `map project with repository, verify calls`() { + // Arrange: real instances wrapped in spies + val entity = ProjectEntity(name = "test project", iid = Project.Id(Uuid.random())).apply { id = 1L } + run { + val repoEntity = + RepositoryEntity( + localPath = "./", + project = entity, + iid = Repository.Id(Uuid.random()) + ).apply { id = 1L } + assertThat(entity.repo).isSameAs(repoEntity) + } + + val domain = projectMapper.toDomain(entity) + + // Verify + verifyOrder { + ctx.findDomain(entity) + ctx.remember(domain, entity) + } + + confirmVerified(ctx) + } + + @Test + fun `map project with repository`() { + val entity = + ProjectEntity( + name = "test project", + iid = Project.Id(Uuid.random()) + ).apply { + id = 1 + repo = RepositoryEntity(localPath = "./", project = this, iid = Repository.Id(Uuid.random())) + } + + val domain = projectMapper.toDomain(entity) + + assertThat(domain.name).isEqualTo(entity.name) + assertThat(domain.repo).isNull() + } + + @Test + fun `map project with repository, check equality`() { + val entity = + ProjectEntity( + name = "test project", + iid = Project.Id(Uuid.random()) + ).apply { + id = 1 + repo = RepositoryEntity(localPath = "./", project = this, iid = Repository.Id(Uuid.random())) + } + + val domain = projectMapper.toDomain(entity) + + assertThat(domain.repo).isNull() + assertThat(domain) + .usingRecursiveComparisonForProject() + .ignoringActualNullFields() // domain.repo is null from mapper + .isEqualTo(entity) + } + + @Test + fun `map project with repository, check iid is equal`() { + val entity = + ProjectEntity( + name = "test project", + iid = Project.Id(Uuid.random()) + ).apply { + id = 1 + repo = RepositoryEntity(localPath = "./", project = this, iid = Repository.Id(Uuid.random())) + } + + val domain = projectMapper.toDomain(entity) + + assertAll( + { assertThat(entity.iid).isEqualTo(domain.iid) }, + { assertThat(entity.iid).isNotSameAs(domain.iid) } + ) + } + } + + @Nested + inner class RefreshDomain { + private lateinit var entity: ProjectEntity + private lateinit var domain: Project + + @BeforeEach + fun setup() { + entity = TestData.Entity.testProjectEntity() + domain = TestData.Domain.testProject(id = null) + } + + @Test + fun `refresh domain object, check that id is set`() { + assertAll( + { assertNull(domain.id) }, + { assertNotNull(entity.id) } + ) + + projectMapper.refreshDomain(domain, entity) + + assertAll( + { assertNotNull(domain.id) }, + { assertThat(domain.id).isEqualTo(entity.id.toString()) }, + ) + } + + @Test + fun `refresh domain with entity id null, domain id should remain null`() { + entity = TestData.Entity.testProjectEntity(id = null) + domain = TestData.Domain.testProject(id = "42") + + assertAll( + { assertThat(domain.id).isEqualTo("42") }, + { assertNull(entity.id) } + ) + + projectMapper.refreshDomain(domain, entity) + + assertThat(domain.id).isNull() + } + + @Test + fun `refresh domain with large entity id, should convert correctly`() { + entity = TestData.Entity.testProjectEntity(id = Long.MAX_VALUE) + domain = TestData.Domain.testProject(id = null) + + projectMapper.refreshDomain(domain, entity) + + assertThat(domain.id).isEqualTo(Long.MAX_VALUE.toString()) + } + + @Test + fun `refresh domain with zero entity id, should set domain id to zero string`() { + entity = TestData.Entity.testProjectEntity(id = 0L) + domain = TestData.Domain.testProject(id = null) + + projectMapper.refreshDomain(domain, entity) + + assertThat(domain.id).isEqualTo("0") + } + + @Test + fun `refresh domain overwrites existing domain id`() { + entity = TestData.Entity.testProjectEntity(id = 100L) + domain = TestData.Domain.testProject(id = "42") + + assertThat(domain.id).isEqualTo("42") + + projectMapper.refreshDomain(domain, entity) + + assertThat(domain.id).isEqualTo("100") + } + + @Test + fun `refresh domain preserves other domain fields`() { + val originalName = domain.name + val originalDescription = domain.description + + projectMapper.refreshDomain(domain, entity) + + assertAll( + { assertThat(domain.name).isEqualTo(originalName) }, + { assertThat(domain.description).isEqualTo(originalDescription) }, + { assertThat(domain.id).isEqualTo(entity.id.toString()) } + ) + } + } + +} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/unit/mapper/RemoteMapperTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/unit/mapper/RemoteMapperTest.kt new file mode 100644 index 000000000..d213fabc5 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/unit/mapper/RemoteMapperTest.kt @@ -0,0 +1,158 @@ +package com.inso_world.binocular.infrastructure.sql.unit.mapper + +import com.inso_world.binocular.core.extension.reset +import com.inso_world.binocular.infrastructure.sql.TestData +import com.inso_world.binocular.infrastructure.sql.persistence.entity.ProjectEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RemoteEntity +import com.inso_world.binocular.infrastructure.sql.persistence.entity.RepositoryEntity +import com.inso_world.binocular.infrastructure.sql.unit.mapper.base.BaseMapperTest +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.vcs.Remote +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +internal class RemoteMapperTest : BaseMapperTest() { + + @Nested + inner class ToEntity { + private lateinit var repositoryEntity: RepositoryEntity + private lateinit var repositoryDomain: Repository + + @BeforeEach + fun setup() { + val project = TestData.Domain.testProject(name = "TestProject") + val projectEntity = TestData.Entity.testProjectEntity(name = project.name, id = 1L) + repositoryDomain = TestData.Domain.testRepository(localPath = "/tmp/repo", project = project) + repositoryEntity = TestData.Entity.testRepositoryEntity( + localPath = repositoryDomain.localPath, + id = 1L, + project = projectEntity, + ) + ctx.remember(repositoryDomain, repositoryEntity) + } + + @Test + fun `maps remote metadata`() { + val remote = Remote(name = "origin", url = "https://example.com/repo.git", repository = repositoryDomain) + + val entity = assertDoesNotThrow { remoteMapper.toEntity(remote) } + + assertAll( + { assertThat(entity.name).isEqualTo("origin") }, + { assertThat(entity.url).isEqualTo("https://example.com/repo.git") }, + { assertThat(entity.repository).isSameAs(repositoryEntity) }, + { assertThat(entity.iid).isEqualTo(remote.iid) }, + ) + } + + @Test + fun `requires repository in context`() { + ctx.reset() + val remote = Remote(name = "origin", url = "https://example.com/repo.git", repository = repositoryDomain) + + val exception = assertThrows { remoteMapper.toEntity(remote) } + + assertThat(exception.message).contains("RepositoryEntity must be mapped before RemoteEntity") + } + + @Test + fun `fast path returns cached entity`() { + val remote = Remote(name = "origin", url = "https://example.com/repo.git", repository = repositoryDomain) + val first = remoteMapper.toEntity(remote) + + val cached = remoteMapper.toEntity(remote) + + assertThat(cached).isSameAs(first) + } + } + + @Nested + inner class ToDomain { + private lateinit var repositoryEntity: RepositoryEntity + private lateinit var repositoryDomain: Repository + + @BeforeEach + fun setup() { + val projectEntity = ProjectEntity( + name = "TestProject", + iid = Project.Id(Uuid.random()) + ).apply { + id = 1L + description = "desc" + } + repositoryEntity = RepositoryEntity( + iid = Repository.Id(Uuid.random()), + localPath = "/tmp/repo", + project = projectEntity, + ).apply { id = 10L } + projectEntity.repo = repositoryEntity + + val project = TestData.Domain.testProject(name = projectEntity.name, id = projectEntity.id.toString()) + repositoryDomain = TestData.Domain.testRepository( + localPath = repositoryEntity.localPath, + id = repositoryEntity.id.toString(), + project = project + ) + ctx.remember(project, projectEntity) + ctx.remember(repositoryDomain, repositoryEntity) + } + + @Test + fun `maps remote metadata`() { + val remoteEntity = RemoteEntity( + name = "origin", + url = "https://example.com/repo.git", + repository = repositoryEntity, + iid = Remote.Id(Uuid.random()) + ).apply { id = 30L } + + val domain = assertDoesNotThrow { remoteMapper.toDomain(remoteEntity) } + + assertAll( + { assertThat(domain.name).isEqualTo(remoteEntity.name) }, + { assertThat(domain.url).isEqualTo(remoteEntity.url) }, + { assertThat(domain.repository).isSameAs(repositoryDomain) }, + { assertThat(domain.iid).isEqualTo(remoteEntity.iid) }, + ) + } + + @Test + fun `requires repository domain in context`() { + ctx.reset() + val remoteEntity = RemoteEntity( + name = "origin", + url = "https://example.com/repo.git", + repository = repositoryEntity, + iid = Remote.Id(Uuid.random()) + ) + + val exception = assertThrows { remoteMapper.toDomain(remoteEntity) } + + assertThat(exception.message).contains("Repository must be mapped before Remote") + } + + @Test + fun `fast path returns cached domain`() { + val remoteEntity = RemoteEntity( + name = "origin", + url = "https://example.com/repo.git", + repository = repositoryEntity, + iid = Remote.Id(Uuid.random()) + ) + val first = remoteMapper.toDomain(remoteEntity) + + val cached = remoteMapper.toDomain(remoteEntity) + + assertThat(cached).isSameAs(first) + } + } +} diff --git a/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/unit/mapper/base/BaseMapperTest.kt b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/unit/mapper/base/BaseMapperTest.kt new file mode 100644 index 000000000..9e2eced10 --- /dev/null +++ b/binocular-backend-new/infrastructure-sql/src/test/kotlin/com/inso_world/binocular/infrastructure/sql/unit/mapper/base/BaseMapperTest.kt @@ -0,0 +1,60 @@ +package com.inso_world.binocular.infrastructure.sql.unit.mapper.base + +import com.inso_world.binocular.core.persistence.mapper.context.MappingContext +import com.inso_world.binocular.core.unit.base.BaseUnitTest +import com.inso_world.binocular.infrastructure.sql.mapper.ProjectMapper +import com.inso_world.binocular.infrastructure.sql.mapper.RemoteMapper +import com.inso_world.binocular.infrastructure.sql.mapper.RepositoryMapper +import io.mockk.spyk +import org.junit.jupiter.api.BeforeEach +import org.springframework.data.util.ReflectionUtils.setField + +internal open class BaseMapperTest : BaseUnitTest() { + lateinit var ctx: MappingContext + lateinit var projectMapper: ProjectMapper + lateinit var repositoryMapper: RepositoryMapper + lateinit var remoteMapper: RemoteMapper + + @BeforeEach + fun setUp() { + ctx = spyk(MappingContext()) + + remoteMapper = spyk(RemoteMapper()) + repositoryMapper = spyk(RepositoryMapper()) + + + projectMapper = spyk(ProjectMapper()) + + // wire up projectMapper + with(projectMapper) { + setField( + this.javaClass.getDeclaredField("ctx"), + this, + ctx + ) + } + + // wire up repositoryMapper + with(repositoryMapper) { + setField( + this.javaClass.getDeclaredField("ctx"), + this, + ctx + ) +// setField( +// this.javaClass.getDeclaredField("projectMapper"), +// this, +// projectMapper +// ) + } + + // wire up remoteMapper + with(remoteMapper) { + setField( + this.javaClass.getDeclaredField("ctx"), + this, + ctx + ) + } + } +} diff --git a/binocular-backend-new/infrastructure-sql/src/test/resources/application-postgres.yaml b/binocular-backend-new/infrastructure-sql/src/test/resources/application-postgres.yaml index 2e3e6a59e..1c6072356 100644 --- a/binocular-backend-new/infrastructure-sql/src/test/resources/application-postgres.yaml +++ b/binocular-backend-new/infrastructure-sql/src/test/resources/application-postgres.yaml @@ -1,4 +1,6 @@ spring: + datasource: + driver-class-name: org.postgresql.Driver jpa: database-platform: org.hibernate.dialect.PostgreSQLDialect properties: diff --git a/binocular-backend-new/infrastructure-test/pom.xml b/binocular-backend-new/infrastructure-test/pom.xml index 6f7f5028d..3dc6bba6d 100644 --- a/binocular-backend-new/infrastructure-test/pom.xml +++ b/binocular-backend-new/infrastructure-test/pom.xml @@ -33,6 +33,13 @@ domain ${project.version} + + com.inso-world.binocular + domain + ${project.version} + test + tests + com.inso-world.binocular ffi @@ -92,13 +99,13 @@ org.testcontainers - postgresql - test + testcontainers-postgresql + provided com.github.goodforgod arangodb-testcontainer - ${arangodb-testcontainer.version} + provided junit diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/BranchTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/BranchTest.kt index 32f8a73c6..41259fdfe 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/BranchTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/BranchTest.kt @@ -9,7 +9,9 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired /** - * create and load branch using BranchInfrastructurePort from core module package + * Integration tests for Branch persistence via BranchInfrastructurePort. + * Tests verify that domain model semantics (particularly the derived commits getter + * and head property validation) are preserved through the infrastructure layer. */ internal class BranchTest : BaseInfrastructureSpringTest() { @Autowired @@ -35,4 +37,37 @@ internal class BranchTest : BaseInfrastructureSpringTest() { // ArangodbInfrastructureDataSetup links branch1->file1,file2 assert(files.isNotEmpty()) } + + @Test + fun `branch commits getter returns all reachable commits in topo order`() { + val branch = TestDataProvider.testBranches.first() + val commits = branch.commits + + // Commits should be non-empty (at least contains head) + assert(commits.isNotEmpty()) + assert(commits.contains(branch.head)) + + // First commit should be the head (children-before-parents order) + assertEquals(branch.head, commits.first()) + } + + @Test + fun `branch head must be from same repository`() { + val expected = TestDataProvider.testBranches.first() + val loaded = branchPort.findById(requireNotNull(expected.id)) + assertNotNull(loaded) + loaded!! + + // Verify head is from same repository + assertEquals(loaded.repository.id, loaded.head.repository?.id) + } + + @Test + fun `branch auto-registers with repository during construction`() { + val branch = TestDataProvider.testBranches.first() + val repository = branch.repository + + // Branch should be in repository's branches collection + assert(repository.branches.contains(branch)) + } } diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/ProjectTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/ProjectTest.kt index 7c0ea2a2f..6f9468673 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/ProjectTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/ProjectTest.kt @@ -3,6 +3,7 @@ package com.inso_world.binocular.infrastructure.test import com.inso_world.binocular.core.service.ProjectInfrastructurePort import com.inso_world.binocular.infrastructure.test.base.BaseInfrastructureSpringTest import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -11,6 +12,11 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +/** + * Integration tests for Project persistence via ProjectInfrastructurePort. + * Tests verify that domain model semantics (especially the set-once repo property) + * are preserved through the infrastructure layer. + */ internal class ProjectTest : BaseInfrastructureSpringTest() { @Autowired lateinit var projectPort: ProjectInfrastructurePort @@ -26,7 +32,7 @@ internal class ProjectTest : BaseInfrastructureSpringTest() { val created = projectPort.create(project) assertNotNull(created.id) - val loaded = projectPort.findById(requireNotNull(created.id)) + val loaded = projectPort.findByIid(created.iid) assertNotNull(loaded) assertEquals(created.id, loaded!!.id) assertEquals(project.name, loaded.name) @@ -35,4 +41,35 @@ internal class ProjectTest : BaseInfrastructureSpringTest() { assertNotNull(byName) assertEquals(created.id, byName!!.id) } + + @Test + fun `project repo property is set-once`() { + val project = Project(name = "project-pt-002") + val repo1 = Repository(localPath = "repo-1", project = project) + + // Repository auto-registers with project during construction + assertNotNull(project.repo) + assertEquals(repo1, project.repo) + + // Attempting to set a different repository should fail + val repo2 = Repository(localPath = "repo-2", project = Project(name = "other-project")) + org.junit.jupiter.api.assertThrows { + project.repo = repo2 + } + + // Setting the same repository again should be a no-op + org.junit.jupiter.api.assertDoesNotThrow { + project.repo = repo1 + } + } + + @Test + fun `project repo property cannot be set to null`() { + val project = Project(name = "project-pt-003") + val repo = Repository(localPath = "repo-3", project = project) + + org.junit.jupiter.api.assertThrows { + project.repo = null + } + } } diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/RepositoryTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/RepositoryTest.kt index 391493d7c..4019eb1f1 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/RepositoryTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/RepositoryTest.kt @@ -3,15 +3,24 @@ package com.inso_world.binocular.infrastructure.test import com.inso_world.binocular.core.data.MockTestDataProvider import com.inso_world.binocular.core.service.RepositoryInfrastructurePort import com.inso_world.binocular.infrastructure.test.base.BaseInfrastructureSpringTest +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDateTime +/** + * Integration tests for Repository persistence via RepositoryInfrastructurePort. + * Tests verify that domain model semantics (particularly bidirectional relationships) + * are preserved through the infrastructure layer. + */ internal class RepositoryTest : BaseInfrastructureSpringTest() { @Autowired lateinit var repositoryPort: RepositoryInfrastructurePort @@ -46,4 +55,45 @@ internal class RepositoryTest : BaseInfrastructureSpringTest() { assertNotNull(loadedByName) assertEquals(created.id, loadedByName!!.id) } + + @Test + fun `create repository, verify automatic registration with project`() { + val newProject = Project(name = "test-project-for-repo") + val repo = Repository(localPath = "repo-rt-002", project = newProject) + + // Repository auto-registers with project during construction + assertNotNull(newProject.repo) + assertEquals(repo, newProject.repo) + + val created = repositoryPort.create(repo) + assertNotNull(created.id) + + // Verify bidirectional relationship persists + assertEquals(newProject.id, created.project?.id) + } + + @Test + fun `repository commits collection is add-only`() { + val repo = Repository(localPath = "repo-rt-003", project = Project(name = "test-project-2")) + val developer = Developer(name = "Test Committer", email = "committer@test.com", repository = repo) + val commit = Commit( + sha = "d".repeat(40), + message = "test commit", + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.now()), + repository = repo, + ) + + // Commits auto-register with repository during construction + assertEquals(1, repo.commits.size) + assert(repo.commits.contains(commit)) + + // Removal operations should throw UnsupportedOperationException + org.junit.jupiter.api.assertThrows { + repo.commits.remove(commit) + } + + org.junit.jupiter.api.assertThrows { + repo.commits.clear() + } + } } diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/UserTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/UserTest.kt index b4d5c8b5d..ebfda35ed 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/UserTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/UserTest.kt @@ -7,13 +7,19 @@ import com.inso_world.binocular.infrastructure.test.base.BaseInfrastructureSprin import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository import com.inso_world.binocular.model.User +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import kotlin.test.assertContains +/** + * Integration tests for User persistence via UserInfrastructurePort. + * Tests verify that domain model semantics are preserved through the infrastructure layer. + */ internal class UserTest : BaseInfrastructureSpringTest() { @Autowired lateinit var userPort: UserInfrastructurePort @@ -28,25 +34,24 @@ internal class UserTest : BaseInfrastructureSpringTest() { @BeforeEach fun setup() { - // clean and baseline - userPort.deleteAll() - repositoryPort.deleteAll() - projectPort.deleteAll() + infrastructureDataSetup.teardown() val project = projectPort.create(Project(name = "proj-user-001")) repository = repositoryPort.create(Repository(localPath = "repo-user-001", project = project)) } @AfterEach fun cleanup() { - userPort.deleteAll() - repositoryPort.deleteAll() - projectPort.deleteAll() + infrastructureDataSetup.teardown() } @Test fun `create user and load it by id`() { - val user = User(name = "Jane Doe", email = "jane@example.com", repository = repository) - val created = userPort.create(user) + val user = User(name = "Jane Doe", repository = repository).apply { email = "jane@example.com" } + val created = run { + val updatedRepository = repositoryPort.update(repository) + assertThat(updatedRepository.user).hasSize(1) + updatedRepository.user.first() + } val id = requireNotNull(created.id) assertNotNull(id) @@ -56,16 +61,68 @@ internal class UserTest : BaseInfrastructureSpringTest() { assertEquals(created.id, loaded.id) assertEquals(user.name, loaded.name) assertEquals(user.email, loaded.email) - assertEquals(repository.id, loaded.repository?.id) + assertEquals(repository.id, loaded.repository.id) + } + + @Test + fun `create user, verify automatic registration with repository`() { + val user = User(name = "Alice", repository = repository).apply { email = "alice@example.com" } + + // User auto-registers with repository during construction + assertEquals(1, repository.user.size) + assert(repository.user.contains(user)) + + val created = run { + val updatedRepository = repositoryPort.update(repository) + assertThat(updatedRepository.user).hasSize(1) + updatedRepository.user.first() + } +// userPort.create(user) + val loaded = userPort.findById(requireNotNull(created.id)) + + assertNotNull(loaded) + assertEquals(user.name, loaded!!.name) + assertEquals(repository.id, loaded.repository.id) } @Test fun `findAll returns created users`() { - val u1 = userPort.create(User(name = "A", email = "a@example.com", repository = repository)) - val u2 = userPort.create(User(name = "B", email = "b@example.com", repository = repository)) + val u1 = run { + val u1 = User(name = "A", repository = repository).apply { email = "a@example.com" } + val updatedRepository = repositoryPort.update(repository) + assertThat(updatedRepository.user).hasSize(1) + requireNotNull( + updatedRepository.user.find { it.name == "A" } + ) + } +// userPort.create() + val u2 = run { + val u1 = User(name = "B", repository = repository).apply { email = "b@example.com" } + val updatedRepository = repositoryPort.update(repository) + assertThat(updatedRepository.user).hasSize(2) + requireNotNull( + updatedRepository.user.find { it.name == "B" } + ) + } +// userPort.create() val all = userPort.findAll().toList() // at least 2 (could include other users if DB not fully isolated); ensure ours are present val ids = all.mapNotNull { it.id }.toSet() - assert(ids.contains(u1.id) && ids.contains(u2.id)) + assertContains(ids, u1.id, u2.id) +// val iids = all.map { it.iid }.toSet() +// assertContains(iids, u1.iid, u2.iid) + } + + @Test + fun `create user with blank email throws exception`() { + val user = User(name = "Bob", repository = repository) + + org.junit.jupiter.api.assertThrows { + user.email = "" + } + + org.junit.jupiter.api.assertThrows { + user.email = " " + } } } diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/ValidationTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/ValidationTest.kt index 5d06dcdc6..741bf022e 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/ValidationTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/ValidationTest.kt @@ -4,139 +4,228 @@ import com.inso_world.binocular.core.service.BranchInfrastructurePort import com.inso_world.binocular.core.service.CommitInfrastructurePort import com.inso_world.binocular.core.service.ProjectInfrastructurePort import com.inso_world.binocular.core.service.RepositoryInfrastructurePort -import com.inso_world.binocular.core.service.UserInfrastructurePort import com.inso_world.binocular.infrastructure.test.base.BaseInfrastructureSpringTest import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.vcs.ReferenceCategory import jakarta.validation.ConstraintViolationException -import org.junit.jupiter.api.AfterEach +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.util.ReflectionUtils.setField import java.time.LocalDateTime +import kotlin.reflect.jvm.javaField /** - * Verifies that domain constraints are enforced via the core ports. - * Only imports from core and model are used, as requested. + * Verifies that domain constraints are enforced via the infrastructure ports. + * Tests ensure that Jakarta validation annotations and domain-level invariants + * are properly validated when persisting entities through infrastructure ports. + * + * These tests focus on validation constraints defined in the domain models: + * - @NotBlank, @NotNull, @Size, @PastOrPresent annotations + * - Domain-level require() checks + * - Repository consistency checks */ internal class ValidationTest : BaseInfrastructureSpringTest() { - @Autowired lateinit var projectPort: ProjectInfrastructurePort + @Autowired + lateinit var projectPort: ProjectInfrastructurePort - @Autowired lateinit var repositoryPort: RepositoryInfrastructurePort + @Autowired + lateinit var repositoryPort: RepositoryInfrastructurePort - @Autowired lateinit var branchPort: BranchInfrastructurePort + @Autowired + lateinit var branchPort: BranchInfrastructurePort - @Autowired lateinit var commitPort: CommitInfrastructurePort - - @Autowired lateinit var userPort: UserInfrastructurePort + @Autowired + lateinit var commitPort: CommitInfrastructurePort private lateinit var validProject: Project private lateinit var validRepository: Repository + private lateinit var validCommit: Commit @BeforeEach fun setup() { // Baseline valid project/repository for tests that need them validProject = projectPort.create(Project(name = "proj-valid")) validRepository = repositoryPort.create(Repository(localPath = "repo-valid", project = validProject)) + val developer = Developer(name = "a", email = "a@a.a", repository = validRepository) + Commit( + sha = "af".repeat(20), + message = "message", + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.now()), + repository = validRepository, + ) + repositoryPort.update(validRepository) + validCommit = validRepository.commits.first() } // Project validations - @Test - fun `project name must not be blank`() { + @ParameterizedTest + @MethodSource("com.inso_world.binocular.data.DummyTestData#provideBlankStrings") + fun `project name must not be blank`(name: String) { + val project = Project(name = "name") + setField(Project::name.javaField!!, project, name) assertThrows(ConstraintViolationException::class.java) { - projectPort.create(Project(name = "")) + projectPort.create(project) } } // Repository validations - @Test - fun `repository name must not be blank`() { - assertThrows(ConstraintViolationException::class.java) { - repositoryPort.create(Repository(localPath = "", project = validProject)) - } - } - - @Test - fun `repository project must not be null`() { - assertThrows(ConstraintViolationException::class.java) { - repositoryPort.create(Repository(localPath = "repo-no-project", project = null)) + @ParameterizedTest + @MethodSource("com.inso_world.binocular.data.DummyTestData#provideBlankStrings") + fun `repository name must not be blank`(name: String) { + val repository = validRepository + setField(Repository::localPath.javaField!!, repository, name) + val ex = assertThrows(ConstraintViolationException::class.java) { + repositoryPort.create(repository) } + assertThat(ex.constraintViolations.size).isEqualTo(1) + assertThat(ex.constraintViolations.first().propertyPath.toString()).isEqualTo("create.value.localPath") } // Branch validations - @Test - fun `branch name must not be blank`() { - assertThrows(ConstraintViolationException::class.java) { - branchPort.create(Branch(name = "", repository = validRepository)) + @ParameterizedTest + @MethodSource("com.inso_world.binocular.data.DummyTestData#provideBlankStrings") + fun `branch name must not be blank`(name: String) { + val branch = Branch( + name = "name", + fullName = "asdf", + repository = validRepository, + category = ReferenceCategory.LOCAL_BRANCH, + head = validCommit + ) + setField(Branch::name.javaField!!, branch, name) + val ex = assertThrows(ConstraintViolationException::class.java) { + branchPort.create(branch) } + assertThat(ex.constraintViolations.size).isEqualTo(1) + assertThat(ex.constraintViolations.first().propertyPath.toString()).isEqualTo("create.value.name") } @Test fun `branch repository must not be null`() { - assertThrows(ConstraintViolationException::class.java) { - branchPort.create(Branch(name = "main", repository = null)) + val branch = Branch( + name = "main", + fullName = "refs/heads/main", + repository = validRepository, + category = ReferenceCategory.LOCAL_BRANCH, + head = validCommit + ) + setField(Branch::repository.javaField!!, branch, null) + val ex = assertThrows { +// branchPort.create(branch) + repositoryPort.update(validRepository) } + assertThat(ex.constraintViolations.size).isEqualTo(1) + assertThat(ex.constraintViolations.first().propertyPath.toString()).isEqualTo("create.value.repository") } // Commit validations - @Test - fun `commit sha must be exactly 40 chars`() { - assertThrows(ConstraintViolationException::class.java) { - commitPort.create( - Commit( - sha = "abc", // invalid length - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), - repository = validRepository, - ), + @ParameterizedTest + @MethodSource("com.inso_world.binocular.model.validation.ValidationTestData#provideInvalidShaHex") + fun `commit sha must be exactly 40 chars`(sha: String) { + val commit = run { + val developer = Developer(name = "Test Committer", email = "committer@test.com", repository = validRepository) + return@run Commit( + sha = "a".repeat(40), + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.now()), + repository = validRepository, ) } + setField(Commit::sha.javaField!!, commit, sha) + val ex = assertThrows(ConstraintViolationException::class.java) { + commitPort.create(commit) + } + + assertThat(ex.constraintViolations).hasSize(1) + assertThat(ex.constraintViolations.first().propertyPath.toString()).contains(".sha") } @Test - fun `commit commitDateTime must not be null`() { - assertThrows(ConstraintViolationException::class.java) { - commitPort.create( - Commit( - sha = "a".repeat(40), - authorDateTime = LocalDateTime.now(), - commitDateTime = null, // invalid - repository = validRepository, - ), - ) + fun `commit authorSignature must not be null`() { + val developer = Developer(name = "Test Committer", email = "committer@test.com", repository = validRepository) + val commit = Commit( + sha = "fa".repeat(20), + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.now()), + repository = validRepository, + ) + // null is invalid + setField(Commit::authorSignature.javaField!!, commit, null) + val ex = assertThrows(ConstraintViolationException::class.java) { + commitPort.create(commit) } + assertThat(ex.constraintViolations.size).isEqualTo(1) + assertThat(ex.constraintViolations.first().propertyPath.toString()).isEqualTo("create.value.authorSignature") } @Test fun `commit repository must not be null`() { - assertThrows(ConstraintViolationException::class.java) { - commitPort.create( - Commit( - sha = "a".repeat(40), - authorDateTime = LocalDateTime.now(), - commitDateTime = LocalDateTime.now(), - repository = null, // invalid - ), - ) + val developer = Developer(name = "Test Committer", email = "committer@test.com", repository = validRepository) + val commit = Commit( + sha = "a".repeat(40), + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.now()), + repository = validRepository, + ) + + // invalid + setField(Commit::repository.javaField!!, commit, null) + val ex = assertThrows(ConstraintViolationException::class.java) { + commitPort.create(commit) } + assertThat(ex.constraintViolations.size).isEqualTo(1) + assertThat(ex.constraintViolations.first().propertyPath.toString()).isEqualTo("create.value.repository") } - // User validations @Test - fun `user name must not be blank`() { - assertThrows(ConstraintViolationException::class.java) { - userPort.create(User(name = "", email = "x@example.com", repository = validRepository)) + fun `commit author must belong to same repository as commit`() { + val differentProject = projectPort.create(Project(name = "different-project")) + val differentRepository = + repositoryPort.create(Repository(localPath = "different-repo", project = differentProject)) + val authorFromDifferentRepo = + Developer(name = "Different Author", email = "different@test.com", repository = differentRepository) + + // Creating a commit with an author from a different repository should fail at construction + val developer = Developer(name = "Test Committer", email = "committer@test.com", repository = validRepository) + assertThrows(IllegalArgumentException::class.java) { + Commit( + sha = "b".repeat(40), + authorSignature = Signature(developer = authorFromDifferentRepo, timestamp = LocalDateTime.now()), + repository = validRepository, + ) } } @Test - fun `user repository must not be null`() { - assertThrows(ConstraintViolationException::class.java) { - userPort.create(User(name = "Alice", email = "a@example.com", repository = null)) + fun `branch head must belong to same repository as branch`() { + val differentProject = projectPort.create(Project(name = "different-project-2")) + val differentRepository = + repositoryPort.create(Repository(localPath = "different-repo-2", project = differentProject)) + val developerFromDifferentRepo = + Developer(name = "Different Committer", email = "different@test.com", repository = differentRepository) + + val headFromDifferentRepo = Commit( + sha = "c".repeat(40), + authorSignature = Signature(developer = developerFromDifferentRepo, timestamp = LocalDateTime.now()), + repository = differentRepository, + ) + + assertThrows(IllegalArgumentException::class.java) { + Branch( + name = "test-branch", + fullName = "refs/heads/test-branch", + repository = validRepository, + category = ReferenceCategory.LOCAL_BRANCH, + head = headFromDifferentRepo + ) } } } diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/base/BaseInfrastructureSpringTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/base/BaseInfrastructureSpringTest.kt index 42ea0fa0a..956f5ad2f 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/base/BaseInfrastructureSpringTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/base/BaseInfrastructureSpringTest.kt @@ -6,6 +6,7 @@ import com.inso_world.binocular.infrastructure.test.config.LocalPostgresConfig import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.EnableAutoConfiguration import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.ComponentScan import org.springframework.test.context.ContextConfiguration @@ -16,6 +17,7 @@ import org.springframework.test.context.ContextConfiguration * This base also ensures DB is populated with TestDataProvider data before each test. */ @SpringBootTest +@EnableAutoConfiguration @ContextConfiguration( classes = [LocalArangodbConfig::class, LocalPostgresConfig::class], initializers = [ @@ -26,7 +28,7 @@ import org.springframework.test.context.ContextConfiguration @ComponentScan(basePackages = ["com.inso_world.binocular.infrastructure.test", "com.inso_world.binocular.core"]) internal abstract class BaseInfrastructureSpringTest { @Autowired - private lateinit var infrastructureDataSetup: InfrastructureDataSetup + protected lateinit var infrastructureDataSetup: InfrastructureDataSetup @BeforeEach fun baseSetup() { diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/commit/CommitSaveOperation.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/commit/CommitSaveOperation.kt new file mode 100644 index 000000000..b50a01157 --- /dev/null +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/commit/CommitSaveOperation.kt @@ -0,0 +1,834 @@ +package com.inso_world.binocular.infrastructure.test.commit + +import com.inso_world.binocular.core.integration.base.InfrastructureDataSetup +import com.inso_world.binocular.core.service.BranchInfrastructurePort +import com.inso_world.binocular.core.service.CommitInfrastructurePort +import com.inso_world.binocular.core.service.UserInfrastructurePort +import com.inso_world.binocular.core.service.ProjectInfrastructurePort +import com.inso_world.binocular.core.service.RepositoryInfrastructurePort +import com.inso_world.binocular.infrastructure.test.base.BaseInfrastructureSpringTest +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.vcs.ReferenceCategory +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.dao.DataAccessException +import java.time.LocalDateTime +import java.util.stream.Stream + +internal class CommitSaveOperation : BaseInfrastructureSpringTest() { + @Autowired + private lateinit var userPort: UserInfrastructurePort + + @Autowired + private lateinit var branchPort: BranchInfrastructurePort + + @Autowired + private lateinit var repositoryPort: RepositoryInfrastructurePort + + @Autowired + private lateinit var projectPort: ProjectInfrastructurePort + + @Autowired + private lateinit var commitPort: CommitInfrastructurePort + + companion object { + @JvmStatic + fun provideCyclicCommits(): Stream { + val repository = run { + val project = Project(name = "proj-valid") + Repository(localPath = "repo-valid", project = project) + } + + fun developer() = + Developer( + name = "test", + email = "test@example.com", + repository = repository + ) + + fun commit1() = + Commit( + sha = "1234567890123456789012345678901234567890", + message = "test commit", + authorSignature = Signature(developer = developer(), timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + + fun commit2() = + Commit( + sha = "fedcbafedcbafedcbafedcbafedcbafedcbafedc", + message = "yet another commit", + authorSignature = Signature(developer = developer(), timestamp = LocalDateTime.of(2021, 1, 1, 0, 0, 0)), + repository = repository, + ) + + fun commit3() = + Commit( + sha = "0987654321098765432109876543210987654321", + message = "commit number three", + authorSignature = Signature(developer = developer(), timestamp = LocalDateTime.of(2022, 1, 1, 0, 0, 0)), + repository = repository, + ) + + return Stream.of( + // 1, one commit, self referencing + Arguments.of( + run { + val c1 = commit1() + c1.parents.add(c1) + listOf( + c1 + ) + } + ), +// 2 + Arguments.of( + run { + val c1 = commit1() + val c2 = commit2() + c1.parents.add(c2) + c2.parents.add(c2) + + listOf(c1) + } + ), +// 3 + Arguments.of( + run { + val c1 = commit1() + val c2 = commit2() + + c1.parents.add(c2) + c2.parents.add(c1) + + listOf(c1) + } + ), +// 4 + Arguments.of( + run { + val c1 = commit1() + val c2 = commit2() + val c3 = commit3() + + c1.parents.add(c2) + c2.parents.add(c3) + c2.parents.add(c1) + + listOf(c1) + } + ), +// 5 + Arguments.of( + run { + val c1 = commit1() + val c2 = commit2() + val c3 = commit3() + + c1.parents.add(c2) + c2.parents.add(c2) + + listOf(c1, c3) + } + ), +// 6, same as 5 but reversed order + Arguments.of( + run { + val c1 = commit1() + val c2 = commit2() + val c3 = commit3() + + c1.parents.add(c2) + c2.parents.add(c2) + + listOf(c3, c1) + } + ), +// 7, just save middle commit c2 + Arguments.of( + run { + val c1 = commit1() + val c2 = commit2() + val c3 = commit3() + + c1.parents.add(c2) + c2.parents.add(c3) + c3.parents.add(c1) + + listOf(c2) + } + ), +// 8, just save first commit c1 + Arguments.of( + run { + val c1 = commit1() + val c2 = commit2() + val c3 = commit3() + + c1.parents.add(c2) + c2.parents.add(c3) + c3.parents.add(c1) + + listOf(c1) + } + ), +// 9, just save last commit c1 + Arguments.of( + run { + val c1 = commit1() + val c2 = commit2() + val c3 = commit3() + + c1.parents.add(c2) + c2.parents.add(c3) + c3.parents.add(c1) + + listOf(c3) + } + ), + ) + } + + @JvmStatic + fun provideCommitsAndLists(): Stream { + val repository = run { + val project = Project(name = "proj-valid") + Repository(localPath = "repo-valid", project = project) + } + + fun developer() = + Developer( + name = "user 1", + email = "user@example.com", + repository = repository + ) + + fun commit1_pc(): Commit { + val cmt = + Commit( + sha = "1".repeat(40), + message = "test commit", + authorSignature = Signature(developer = developer(), timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + return cmt + } + + fun commit2_pc(): Commit { + val cmt = + Commit( + sha = "2".repeat(40), + message = "yet another commit", + authorSignature = Signature(developer = developer(), timestamp = LocalDateTime.of(2021, 1, 1, 0, 0, 0)), + repository = repository, + ) + return cmt + } + + fun commit3_pc(): Commit { + val cmt = + Commit( + sha = "3".repeat(40), + message = "commit number three", + authorSignature = Signature(developer = developer(), timestamp = LocalDateTime.of(2022, 1, 1, 0, 0, 0)), + repository = repository, + ) + return cmt + } + + return Stream.of( +// 1 + Arguments.of( + listOf( + commit1_pc(), + ), + ), +// 2 + Arguments.of( + run { + val c1 = commit1_pc() + val c2 = commit2_pc() + listOf( + c1, c2 + ) + } + ), +// 3 + Arguments.of( + run { + val c1 = commit1_pc() + val c2 = commit2_pc() + val c3 = commit3_pc() + + listOf( + c1, c2, c3 + ) + } + ), + // 4, two commits, with relationship c1->c2 + Arguments.of( + run { + val c1 = commit1_pc() + val c2 = commit2_pc() + + c1.parents.add(c2) + + listOf( +// intentionally missing c2 here + c1 + ) + } + ), + // 4.2, two commits, with relationship c1<-c2 + Arguments.of( + run { + val c1 = commit1_pc() + val c2 = commit2_pc() + + c1.children.add(c2) + + listOf( +// intentionally missing c2 here + c1 + ) + } + ), + // 5, second commit without extra + Arguments.of( + run { + val c1 = commit1_pc() + val c2 = commit2_pc() + val c3 = commit3_pc() + + c1.parents.add(c2) + + listOf( +// intentionally missing c2 here + c1, c3 + ) + } + ), + // 6, two commits, with relationship, with extra + Arguments.of( + run { + val c1 = commit1_pc() + val c2 = commit2_pc() + c1.parents.add(c2) + + listOf( + c1, c2 + ) + }, + ), +// 7, octopus merge + Arguments.of( + run { + val c1 = commit1_pc() + c1.parents.add(commit2_pc()) + c1.parents.add(commit3_pc()) + + listOf(c1) + } + ), +// 8, octopus merge + Arguments.of( + run { + val c1 = commit1_pc() + val c2 = commit2_pc() + val c3 = commit3_pc() + + c1.parents.add(c3) + c1.parents.add(c2) + + c3.parents.add(c2) + listOf( + c1, c2, c3 + ) + } + ), +// 9, octopus merge + Arguments.of( + run { + val c1 = commit1_pc() + val c2 = commit2_pc() + val c3 = commit3_pc() + + c1.parents.add(c2) + c1.parents.add(c3) + +// vice versa to 7 + c2.parents.add(c3) + + listOf( + c1, c2, c3 + ) + } + ), + ) + } + } + + private var repository = + Repository( + localPath = "test repository", + project = Project(name = "proj-valid") + ) + + private lateinit var branchDomain: Branch + private lateinit var project: Project + + @BeforeEach + fun setup() { + infrastructureDataSetup.teardown() + this.project = + projectPort.create( + Project( + name = "test project", + ), + ) + this.repository = this.project.repo ?: throw IllegalStateException("test repository can not be null") + val developer = Developer(name = "a", email = "a@example.com", repository = this.repository) + this.branchDomain = + Branch( + name = "test branch", + fullName = "refs/heads/test branch", + category = ReferenceCategory.LOCAL_BRANCH, + repository = this.repository, + head = Commit( + sha = "a".repeat(40), + message = "message", + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.now()), + repository = repository, + ) + ) + } + + @ParameterizedTest + @MethodSource( + "com.inso_world.binocular.infrastructure.test.commit.CommitSaveOperation#provideCyclicCommits", + ) + @Disabled("until something clever is implemented for cycle detection") + fun `save multiple commits with cycle, expect ValidationException`(commitList: List) { + var branch = Branch( + name = "test branch", + fullName = "refs/heads/test branch", + category = ReferenceCategory.LOCAL_BRANCH, + repository = this.repository, + head = commitList.first() + ) + + val repositoryDao = mockk() + + val ex = assertThrows { + commitList.forEach { cmt -> + repository.commits.add(cmt) + } + } + assertThat(ex.message).contains("Cyclic dependency detected") + verify(exactly = 0) { repositoryDao.create(any()) } + } + + @ParameterizedTest + @MethodSource( + "com.inso_world.binocular.infrastructure.test.commit.CommitSaveOperation#provideCommitsAndLists", + ) + fun `save multiple commits with repository, expecting in database`(commitList: List) { + repository.commits.addAll(commitList) + + assertAll( + "check database numbers", + { assertThat(projectPort.findAll()).hasSize(1) }, + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(branchPort.findAll()).hasSize(1) }, + { assertThat(userPort.findAll()).hasSize(1) }, + ) + assertThat(commitPort.findAll()).hasSize( + commitList + .map { it.sha } + .union( + commitList.flatMap { it.parents.map { parent -> parent.sha } }, + ).union( + commitList.flatMap { it.children.map { parent -> parent.sha } }, + ).distinct() + .size, + ) + run { +// check that branch with same identity map onto same object after mapping + val allBranches = repositoryPort.findAll().flatMap { it.branches } + if (allBranches.isNotEmpty()) { + val first = allBranches.first() + assertAll( + allBranches.map { branch -> + { assertSame(first, branch, "Branch is not the same instance as the first") } + }, + ) + } + } + run { + val elements = commitPort.findExistingSha(repository, commitList.map { it.sha }) + assertThat(elements) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") // This ignores only fields starting with _ + .isEqualTo(commitList) + } + run { + val elements = commitPort.findExistingSha(repository, commitList.map { it.sha }) + assertThat(elements) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .ignoringFields( + "users", // deprecated field + ).isEqualTo( + project.repo + ?.commits, + ) + } + run { + val elements = commitPort.findExistingSha(repository, commitList.map { it.sha }) + assertThat(elements) + .usingRecursiveComparison() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .ignoringFields( + "users", // deprecated field + ).ignoringCollectionOrder() + .isEqualTo( + repository + .commits, + ) + } + } + + @ParameterizedTest + @MethodSource( + "com.inso_world.binocular.infrastructure.test.commit.CommitSaveOperation#provideCommitsAndLists", + ) + fun `save multiple commits with repository, verify relationship to repository`(commitList: List) { + val savedEntities = commitList + repository.commits.clear() + repository.commits.addAll(savedEntities) + + val expectedCommits = + (savedEntities + savedEntities.flatMap { it.parents } + savedEntities.flatMap { it.children }) + .distinctBy { it.sha } + + assertAll( + "Check database numbers", + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(expectedCommits.size) }, + { assertThat(userPort.findAll()).hasSize(1) }, + ) + run { + val elem = repositoryPort.findAll().toList()[0] + assertThat(elem.commits).hasSize(expectedCommits.size) + } + run { + val elem = repositoryPort.findAll().toList()[0] + assertThat(elem.commits) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .isEqualTo(expectedCommits) + } + + run { + val elem = commitPort.findAll() + assertThat(elem) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .isEqualTo(expectedCommits) + } + // do not continue here as it fails anyway then + run { + assertThat( + repositoryPort + .findAll() + .toList()[0] + .commits, + ).usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .isEqualTo(expectedCommits) + } + run { + val elements = commitPort.findExistingSha(repository, expectedCommits.map { it.sha }) + assertThat( + repositoryPort + .findAll() + .toList()[0] + .commits, + ).usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .isEqualTo(elements) + } + } + + @ParameterizedTest + @MethodSource( + "com.inso_world.binocular.infrastructure.test.commit.CommitSaveOperation#provideCommitsAndLists", + ) + fun `save multiple commits with repository, verify relationship to project`(commitList: List) { + val savedEntities = + run { + val developer = Developer(name = "test", email = "test@example.com", repository = repository) + repository.developers.add(developer) + val savedCommits = commitList + repository.commits.clear() + repository.commits.addAll(savedCommits) + + return@run savedCommits + } + + val expectedCommits = + (savedEntities + savedEntities.flatMap { it.parents } + savedEntities.flatMap { it.children }) + .distinctBy { it.sha } + assertAll( + "check database numbers", + { assertThat(projectPort.findAll()).hasSize(1) }, + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(expectedCommits.size) }, + { assertThat(userPort.findAll()).hasSize(1) }, + ) + // do not continue here as it fails anyway then + run { + val elem = projectPort.findAll().toList()[0] + assertThat(elem.repo?.commits).hasSize(expectedCommits.size) + } + run { + assertThat( + projectPort + .findAll() + .toList()[0] + .repo + ?.commits, + ).usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .isEqualTo(expectedCommits) + } + run { + val elements = commitPort.findExistingSha(repository, expectedCommits.map { it.sha }) + assertThat(elements) + .usingRecursiveComparison() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .ignoringCollectionOrder() + .isEqualTo( + projectPort + .findAll() + .toList()[0] + .repo + ?.commits, + ) + } + } + + @Test + fun `save 1 commit with repository, expecting in database`() { + val savedCommit = + run { + val developer = Developer(name = "test", email = "test@example.com", repository = repository) + val cmt = + Commit( + sha = "1234567890123456789012345678901234567890", + message = "test commit", + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.of(2025, 7, 13, 1, 1)), + repository = repository, + ) + + repository.commits.add(cmt) + repository.developers.add(developer) + repository.branches.add(branchDomain) + + assertAll( + "check model", + { assertThat(cmt.committer).isNotNull() }, + { assertThat(branchDomain.commits).hasSize(1) }, + { assertThat(cmt.repository).isNotNull() }, + { assertThat(cmt.repository.id).isNotNull() }, + { assertThat(developer.repository).isNotNull() }, + { assertThat(cmt.repository.id).isEqualTo(repository.id) }, + { assertThat(repository.commits).hasSize(1) }, + { assertThat(repository.developers).hasSize(2) }, // includes developer from setup + { assertThat(developer.committedCommits).hasSize(1) }, + ) + val saved = + assertDoesNotThrow { + commitPort.create(cmt) + } + + assertAll( + "check saved entity", + { assertThat(saved.committer).isNotNull() }, + { assertThat(saved.author).isNotNull() }, + { assertThat(saved.repository).isNotNull() }, + { assertThat(saved.repository.id).isNotNull() }, + { assertThat(saved.repository.id).isEqualTo(repository.id) }, + ) + + return@run saved + } + + assertAll( + "check database numbers", + { assertThat(projectPort.findAll()).hasSize(1) }, + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(1) }, + { assertThat(branchPort.findAll()).hasSize(1) }, + { assertThat(userPort.findAll()).hasSize(2) }, // includes developer from setup + ) + + assertThat(commitPort.findAll().toList()[0]) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .isEqualTo(savedCommit) + + assertThat( + commitPort.findAll().toList()[0], + ).usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .ignoringFields( + "users", // deprecated field + ).isEqualTo( + project.repo + ?.commits + ?.toList() + ?.get(0), + ) + assertThat( + commitPort.findAll().toList()[0], + ).usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*_*", ".*logger") + .ignoringFields( + "users", // deprecated field + ).isEqualTo(repository.commits.toList()[0]) + assertThat( + commitPort.findAll().toList()[0], + ).usingRecursiveComparison() + .ignoringCollectionOrder() + .isEqualTo(savedCommit) + + assertAll( + "check ids", + { assertThat(commitPort.findAll().toList()[0].id).isNotNull() }, + { assertThat(commitPort.findAll().toList()[0].repository).isNotNull() }, + { assertThat(commitPort.findAll().toList()[0].repository.id).isNotNull() }, + { assertThat(commitPort.findAll().toList()[0].repository.id).isEqualTo(project.repo?.id) }, + { assertThat(commitPort.findAll().toList()[0].repository.id).isEqualTo(repository.id) }, + ) + } + + @Test + fun `save 1 commit with repository, verify relationship to repository`() { + val savedCommit = + run { + val developer = Developer(name = "test", email = "test@example.com", repository = repository) + val cmt = + Commit( + sha = "1234567890123456789012345678901234567890", + message = "test commit", + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.of(2025, 7, 13, 1, 1)), + repository = repository, + ) + + assertDoesNotThrow { + return@run commitPort.create(cmt) + } + } + + assertAll( + "check database numbers", + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(branchPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(1) }, + { assertThat(userPort.findAll()).hasSize(2) }, // includes developer from setup + ) + // do not continue with assertAll as list access will be wrong + assertAll( + { + val elem = repositoryPort.findAll().toList()[0] + assertThat(elem.commits).hasSize(1) + }, + { + val elem = + repositoryPort + .findAll() + .toList()[0] + .commits + .toList()[0] + assertThat(elem) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .isEqualTo(savedCommit) + }, + ) + } + + @Test + fun `save 1 commit with repository, verify relationship to project`() { + val savedCommit = + run { + val developer = Developer(name = "test", email = "test@example.com", repository = repository) + val cmt = + Commit( + sha = "1234567890123456789012345678901234567890", + message = "test commit", + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.of(2025, 7, 13, 1, 1)), + repository = repository, + ) + + assertDoesNotThrow { + return@run commitPort.create(cmt) + } + } + + assertAll( + "projectport", + { assertThat(projectPort.findAll()).hasSize(1) }, + { assertThat(projectPort.findAll().toList()[0]).isNotNull() }, + { assertThat(projectPort.findAll().toList()[0].repo).isNotNull() }, + ) + // do not continue with assertAll as list access will be wrong + assertAll( + { + val elem = projectPort.findAll().toList()[0] + assertThat(elem.repo?.commits).hasSize(1) + }, + { + val elem = + projectPort + .findAll() + .toList()[0] + .repo + ?.commits + ?.toList()?.get(0) + assertThat(elem) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .isEqualTo(savedCommit) + }, + ) + } +} \ No newline at end of file diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/commit/CommitUpdateOperation.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/commit/CommitUpdateOperation.kt new file mode 100644 index 000000000..a197a5e7d --- /dev/null +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/commit/CommitUpdateOperation.kt @@ -0,0 +1,205 @@ +package com.inso_world.binocular.infrastructure.test.commit + +import com.inso_world.binocular.core.integration.base.InfrastructureDataSetup +import com.inso_world.binocular.core.service.BranchInfrastructurePort +import com.inso_world.binocular.core.service.CommitInfrastructurePort +import com.inso_world.binocular.core.service.UserInfrastructurePort +import com.inso_world.binocular.core.service.ProjectInfrastructurePort +import com.inso_world.binocular.core.service.RepositoryInfrastructurePort +import com.inso_world.binocular.infrastructure.test.base.BaseInfrastructureSpringTest +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.vcs.ReferenceCategory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDateTime + +/** + * Tests for updating commits through CommitInfrastructurePort. + * Verifies that commit updates preserve domain model semantics including + * parent/child relationships and repository consistency. + */ +internal class CommitUpdateOperation : BaseInfrastructureSpringTest() { + @Autowired + private lateinit var branchPort: BranchInfrastructurePort + + @Autowired + private lateinit var userPort: UserInfrastructurePort + + @Autowired + private lateinit var projectPort: ProjectInfrastructurePort + + @Autowired + private lateinit var repositoryPort: RepositoryInfrastructurePort + + @Autowired + private lateinit var commitPort: CommitInfrastructurePort + + private lateinit var repository: Repository + private lateinit var project: Project + private lateinit var savedCommit: Commit + + @BeforeEach + fun setup() { + infrastructureDataSetup.teardown() + + val tempProject = Project(name = "test project") + val tempRepo = Repository( + localPath = "test repository", + project = tempProject, + ) + this.project = projectPort.create(tempProject) + this.repository = this.project.repo ?: throw IllegalStateException("test repository cannot be null") + + val developer = Developer(name = "user 1", email = "user@example.com", repository = repository) + val baseCommit = Commit( + sha = "1234567890123456789012345678901234567890", + message = "test commit", + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + // Create a branch to ensure repository has at least one branch + Branch( + name = "fixed branch", + fullName = "refs/heads/fixed-branch", + category = ReferenceCategory.LOCAL_BRANCH, + repository = repository, + head = baseCommit, + ) + + this.savedCommit = commitPort.create(baseCommit) + } + + @Test + fun `update commit unchanged, should not fail`() { + assertDoesNotThrow { + commitPort.update(savedCommit) + } + assertAll( + "check database numbers", + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(1) }, + { assertThat(branchPort.findAll()).hasSize(1) }, + { assertThat(userPort.findAll()).hasSize(1) }, + ) + } + + @Test + fun `update commit, add parent relationship`() { + // Create a parent commit + val parentDeveloper = Developer(name = "parent author", email = "parent@example.com", repository = repository) + val parentCommit = Commit( + sha = "0".repeat(40), + message = "parent commit", + authorSignature = Signature(developer = parentDeveloper, timestamp = LocalDateTime.of(2019, 12, 31, 23, 59, 59)), + repository = repository, + ) + val savedParent = commitPort.create(parentCommit) + + // Add parent relationship + savedCommit.parents.add(savedParent) + + val updatedEntity = assertDoesNotThrow { + commitPort.update(savedCommit) + } + + assertAll( + "check parent-child relationship", + { assertThat(updatedEntity.parents).hasSize(1) }, + { assertThat(updatedEntity.parents).contains(savedParent) }, + { assertThat(savedParent.children).contains(updatedEntity) }, + ) + + assertAll( + "check database numbers", + { assertThat(commitPort.findAll()).hasSize(2) }, + { assertThat(userPort.findAll()).hasSize(2) }, + ) + } + + @Test + fun `update commit, add child relationship`() { + // Create a child commit + val childDeveloper = Developer(name = "child author", email = "child@example.com", repository = repository) + val childCommit = Commit( + sha = "f".repeat(40), + message = "child commit", + authorSignature = Signature(developer = childDeveloper, timestamp = LocalDateTime.of(2020, 1, 2, 0, 0, 0)), + repository = repository, + ) + val savedChild = commitPort.create(childCommit) + + // Add child relationship + savedCommit.children.add(savedChild) + + val updatedEntity = assertDoesNotThrow { + commitPort.update(savedCommit) + } + + assertAll( + "check parent-child relationship", + { assertThat(updatedEntity.children).hasSize(1) }, + { assertThat(updatedEntity.children).contains(savedChild) }, + { assertThat(savedChild.parents).contains(updatedEntity) }, + ) + + assertAll( + "check database numbers", + { assertThat(commitPort.findAll()).hasSize(2) }, + { assertThat(userPort.findAll()).hasSize(2) }, + ) + } + + @Test + fun `update commit with complex parent graph`() { + // Create a merge scenario: commit has two parents + val developer1 = Developer(name = "user1", email = "user1@example.com", repository = repository) + val developer2 = Developer(name = "user2", email = "user2@example.com", repository = repository) + + val parent1 = Commit( + sha = "a".repeat(40), + message = "parent 1", + authorSignature = Signature(developer = developer1, timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + val parent2 = Commit( + sha = "b".repeat(40), + message = "parent 2", + authorSignature = Signature(developer = developer2, timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + + val savedParent1 = commitPort.create(parent1) + val savedParent2 = commitPort.create(parent2) + + // Add both parents to savedCommit + savedCommit.parents.add(savedParent1) + savedCommit.parents.add(savedParent2) + + val updatedEntity = assertDoesNotThrow { + commitPort.update(savedCommit) + } + + assertAll( + "check parent relationships", + { assertThat(updatedEntity.parents).hasSize(2) }, + { assertThat(updatedEntity.parents).containsExactlyInAnyOrder(savedParent1, savedParent2) }, + { assertThat(savedParent1.children).contains(updatedEntity) }, + { assertThat(savedParent2.children).contains(updatedEntity) }, + ) + + assertAll( + "check database numbers", + { assertThat(commitPort.findAll()).hasSize(3) }, + { assertThat(userPort.findAll()).hasSize(3) }, + ) + } +} \ No newline at end of file diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/commit/CommitValidationTests.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/commit/CommitValidationTests.kt new file mode 100644 index 000000000..2f3134aa3 --- /dev/null +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/commit/CommitValidationTests.kt @@ -0,0 +1,37 @@ +package com.inso_world.binocular.infrastructure.test.commit + +import com.inso_world.binocular.core.service.CommitInfrastructurePort +import com.inso_world.binocular.infrastructure.test.base.BaseInfrastructureSpringTest +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import jakarta.validation.ConstraintViolationException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDateTime + +internal class CommitValidationTests : BaseInfrastructureSpringTest() { + @Autowired + private lateinit var commitPort: CommitInfrastructurePort + + @Test + fun `save 1 commit without branch, expect ConstraintViolationException`() { + val project = Project(name = "test-project") + val repository = Repository(localPath = "test-repo", project = project) + val developer = Developer(name = "Test Committer", email = "committer@test.com", repository = repository) + + assertThrows { + commitPort.create( + Commit( + sha = "B".repeat(40), + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.now()), + message = null, + repository = repository, + ), + ) + } + } +} \ No newline at end of file diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/project/ProjectSaveOperation.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/project/ProjectSaveOperation.kt index c4a751807..bd83e8c14 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/project/ProjectSaveOperation.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/project/ProjectSaveOperation.kt @@ -2,15 +2,17 @@ package com.inso_world.binocular.infrastructure.test.project import com.inso_world.binocular.core.service.BranchInfrastructurePort import com.inso_world.binocular.core.service.CommitInfrastructurePort +import com.inso_world.binocular.core.service.UserInfrastructurePort import com.inso_world.binocular.core.service.ProjectInfrastructurePort import com.inso_world.binocular.core.service.RepositoryInfrastructurePort -import com.inso_world.binocular.core.service.UserInfrastructurePort import com.inso_world.binocular.infrastructure.test.base.BaseInfrastructureSpringTest import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository -import com.inso_world.binocular.model.User +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.vcs.ReferenceCategory import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -18,6 +20,10 @@ import org.junit.jupiter.api.assertAll import org.springframework.beans.factory.annotation.Autowired import java.time.LocalDateTime +/** + * Tests for saving projects through ProjectInfrastructurePort. + * Verifies that projects with and without repositories are persisted correctly. + */ internal class ProjectSaveOperation : BaseInfrastructureSpringTest() { @Autowired private lateinit var projectPort: ProjectInfrastructurePort @@ -34,12 +40,6 @@ internal class ProjectSaveOperation : BaseInfrastructureSpringTest() { @Autowired private lateinit var userPort: UserInfrastructurePort - private var repository = - Repository( - localPath = "test repository", - ) - - @BeforeEach fun setup() { super.baseTearDown() @@ -68,30 +68,45 @@ internal class ProjectSaveOperation : BaseInfrastructureSpringTest() { ) } + @Test + fun `save project with repository, check identities`() { + val project = Project(name = "test project") + val repository = Repository( + localPath = "test repository", + project = project, + ) + + val createdProject = projectPort.create(project) + + assertAll( + { assertThat(createdProject).isSameAs(project) }, + { assertThat(createdProject.repo).isSameAs(project.repo) }, + { assertThat(createdProject.repo).isSameAs(repository) }, + ) + } + @Test fun `save project with repository, expecting in database`() { - val createdProject = - projectPort.create( - Project( - name = "test project", - repo = repository, - ), - ) + val project = Project(name = "test project") + val repository = Repository( + localPath = "test repository", + project = project, + ) + + val createdProject = projectPort.create(project) assertThat(projectPort.findAll()).hasSize(1) run { - val elem = projectPort.findAll().toList()[0] - assertThat(elem.id).isEqualTo(elem.repo?.project?.id) + val elem = projectPort.findAll().first() + assertThat(elem).isSameAs(requireNotNull(elem.repo).project) assertThat(elem) .usingRecursiveComparison() - .ignoringFields("id") .isEqualTo(createdProject) assertThat(elem.repo).isNotNull() assertThat(elem.repo?.id).isNotNull() assertThat(elem.repo) .usingRecursiveComparison() .ignoringCollectionOrder() - .ignoringFields("id", "project") .isEqualTo(repository) } assertAll( @@ -106,37 +121,27 @@ internal class ProjectSaveOperation : BaseInfrastructureSpringTest() { @Test fun `save project with repository and commits, expecting in database`() { - val user = - User( - name = "test", - email = "test@example.com", - repository = repository, - ) - val branch = - Branch( - name = "test branch", - repository = repository, - ) - val cmt = - Commit( - sha = "1234567890123456789012345678901234567890", - message = "test commit", - commitDateTime = LocalDateTime.of(2025, 7, 13, 1, 1), - ) - branch.commits.add(cmt) - user.committedCommits.add(cmt) - repository.user.add(user) - branch.commits.add(cmt) - repository.commits.add(cmt) - repository.branches.add(branch) + val project = Project(name = "test project") + val repository = Repository( + localPath = "test repository", + project = project, + ) + val developer = Developer(name = "test", email = "test@example.com", repository = repository) + val cmt = Commit( + sha = "1234567890123456789012345678901234567890", + message = "test commit", + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.of(2025, 7, 13, 1, 1)), + repository = repository, + ) + val branch = Branch( + name = "test branch", + fullName = "refs/heads/test-branch", + category = ReferenceCategory.LOCAL_BRANCH, + repository = repository, + head = cmt, + ) - val repositoryProject = - projectPort.create( - Project( - name = "test project", - repo = repository, - ), - ) + val repositoryProject = projectPort.create(project) assertAll( "check database numbers", @@ -160,14 +165,18 @@ internal class ProjectSaveOperation : BaseInfrastructureSpringTest() { ).usingRecursiveComparison() .ignoringCollectionOrder() .ignoringFieldsMatchingRegexes(".*id", ".*repositoryId", ".*project") - .isEqualTo(repositoryProject.repo?.commits?.toList()[0]) + .isEqualTo(repositoryProject.repo?.commits?.toList()?.get(0)) + } + val allCommits = commitPort.findAll() + assertThat(allCommits).hasSize(1) + with(allCommits.first()) { + assertAll( + "check ids", + { assertThat(this.id).isNotNull() }, + { assertThat(this.repository).isNotNull() }, + { assertThat(this.repository.id).isNotNull() }, + { assertThat(this.repository.id).isEqualTo(repositoryProject.repo?.id) }, + ) } - assertAll( - "check ids", - { assertThat(commitPort.findAll().toList()[0].id).isNotNull() }, - { assertThat(commitPort.findAll().toList()[0].repository).isNotNull() }, - { assertThat(commitPort.findAll().toList()[0].repository?.id).isNotNull() }, - { assertThat(commitPort.findAll().toList()[0].repository?.id).isEqualTo(repositoryProject.repo?.id) }, - ) } -} +} \ No newline at end of file diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/RepositoryInfrastructurePortTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/RepositoryInfrastructurePortTest.kt index dd2e18377..5787f6d6a 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/RepositoryInfrastructurePortTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/RepositoryInfrastructurePortTest.kt @@ -5,12 +5,12 @@ import com.inso_world.binocular.core.service.ProjectInfrastructurePort import com.inso_world.binocular.core.service.RepositoryInfrastructurePort import com.inso_world.binocular.infrastructure.test.base.BasePortNoDataTest import com.inso_world.binocular.infrastructure.test.repository.base.BasePortWithDataTest -import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Project import com.inso_world.binocular.model.Repository import jakarta.validation.ConstraintViolationException import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertAll @@ -20,6 +20,7 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.dao.DataIntegrityViolationException +import org.springframework.data.util.ReflectionUtils.setField internal class RepositoryInfrastructurePortTest() : BasePortNoDataTest() { @all:Autowired @@ -41,14 +42,16 @@ internal class RepositoryInfrastructurePortTest() : BasePortNoDataTest() { } @Test + @Disabled("DELETE not yet supported") fun `repository deletion leaves project intact`() { // Given val savedProject = - projectPort.create(Project(name = "Surviving Project", description = "Will survive repo deletion")) + projectPort.create(Project(name = "Surviving Project").apply { + description = "Will survive repo deletion" + }) val savedRepo = - repositoryPort.create(Repository(id = null, localPath = "to-be-deleted-repo", project = savedProject)) + repositoryPort.create(Repository(localPath = "to-be-deleted-repo", project = savedProject)) // updated dependencies, as not managed by JPA - savedProject.repo = savedRepo projectPort.update(savedProject) // When @@ -65,11 +68,14 @@ internal class RepositoryInfrastructurePortTest() : BasePortNoDataTest() { // Negative Tests - Invalid scenarios @Test + @Disabled("DELETE not yet supported") fun `repository cannot exist without project`() { // Given - val savedProject = projectPort.create(Project(name = "Temporary Project", description = "Will be deleted")) - val repository = Repository(id = null, localPath = "orphaned-repo", project = savedProject) - savedProject.repo = repositoryPort.create(repository) + val savedProject = projectPort.create(Project(name = "Temporary Project").apply { + description = "Will be deleted" + }) + val repository = Repository(localPath = "orphaned-repo", project = savedProject) + repositoryPort.create(repository) // updated dependencies, as not managed by JPA projectPort.update(savedProject) @@ -91,24 +97,23 @@ internal class RepositoryInfrastructurePortTest() : BasePortNoDataTest() { fun `multiple repositories cannot reference same project`() { // Given val savedProject = - projectPort.create(Project(name = "Shared Project", description = "Should only have one repo")) + projectPort.create(Project(name = "Shared Project").apply { + description = "Should only have one repo" + }) // When - First repository should be created successfully val firstRepo = - repositoryPort.create(Repository(id = null, localPath = "first-repo", project = savedProject)) + repositoryPort.create(Repository(localPath = "first-repo", project = savedProject)) -// entityManager.flush() -// entityManager.clear() // Then - Verify first repository was created assertAll( { assertThat(firstRepo).isNotNull() }, -// { assertThat(firstRepo?.id).isNotNull() }, { assertThat(repositoryPort.findAll()).hasSize(1) }, ) // When - Second repository with same project should fail val ex = assertThrows { - repositoryPort.create(Repository(id = null, localPath = "second-repo", project = savedProject)) + repositoryPort.create(Repository(localPath = "second-repo", project = savedProject)) } // Then - Verify only one repository still exists @@ -118,32 +123,41 @@ internal class RepositoryInfrastructurePortTest() : BasePortNoDataTest() { // Mutation Tests - Testing edge cases and boundary conditions @ParameterizedTest - @MethodSource("com.inso_world.binocular.core.data.DummyTestData#provideBlankStrings") + @MethodSource("com.inso_world.binocular.data.DummyTestData#provideBlankStrings") fun `repository with invalid name should fail`(invalidName: String) { // When - val savedProject = projectPort.create(Project(name = "Valid Project", description = "Valid project")) - + val savedProject = projectPort.create(Project(name = "Valid Project").apply { + description = "Valid project" + }) + + val repo = Repository(localPath = "invalidName", project = savedProject) + setField( + Repository::class.java.getDeclaredField("localPath"), + repo, + invalidName + ) // Then - This should fail due to validation constraint assertThrows { - repositoryPort.create(Repository(id = null, localPath = invalidName, project = savedProject)) + repositoryPort.create(repo) } } @ParameterizedTest - @MethodSource("com.inso_world.binocular.core.data.DummyTestData#provideAllowedStrings") + @MethodSource("com.inso_world.binocular.data.DummyTestData#provideAllowedStrings") fun `repository with allowed names should be handled`(allowedName: String) { // When - val savedProject = projectPort.create(Project(name = "Valid Project", description = "Valid project")) + val savedProject = projectPort.create(Project(name = "Valid Project").apply { + description = "Valid project" + }) val savedRepo = - repositoryPort.create(Repository(id = null, localPath = allowedName, project = savedProject)) - savedProject.repo = savedRepo + repositoryPort.create(Repository(localPath = allowedName, project = savedProject)) projectPort.update(savedProject) // Then assertAll( { assertThat(savedRepo.localPath).isEqualTo(allowedName) }, { assertThat(savedRepo.project).isNotNull() }, - { assertThat(savedRepo.project?.id).isEqualTo(savedProject.id) }, + { assertThat(savedRepo.project.id).isEqualTo(savedProject.id) }, { assertThat(projectPort.findAll()).hasSize(1) }, { assertThat(repositoryPort.findAll()).hasSize(1) }, ) @@ -152,27 +166,27 @@ internal class RepositoryInfrastructurePortTest() : BasePortNoDataTest() { @Test fun `duplicate repository names should fail`() { // When - val savedProject1 = projectPort.create(Project(name = "Project 1", description = "First project")) - val savedProject2 = projectPort.create(Project(name = "Project 2", description = "Second project")) + val savedProject1 = projectPort.create(Project(name = "Project 1").apply { + description = "First project" + }) + val savedProject2 = projectPort.create(Project(name = "Project 2").apply { + description = "Second project" + }) assertDoesNotThrow { repositoryPort.create( Repository( - id = null, localPath = "Duplicate Repo", project = savedProject1, ), ) } -// entityManager.flush() -// entityManager.clear() // Then - This should fail due to unique constraint val ex = assertThrows { - repositoryPort.create(Repository(id = null, localPath = "Duplicate Repo", project = savedProject2)) + repositoryPort.create(Repository(localPath = "Duplicate Repo", project = savedProject2)) } assertThat(repositoryPort.findAll()).hasSize(1) -// entityManager.clear() } } @@ -185,7 +199,7 @@ internal class RepositoryInfrastructurePortTest() : BasePortNoDataTest() { prepare( "${BaseFixturesIntegrationTest.Companion.FIXTURES_PATH}/${BaseFixturesIntegrationTest.Companion.SIMPLE_REPO}", projectName = BaseFixturesIntegrationTest.Companion.SIMPLE_PROJECT_NAME, - branch = Branch(name = "master") + branchName = "master" ).repo ) { "${BaseFixturesIntegrationTest.Companion.FIXTURES_PATH}/${BaseFixturesIntegrationTest.Companion.SIMPLE_REPO} repository cannot be null" @@ -194,7 +208,7 @@ internal class RepositoryInfrastructurePortTest() : BasePortNoDataTest() { prepare( "${BaseFixturesIntegrationTest.Companion.FIXTURES_PATH}/${BaseFixturesIntegrationTest.Companion.OCTO_REPO}", projectName = BaseFixturesIntegrationTest.Companion.OCTO_PROJECT_NAME, - branch = Branch(name = "master") + branchName = "master" ).repo ) { "${BaseFixturesIntegrationTest.Companion.FIXTURES_PATH}/${BaseFixturesIntegrationTest.Companion.OCTO_REPO} repository cannot be null" diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/RepositorySaveOperation.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/RepositorySaveOperation.kt new file mode 100644 index 000000000..734b5864f --- /dev/null +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/RepositorySaveOperation.kt @@ -0,0 +1,299 @@ +package com.inso_world.binocular.infrastructure.test.repository + +import com.inso_world.binocular.core.service.BranchInfrastructurePort +import com.inso_world.binocular.core.service.CommitInfrastructurePort +import com.inso_world.binocular.core.service.UserInfrastructurePort +import com.inso_world.binocular.core.service.ProjectInfrastructurePort +import com.inso_world.binocular.core.service.RepositoryInfrastructurePort +import com.inso_world.binocular.infrastructure.test.base.BaseInfrastructureSpringTest +import com.inso_world.binocular.model.Branch +import com.inso_world.binocular.model.Commit +import com.inso_world.binocular.model.Developer +import com.inso_world.binocular.model.Project +import com.inso_world.binocular.model.Repository +import com.inso_world.binocular.model.Signature +import com.inso_world.binocular.model.vcs.ReferenceCategory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDateTime + +/** + * Tests for saving repositories through RepositoryInfrastructurePort. + * Verifies that repositories with commits, branches, and developers are persisted correctly + * while maintaining domain model semantics. + */ +internal class RepositorySaveOperation : BaseInfrastructureSpringTest() { + @Autowired + private lateinit var projectPort: ProjectInfrastructurePort + + @Autowired + private lateinit var repositoryPort: RepositoryInfrastructurePort + + @Autowired + private lateinit var commitPort: CommitInfrastructurePort + + @Autowired + private lateinit var userPort: UserInfrastructurePort + + @Autowired + private lateinit var branchPort: BranchInfrastructurePort + + private lateinit var project: Project + + @BeforeEach + fun setup() { + super.baseTearDown() + project = projectPort.create(Project(name = "test-project")) + } + + @Test + fun `save plain repository, expecting in database`() { + val repository = Repository( + localPath = "path/to/repo", + project = project, + ) + + assertDoesNotThrow { + repositoryPort.create(repository) + } + + assertAll( + "check database numbers", + { assertThat(projectPort.findAll()).hasSize(1) }, + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(0) }, + { assertThat(branchPort.findAll()).hasSize(0) }, + ) + } + + @Test + fun `save repository with one commit, expecting in database`() { + val repository = Repository(localPath = "path/to/repo-2", project = project) + val developer = Developer(name = "test-user-a", email = "a@test.com", repository = repository) + val commit = Commit( + sha = "a".repeat(40), + message = "Initial commit", + authorSignature = Signature(developer = developer, timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + val branch = Branch( + name = "main", + fullName = "refs/heads/main", + category = ReferenceCategory.LOCAL_BRANCH, + repository = repository, + head = commit, + ) + + val savedRepo = repositoryPort.create(repository) + + assertAll( + "check database numbers", + { assertThat(projectPort.findAll()).hasSize(1) }, + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(1) }, + { assertThat(branchPort.findAll()).hasSize(1) }, + { assertThat(userPort.findAll()).hasSize(1) }, + ) + assertThat(commitPort.findAll().toList()[0]).usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFieldsMatchingRegexes(".*id", ".*repositoryId", ".*project") + .isEqualTo(savedRepo.commits.toList()[0]) + assertAll( + "check commit relationship", + { assertThat(commitPort.findAll().toList()[0].id).isNotNull() }, + { assertThat(commitPort.findAll().toList()[0].repository).isNotNull() }, + { assertThat(commitPort.findAll().toList()[0].repository.id).isNotNull() }, + { assertThat(commitPort.findAll().toList()[0].repository.id).isEqualTo(savedRepo.id) }, + ) + } + + @Test + fun `save repository with one commit with one parent, expecting both in database`() { + val repository = Repository(localPath = "path/to/repo-3", project = project) + val developerA = Developer(name = "user-a", email = "a@test.com", repository = repository) + val developerB = Developer(name = "user-b", email = "b@test.com", repository = repository) + + val cmtB = Commit( + sha = "b".repeat(40), + message = "Parent commit", + authorSignature = Signature(developer = developerB, timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + val cmtA = Commit( + sha = "a".repeat(40), + message = "Child commit", + authorSignature = Signature(developer = developerA, timestamp = LocalDateTime.of(2020, 1, 2, 0, 0, 0)), + repository = repository, + ) + cmtA.parents.add(cmtB) + + val branch = Branch( + name = "feature/test", + fullName = "refs/heads/feature/test", + category = ReferenceCategory.LOCAL_BRANCH, + repository = repository, + head = cmtA, + ) + + val savedRepo = repositoryPort.create(repository) + + assertAll( + "check database numbers", + { assertThat(projectPort.findAll()).hasSize(1) }, + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(2) }, + { assertThat(branchPort.findAll()).hasSize(1) }, + { assertThat(userPort.findAll()).hasSize(2) }, + ) + + // Verify parent commit + val savedParent = commitPort.findAll().find { it.sha == "b".repeat(40) } + assertThat(savedParent).isNotNull() + assertThat(savedParent!!.children).hasSize(1) + assertThat(savedParent.children.first().sha).isEqualTo("a".repeat(40)) + + // Verify child commit + val savedChild = commitPort.findAll().find { it.sha == "a".repeat(40) } + assertThat(savedChild).isNotNull() + assertThat(savedChild!!.parents).hasSize(1) + assertThat(savedChild.parents.first().sha).isEqualTo("b".repeat(40)) + + assertAll( + "check commit relationship", + { assertThat(commitPort.findAll().map { it.id }).doesNotContainNull() }, + { assertThat(commitPort.findAll().map { it.repository.id }).doesNotContainNull() }, + { assertThat(commitPort.findAll().map { it.repository.id }).containsOnly(savedRepo.id) }, + ) + } + + @Test + fun `save repository with one commit with two parents, expecting all in database`() { + val repository = Repository(localPath = "path/to/repo-4", project = project) + val developerA = Developer(name = "user-a", email = "a@test.com", repository = repository) + val developerB = Developer(name = "user-b", email = "b@test.com", repository = repository) + val developerC = Developer(name = "user-c", email = "author@test.com", repository = repository) + + val cmtB = Commit( + sha = "b".repeat(40), + message = "Parent 1", + authorSignature = Signature(developer = developerB, timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + val cmtC = Commit( + sha = "c".repeat(40), + message = "Parent 2", + authorSignature = Signature(developer = developerC, timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + val cmtA = Commit( + sha = "a".repeat(40), + message = "Merge commit", + authorSignature = Signature(developer = developerA, timestamp = LocalDateTime.of(2020, 1, 2, 0, 0, 0)), + repository = repository, + ) + cmtA.parents.add(cmtB) + cmtA.parents.add(cmtC) + + val branch = Branch( + name = "main", + fullName = "refs/heads/main", + category = ReferenceCategory.LOCAL_BRANCH, + repository = repository, + head = cmtA, + ) + + assertThat(repository.developers).hasSize(3) + + val savedRepo = repositoryPort.create(repository) + + assertAll( + "check database numbers", + { assertThat(projectPort.findAll()).hasSize(1) }, + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(3) }, + { assertThat(branchPort.findAll()).hasSize(1) }, + { assertThat(userPort.findAll()).hasSize(3) }, + ) + + // Verify merge commit + val mergeCommit = commitPort.findAll().find { it.sha == "a".repeat(40) } + assertThat(mergeCommit).isNotNull() + assertThat(mergeCommit!!.parents).hasSize(2) + + // Verify parent commits + val parent1 = commitPort.findAll().find { it.sha == "b".repeat(40) } + val parent2 = commitPort.findAll().find { it.sha == "c".repeat(40) } + assertThat(parent1).isNotNull() + assertThat(parent2).isNotNull() + assertThat(parent1!!.children).contains(mergeCommit) + assertThat(parent2!!.children).contains(mergeCommit) + + // Verify parent 2 has both author and committer set + assertThat(parent2.author).isNotNull() + assertThat(parent2.committer).isNotNull() + assertThat(parent2.author).isEqualTo(parent2.committer) + } + + @Test + fun `save repository with two commits, expecting in database`() { + val repository = Repository(localPath = "path/to/repo-5", project = project) + + val developerA = Developer(name = "user-a", email = "a@test.com", repository = repository) + val developerB = Developer(name = "user-b", email = "b@test.com", repository = repository) + + val cmtA = Commit( + sha = "a".repeat(40), + message = "First commit", + authorSignature = Signature(developer = developerA, timestamp = LocalDateTime.of(2020, 1, 1, 0, 0, 0)), + repository = repository, + ) + val cmtB = Commit( + sha = "b".repeat(40), + message = "Second commit", + authorSignature = Signature(developer = developerB, timestamp = LocalDateTime.of(2020, 1, 2, 0, 0, 0)), + repository = repository, + ) + + // Make cmtB a child of cmtA + cmtB.parents.add(cmtA) + + val branch = Branch( + name = "test branch", + fullName = "refs/heads/test-branch", + category = ReferenceCategory.LOCAL_BRANCH, + repository = repository, + head = cmtB, + ) + + assertAll( + "check model before save", + { assertThat(branch.commits).hasSize(2).withFailMessage("branch.commits should contain both commits") }, + { assertThat(repository.branches).hasSize(1).withFailMessage("repository.branches") }, + { assertThat(repository.commits).hasSize(2).withFailMessage("repository.commits") }, + { assertThat(repository.developers).hasSize(2).withFailMessage("repository.developers") }, + ) + + val savedEntity = assertDoesNotThrow { + repositoryPort.create(repository) + } + + assertAll( + "check saved entity", + { assertThat(savedEntity.branches).hasSize(1) }, + { assertThat(savedEntity.commits).hasSize(2) }, + ) + + assertAll( + "check database numbers", + { assertThat(projectPort.findAll()).hasSize(1) }, + { assertThat(repositoryPort.findAll()).hasSize(1) }, + { assertThat(commitPort.findAll()).hasSize(2) }, + { assertThat(branchPort.findAll()).hasSize(1) }, + { assertThat(userPort.findAll()).hasSize(2) }, + ) + } +} \ No newline at end of file diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortOctoDataTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortOctoDataTest.kt index 82354d6a1..6fcbf8033 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortOctoDataTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortOctoDataTest.kt @@ -1,6 +1,5 @@ package com.inso_world.binocular.infrastructure.test.repository.base -import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Repository import org.junit.jupiter.api.BeforeEach @@ -14,7 +13,7 @@ internal class BasePortOctoDataTest : BasePortWithDataTest() { prepare( "${FIXTURES_PATH}/${OCTO_REPO}", projectName = OCTO_PROJECT_NAME, - branch = Branch(name = "master") + branchName = "master" ).repo ) { "${FIXTURES_PATH}/${OCTO_REPO} repository cannot be null" diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortSimpleDataTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortSimpleDataTest.kt index f152a6845..a3df892e6 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortSimpleDataTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortSimpleDataTest.kt @@ -1,6 +1,5 @@ package com.inso_world.binocular.infrastructure.test.repository.base -import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Repository import org.junit.jupiter.api.BeforeEach @@ -13,7 +12,7 @@ internal class BasePortSimpleDataTest() : BasePortWithDataTest() { prepare( "${FIXTURES_PATH}/${SIMPLE_REPO}", projectName = SIMPLE_PROJECT_NAME, - branch = Branch(name = "master") + branchName = "master" ).repo ) { "${FIXTURES_PATH}/${SIMPLE_REPO} repository cannot be null" diff --git a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortWithDataTest.kt b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortWithDataTest.kt index ad28dac94..c6923cbd1 100644 --- a/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortWithDataTest.kt +++ b/binocular-backend-new/infrastructure-test/src/test/kotlin/com/inso_world/binocular/infrastructure/test/repository/base/BasePortWithDataTest.kt @@ -7,7 +7,6 @@ import com.inso_world.binocular.core.service.ProjectInfrastructurePort import com.inso_world.binocular.infrastructure.test.config.LocalArangodbConfig import com.inso_world.binocular.infrastructure.test.config.LocalGixConfig import com.inso_world.binocular.infrastructure.test.config.LocalPostgresConfig -import com.inso_world.binocular.model.Branch import com.inso_world.binocular.model.Project import org.junit.jupiter.api.AfterEach import org.springframework.beans.factory.annotation.Autowired @@ -16,6 +15,10 @@ import org.springframework.context.annotation.ComponentScan import org.springframework.test.context.ContextConfiguration import kotlin.io.path.Path +/** + * Base test class for infrastructure tests that require actual Git repository data. + * Provides utilities to index Git repositories and prepare test data. + */ @SpringBootTest @ContextConfiguration( classes = [LocalArangodbConfig::class, LocalPostgresConfig::class, LocalGixConfig::class], @@ -35,19 +38,24 @@ class BasePortWithDataTest : BaseFixturesIntegrationTest() { @all:Autowired private lateinit var projectPort: ProjectInfrastructurePort - protected fun prepare(path: String, projectName: String, branch: Branch): Project { - val repo = indexer.findRepo(Path(path)) - require(repo.branches.add(branch)) - val hashes = indexer.traverseBranch(repo,branch) - val project = - Project( - name = projectName, - ) - project.repo = repo - repo.project = project + /** + * Prepares a Git repository for testing by indexing a branch and creating a project. + * + * @param path Local filesystem path to the Git repository + * @param projectName Name for the project that will own this repository + * @param branchName Name of the branch to traverse (e.g., "main", "refs/heads/main") + * @return The persisted Project with repository and commit data + */ + protected fun prepare(path: String, projectName: String, branchName: String): Project { + val project = Project(name = projectName) + val repo = indexer.findRepo(Path(path), project) - repo.branches.add(branch) - repo.commits.addAll(hashes) + // Traverse the specified branch to get all its commits + val (branch, commits) = indexer.traverseBranch(repo, branchName) + + // Repository automatically registers commits when they're created + // Branch automatically registers with repository during construction + // All relationships are now established return projectPort.create(project) } @@ -55,10 +63,5 @@ class BasePortWithDataTest : BaseFixturesIntegrationTest() { @AfterEach fun cleanup() { testDataSetupService.teardown() -// entityManager.clear() -// projectRepository.deleteAll() -// repositoryRepository.deleteAll() -// commitRepository.deleteAll() -// userRepository.deleteAll() } } diff --git a/binocular-backend-new/infrastructure-test/src/test/resources/fixtures/advanced.sh b/binocular-backend-new/infrastructure-test/src/test/resources/fixtures/advanced.sh deleted file mode 100755 index 6f34f0183..000000000 --- a/binocular-backend-new/infrastructure-test/src/test/resources/fixtures/advanced.sh +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export TZ=UTC - -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -REPO_DIR="$1" -mkdir -p "$REPO_DIR" -cd "$REPO_DIR" - -git init -q -b master - -git_commit() { - local msg="$1"; shift - local date="$1"; shift - local name="$1"; shift - local email="$1" - GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ - GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" \ - GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" \ - git commit -m "$msg" -q -} - -echo "Hello, world!" > file1.txt -git add file1.txt -git_commit "Initial commit" "2023-01-01T12:01:00+00:00" "Alice" "alice@example.com" - -echo "Additional content" >> file1.txt -git add file1.txt -git_commit "Append to file1.txt" "2023-01-01T13:00:00+00:00" "Bob" "bob@example.com" - -echo "This is file2" > file2.txt -git add file2.txt -git_commit "Add file2.txt" "2023-01-01T14:00:00+00:00" "Carol" "carol@example.com" - -echo "More content for file2" >> file2.txt -git add file2.txt -git_commit "Modify file2.txt" "2023-01-01T15:00:00+00:00" "Alice" "alice@example.com" - -git mv file1.txt file1-renamed.txt -git_commit "Rename file1.txt to file1-renamed.txt" "2023-01-01T16:00:00+00:00" "Bob" "bob@example.com" - -git rm file2.txt -git_commit "Delete file2.txt" "2023-01-01T17:00:00+00:00" "Carol" "carol@example.com" - -echo "Content of file3" > file3.txt -git add file3.txt -GIT_AUTHOR_DATE="2023-01-01T18:00:00+00:00" git_commit "Create file3.txt" "2023-01-01T18:05:00+00:00" "Alice" "alice@example.com" - -echo "Appending more to file3" >> file3.txt -git add file3.txt -git_commit "Update file3.txt with more content" "2023-01-01T19:00:00+00:00" "Bob" "bob@example.com" - -mkdir -p dir1 -echo "Inside dir1" > dir1/file4.txt -git add dir1/file4.txt -git_commit "Create dir1 and add file4.txt" "2023-01-01T20:00:00+00:00" "Carol" "carol@example.com" - -git mv dir1/file4.txt dir1/file4-renamed.txt -git_commit "Rename file4.txt to file4-renamed.txt in dir1" "2023-01-01T21:00:00+00:00" "Alice" "alice@example.com" - -dd if=/dev/zero bs=100 count=1 of=file5.bin status=none -git add file5.bin -git_commit "Add binary file file5.bin" "2023-01-01T22:00:00+00:00" "Bob" "bob@example.com" - -git rm file3.txt -git_commit "Delete file3.txt" "2023-01-01T23:00:00+00:00" "Carol" "carol@example.com" - -awk 'NR==1{print; print "Inserted line"; next}1' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -git_commit "Modify file1-renamed.txt by inserting a line" "2023-01-02T00:00:00+00:00" "Alice" "alice@example.com" - -echo "Recreated file2" > file2.txt -git add file2.txt -GIT_AUTHOR_DATE="2023-01-02T00:30:00+00:00" git_commit "Re-add file2.txt with new content" "2023-01-02T01:00:00+00:00" "Bob" "bob@example.com" - -echo "Final update to file1-renamed.txt" >> file1-renamed.txt -echo "Final update to file2.txt" >> file2.txt -git add file1-renamed.txt file2.txt -git_commit "Final update: modify multiple files" "2023-01-02T02:00:00+00:00" "Carol" "carol@example.com" - -git checkout --orphan imported -q -git rm -rf . > /dev/null 2>&1 || true -echo "Imported commit content" > imported.txt -git add imported.txt -git_commit "Imported commit: independent history from another remote" "2023-01-03T00:00:00+00:00" "Dave" "dave@example.com" - -git checkout master -q -git_commit "Merge imported history from remote" "2023-01-03T01:00:00+00:00" "Alice" "alice@example.com" && \ - git merge --allow-unrelated-histories imported -m "$(git log -1 --pretty=%B)" -q - -git checkout -b feature -q -echo "Feature update: appended line" >> file1-renamed.txt -git add file1-renamed.txt -git_commit "Feature: update file1-renamed.txt" "2023-01-02T03:00:00+00:00" "Bob" "bob@example.com" - -echo "Content for file6 from feature branch" > file6.txt -git add file6.txt -git_commit "Feature: add file6.txt" "2023-01-02T03:30:00+00:00" "Carol" "carol@example.com" - -git checkout master -q -git_commit "Merge branch 'feature'" "2023-01-02T04:00:00+00:00" "Alice" "alice@example.com" && \ - git merge --no-ff feature -m "$(git log -1 --pretty=%B)" -q - -git checkout -b bugfix -q -echo "Bugfix: corrected a typo in file2.txt" >> file2.txt -git add file2.txt -git_commit "Bugfix: update file2.txt with correction" "2023-01-02T04:30:00+00:00" "Alice" "alice@example.com" - -echo "Bugfix: final adjustment to file2.txt" >> file2.txt -git add file2.txt -git_commit "Bugfix: further update to file2.txt" "2023-01-02T05:00:00+00:00" "Bob" "bob@example.com" - -git checkout master -q -git_commit "Merge branch 'bugfix'" "2023-01-02T05:30:00+00:00" "Carol" "carol@example.com" && \ - git merge --no-ff bugfix -m "$(git log -1 --pretty=%B)" -q - -for b in octo1 octo2 octo3; do - git checkout -b "$b" master -q - echo "Change from $b" > "$b".txt - git add "$b".txt - case "$b" in - octo1) name="Alice"; email="alice@example.com"; date="2023-01-02T06:00:00+00:00";; - octo2) name="Bob"; email="bob@example.com"; date="2023-01-02T06:30:00+00:00";; - octo3) name="Carol"; email="carol@example.com"; date="2023-01-02T07:00:00+00:00";; - esac - git_commit "Octo ${b: -1}: Add ${b}.txt" "$date" "$name" "$email" -done - -git checkout master -q -GIT_AUTHOR_DATE="2023-01-02T07:30:00+00:00" GIT_COMMITTER_DATE="2023-01-02T07:30:00+00:00" \ -GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_COMMITTER_NAME="Alice" GIT_COMMITTER_EMAIL="alice@example.com" \ - git merge --no-ff octo1 octo2 octo3 -m "Octopus merge of octo1, octo2, and octo3" -q - -# extended commits -echo "Remove the inserted line" > tmp && sed '/Inserted line/d' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -git_commit "Remove inserted line from file1-renamed.txt" "2023-01-02T08:00:00+00:00" "Bob" "bob@example.com" - -echo "Post-merge note" >> file6.txt -git add file6.txt -git_commit "Append post-merge note to file6.txt" "2023-01-02T08:30:00+00:00" "Dave" "dave@example.com" - - -# 3 additional commits on 'imported' branch -git checkout imported -q - -echo "Imported update 1" >> imported.txt -git add imported.txt -git_commit "Imported: update 1 to imported.txt" "2023-01-03T02:00:00+00:00" "Dave" "dave@example.com" - -echo "Imported update 2" >> imported.txt -git add imported.txt -git_commit "Imported: update 2 to imported.txt" "2023-01-03T02:30:00+00:00" "Carol" "carol@example.com" - -echo "Imported update 3" >> imported.txt -git add imported.txt -git_commit "Imported: update 3 to imported.txt" "2023-01-03T03:00:00+00:00" "Bob" "bob@example.com" - -# Merge 'imported' back into master again -git checkout master -q -GIT_AUTHOR_DATE="2023-01-03T03:30:00+00:00" GIT_COMMITTER_DATE="2023-01-03T03:30:00+00:00" \ -GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_COMMITTER_NAME="Alice" GIT_COMMITTER_EMAIL="alice@example.com" \ - git merge --no-ff imported -m "Second merge of imported history" -q --allow-unrelated-histories - -# 3 commits on master post-import -echo "Master post-import 1" >> file1-renamed.txt -git add file1-renamed.txt -git_commit "Master: post-import update 1" "2023-01-03T04:00:00+00:00" "Alice" "alice@example.com" - -echo "Master post-import 2" >> file2.txt -git add file2.txt -git_commit "Master: post-import update 2" "2023-01-03T04:30:00+00:00" "Bob" "bob@example.com" - -echo "Master post-import 3" >> file6.txt -git add file6.txt -git_commit "Master: post-import update 3" "2023-01-03T05:00:00+00:00" "Carol" "carol@example.com" - -# 5 commits on 'extra' branch and merge into master - -git checkout -b extra -q -# 1/5 -echo "Extra change 1" >> file1-renamed.txt -git add file1-renamed.txt -git_commit "Extra: change 1" "2023-01-03T06:00:00+00:00" "Alice" "alice@example.com" -# 2/5 -echo "Extra change 2" >> file2.txt -git add file2.txt -git_commit "Extra: change 2" "2023-01-03T06:30:00+00:00" "Bob" "bob@example.com" -# 3/5 -echo "Extra change 3" > file7.txt -git add file7.txt -git_commit "Extra: add file7.txt" "2023-01-03T07:00:00+00:00" "Carol" "carol@example.com" -# 4/5 -echo "Extra change 4" >> file7.txt -git add file7.txt -git_commit "Extra: update file7.txt" "2023-01-03T07:30:00+00:00" "Alice" "alice@example.com" -# 5/5 -git rm file5.bin -git_commit "Extra: remove file5.bin" "2023-01-03T08:00:00+00:00" "Carol" "carol@example.com" - -git checkout master -q -GIT_AUTHOR_DATE="2023-01-03T08:30:00+00:00" GIT_COMMITTER_DATE="2023-01-03T08:30:00+00:00" \ -GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_COMMITTER_NAME="Alice" GIT_COMMITTER_EMAIL="alice@example.com" \ - git merge --no-ff extra -m "Merge branch 'extra' with five extra changes" -q - -exit 0 diff --git a/binocular-backend-new/infrastructure-test/src/test/resources/fixtures/octo.sh b/binocular-backend-new/infrastructure-test/src/test/resources/fixtures/octo.sh deleted file mode 100755 index e3a99ef5b..000000000 --- a/binocular-backend-new/infrastructure-test/src/test/resources/fixtures/octo.sh +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Force Git to use UTC for all timestamps -export TZ=UTC - -# Usage check -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -REPO_DIR="$1" -mkdir -p "$REPO_DIR" -cd "$REPO_DIR" - -# Initialize on master branch explicitly -git init -q -b master - -############################################################################### -# Helper to commit with fixed author/committer data -############################################################################### -git_commit() { - local msg="$1"; shift - local date="$1"; shift - local name="$1"; shift - local email="$1" - GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ - GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" \ - GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" \ - git commit -m "$msg" -q -} - -############################################################################### -# Commits 1–15: Initial history -############################################################################### - -# 1: Initial commit by Alice -echo "Hello, world!" > file1.txt -git add file1.txt -git_commit "Initial commit" \ - "2023-01-01T12:16:00+00:00" \ - "Alice" "alice@example.com" - -# 2: Append to file1.txt by Bob -echo "Additional content" >> file1.txt -git add file1.txt -git_commit "Append to file1.txt" \ - "2023-01-01T13:00:00+00:00" \ - "Bob" "bob@example.com" - -# 3: Add file2.txt by Carol -echo "This is file2" > file2.txt -git add file2.txt -git_commit "Add file2.txt" \ - "2023-01-01T14:00:00+00:00" \ - "Carol" "carol@example.com" - -# 4: Modify file2.txt by Alice -echo "More content for file2" >> file2.txt -git add file2.txt -git_commit "Modify file2.txt" \ - "2023-01-01T15:00:00+00:00" \ - "Alice" "alice@example.com" - -# 5: Rename file1.txt to file1-renamed.txt by Bob -git mv file1.txt file1-renamed.txt -git_commit "Rename file1.txt to file1-renamed.txt" \ - "2023-01-01T16:00:00+00:00" \ - "Bob" "bob@example.com" - -# 6: Delete file2.txt by Carol -git rm file2.txt -git_commit "Delete file2.txt" \ - "2023-01-01T17:00:00+00:00" \ - "Carol" "carol@example.com" - -# 7: Create file3.txt by Alice (with differing author/committer times) -echo "Content of file3" > file3.txt -git add file3.txt -GIT_AUTHOR_DATE="2023-01-01T18:00:00+00:00" \ -git_commit "Create file3.txt" \ - "2023-01-01T18:05:00+00:00" \ - "Alice" "alice@example.com" - -# 8: Update file3.txt by Bob -echo "Appending more to file3" >> file3.txt -git add file3.txt -git_commit "Update file3.txt with more content" \ - "2023-01-01T19:00:00+00:00" \ - "Bob" "bob@example.com" - -# 9: Create dir1 and add file4.txt by Carol -mkdir -p dir1 -echo "Inside dir1" > dir1/file4.txt -git add dir1/file4.txt -git_commit "Create dir1 and add file4.txt" \ - "2023-01-01T20:00:00+00:00" \ - "Carol" "carol@example.com" - -# 10: Rename file4.txt inside dir1 by Alice -git mv dir1/file4.txt dir1/file4-renamed.txt -git_commit "Rename file4.txt to file4-renamed.txt in dir1" \ - "2023-01-01T21:00:00+00:00" \ - "Alice" "alice@example.com" - -# 11: Add a deterministic binary blob by Bob -dd if=/dev/zero bs=100 count=1 of=file5.bin status=none -git add file5.bin -git_commit "Add binary file file5.bin" \ - "2023-01-01T22:00:00+00:00" \ - "Bob" "bob@example.com" - -# 12: Delete file3.txt by Carol -git rm file3.txt -git_commit "Delete file3.txt" \ - "2023-01-01T23:00:00+00:00" \ - "Carol" "carol@example.com" - -# 13: Insert a line in file1-renamed.txt by Alice -awk 'NR==1{print; print "Inserted line"; next}1' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -git_commit "Modify file1-renamed.txt by inserting a line" \ - "2023-01-02T00:00:00+00:00" \ - "Alice" "alice@example.com" - -# 14: Re-add file2.txt with new content by Bob -echo "Recreated file2" > file2.txt -git add file2.txt -GIT_AUTHOR_DATE="2023-01-02T00:30:00+00:00" \ -git_commit "Re-add file2.txt with new content" \ - "2023-01-02T01:00:00+00:00" \ - "Bob" "bob@example.com" - -# 15: Final multi-file update by Carol -echo "Final update to file1-renamed.txt" >> file1-renamed.txt -echo "Final update to file2.txt" >> file2.txt -git add file1-renamed.txt file2.txt -git_commit "Final update: modify multiple files" \ - "2023-01-02T02:00:00+00:00" \ - "Carol" "carol@example.com" - -############################################################################### -# Orphan commit from another remote + merge -############################################################################### -git checkout --orphan imported -q -git rm -rf . > /dev/null 2>&1 || true -echo "Imported commit content" > imported.txt -git add imported.txt -git_commit "Imported commit: independent history from another remote" \ - "2023-01-03T00:00:00+00:00" \ - "Dave" "dave@example.com" - -git checkout master -q -git_commit "Merge imported history from remote" \ - "2023-01-03T01:00:00+00:00" \ - "Alice" "alice@example.com" \ - && git merge --allow-unrelated-histories imported -m "$(git log -1 --pretty=%B)" -q - -############################################################################### -# Classical branch merges -############################################################################### - -# feature branch -git checkout -b feature -q -echo "Feature update: appended line" >> file1-renamed.txt -git add file1-renamed.txt -git_commit "Feature: update file1-renamed.txt" \ - "2023-01-02T03:00:00+00:00" \ - "Bob" "bob@example.com" - -echo "Content for file6 from feature branch" > file6.txt -git add file6.txt -git_commit "Feature: add file6.txt" \ - "2023-01-02T03:30:00+00:00" \ - "Carol" "carol@example.com" - -git checkout master -q -git_commit "Merge branch 'feature'" \ - "2023-01-02T04:00:00+00:00" \ - "Alice" "alice@example.com" \ - && git merge --no-ff feature -m "$(git log -1 --pretty=%B)" -q - -# bugfix branch -git checkout -b bugfix -q -echo "Bugfix: corrected a typo in file2.txt" >> file2.txt -git add file2.txt -git_commit "Bugfix: update file2.txt with correction" \ - "2023-01-02T04:30:00+00:00" \ - "Alice" "alice@example.com" - -echo "Bugfix: final adjustment to file2.txt" >> file2.txt -git add file2.txt -git_commit "Bugfix: further update to file2.txt" \ - "2023-01-02T05:00:00+00:00" \ - "Bob" "bob@example.com" - -git checkout master -q -git_commit "Merge branch 'bugfix'" \ - "2023-01-02T05:30:00+00:00" \ - "Carol" "carol@example.com" \ - && git merge --no-ff bugfix -m "$(git log -1 --pretty=%B)" -q - -############################################################################### -# Octopus merge of three branches -############################################################################### -# Create each octo branch -for b in octo1 octo2 octo3; do - git checkout -b "$b" master -q - echo "Change from $b" > "$b".txt - git add "$b".txt - - case "$b" in - octo1) - author="Alice"; email="alice@example.com"; date="2023-01-02T06:00:00+00:00" - ;; - octo2) - author="Bob"; email="bob@example.com"; date="2023-01-02T06:30:00+00:00" - ;; - octo3) - author="Carol"; email="carol@example.com"; date="2023-01-02T07:00:00+00:00" - ;; - esac - - msg="Octo ${b: -1}: Add ${b}.txt" - git_commit "$msg" "$date" "$author" "$email" -done - -# Back to master and do a *single* deterministic octopus merge: -git checkout master -q -GIT_AUTHOR_DATE="2023-01-02T07:30:00+00:00" \ -GIT_COMMITTER_DATE="2023-01-02T07:30:00+00:00" \ -GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" \ -GIT_COMMITTER_NAME="Alice" GIT_COMMITTER_EMAIL="alice@example.com" \ - git merge --no-ff octo1 octo2 octo3 \ - -m "Octopus merge of octo1, octo2, and octo3" -q - -exit 0 diff --git a/binocular-backend-new/infrastructure-test/src/test/resources/fixtures/simple.sh b/binocular-backend-new/infrastructure-test/src/test/resources/fixtures/simple.sh deleted file mode 100755 index 665c1c6c4..000000000 --- a/binocular-backend-new/infrastructure-test/src/test/resources/fixtures/simple.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Force Git to use UTC for all timestamps -export TZ=UTC - -# Usage check -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -REPO_DIR="$(pwd)/$1" -REMOTE_DIR="${REPO_DIR}_remote.git" - -# Create bare repository to act as dummy remote -mkdir -p "$REMOTE_DIR" -git init --bare "$REMOTE_DIR" >/dev/null 2>&1 - -# Create and populate local repository -mkdir -p "$REPO_DIR" -cd "$REPO_DIR" - -# Initialize on master branch explicitly -git init -q -b master - -############################################################################### -# Helper to commit with fixed author/committer data -############################################################################### -git_commit() { - local msg="$1"; shift - local date="$1"; shift - local name="$1"; shift - local email="$1" - GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ - GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" \ - GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" \ - git commit -m "$msg" -q -} - -# Add dummy remote reference -git remote add origin "$REMOTE_DIR" - -############################################################################### -# Commits 1–15: Initial history -############################################################################### - -# 1: Initial commit by Alice -echo "Hello, world!" > file1.txt -git add file1.txt -git_commit "Initial commit" \ - "2023-01-01T12:00:00+00:00" \ - "Alice" "alice@example.com" - -git push -u origin master -q - -# 2: Append to file1.txt by Bob -echo "Additional content" >> file1.txt -git add file1.txt -git_commit "Append to file1.txt" \ - "2023-01-01T13:00:00+00:00" \ - "Bob" "bob@example.com" - -# 3: Add file2.txt by Carol -echo "This is file2" > file2.txt -git add file2.txt -git_commit "Add file2.txt" \ - "2023-01-01T14:00:00+00:00" \ - "Carol" "carol@example.com" - -# 4: Modify file2.txt by Alice -echo "More content for file2" >> file2.txt -git add file2.txt -git_commit "Modify file2.txt" \ - "2023-01-01T15:00:00+00:00" \ - "Alice" "alice@example.com" - -# 5: Rename file1.txt to file1-renamed.txt by Bob -git mv file1.txt file1-renamed.txt -git_commit "Rename file1.txt to file1-renamed.txt" \ - "2023-01-01T16:00:00+00:00" \ - "Bob" "bob@example.com" - -# 6: Delete file2.txt by Carol -git rm file2.txt -git_commit "Delete file2.txt" \ - "2023-01-01T17:00:00+00:00" \ - "Carol" "carol@example.com" - -# 7: Create file3.txt by Alice (with differing author/committer times) -echo "Content of file3" > file3.txt -git add file3.txt -GIT_AUTHOR_DATE="2023-01-01T18:00:00+00:00" \ -git_commit "Create file3.txt" \ - "2023-01-01T18:05:00+00:00" \ - "Alice" "alice@example.com" - -# 8: Update file3.txt by Bob -echo "Appending more to file3" >> file3.txt -git add file3.txt -git_commit "Update file3.txt with more content" \ - "2023-01-01T19:00:00+00:00" \ - "Bob" "bob@example.com" - -# 9: Create dir1 and add file4.txt by Carol -mkdir -p dir1 -echo "Inside dir1" > dir1/file4.txt -git add dir1/file4.txt -git_commit "Create dir1 and add file4.txt" \ - "2023-01-01T20:00:00+00:00" \ - "Carol" "carol@example.com" - -# 10: Rename file4.txt inside dir1 by Alice -git mv dir1/file4.txt dir1/file4-renamed.txt -git_commit "Rename file4.txt to file4-renamed.txt in dir1" \ - "2023-01-01T21:00:00+00:00" \ - "Alice" "alice@example.com" - -# 11: Add a deterministic binary blob by Bob -dd if=/dev/zero bs=100 count=1 of=file5.bin status=none -git add file5.bin -git_commit "Add binary file file5.bin" \ - "2023-01-01T22:00:00+00:00" \ - "Bob" "bob@example.com" - -# 12: Delete file3.txt by Carol -git rm file3.txt -git_commit "Delete file3.txt" \ - "2023-01-01T23:00:00+00:00" \ - "Carol" "carol@example.com" - -# 13: Insert a line in file1-renamed.txt by Alice -awk 'NR==1{print; print "Inserted line"; next}1' file1-renamed.txt > tmp && mv tmp file1-renamed.txt -git add file1-renamed.txt -git_commit "Modify file1-renamed.txt by inserting a line" \ - "2023-01-02T00:00:00+00:00" \ - "Alice" "alice@example.com" - -# Push current branches to remote -git push origin master -q - -# 14: Re-add file2.txt with new content by Bob -echo "Recreated file2" > file2.txt -git add file2.txt -GIT_AUTHOR_DATE="2023-01-02T00:30:00+00:00" \ -git_commit "Re-add file2.txt with new content" \ - "2023-01-02T01:00:00+00:00" \ - "Bob" "bob@example.com" - -exit 0 diff --git a/binocular-backend-new/pom.xml b/binocular-backend-new/pom.xml index 074e889b0..9a82baf55 100644 --- a/binocular-backend-new/pom.xml +++ b/binocular-backend-new/pom.xml @@ -51,7 +51,10 @@ 2.2.20 5.13.1 1.14.4 - 1.21.3 + + + 2.0.2 + 2.0.2 4.0.0 @@ -137,10 +140,16 @@ + + com.github.goodforgod + arangodb-testcontainer + ${arangodb-testcontainer.version} + test + org.testcontainers - postgresql - ${testcontainers.version} + testcontainers-postgresql + ${sql-testcontainers.version} test diff --git a/binocular-backend-new/web/pom.xml b/binocular-backend-new/web/pom.xml index df4152541..1b1ad098b 100644 --- a/binocular-backend-new/web/pom.xml +++ b/binocular-backend-new/web/pom.xml @@ -65,7 +65,6 @@ com.github.goodforgod arangodb-testcontainer - ${arangodb-testcontainer.version} provided diff --git a/binocular-backend-new/web/src/main/kotlin/com/inso_world/binocular/web/BinocularWebAppConfig.kt b/binocular-backend-new/web/src/main/kotlin/com/inso_world/binocular/web/BinocularWebAppConfig.kt deleted file mode 100644 index 575d18323..000000000 --- a/binocular-backend-new/web/src/main/kotlin/com/inso_world/binocular/web/BinocularWebAppConfig.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.inso_world.binocular.web - -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile - -@Configuration -@ConfigurationProperties(prefix = "binocular") -class BinocularAppConfig { - lateinit var database: DatabaseConfig -} - -@Profile("arangodb") -class DatabaseConfig( - val databaseName: String, - val host: String, - val port: String, - var user: String?, - var password: String?, -) diff --git a/binocular-backend-new/web/src/main/resources/application-arangodb.yaml b/binocular-backend-new/web/src/main/resources/application-arangodb.yaml index 3984c00ef..69fa1e474 100644 --- a/binocular-backend-new/web/src/main/resources/application-arangodb.yaml +++ b/binocular-backend-new/web/src/main/resources/application-arangodb.yaml @@ -1,7 +1,9 @@ binocular: - database: - database_name: "binocular-repo" - host: "localhost" - port: 8529 - user: TODO - password: TODO + arangodb: + database: + name: "binocular-repo" + host: "localhost" + port: 8529 + user: TODO + password: TODO + diff --git a/binocular-backend-new/web/src/test/kotlin/com/inso_world/binocular/web/TestDataSetupService.kt b/binocular-backend-new/web/src/test/kotlin/com/inso_world/binocular/web/TestDataSetupService.kt index d493be9bd..7cd103ea9 100644 --- a/binocular-backend-new/web/src/test/kotlin/com/inso_world/binocular/web/TestDataSetupService.kt +++ b/binocular-backend-new/web/src/test/kotlin/com/inso_world/binocular/web/TestDataSetupService.kt @@ -48,18 +48,6 @@ internal class TestDataSetupService( */ fun clearAllData() { infrastructureDataSetup.teardown() - - commitRepository.deleteAll() - accountRepository.deleteAll() - branchRepository.deleteAll() - buildRepository.deleteAll() - fileRepository.deleteAll() - issueRepository.deleteAll() - mergeRequestRepository.deleteAll() - milestoneRepository.deleteAll() - moduleRepository.deleteAll() - noteRepository.deleteAll() - userRepository.deleteAll() } /** diff --git a/binocular-backend-new/web/src/test/resources/application-arangodb.yaml b/binocular-backend-new/web/src/test/resources/application-arangodb.yaml index 5512514f7..5fe951927 100644 --- a/binocular-backend-new/web/src/test/resources/application-arangodb.yaml +++ b/binocular-backend-new/web/src/test/resources/application-arangodb.yaml @@ -1,7 +1,8 @@ binocular: - database: - database_name: "binocular-web-test" - host: ${ARANGODB_HOST:localhost} - port: ${ARANGODB_PORT:8529} - user: TODO - password: TODO + arangodb: + database: + name: "binocular-web-test" + host: ${ARANGODB_HOST:localhost} + port: ${ARANGODB_PORT:8529} + user: TODO + password: TODO