diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d8b1ec..0f017ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ on: jobs: test: - name: Run Backend Test Suite + name: Run Go Backend Test Suite if: github.event.pull_request.draft == false runs-on: ubuntu-latest permissions: @@ -24,28 +24,12 @@ jobs: with: persist-credentials: false - - name: Setup Node.js 20 - uses: actions/setup-node@v6 + - name: Setup Go + uses: actions/setup-go@v5 with: - node-version: '20' - cache: 'npm' - cache-dependency-path: 'frontend/package-lock.json' + go-version: '1.25' + cache-dependency-path: backend/go.sum - - name: Install Frontend Dependencies - working-directory: ./frontend - run: npm ci - - - name: Setup Java 21 (Temurin) - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - java-version: '21' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Run Backend Tests + - name: Run Go Backend Tests working-directory: ./backend - run: | - chmod +x gradlew - ./gradlew test --no-daemon + run: go test ./... -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1534949..f07448f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag name to release (e.g. v0.1.0-alpha)' + description: 'Tag name to release (e.g. v0.1.6)' required: true type: string @@ -20,10 +20,10 @@ concurrency: jobs: # =========================================== - # STAGE 0: Tests & Version Sanity Check + # STAGE 0: Go Tests & Version Sanity Check # =========================================== verify: - name: Run Tests & Verify Version + name: Run Go Tests & Verify Version runs-on: ubuntu-latest steps: @@ -33,47 +33,30 @@ jobs: ref: ${{ inputs.tag || github.ref_name }} persist-credentials: false - - name: Setup Node.js 20 - uses: actions/setup-node@v6 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: 'frontend/package-lock.json' - - - name: Install Frontend Dependencies - working-directory: ./frontend - run: npm ci - - - name: Setup Java 21 (Temurin) - uses: actions/setup-java@v5 + - name: Setup Go + uses: actions/setup-go@v5 with: - distribution: 'temurin' - java-version: '21' + go-version: '1.25' + cache-dependency-path: backend/go.sum - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Run Backend Tests + - name: Run Go Backend Tests working-directory: ./backend - run: | - chmod +x gradlew - ./gradlew test --no-daemon + run: go test ./... -v - - name: Verify Tag Matches Gradle Version - working-directory: ./backend + - name: Verify Tag Matches Package Version env: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} run: | TAG_VERSION="${RELEASE_TAG#v}" - GRADLE_VERSION=$(./gradlew properties -q | grep "^version:" | awk '{print $2}') - if [ "$TAG_VERSION" != "$GRADLE_VERSION" ]; then - echo "Tag version ($TAG_VERSION) does not match Gradle version ($GRADLE_VERSION)" + PKG_VERSION=$(jq -r '.version' frontend/package.json) + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" exit 1 fi - echo "Tag version matches Gradle version: $TAG_VERSION" + echo "Tag version matches package.json version: $TAG_VERSION" # =========================================== - # STAGE 1: Linux Packaging + # STAGE 1: Linux Packaging (.deb, .rpm) # =========================================== build-linux: name: Package Linux Installers (.deb, .rpm) @@ -94,41 +77,41 @@ jobs: cache: 'npm' cache-dependency-path: 'frontend/package-lock.json' - - name: Install Frontend Dependencies - working-directory: ./frontend - run: npm ci - - - name: Setup Java 21 (Temurin) - uses: actions/setup-java@v5 + - name: Setup Go + uses: actions/setup-go@v5 with: - distribution: 'temurin' - java-version: '21' + go-version: '1.25' + cache-dependency-path: backend/go.sum - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable - - name: Install Linux Package Utilities (rpm, dpkg-dev) + - name: Install Linux Build Dependencies run: | sudo apt-get update - sudo apt-get install -y rpm dpkg-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev rpm dpkg-dev - - name: Build Linux Package Installers (.deb, .rpm) - working-directory: ./backend + - name: Install Frontend Dependencies + working-directory: ./frontend + run: npm ci + + - name: Build Linux Installers via Tauri + working-directory: ./frontend run: | - chmod +x gradlew - ./gradlew packageDeb packageRpm --no-daemon + node scripts/build-all.js + npx tauri build --bundles deb,rpm - name: Upload Linux Installer Artifacts uses: actions/upload-artifact@v4 with: name: linux-installers path: | - backend/build/jpackage/**/*.deb - backend/build/jpackage/**/*.rpm + frontend/src-tauri/target/release/bundle/deb/*.deb + frontend/src-tauri/target/release/bundle/rpm/*.rpm if-no-files-found: error # =========================================== - # STAGE 2: Windows Packaging + # STAGE 2: Windows Packaging (.msi) # =========================================== build-windows: name: Package Windows Installer (.msi) @@ -149,35 +132,34 @@ jobs: cache: 'npm' cache-dependency-path: 'frontend/package-lock.json' - - name: Install Frontend Dependencies - working-directory: ./frontend - run: npm ci - - - name: Setup Java 21 (Temurin) - uses: actions/setup-java@v5 + - name: Setup Go + uses: actions/setup-go@v5 with: - distribution: 'temurin' - java-version: '21' + go-version: '1.25' + cache-dependency-path: backend/go.sum - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable - - name: Install Wix Toolset 3.x (Chocolatey) - run: choco install wixtoolset -y + - name: Install Frontend Dependencies + working-directory: ./frontend + run: npm ci - - name: Build Windows Package Installer (.msi) - working-directory: ./backend - run: .\gradlew.bat packageMsi --no-daemon + - name: Build Windows Installer via Tauri + working-directory: ./frontend + run: | + node scripts/build-all.js + npx tauri build --bundles msi - name: Upload Windows Installer Artifact uses: actions/upload-artifact@v4 with: name: windows-installer - path: backend/build/jpackage/**/*.msi + path: frontend/src-tauri/target/release/bundle/msi/*.msi if-no-files-found: error # =========================================== - # STAGE 3: macOS Packaging + # STAGE 3: macOS Packaging (.dmg) # =========================================== build-macos: name: Package macOS Installer (.dmg) @@ -198,30 +180,30 @@ jobs: cache: 'npm' cache-dependency-path: 'frontend/package-lock.json' + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache-dependency-path: backend/go.sum + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Install Frontend Dependencies working-directory: ./frontend run: npm ci - - name: Setup Java 21 (Temurin) - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - java-version: '21' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Build macOS Package Installer (.dmg) - working-directory: ./backend + - name: Build macOS Installer via Tauri + working-directory: ./frontend run: | - chmod +x gradlew - ./gradlew packageDmg --no-daemon + node scripts/build-all.js + npx tauri build --bundles dmg - name: Upload macOS Installer Artifact uses: actions/upload-artifact@v4 with: name: macos-installer - path: backend/build/jpackage/**/*.dmg + path: frontend/src-tauri/target/release/bundle/dmg/*.dmg if-no-files-found: error # =========================================== @@ -254,10 +236,10 @@ jobs: with: tag_name: ${{ inputs.tag || github.ref_name }} files: | - release-assets/**/*.deb - release-assets/**/*.rpm - release-assets/**/*.msi - release-assets/**/*.dmg + release-assets/*.deb + release-assets/*.rpm + release-assets/*.msi + release-assets/*.dmg generate_release_notes: true draft: false prerelease: false diff --git a/backend/.gitattributes b/backend/.gitattributes deleted file mode 100644 index 8af972c..0000000 --- a/backend/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/backend/.gitignore b/backend/.gitignore index ac12a84..459c6b7 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,39 +1,32 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ +# Binaries & Build Output +/bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +/main +/api -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ +# Test binaries & coverage +*.test +*.out -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### +# IDEs and Editors +.idea/ .vscode/ +*.swp +*.swo +*~ + +# Environment & Secrets +.env +.env.* +!.env.example -data +# Local SQLite Databases +*.db +*.db-journal +*.sqlite +*.sqlite3 +/data/* diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts deleted file mode 100644 index 59f4237..0000000 --- a/backend/build.gradle.kts +++ /dev/null @@ -1,313 +0,0 @@ -plugins { - java - application - id("org.springframework.boot") version "4.1.0" - id("io.spring.dependency-management") version "1.1.7" - id("org.openjfx.javafxplugin") version "0.1.0" - id("org.beryx.runtime") version "2.0.1" -} - -val mapStructVersion = "1.6.3" -val swaggerOpenAPIVersion = "3.0.3" -val bouncyCastleVersion = "1.84" - -val yamlVersion = file("src/main/resources/application.yaml") - .readLines() - .firstOrNull { it.trim().startsWith("version:") } - ?.substringAfter("version:") - ?.trim() - ?.removeSurrounding("\"") - ?.removeSurrounding("'") - ?: "0.1.0-alpha" - -group = "com.devaulty" -version = yamlVersion - -// Clean version for Linux/Windows (e.g. "0.1.0-alpha" -> "0.1.0") -val packageVersion = yamlVersion.replace(Regex("(?i)-.*$"), "") - -// Numeric 3-part version for macOS (e.g. "0.1.0") -val macPackageVersion = if (packageVersion.split(".").size >= 3) packageVersion else "$packageVersion.0" - -// macOS jpackage requires the first version component to be >= 1 (CFBundleVersion spec). -// Map 0.x.y → 1.x.y for the jpackageImage task so the image build succeeds on all platforms. -// Each platform installer task already passes its own --app-version independently. -val jpackageImageVersion = run { - val parts = packageVersion.split(".") - val major = parts.getOrElse(0) { "1" }.toIntOrNull() ?: 1 - (if (major == 0) listOf("1") + parts.drop(1) else parts).joinToString(".") -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -application { - mainClass.set("com.devaulty.backend.desktop.DevaultyMainLauncher") -} - -repositories { - mavenCentral() -} - -dependencies { - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-liquibase") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-webmvc"){ - exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat") - } - implementation("org.springframework.boot:spring-boot-starter-jetty") - implementation("org.springframework.security:spring-security-crypto") - implementation("org.mapstruct:mapstruct:${mapStructVersion}") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${swaggerOpenAPIVersion}") - implementation("org.bouncycastle:bcprov-jdk18on:${bouncyCastleVersion}") - - developmentOnly("org.springframework.boot:spring-boot-devtools") - - runtimeOnly("org.xerial:sqlite-jdbc") - runtimeOnly("org.hibernate.orm:hibernate-community-dialects") - - // Annotation Processor (mapstruct) - annotationProcessor("org.mapstruct:mapstruct-processor:${mapStructVersion}") - - testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test") - testImplementation("org.springframework.boot:spring-boot-starter-liquibase-test") - testImplementation("org.springframework.boot:spring-boot-starter-validation-test") - testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") - - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -javafx { - version = "21.0.2" - modules = listOf("javafx.controls", "javafx.web") -} - -tasks.withType { - useJUnitPlatform() -} - -tasks.named("bootJar") { - mainClass.set("com.devaulty.backend.BackendApplication") -} - -tasks.named("bootRun") { - mainClass.set("com.devaulty.backend.BackendApplication") -} - -tasks.withType().configureEach { - jvmArgs( - "-Xms64m", - "-Xmx256m", - "-XX:MetaspaceSize=96m", - "-XX:MaxMetaspaceSize=192m", - "-XX:ParallelGCThreads=2", - "-XX:ConcGCThreads=1", - "-XX:+UseG1GC", - "-XX:MaxGCPauseMillis=100" - ) -} - -// task to build frontend (npm run build) -val buildFrontend by tasks.registering(Exec::class) { - group = "build" - description = "Executes React/Vite application build" - - workingDir = file("../frontend") - - val isWindows = org.gradle.internal.os.OperatingSystem.current().isWindows - if (isWindows) { - commandLine("cmd", "/c", "npm run build") - } else { - commandLine("bash", "-l", "-c", "npm run build") - } - - // Gradle will only execute the task if any of these inputs/outputs have changed - inputs.dir("../frontend/src") - inputs.file("../frontend/package.json") - outputs.dir("../frontend/dist") -} - -// Copy frontend build to statics resources -val copyFrontendResources by tasks.registering(Copy::class) { - group = "build" - description = "Copies frontend compilated files to statics resources from Spring Boot" - - dependsOn(buildFrontend) - - from("../frontend/dist") - into(layout.buildDirectory.dir("resources/main/static")) -} - -// Ties frontend build to Spring Boot resources -tasks.named("processResources") { - dependsOn(copyFrontendResources) -} - -runtime { - options.set(listOf("--strip-debug", "--compress", "zip-6", "--no-header-files", "--no-man-pages")) - modules.set( - listOf( - "java.xml", "java.sql", "java.naming", "java.desktop", "java.management", - "java.instrument", "java.scripting", "java.security.jgss", "jdk.unsupported", - "java.compiler", "java.net.http", "java.logging", "java.prefs", "java.rmi", - "java.transaction.xa", "jdk.crypto.ec", "jdk.zipfs" - ) - ) - - jpackage { - imageName = "devaulty" - appVersion = jpackageImageVersion - imageOptions = listOf( - // 1. Memory do Heap Limits (Obj dynamic RAM) - "--java-options", "-Xms64m", - "--java-options", "-Xmx256m", - - // 2. Metaspace limits (Spring/Hibernate Class Metadata Memory) - "--java-options", "-XX:MetaspaceSize=96m", - "--java-options", "-XX:MaxMetaspaceSize=192m", - - // 3. Garbage Collector (GC) Thread Control - "--java-options", "-XX:ParallelGCThreads=2", - "--java-options", "-XX:ConcGCThreads=1", - "--java-options", "-XX:+UseG1GC", - "--java-options", "-XX:MaxGCPauseMillis=100", - - // 4. Spring Production Profile - "--java-options", "-Dspring.profiles.active=prod" - ) - - } -} - -val isMac = org.gradle.internal.os.OperatingSystem.current().isMacOsX -val appImageDir = if (isMac) { - layout.buildDirectory.dir("jpackage/devaulty.app") -} else { - layout.buildDirectory.dir("jpackage/devaulty") -} - -fun getResourceDirArgs(dirPath: String): List { - val dir = file(dirPath) - return if (dir.exists() && (dir.listFiles()?.isNotEmpty() == true)) { - listOf("--resource-dir", dir.absolutePath) - } else { - emptyList() - } -} - -val packageDeb by tasks.registering(Exec::class) { - group = "distribution" - dependsOn("jpackageImage") - onlyIf { org.gradle.internal.os.OperatingSystem.current().isLinux } - commandLine( - buildList { - addAll(listOf( - "jpackage", - "--type", "deb", - "--app-image", appImageDir.get().asFile.path, - "--name", "devaulty", - "--app-version", packageVersion, - "--vendor", "Devaulty", - "--icon", file("src/main/resources/static/icon/devaulty-icon.png").absolutePath - )) - addAll(getResourceDirArgs("src/main/resources/jpackage/linux")) - addAll(listOf( - "--linux-shortcut", - "--linux-menu-group", "Utility", - "--dest", layout.buildDirectory.dir("jpackage/deb").get().asFile.path - )) - } - ) -} - -val packageRpm by tasks.registering(Exec::class) { - group = "distribution" - dependsOn("jpackageImage") - onlyIf { org.gradle.internal.os.OperatingSystem.current().isLinux } - commandLine( - buildList { - addAll(listOf( - "jpackage", - "--type", "rpm", - "--app-image", appImageDir.get().asFile.path, - "--name", "devaulty", - "--app-version", packageVersion, - "--vendor", "Devaulty", - "--icon", file("src/main/resources/static/icon/devaulty-icon.png").absolutePath - )) - addAll(getResourceDirArgs("src/main/resources/jpackage/linux")) - addAll(listOf( - "--linux-shortcut", - "--linux-menu-group", "Utility", - "--dest", layout.buildDirectory.dir("jpackage/rpm").get().asFile.path - )) - } - ) -} - -val packageMsi by tasks.registering(Exec::class) { - group = "distribution" - dependsOn("jpackageImage") - onlyIf { org.gradle.internal.os.OperatingSystem.current().isWindows } - commandLine( - buildList { - addAll(listOf( - "jpackage", - "--type", "msi", - "--app-image", appImageDir.get().asFile.path, - "--name", "devaulty", - "--app-version", packageVersion, - "--vendor", "Devaulty", - "--icon", file("src/main/resources/static/icon/devaulty-icon.ico").absolutePath - )) - addAll(getResourceDirArgs("src/main/resources/jpackage/windows")) - addAll(listOf( - "--win-shortcut", - "--win-menu", - "--win-menu-group", "Utility", - "--dest", layout.buildDirectory.dir("jpackage/msi").get().asFile.path - )) - } - ) -} - -val packageDmg by tasks.registering(Exec::class) { - group = "distribution" - dependsOn("jpackageImage") - onlyIf { org.gradle.internal.os.OperatingSystem.current().isMacOsX } - commandLine( - buildList { - addAll(listOf( - "jpackage", - "--type", "dmg", - "--app-image", appImageDir.get().asFile.path, - "--name", "devaulty", - "--app-version", macPackageVersion, - "--vendor", "Devaulty", - "--icon", file("src/main/resources/static/icon/devaulty-icon.icns").absolutePath - )) - addAll(getResourceDirArgs("src/main/resources/jpackage/macos")) - addAll(listOf( - "--mac-package-name", "Devaulty", - "--dest", layout.buildDirectory.dir("jpackage/dmg").get().asFile.path - )) - } - ) -} - -val packageInstallers by tasks.registering { - group = "distribution" - description = "Generates all applicable installer for the current OS" - dependsOn( - when { - org.gradle.internal.os.OperatingSystem.current().isLinux -> listOf(packageDeb, packageRpm) - org.gradle.internal.os.OperatingSystem.current().isWindows -> listOf(packageMsi) - org.gradle.internal.os.OperatingSystem.current().isMacOsX -> listOf(packageDmg) - else -> emptyList() - } - ) -} diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..e35f3ea --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "devaulty-backend/internal/adapter/in/scheduler" + "devaulty-backend/internal/adapter/in/web" + "devaulty-backend/internal/adapter/in/web/handler" + "devaulty-backend/internal/usecase" + "devaulty-backend/migrations" + "fmt" + "log" + "net" + "os" + "path/filepath" + "runtime/debug" + + "devaulty-backend/internal/adapter/out/persistence" + "devaulty-backend/internal/adapter/out/security" + + "github.com/jmoiron/sqlx" +) + +// resolveDataDir returns the absolute path to the application data directory. +// In production, Tauri sets DEVAULTY_DATA_DIR to the OS user config path. +// In development, it falls back to a relative "data" directory. +func resolveDataDir() string { + if dir := os.Getenv("DEVAULTY_DATA_DIR"); dir != "" { + return dir + } + return "data" +} + +func main() { + if os.Getenv("APP_ENV") != "dev" { + debug.SetGCPercent(20) + } + log.Println("Starting Devaulty API") + + dataDir := resolveDataDir() + if err := os.MkdirAll(dataDir, 0700); err != nil { + log.Fatalf("Critical error while creating data directory %s: %v", dataDir, err) + } + + dbPath := filepath.Join(dataDir, "devaulty.db") + + // Use embedded migrations in production for a self-contained binary. + // In development (APP_ENV=dev), use file-based migrations for hot-reload convenience. + var db *sqlx.DB + var err error + if os.Getenv("APP_ENV") == "dev" { + db, err = persistence.InitDB(dbPath, "migrations") + } else { + db, err = persistence.InitDBWithFS(dbPath, migrations.FS) + } + if err != nil { + log.Fatalf("Critical error while initializing Devaulty database: %v", err) + } + defer func(db *sqlx.DB) { + err := db.Close() + if err != nil { + log.Fatalf("Critical error while closing Devaulty database: %v", err) + } + }(db) + log.Println("Devaulty DB initialized successfully") + + devaultyInternalToken := os.Getenv("DEVAULTY_INTERNAL_TOKEN") + if devaultyInternalToken == "" { + devaultyInternalToken = "dev-token" // Default token for development + } + + appSettingRepo := persistence.NewAppSettingRepository(db) + masterKeySession := security.NewMasterKeySessionHolder() + keyDeriver := security.NewArgon2KeyDeriver() + vaultUseCase := usecase.NewVaultUseCase(keyDeriver, masterKeySession, appSettingRepo) + securityHandler := handler.NewSecurityHandler(vaultUseCase) + + itemTagRepo := persistence.NewItemTagRepository(db) + + projectRepo := persistence.NewProjectRepository(db) + projectUseCase := usecase.NewProjectUseCase(projectRepo) + projectHandler := handler.NewProjectHandler(projectUseCase) + + snippetRepo := persistence.NewSnippetRepository(db) + snippetUseCase := usecase.NewSnippetUseCase(snippetRepo, projectRepo, itemTagRepo) + snippetHandler := handler.NewSnippetHandler(snippetUseCase) + + cryptoAdapter := security.NewAESGCMCrypto() + credentialRepo := persistence.NewCredentialRepository(db) + credentialUseCase := usecase.NewCredentialUseCase(credentialRepo, projectRepo, itemTagRepo, cryptoAdapter, masterKeySession, *vaultUseCase) + credentialHandler := handler.NewCredentialHandler(credentialUseCase) + + linkRepo := persistence.NewLinkRepository(db) + linkUseCase := usecase.NewLinkUseCase(linkRepo, projectRepo, itemTagRepo) + linkHandler := handler.NewLinkHandler(linkUseCase) + + problemRepo := persistence.NewProblemRepository(db) + problemUseCase := usecase.NewProblemUseCase(problemRepo, projectRepo, itemTagRepo) + problemHandler := handler.NewProblemHandler(problemUseCase) + + tagRepo := persistence.NewTagRepository(db) + tagUseCase := usecase.NewTagUseCase(tagRepo, projectRepo) + tagHandler := handler.NewTagHandler(tagUseCase) + + noteRepo := persistence.NewNoteRepository(db) + noteUseCase := usecase.NewNoteUseCase(noteRepo, projectRepo, itemTagRepo) + noteHandler := handler.NewNoteHandler(noteUseCase) + + itemTagUseCase := usecase.NewItemTagUseCase(itemTagRepo, tagRepo, projectRepo, snippetRepo, credentialRepo, linkRepo, problemRepo, noteRepo) + itemTagHandler := handler.NewItemTagHandler(itemTagUseCase, tagUseCase, projectUseCase) + + handlers := &web.Handlers{ + Project: projectHandler, + Snippet: snippetHandler, + Credential: credentialHandler, + Link: linkHandler, + Problem: problemHandler, + Note: noteHandler, + Tag: tagHandler, + ItemTag: itemTagHandler, + Security: securityHandler, + } + r := web.SetupRouter(handlers, devaultyInternalToken) + + autoLock := scheduler.NewVaultAutoLock(masterKeySession) + go autoLock.PurgeExpiredSession() + + addr := "127.0.0.1:0" + if os.Getenv("APP_ENV") == "dev" { + addr = "127.0.0.1:8080" + } + + listener, err := net.Listen("tcp", addr) + if err != nil { + log.Fatalf("Failed to bind listener: %v", err) + } + + port := listener.Addr().(*net.TCPAddr).Port + + // Structured session handshake line for Tauri IPC. + // Tauri reads stdout via a private pipe and parses this exact format + // to extract the dynamic port and security token. + fmt.Printf("[DEVAULTY_SESSION] PORT=%d TOKEN=%s\n", port, devaultyInternalToken) + + log.Printf("Starting server on port %d...", port) + if err := r.RunListener(listener); err != nil { + log.Fatalf("Critical error while starting Devaulty API: %v", err) + } +} diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml new file mode 100644 index 0000000..416e79a --- /dev/null +++ b/backend/docs/openapi.yaml @@ -0,0 +1,3531 @@ +openapi: 3.0.3 +info: + title: Devaulty API Documentation + description: REST API for Devaulty Desktop App Engine + version: 1.0.0 +servers: + - url: http://localhost:8080/api/v1 + description: Development Server + +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: DEVAULTY_INTERNAL_TOKEN + description: | + Internal security token generated in memory per app lifecycle. + Note: When running in DEV environment (APP_ENV=dev), this token defaults to 'dev-token' for interactive testing. + + schemas: + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + example: "Invalid project ID" + + MessageResponse: + type: object + required: + - message + properties: + message: + type: string + example: "Project archived" + + Project: + type: object + required: + - id + - name + - archived + - createdAt + properties: + id: + type: string + format: uuid + example: "06f891ae-368b-4bd6-b1e6-d957f1496f9a" + name: + type: string + example: "Devaulty Go" + description: + type: string + nullable: true + example: "Developer Vault Application" + icon: + type: string + nullable: true + example: "rocket" + color: + type: string + nullable: true + example: "#4A90E2" + archived: + type: boolean + example: false + createdAt: + type: string + format: date-time + example: "2026-08-01T12:35:07.84097684-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-01T16:22:46.12345678-03:00" + + CreateProjectCommand: + type: object + required: + - name + properties: + name: + type: string + minLength: 2 + maxLength: 255 + example: "Devaulty Go" + description: + type: string + minLength: 1 + maxLength: 255 + nullable: true + example: "Developer Vault Application" + icon: + type: string + maxLength: 100 + nullable: true + example: "rocket" + color: + type: string + nullable: true + pattern: "^#([A-Fa-f0-9]{3,4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$" + example: "#4A90E2" + + UpdateProjectCommand: + type: object + properties: + name: + type: string + nullable: true + example: "Updated Devaulty Name" + description: + type: string + nullable: true + example: "Updated Description" + icon: + type: string + nullable: true + example: "folder" + color: + type: string + nullable: true + example: "#00FF00" + + ProjectPage: + type: object + required: + - content + - size + - number + - totalElements + - totalPages + properties: + content: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/Project' + size: + type: integer + example: 10 + number: + type: integer + example: 0 + totalElements: + type: integer + format: int64 + example: 1 + totalPages: + type: integer + example: 1 + + SnippetType: + type: string + enum: + - COMMAND + - CODE + example: "CODE" + + SnippetLanguage: + type: string + enum: + - BASH + - FISH + - ZSH + - SH + - POWERSHELL + - BATCH + - JAVA + - KOTLIN + - JAVASCRIPT + - TYPESCRIPT + - PYTHON + - GO + - RUST + - C + - CPP + - CSHARP + - PHP + - RUBY + - SWIFT + - DART + - SCALA + - LUA + - PERL + - R + - ELIXIR + - HASKELL + - CLOJURE + - GROOVY + - HTML + - CSS + - SCSS + - LESS + - JSX + - TSX + - VUE + - SVELTE + - JSON + - YAML + - XML + - TOML + - INI + - ENV + - CSV + - MARKDOWN + - PROPERTIES + - DOCKERFILE + - DOCKER_COMPOSE + - NGINX + - APACHE + - TERRAFORM + - ANSIBLE + - KUBERNETES_YAML + - HELM + - MAKEFILE + - CMAKE + - GRADLE + - MAVEN_POM + - SQL + - PLSQL + - GRAPHQL + - MONGODB + - GITHUB_ACTIONS + - GITLAB_CI + - JENKINSFILE + - REGEX + - DIFF + - LOG + - PLAIN_TEXT + example: "GO" + + Snippet: + type: object + required: + - id + - projectId + - title + - content + - snippetType + - createdAt + properties: + id: + type: string + format: uuid + example: "784f6f5c-57af-4313-b160-688a3e0d283b" + projectId: + type: string + format: uuid + example: "06f891ae-368b-4bd6-b1e6-d957f1496f9a" + title: + type: string + example: "Print Hello World" + description: + type: string + nullable: true + example: "Simple print example in Go" + content: + type: string + example: "fmt.Println(\"Hello World\")" + language: + $ref: '#/components/schemas/SnippetLanguage' + snippetType: + $ref: '#/components/schemas/SnippetType' + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + createdAt: + type: string + format: date-time + example: "2026-08-03T12:35:07.84097684-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-03T16:22:46.12345678-03:00" + + CreateSnippetCommand: + type: object + required: + - title + - content + - language + - snippetType + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Print Hello World" + description: + type: string + minLength: 1 + maxLength: 255 + nullable: true + example: "Simple print example in Go" + content: + type: string + minLength: 1 + example: "fmt.Println(\"Hello World\")" + language: + $ref: '#/components/schemas/SnippetLanguage' + snippetType: + $ref: '#/components/schemas/SnippetType' + + UpdateSnippetCommand: + type: object + properties: + title: + type: string + minLength: 2 + maxLength: 255 + nullable: true + example: "Updated Snippet Title" + description: + type: string + minLength: 1 + maxLength: 255 + nullable: true + example: "Updated description" + content: + type: string + minLength: 1 + nullable: true + example: "log.Println(\"Updated Content\")" + language: + $ref: '#/components/schemas/SnippetLanguage' + snippetType: + $ref: '#/components/schemas/SnippetType' + + SnippetPage: + type: object + required: + - content + - size + - number + - totalElements + - totalPages + properties: + content: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/Snippet' + size: + type: integer + example: 10 + number: + type: integer + example: 0 + totalElements: + type: integer + format: int64 + example: 1 + totalPages: + type: integer + example: 1 + + Link: + type: object + required: + - id + - projectId + - title + - url + - createdAt + properties: + id: + type: string + format: uuid + example: "784f6f5c-57af-4313-b160-688a3e0d283b" + projectId: + type: string + format: uuid + example: "06f891ae-368b-4bd6-b1e6-d957f1496f9a" + title: + type: string + example: "Go Documentation" + url: + type: string + format: uri + example: "https://go.dev/doc" + description: + type: string + nullable: true + example: "Official Go Documentation" + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + createdAt: + type: string + format: date-time + example: "2026-08-03T12:35:07.84097684-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-03T16:22:46.12345678-03:00" + + CreateLinkCommand: + type: object + required: + - title + - url + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Go Documentation" + url: + type: string + format: uri + example: "https://go.dev/doc" + description: + type: string + nullable: true + example: "Official Go Documentation" + + UpdateLinkCommand: + type: object + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Updated Go Documentation" + url: + type: string + format: uri + example: "https://go.dev/doc" + description: + type: string + example: "Updated description" + + LinkPage: + type: object + required: + - content + - size + - number + - totalElements + - totalPages + properties: + content: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/Link' + size: + type: integer + example: 10 + number: + type: integer + example: 0 + totalElements: + type: integer + format: int64 + example: 1 + totalPages: + type: integer + example: 1 + + ProblemStatus: + type: string + enum: + - OPEN + - WORKING_ON + - RESOLVED + - WONT_FIX + example: "OPEN" + + ProblemSeverity: + type: string + enum: + - LOW + - MEDIUM + - HIGH + - CRITICAL + example: "HIGH" + + Problem: + type: object + required: + - id + - projectId + - title + - errorDescription + - status + - severity + - createdAt + properties: + id: + type: string + format: uuid + example: "24199dbd-36c4-43c6-aeb2-680155af8293" + projectId: + type: string + format: uuid + example: "70d68fba-be62-46bb-8e6c-e4c4b6912e91" + title: + type: string + example: "Database Connection Failure" + errorDescription: + type: string + example: "Connection refused at localhost:5432" + solution: + type: string + nullable: true + example: "Restart PostgreSQL database container" + status: + $ref: '#/components/schemas/ProblemStatus' + severity: + $ref: '#/components/schemas/ProblemSeverity' + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + createdAt: + type: string + format: date-time + example: "2026-08-05T19:29:23.61280878-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-05T21:07:31.992077709-03:00" + + ProblemSummary: + type: object + required: + - id + - projectId + - title + - status + - severity + - createdAt + properties: + id: + type: string + format: uuid + example: "24199dbd-36c4-43c6-aeb2-680155af8293" + projectId: + type: string + format: uuid + example: "70d68fba-be62-46bb-8e6c-e4c4b6912e91" + title: + type: string + example: "Database Connection Failure" + status: + $ref: '#/components/schemas/ProblemStatus' + severity: + $ref: '#/components/schemas/ProblemSeverity' + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + createdAt: + type: string + format: date-time + example: "2026-08-05T19:29:23.61280878-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-05T21:07:31.992077709-03:00" + + CreateProblemCommand: + type: object + required: + - title + - errorDescription + - status + - severity + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Database Connection Failure" + errorDescription: + type: string + minLength: 2 + maxLength: 255 + example: "Connection refused at localhost:5432" + solution: + type: string + minLength: 2 + maxLength: 255 + nullable: true + example: "Restart PostgreSQL database container" + status: + $ref: '#/components/schemas/ProblemStatus' + severity: + $ref: '#/components/schemas/ProblemSeverity' + + UpdateProblemCommand: + type: object + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Updated Problem Title" + errorDescription: + type: string + minLength: 2 + maxLength: 255 + example: "Updated error details" + solution: + type: string + minLength: 2 + maxLength: 255 + example: "Updated solution steps" + severity: + $ref: '#/components/schemas/ProblemSeverity' + + UpdateProblemStatusCommand: + type: object + required: + - status + properties: + status: + $ref: '#/components/schemas/ProblemStatus' + + ProblemSummaryPage: + type: object + required: + - content + - size + - number + - totalElements + - totalPages + properties: + content: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/ProblemSummary' + size: + type: integer + example: 10 + number: + type: integer + example: 0 + totalElements: + type: integer + format: int64 + example: 1 + totalPages: + type: integer + example: 1 + + Note: + type: object + required: + - id + - projectId + - title + - content + - archived + - tags + properties: + id: + type: string + format: uuid + example: "784f6f5c-57af-4313-b160-688a3e0d283b" + projectId: + type: string + format: uuid + example: "70d68fba-be62-46bb-8e6c-e4c4b6912e91" + title: + type: string + example: "Meeting Notes" + content: + type: string + example: "Discussed system architecture and hexagonal pattern." + archived: + type: boolean + example: false + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + + NoteSummary: + type: object + required: + - id + - projectId + - title + - archived + - tags + properties: + id: + type: string + format: uuid + example: "784f6f5c-57af-4313-b160-688a3e0d283b" + projectId: + type: string + format: uuid + example: "70d68fba-be62-46bb-8e6c-e4c4b6912e91" + title: + type: string + example: "Meeting Notes" + archived: + type: boolean + example: false + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + + CreateNoteCommand: + type: object + required: + - title + - content + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "Meeting Notes" + content: + type: string + minLength: 1 + example: "Discussed system architecture and hexagonal pattern." + + UpdateNoteCommand: + type: object + properties: + title: + type: string + minLength: 2 + maxLength: 255 + nullable: true + example: "Updated Meeting Notes" + content: + type: string + minLength: 1 + nullable: true + example: "Updated content" + + NoteSummaryPage: + type: object + required: + - content + - size + - number + - totalElements + - totalPages + properties: + content: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/NoteSummary' + size: + type: integer + example: 10 + number: + type: integer + example: 0 + totalElements: + type: integer + format: int64 + example: 1 + totalPages: + type: integer + example: 1 + + MasterPasswordRequest: + type: object + required: + - masterPassword + properties: + masterPassword: + type: string + minLength: 8 + maxLength: 255 + example: "mySuperSecretPassword123" + + MasterPasswordSetupRequiredView: + type: object + required: + - isRequired + properties: + isRequired: + type: boolean + example: true + + VaultStatusView: + type: object + required: + - active + - secondsLeft + properties: + active: + type: boolean + example: true + secondsLeft: + type: integer + format: int64 + example: 895 + + ItemType: + type: string + enum: + - SNIPPET + - LINK + - PROBLEM + - NOTE + - CREDENTIAL + example: "CREDENTIAL" + + TagSummary: + type: object + required: + - id + - name + properties: + id: + type: string + format: uuid + example: "06f891ae-368b-4bd6-b1e6-d957f1496f9a" + name: + type: string + example: "Go Backend" + color: + type: string + nullable: true + example: "#FF0000" + + Tag: + type: object + required: + - id + - projectId + - name + - createdAt + properties: + id: + type: string + format: uuid + example: "06f891ae-368b-4bd6-b1e6-d957f1496f9a" + projectId: + type: string + format: uuid + example: "70d68fba-be62-46bb-8e6c-e4c4b6912e91" + name: + type: string + example: "Go Backend" + color: + type: string + nullable: true + example: "#FF0000" + createdAt: + type: string + format: date-time + example: "2026-08-05T19:29:23.61280878-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-05T21:07:31.992077709-03:00" + + CreateTagCommand: + type: object + required: + - name + properties: + name: + type: string + minLength: 2 + maxLength: 40 + example: "Go Backend" + color: + type: string + nullable: true + pattern: "^#([A-Fa-f0-9]{3,4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$" + example: "#FF0000" + + UpdateTagCommand: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 40 + nullable: true + example: "Updated Tag Name" + color: + type: string + nullable: true + pattern: "^#([A-Fa-f0-9]{3,4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$" + example: "#00FF00" + + CredentialSecretType: + type: string + enum: + - LOGIN + - API_KEY + - RAW_TEXT + example: "LOGIN" + + CreateCredentialCommand: + type: object + required: + - title + - secretType + properties: + title: + type: string + minLength: 2 + maxLength: 255 + example: "My AWS Login" + secretType: + $ref: '#/components/schemas/CredentialSecretType' + username: + type: string + nullable: true + example: "admin@aws.com" + password: + type: string + nullable: true + example: "SuperSecretPassword123!" + apiKey: + type: string + nullable: true + example: "sk_test_123456789" + rawTextContent: + type: string + nullable: true + example: "pem_certificate_content" + notes: + type: string + nullable: true + example: "Main AWS root account" + relatedUrl: + type: string + nullable: true + example: "https://aws.amazon.com" + + UpdateCredentialCommand: + type: object + properties: + title: + type: string + minLength: 2 + maxLength: 255 + nullable: true + example: "Updated AWS Title" + secretType: + $ref: '#/components/schemas/CredentialSecretType' + username: + type: string + nullable: true + example: "updated_admin" + password: + type: string + nullable: true + example: "NewPassword123!" + apiKey: + type: string + nullable: true + example: "new_sk_test_999" + rawTextContent: + type: string + nullable: true + example: "new_raw_content" + notes: + type: string + nullable: true + example: "Updated notes" + relatedUrl: + type: string + nullable: true + example: "https://new-url.com" + + CredentialSummaryView: + type: object + required: + - id + - projectId + - title + - secretType + - tags + - createdAt + properties: + id: + type: string + format: uuid + example: "81e5768c-2598-4e37-a08d-013e7f4ca48d" + projectId: + type: string + format: uuid + example: "70d68fba-be62-46bb-8e6c-e4c4b6912e91" + title: + type: string + example: "My AWS Login" + secretType: + $ref: '#/components/schemas/CredentialSecretType' + relatedUrl: + type: string + nullable: true + example: "https://aws.amazon.com" + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + createdAt: + type: string + format: date-time + example: "2026-08-13T19:30:25.081066457-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-13T20:15:00.123456789-03:00" + + CredentialView: + type: object + required: + - id + - projectId + - title + - secretType + - decryptedPayload + - tags + - createdAt + properties: + id: + type: string + format: uuid + example: "81e5768c-2598-4e37-a08d-013e7f4ca48d" + projectId: + type: string + format: uuid + example: "70d68fba-be62-46bb-8e6c-e4c4b6912e91" + title: + type: string + example: "My AWS Login" + secretType: + $ref: '#/components/schemas/CredentialSecretType' + decryptedPayload: + type: object + additionalProperties: + type: string + example: + username: "admin@aws.com" + password: "SuperSecretPassword123!" + notes: + type: string + nullable: true + example: "Main AWS root account" + relatedUrl: + type: string + nullable: true + example: "https://aws.amazon.com" + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + createdAt: + type: string + format: date-time + example: "2026-08-13T19:30:25.081066457-03:00" + updatedAt: + type: string + format: date-time + nullable: true + example: "2026-08-13T20:15:00.123456789-03:00" + + CredentialSummaryPage: + type: object + required: + - content + - size + - number + - totalElements + - totalPages + properties: + content: + type: array + items: + $ref: '#/components/schemas/CredentialSummaryView' + size: + type: integer + example: 10 + number: + type: integer + example: 0 + totalElements: + type: integer + format: int64 + example: 1 + totalPages: + type: integer + example: 1 + +security: + - ApiKeyAuth: [] + +paths: + /projects: + post: + summary: Create a new project + description: Creates a new project entity in the database. + tags: + - Projects + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectCommand' + responses: + '201': + description: Project created successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created project + example: "/api/v1/projects/06f891ae-368b-4bd6-b1e6-d957f1496f9a" + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '400': + description: Invalid JSON format or validation constraints failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + summary: Get paginated list of projects + description: Fetches a paginated list of projects ordered by creation date descending. + tags: + - Projects + parameters: + - name: page + in: query + description: Zero-based page index + required: false + schema: + type: integer + default: 0 + minimum: 0 + - name: size + in: query + description: Page size limit + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 100 + responses: + '200': + description: Paginated list of projects retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectPage' + '400': + description: Invalid query parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{id}: + get: + summary: Get project by ID + description: Retrieves a single project entity by its UUID. + tags: + - Projects + parameters: + - name: id + in: path + description: Project UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Project details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '400': + description: Invalid project ID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + patch: + summary: Update project (Partial Update) + description: Performs a partial update (PATCH) on project fields specified in the request body. + tags: + - Projects + parameters: + - name: id + in: path + description: Project UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProjectCommand' + responses: + '200': + description: Project updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '400': + description: Invalid UUID format or invalid JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + summary: Delete project by ID + description: Permanently deletes a project by its UUID. + tags: + - Projects + parameters: + - name: id + in: path + description: Project UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Project deleted successfully (No Content) + '400': + description: Invalid UUID format or delete error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{id}/archive: + patch: + summary: Archive project + description: Sets project archived status to true. + tags: + - Projects + parameters: + - name: id + in: path + description: Project UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Project archived successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Invalid UUID or project already archived + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{id}/unarchive: + patch: + summary: Unarchive project + description: Sets project archived status back to false. + tags: + - Projects + parameters: + - name: id + in: path + description: Project UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Project unarchived successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Invalid UUID or project already active + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/snippets: + post: + summary: Create a new snippet in a project + description: Creates a new code or command snippet linked to the specified project. + tags: + - Snippets + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSnippetCommand' + responses: + '201': + description: Snippet created successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created snippet + example: "/api/v1/projects/06f891ae-368b-4bd6-b1e6-d957f1496f9a/snippets/784f6f5c-57af-4313-b160-688a3e0d283b" + content: + application/json: + schema: + $ref: '#/components/schemas/Snippet' + '400': + description: Invalid JSON format or validation constraints failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + summary: Get paginated list of snippets for a project + description: Fetches a paginated list of snippets for the specified project ordered by creation date descending. + tags: + - Snippets + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: page + in: query + description: Zero-based page index + required: false + schema: + type: integer + default: 0 + minimum: 0 + - name: size + in: query + description: Page size limit + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 100 + responses: + '200': + description: Paginated list of snippets retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SnippetPage' + '400': + description: Invalid query parameters or UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/snippets/{snippet_id}: + get: + summary: Get snippet by ID + description: Retrieves a single snippet entity by its UUID and parent project UUID. + tags: + - Snippets + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: snippet_id + in: path + description: Snippet UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Snippet details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Snippet' + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Snippet not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + patch: + summary: Update snippet (Partial Update) + description: Performs a partial update (PATCH) on snippet fields specified in the request body. + tags: + - Snippets + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: snippet_id + in: path + description: Snippet UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSnippetCommand' + responses: + '200': + description: Snippet updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Snippet' + '400': + description: Invalid UUID format or invalid JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Snippet not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + summary: Delete snippet by ID + description: Permanently deletes a snippet by its UUID within a project. + tags: + - Snippets + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: snippet_id + in: path + description: Snippet UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Snippet deleted successfully (No Content) + '400': + description: Invalid UUID format or delete error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Snippet not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/credentials: + post: + summary: Create a new credential in a project + description: | + Creates and encrypts a new credential for the specified project. + The secret payload is encrypted at-rest using AES-256-GCM authenticated with AAD binding (project_id + credential_id). + Requires the vault to be unlocked. + tags: + - Credentials + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCredentialCommand' + responses: + '201': + description: Credential created and encrypted successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created credential + example: "/api/v1/projects/70d68fba-be62-46bb-8e6c-e4c4b6912e91/credentials/81e5768c-2598-4e37-a08d-013e7f4ca48d" + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialView' + '400': + description: Invalid JSON payload, validation constraint failure, or missing required secret fields per secretType + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Vault is locked, please unlock it first + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Master password not configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + summary: Get paginated list of credential summaries for a project + description: | + Fetches a paginated list of credential summaries for the specified project ordered by creation date descending. + For security, summaries do NOT contain decrypted secret payloads. + tags: + - Credentials + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: page + in: query + description: Zero-based page index + required: false + schema: + type: integer + default: 0 + minimum: 0 + - name: size + in: query + description: Page size limit + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 100 + responses: + '200': + description: Paginated list of credential summaries retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialSummaryPage' + '400': + description: Invalid query parameters or UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Vault is locked, please unlock it first + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Master password not configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/credentials/{credential_id}: + get: + summary: Get credential by ID (with decrypted payload) + description: | + Retrieves a single credential entity by its UUID and parent project UUID, including its decrypted secret payload. + Requires the vault to be unlocked. + tags: + - Credentials + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: credential_id + in: path + description: Credential UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Credential details and decrypted payload retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialView' + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Vault is locked, please unlock it first + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Master password not configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Credential not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + patch: + summary: Update credential (Partial Update) + description: | + Performs a partial update on a credential entity. + If title or metadata fields are passed without secret payload fields, metadata is updated and existing encrypted payload is preserved. + If new secret payload fields are passed, the secret payload is re-encrypted with AES-256-GCM. + tags: + - Credentials + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: credential_id + in: path + description: Credential UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCredentialCommand' + responses: + '200': + description: Credential updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialView' + '400': + description: Invalid JSON format or validation constraints failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Vault is locked, please unlock it first + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Master password not configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Credential not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + summary: Delete credential by ID + description: | + Permanently deletes a credential by its UUID within a project. + Automatically removes associated item tags. + tags: + - Credentials + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: credential_id + in: path + description: Credential UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Credential deleted successfully (No Content) + '400': + description: Invalid project_id or credential_id UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Vault is locked, please unlock it first + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Master password not configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Credential not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/links: + post: + summary: Create a new link in a project + description: Creates a new link entity linked to the specified project. + tags: + - Links + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateLinkCommand' + responses: + '201': + description: Link created successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created link + example: "/api/v1/projects/06f891ae-368b-4bd6-b1e6-d957f1496f9a/links/784f6f5c-57af-4313-b160-688a3e0d283b" + content: + application/json: + schema: + $ref: '#/components/schemas/Link' + '400': + description: Invalid JSON format or validation constraints failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + summary: Get paginated list of links for a project + description: Fetches a paginated list of links for the specified project ordered by creation date descending. + tags: + - Links + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: page + in: query + description: Zero-based page index + required: false + schema: + type: integer + default: 0 + minimum: 0 + - name: size + in: query + description: Page size limit + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 100 + responses: + '200': + description: Paginated list of links retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/LinkPage' + '400': + description: Invalid query parameters or UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/links/{link_id}: + get: + summary: Get link by ID + description: Retrieves a single link entity by its UUID and parent project UUID. + tags: + - Links + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: link_id + in: path + description: Link UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Link details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Link' + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Link not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + patch: + summary: Update link (Partial Update) + description: Performs a partial update (PATCH) on link fields specified in the request body. + tags: + - Links + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: link_id + in: path + description: Link UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateLinkCommand' + responses: + '200': + description: Link updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Link' + '400': + description: Invalid UUID format or invalid JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Link not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + summary: Delete link by ID + description: Permanently deletes a link by its UUID within a project. + tags: + - Links + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: link_id + in: path + description: Link UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Link deleted successfully (No Content) + '400': + description: Invalid UUID format or delete error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Link not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/problems: + post: + summary: Create a new problem in a project + description: Creates a new problem entity linked to the specified project. + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProblemCommand' + responses: + '201': + description: Problem created successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created problem + example: "/api/v1/projects/70d68fba-be62-46bb-8e6c-e4c4b6912e91/problems/24199dbd-36c4-43c6-aeb2-680155af8293" + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + '400': + description: Invalid JSON format or validation constraints failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + summary: Get paginated list of problems for a project + description: Fetches a paginated list of problem summaries for the specified project ordered by creation date descending. + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: page + in: query + description: Zero-based page index + required: false + schema: + type: integer + default: 0 + minimum: 0 + - name: size + in: query + description: Page size limit + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 100 + responses: + '200': + description: Paginated list of problem summaries retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemSummaryPage' + '400': + description: Invalid query parameters or UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/problems/{problem_id}: + get: + summary: Get problem by ID + description: Retrieves a single problem entity by its UUID and parent project UUID. + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: problem_id + in: path + description: Problem UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Problem details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Problem not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + patch: + summary: Update problem (Partial Update) + description: Performs a partial update (PATCH) on problem fields specified in the request body (title, errorDescription, solution, severity). + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: problem_id + in: path + description: Problem UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProblemCommand' + responses: + '200': + description: Problem updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + '400': + description: Invalid UUID format or invalid JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Problem not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + summary: Delete problem by ID + description: Permanently deletes a problem by its UUID within a project. + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: problem_id + in: path + description: Problem UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Problem deleted successfully (No Content) + '400': + description: Invalid UUID format or delete error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Problem not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/problems/{problem_id}/status: + patch: + summary: Update problem status + description: Updates problem status (OPEN, WORKING_ON, RESOLVED, WONT_FIX). + tags: + - Problems + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: problem_id + in: path + description: Problem UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProblemStatusCommand' + responses: + '200': + description: Problem status updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + '400': + description: Invalid UUID format or missing status field + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Problem not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/notes: + post: + summary: Create a new note in a project + description: Creates a new note linked to the specified project. + tags: + - Notes + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateNoteCommand' + responses: + '201': + description: Note created successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created note + example: "/api/v1/projects/70d68fba-be62-46bb-8e6c-e4c4b6912e91/notes/784f6f5c-57af-4313-b160-688a3e0d283b" + content: + application/json: + schema: + $ref: '#/components/schemas/Note' + '400': + description: Invalid UUID format or missing required fields + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + summary: List all notes for a project + description: Returns a paginated list of notes for a project with attached tags. + tags: + - Notes + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: page + in: query + description: Page number (0-indexed) + required: false + schema: + type: integer + default: 0 + minimum: 0 + - name: size + in: query + description: Page size + required: false + schema: + type: integer + default: 20 + minimum: 1 + maximum: 100 + responses: + '200': + description: Paginated list of notes retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/NoteSummaryPage' + '400': + description: Invalid UUID format or query parameter + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/notes/{note_id}: + get: + summary: Get note by ID + description: Returns a single note by ID with attached tags. + tags: + - Notes + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: note_id + in: path + description: Target Note UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Note retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Note' + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Note not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + patch: + summary: Update a note + description: Updates title and/or content of an existing note. + tags: + - Notes + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: note_id + in: path + description: Target Note UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateNoteCommand' + responses: + '200': + description: Note updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Note' + '400': + description: Invalid UUID format or payload validation failure + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Note not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + summary: Delete a note + description: Deletes a note by ID and removes its tag associations. + tags: + - Notes + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: note_id + in: path + description: Target Note UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Note deleted successfully (No Content) + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Note not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/tags: + post: + summary: Create a new tag in a project + description: Creates a new tag linked to the specified project. Returns 400 if tag with same name already exists in project. + tags: + - Tags + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTagCommand' + responses: + '201': + description: Tag created successfully + headers: + Location: + schema: + type: string + description: Relative URL path to the newly created tag + example: "/api/v1/projects/70d68fba-be62-46bb-8e6c-e4c4b6912e91/tags/06f891ae-368b-4bd6-b1e6-d957f1496f9a" + content: + application/json: + schema: + $ref: '#/components/schemas/Tag' + '400': + description: Invalid JSON format, validation failure, or tag already exists in project + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + summary: Get all tags for a project + description: Fetches an unpaginated list of all tags for the specified project. + tags: + - Tags + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: List of tags retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/tags/search: + get: + summary: Search tags by name + description: Searches tags in a project by matching tag name prefix or substring. + tags: + - Tags + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: tag_name + in: query + description: Tag name search term + required: false + schema: + type: string + responses: + '200': + description: Matching tags retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/tags/{tag_id}: + get: + summary: Get tag by ID + description: Retrieves a single tag entity by its UUID and parent project UUID. + tags: + - Tags + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: tag_id + in: path + description: Tag UUID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Tag details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Tag' + '400': + description: Invalid UUID format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Tag not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + patch: + summary: Update tag (Partial Update) + description: Performs a partial update (PATCH) on tag fields (name, color). Returns 400 if updated name already exists in project. + tags: + - Tags + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: tag_id + in: path + description: Tag UUID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTagCommand' + responses: + '200': + description: Tag updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Tag' + '400': + description: Invalid UUID format, invalid JSON, or tag already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Tag not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + summary: Delete tag by ID + description: Permanently deletes a tag by its UUID within a project. + tags: + - Tags + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: tag_id + in: path + description: Tag UUID + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Tag deleted successfully (No Content) + '400': + description: Invalid UUID format or delete error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project or Tag not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /projects/{project_id}/items/{item_type}/{item_id}/tags/{tag_id}: + put: + summary: Associate tag to item + description: Associates an existing tag to an item (SNIPPET, LINK, PROBLEM, NOTE) within a project. + tags: + - ItemTags + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: item_type + in: path + description: Type of item (SNIPPET, LINK, PROBLEM, NOTE) + required: true + schema: + $ref: '#/components/schemas/ItemType' + - name: item_id + in: path + description: Target Item UUID + required: true + schema: + type: string + format: uuid + - name: tag_id + in: path + description: Tag UUID to associate + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Tag associated to item successfully (No Content) + '400': + description: Invalid UUID format or unsupported item type + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project, Item, or Tag not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + summary: Disassociate tag from item + description: Removes association between a tag and an item (SNIPPET, LINK, PROBLEM, NOTE) within a project. + tags: + - ItemTags + parameters: + - name: project_id + in: path + description: Parent Project UUID + required: true + schema: + type: string + format: uuid + - name: item_type + in: path + description: Type of item (SNIPPET, LINK, PROBLEM, NOTE) + required: true + schema: + $ref: '#/components/schemas/ItemType' + - name: item_id + in: path + description: Target Item UUID + required: true + schema: + type: string + format: uuid + - name: tag_id + in: path + description: Tag UUID to disassociate + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Tag disassociated from item successfully (No Content) + '400': + description: Invalid UUID format or unsupported item type + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Project, Item, or Tag not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /security/master-password: + post: + summary: Setup initial master password + description: Configures the master password for the vault during initial setup. Returns 400 if master password is already configured. + tags: + - Security + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MasterPasswordRequest' + responses: + '204': + description: Master password configured successfully (No Content) + '400': + description: Invalid master password format or already configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /security/master-password/required-status: + get: + summary: Check if master password setup is required + description: Returns true if the vault has not been initialized with a master password yet. + tags: + - Security + responses: + '200': + description: Master password setup requirement status retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MasterPasswordSetupRequiredView' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /security/vault/status: + get: + summary: Get active vault session status + description: Returns whether the vault session is currently unlocked and the remaining inactive seconds before auto-lock. + tags: + - Security + responses: + '200': + description: Vault session status retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/VaultStatusView' + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /security/vault/unlock: + post: + summary: Unlock vault with master password + description: Verifies master password and initializes the in-memory AES key session. + tags: + - Security + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MasterPasswordRequest' + responses: + '200': + description: Vault unlocked successfully + content: + application/json: + schema: + type: boolean + example: true + '400': + description: Invalid payload or master password not configured + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Invalid master password or missing DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /security/vault/lock: + post: + summary: Lock vault + description: Immediately purges the master key from memory RAM and invalidates active session. + tags: + - Security + responses: + '204': + description: Vault locked successfully (No Content) + '401': + description: Missing or invalid DEVAULTY_INTERNAL_TOKEN header + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..a9c2344 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,49 @@ +module devaulty-backend + +go 1.25.0 + +require ( + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/mattn/go-sqlite3 v1.14.49 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.12.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..e015633 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,114 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM= +github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= +github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver v1.7.5 h1:ny3p0reEpgsR2cfA5cjgwFZg3Cv/ofFh/8jbhGtz9VI= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index b1b8ef5..0000000 Binary files a/backend/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index df6a6ad..0000000 --- a/backend/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,9 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip -networkTimeout=10000 -retries=0 -retryBackOffMs=500 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew deleted file mode 100755 index b9bb139..0000000 --- a/backend/gradlew +++ /dev/null @@ -1,248 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat deleted file mode 100644 index 24c62d5..0000000 --- a/backend/gradlew.bat +++ /dev/null @@ -1,82 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/backend/internal/adapter/in/scheduler/vault_auto_lock.go b/backend/internal/adapter/in/scheduler/vault_auto_lock.go new file mode 100644 index 0000000..964d87b --- /dev/null +++ b/backend/internal/adapter/in/scheduler/vault_auto_lock.go @@ -0,0 +1,27 @@ +package scheduler + +import ( + "devaulty-backend/internal/domain/port" + "time" +) + +var ( + SessionPurgeInterval = time.Minute * 1 +) + +type VaultAutoLock struct { + masterKeySession port.MasterKeySession +} + +func NewVaultAutoLock(masterKeySession port.MasterKeySession) *VaultAutoLock { + return &VaultAutoLock{masterKeySession: masterKeySession} +} + +func (s *VaultAutoLock) PurgeExpiredSession() { + ticker := time.NewTicker(SessionPurgeInterval) + defer ticker.Stop() + for range ticker.C { + // HasKey automatically checks expiration and purges expired key bytes from memory + _ = s.masterKeySession.HasKey() + } +} diff --git a/backend/internal/adapter/in/scheduler/vault_auto_lock_test.go b/backend/internal/adapter/in/scheduler/vault_auto_lock_test.go new file mode 100644 index 0000000..0c04adc --- /dev/null +++ b/backend/internal/adapter/in/scheduler/vault_auto_lock_test.go @@ -0,0 +1,78 @@ +package scheduler_test + +import ( + "devaulty-backend/internal/adapter/in/scheduler" + "devaulty-backend/internal/adapter/out/security" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockMasterKeySession struct { + mock.Mock +} + +func (m *MockMasterKeySession) SetKey(key []byte) { + m.Called(key) +} + +func (m *MockMasterKeySession) GetKey() []byte { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).([]byte) +} + +func (m *MockMasterKeySession) HasKey() bool { + args := m.Called() + return args.Bool(0) +} + +func (m *MockMasterKeySession) Clear() { + m.Called() +} + +func (m *MockMasterKeySession) Touch() { + m.Called() +} + +func (m *MockMasterKeySession) GetSecondsRemaining(timeout time.Duration) int64 { + args := m.Called(timeout) + return int64(args.Int(0)) +} + +func TestVaultAutoLock_PurgeExpiredSession(t *testing.T) { + origInterval := scheduler.SessionPurgeInterval + scheduler.SessionPurgeInterval = 10 * time.Millisecond + defer func() { scheduler.SessionPurgeInterval = origInterval }() + + t.Run("PurgeExpiredSession invokes HasKey on ticker tick", func(t *testing.T) { + mockSession := new(MockMasterKeySession) + mockSession.On("HasKey").Return(false) + + autoLock := scheduler.NewVaultAutoLock(mockSession) + + go autoLock.PurgeExpiredSession() + + // Allow ticker loop to execute + time.Sleep(50 * time.Millisecond) + + mockSession.AssertCalled(t, "HasKey") + }) + + t.Run("PurgeExpiredSession with real MasterKeySessionHolderAdapter", func(t *testing.T) { + sessionHolder := security.NewMasterKeySessionHolder() + autoLock := scheduler.NewVaultAutoLock(sessionHolder) + + assert.False(t, sessionHolder.HasKey()) + + go autoLock.PurgeExpiredSession() + + time.Sleep(50 * time.Millisecond) + + assert.False(t, sessionHolder.HasKey()) + }) +} diff --git a/backend/internal/adapter/in/web/common/pagination_query.go b/backend/internal/adapter/in/web/common/pagination_query.go new file mode 100644 index 0000000..5ee0ab2 --- /dev/null +++ b/backend/internal/adapter/in/web/common/pagination_query.go @@ -0,0 +1,6 @@ +package common + +type PaginationQuery struct { + PageNumber int `form:"page,default=0" binding:"gte=0"` + PageSize int `form:"size,default=10" binding:"gte=1,lte=100"` +} diff --git a/backend/internal/adapter/in/web/common/params.go b/backend/internal/adapter/in/web/common/params.go new file mode 100644 index 0000000..78b52cb --- /dev/null +++ b/backend/internal/adapter/in/web/common/params.go @@ -0,0 +1,22 @@ +package common + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func ExtractUUIDParam(c *gin.Context, paramName string) (uuid.UUID, error) { + paramVal := c.Param(paramName) + if paramVal == "" { + return uuid.Nil, fmt.Errorf("missing %s param", paramName) + } + + id, err := uuid.Parse(paramVal) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid %s format", paramName) + } + + return id, nil +} diff --git a/backend/internal/adapter/in/web/handler/credential_handler.go b/backend/internal/adapter/in/web/handler/credential_handler.go new file mode 100644 index 0000000..cb97a27 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/credential_handler.go @@ -0,0 +1,219 @@ +package handler + +import ( + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + "errors" + "fmt" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +type CredentialHandler struct { + credentialUseCase *usecase.CredentialUseCase +} + +func NewCredentialHandler(credentialUseCase *usecase.CredentialUseCase) *CredentialHandler { + return &CredentialHandler{credentialUseCase: credentialUseCase} +} + +func (h *CredentialHandler) Create(c *gin.Context) { + var cmd dto.CreateCredentialCommand + err := c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + + credential, err := h.credentialUseCase.Create(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrVaultLocked) { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrMasterPasswordNotConfigured) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrInvalidSecretPayload) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[CredentialHandler.Create] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, credential.ID) + c.Header("Location", location) + c.JSON(http.StatusCreated, credential) +} + +func (h *CredentialHandler) GetAll(c *gin.Context) { + var query common.PaginationQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + paginatedCredentials, err := h.credentialUseCase.GetAllByProjectID(c.Request.Context(), projectID, query.PageNumber, query.PageSize) + if err != nil { + if errors.Is(err, usecase.ErrVaultLocked) { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrMasterPasswordNotConfigured) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[CredentialHandler.GetAll] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, paginatedCredentials) +} + +func (h *CredentialHandler) Get(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + credentialID, err := common.ExtractUUIDParam(c, "credential_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + credential, err := h.credentialUseCase.GetById(c.Request.Context(), projectID, credentialID) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrCredentialNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrVaultLocked) { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrMasterPasswordNotConfigured) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + log.Printf("[CredentialHandler.Get] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, credential) +} + +func (h *CredentialHandler) Update(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + credentialID, err := common.ExtractUUIDParam(c, "credential_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var cmd dto.UpdateCredentialCommand + if err := c.ShouldBindJSON(&cmd); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + cmd.ID = credentialID + + credential, err := h.credentialUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrCredentialNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrVaultLocked) { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrMasterPasswordNotConfigured) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrInvalidSecretPayload) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[CredentialHandler.Update] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, credential) +} + +func (h *CredentialHandler) Delete(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "credential_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.credentialUseCase.Delete(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrCredentialNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrVaultLocked) { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrMasterPasswordNotConfigured) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + log.Printf("[CredentialHandler.Delete] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/adapter/in/web/handler/credential_handler_test.go b/backend/internal/adapter/in/web/handler/credential_handler_test.go new file mode 100644 index 0000000..c1e8ab3 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/credential_handler_test.go @@ -0,0 +1,308 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupUnlockedVaultWithProject(t *testing.T, app *TestApp) string { + // Setup master password & unlock vault + setupBody := []byte(`{"masterPassword":"mySuperSecretMasterPassword123"}`) + respSetup := app.DoRequest(t, http.MethodPost, "/api/v1/security/master-password", setupBody, true) + require.Equal(t, http.StatusNoContent, respSetup.StatusCode) + + unlockBody := []byte(`{"masterPassword":"mySuperSecretMasterPassword123"}`) + respUnlock := app.DoRequest(t, http.MethodPost, "/api/v1/security/vault/unlock", unlockBody, true) + require.Equal(t, http.StatusOK, respUnlock.StatusCode) + + // Create project + projBody := []byte(`{"name":"Integration Credential Project"}`) + respProj := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projBody, true) + require.Equal(t, http.StatusCreated, respProj.StatusCode) + + var proj map[string]interface{} + err := json.NewDecoder(respProj.Body).Decode(&proj) + require.NoError(t, err) + + return proj["id"].(string) +} + +func TestCredentialHandler_Create(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectID := setupUnlockedVaultWithProject(t, app) + + t.Run("Create LOGIN credential success", func(t *testing.T) { + body := []byte(`{ + "title": "My Login DB", + "secretType": "LOGIN", + "username": "dbuser", + "password": "dbpassword", + "notes": "some notes", + "relatedUrl": "http://db-server" + }`) + url := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + resp := app.DoRequest(t, http.MethodPost, url, body, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Location")) + + var view map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&view) + require.NoError(t, err) + + assert.Equal(t, projectID, view["projectId"]) + assert.Equal(t, "My Login DB", view["title"]) + assert.Equal(t, "LOGIN", view["secretType"]) + payload := view["decryptedPayload"].(map[string]interface{}) + assert.Equal(t, "dbuser", payload["username"]) + assert.Equal(t, "dbpassword", payload["password"]) + }) + + t.Run("Create API_KEY credential success", func(t *testing.T) { + body := []byte(`{ + "title": "Stripe API Key", + "secretType": "API_KEY", + "apiKey": "sk_test_123456789" + }`) + url := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + resp := app.DoRequest(t, http.MethodPost, url, body, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var view map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&view) + require.NoError(t, err) + + assert.Equal(t, "API_KEY", view["secretType"]) + payload := view["decryptedPayload"].(map[string]interface{}) + assert.Equal(t, "sk_test_123456789", payload["apiKey"]) + }) + + t.Run("Create RAW_TEXT credential success", func(t *testing.T) { + body := []byte(`{ + "title": "Private Certificate", + "secretType": "RAW_TEXT", + "rawTextContent": "certificate content" + }`) + url := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + resp := app.DoRequest(t, http.MethodPost, url, body, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var view map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&view) + require.NoError(t, err) + + assert.Equal(t, "RAW_TEXT", view["secretType"]) + payload := view["decryptedPayload"].(map[string]interface{}) + assert.Equal(t, "certificate content", payload["rawText"]) + }) + + t.Run("Create failure - missing password for LOGIN", func(t *testing.T) { + body := []byte(`{ + "title": "Invalid Login", + "secretType": "LOGIN", + "username": "admin" + }`) + url := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + resp := app.DoRequest(t, http.MethodPost, url, body, true) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - title too short", func(t *testing.T) { + body := []byte(`{ + "title": "A", + "secretType": "API_KEY", + "apiKey": "key" + }`) + url := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + resp := app.DoRequest(t, http.MethodPost, url, body, true) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - project not found", func(t *testing.T) { + randomProjID := uuid.New().String() + body := []byte(`{ + "title": "Valid Title", + "secretType": "API_KEY", + "apiKey": "key" + }`) + url := fmt.Sprintf("/api/v1/projects/%s/credentials", randomProjID) + resp := app.DoRequest(t, http.MethodPost, url, body, true) + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +func TestCredentialHandler_GetAll(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectID := setupUnlockedVaultWithProject(t, app) + + // Create 1 credential + body1 := []byte(`{"title":"Cred 1","secretType":"API_KEY","apiKey":"key1"}`) + url := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + resp1 := app.DoRequest(t, http.MethodPost, url, body1, true) + require.Equal(t, http.StatusCreated, resp1.StatusCode) + + t.Run("GetAll credentials success", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, url+"?page=0&size=10", nil, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 1) + + summary := content[0].(map[string]interface{}) + assert.Equal(t, "Cred 1", summary["title"]) + assert.Nil(t, summary["decryptedPayload"]) // Summary must NOT leak payload + }) + + t.Run("GetAll failure - project not found", func(t *testing.T) { + randomProjID := uuid.New().String() + url := fmt.Sprintf("/api/v1/projects/%s/credentials?page=0&size=10", randomProjID) + resp := app.DoRequest(t, http.MethodGet, url, nil, true) + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +func TestCredentialHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectID := setupUnlockedVaultWithProject(t, app) + + body := []byte(`{"title":"Get Credential","secretType":"LOGIN","username":"user","password":"pass"}`) + urlPost := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + respPost := app.DoRequest(t, http.MethodPost, urlPost, body, true) + require.Equal(t, http.StatusCreated, respPost.StatusCode) + + var createdView map[string]interface{} + _ = json.NewDecoder(respPost.Body).Decode(&createdView) + credID := createdView["id"].(string) + + t.Run("Get credential by ID success", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/credentials/%s", projectID, credID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var view map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&view) + require.NoError(t, err) + + assert.Equal(t, credID, view["id"]) + payload := view["decryptedPayload"].(map[string]interface{}) + assert.Equal(t, "user", payload["username"]) + }) + + t.Run("Get credential by ID failure - credential not found", func(t *testing.T) { + randomID := uuid.New().String() + urlGet := fmt.Sprintf("/api/v1/projects/%s/credentials/%s", projectID, randomID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +func TestCredentialHandler_Update(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectID := setupUnlockedVaultWithProject(t, app) + + body := []byte(`{"title":"Original Title","secretType":"API_KEY","apiKey":"orig_key"}`) + urlPost := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + respPost := app.DoRequest(t, http.MethodPost, urlPost, body, true) + require.Equal(t, http.StatusCreated, respPost.StatusCode) + + var createdView map[string]interface{} + _ = json.NewDecoder(respPost.Body).Decode(&createdView) + credID := createdView["id"].(string) + + t.Run("Update credential title only success", func(t *testing.T) { + updateBody := []byte(`{"title":"Updated Title"}`) + urlPatch := fmt.Sprintf("/api/v1/projects/%s/credentials/%s", projectID, credID) + resp := app.DoRequest(t, http.MethodPatch, urlPatch, updateBody, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var view map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&view) + require.NoError(t, err) + + assert.Equal(t, "Updated Title", view["title"]) + payload := view["decryptedPayload"].(map[string]interface{}) + assert.Equal(t, "orig_key", payload["apiKey"]) + }) +} + +func TestCredentialHandler_Delete(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectID := setupUnlockedVaultWithProject(t, app) + + body := []byte(`{"title":"Cred To Delete","secretType":"API_KEY","apiKey":"key"}`) + urlPost := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + respPost := app.DoRequest(t, http.MethodPost, urlPost, body, true) + require.Equal(t, http.StatusCreated, respPost.StatusCode) + + var createdView map[string]interface{} + _ = json.NewDecoder(respPost.Body).Decode(&createdView) + credID := createdView["id"].(string) + + t.Run("Delete credential success", func(t *testing.T) { + urlDel := fmt.Sprintf("/api/v1/projects/%s/credentials/%s", projectID, credID) + resp := app.DoRequest(t, http.MethodDelete, urlDel, nil, true) + + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Verify it's gone + respGet := app.DoRequest(t, http.MethodGet, urlDel, nil, true) + assert.Equal(t, http.StatusNotFound, respGet.StatusCode) + }) + + t.Run("Delete failure - credential not found", func(t *testing.T) { + randomID := uuid.New().String() + urlDel := fmt.Sprintf("/api/v1/projects/%s/credentials/%s", projectID, randomID) + resp := app.DoRequest(t, http.MethodDelete, urlDel, nil, true) + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +func TestCredentialHandler_VaultLockedSecurity(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectID := setupUnlockedVaultWithProject(t, app) + + // Lock the vault + respLock := app.DoRequest(t, http.MethodPost, "/api/v1/security/vault/lock", nil, true) + require.Equal(t, http.StatusNoContent, respLock.StatusCode) + + t.Run("Create while vault locked returns 401 Unauthorized", func(t *testing.T) { + body := []byte(`{"title":"Locked","secretType":"API_KEY","apiKey":"key"}`) + url := fmt.Sprintf("/api/v1/projects/%s/credentials", projectID) + resp := app.DoRequest(t, http.MethodPost, url, body, true) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} diff --git a/backend/internal/adapter/in/web/handler/item_tag_handler.go b/backend/internal/adapter/in/web/handler/item_tag_handler.go new file mode 100644 index 0000000..69e73d2 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/item_tag_handler.go @@ -0,0 +1,94 @@ +package handler + +import ( + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/usecase" + "errors" + "log" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +type ItemTagHandler struct { + itemTagUseCase *usecase.ItemTagUseCase + tagUseCase *usecase.TagUseCase + projectUseCase *usecase.ProjectUseCase +} + +func NewItemTagHandler(itemTagUseCase *usecase.ItemTagUseCase, tagUseCase *usecase.TagUseCase, projectUseCase *usecase.ProjectUseCase) *ItemTagHandler { + return &ItemTagHandler{itemTagUseCase: itemTagUseCase, tagUseCase: tagUseCase, projectUseCase: projectUseCase} +} + +func (h *ItemTagHandler) Associate(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + itemID, err := common.ExtractUUIDParam(c, "item_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tagID, err := common.ExtractUUIDParam(c, "tag_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + itemType := model.ItemType(strings.ToUpper(c.Param("item_type"))) + + err = h.itemTagUseCase.AssociateTagToItem(c.Request.Context(), projectID, itemType, itemID, tagID) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrTagNotFound) || errors.Is(err, usecase.ErrItemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrUnsupportedItemType) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[ItemTagHandler.Associate] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.Status(http.StatusNoContent) +} + +func (h *ItemTagHandler) Disassociate(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + itemID, err := common.ExtractUUIDParam(c, "item_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tagID, err := common.ExtractUUIDParam(c, "tag_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + itemType := model.ItemType(strings.ToUpper(c.Param("item_type"))) + + err = h.itemTagUseCase.DisassociateTagFromItem(c.Request.Context(), projectID, itemType, itemID, tagID) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrTagNotFound) || errors.Is(err, usecase.ErrItemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrUnsupportedItemType) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[ItemTagHandler.Disassociate] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/adapter/in/web/handler/item_tag_handler_test.go b/backend/internal/adapter/in/web/handler/item_tag_handler_test.go new file mode 100644 index 0000000..7a7fff3 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/item_tag_handler_test.go @@ -0,0 +1,161 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestItemTagHandler_Associate(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed Project + respProj := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Parent Project"}`), true) + var proj map[string]interface{} + _ = json.NewDecoder(respProj.Body).Decode(&proj) + projectID := proj["id"].(string) + + // Seed Snippet + urlSnippet := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + respSnippet := app.DoRequest(t, http.MethodPost, urlSnippet, []byte(`{"title":"Sample Snippet","content":"code","language":"GO","snippetType":"CODE"}`), true) + var snippet map[string]interface{} + _ = json.NewDecoder(respSnippet.Body).Decode(&snippet) + snippetID := snippet["id"].(string) + + // Seed Tag + urlTag := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + respTag := app.DoRequest(t, http.MethodPost, urlTag, []byte(`{"name":"Backend","color":"#FF0000"}`), true) + var tag map[string]interface{} + _ = json.NewDecoder(respTag.Body).Decode(&tag) + tagID := tag["id"].(string) + + t.Run("Associate success", func(t *testing.T) { + urlAssoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/%s/tags/%s", projectID, snippetID, tagID) + resp := app.DoRequest(t, http.MethodPut, urlAssoc, nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + }) + + t.Run("Associate failure - project not found", func(t *testing.T) { + urlAssoc := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/items/snippet/%s/tags/%s", snippetID, tagID) + resp := app.DoRequest(t, http.MethodPut, urlAssoc, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Associate failure - tag not found", func(t *testing.T) { + urlAssoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/%s/tags/00000000-0000-0000-0000-000000000000", projectID, snippetID) + resp := app.DoRequest(t, http.MethodPut, urlAssoc, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Associate failure - item not found", func(t *testing.T) { + urlAssoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/00000000-0000-0000-0000-000000000000/tags/%s", projectID, tagID) + resp := app.DoRequest(t, http.MethodPut, urlAssoc, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Associate failure - unsupported item type", func(t *testing.T) { + urlAssoc := fmt.Sprintf("/api/v1/projects/%s/items/invalid_type/%s/tags/%s", projectID, snippetID, tagID) + resp := app.DoRequest(t, http.MethodPut, urlAssoc, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Associate failure - invalid project UUID", func(t *testing.T) { + urlAssoc := fmt.Sprintf("/api/v1/projects/invalid-id/items/snippet/%s/tags/%s", snippetID, tagID) + resp := app.DoRequest(t, http.MethodPut, urlAssoc, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Associate failure - invalid item UUID", func(t *testing.T) { + urlAssoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/invalid-id/tags/%s", projectID, tagID) + resp := app.DoRequest(t, http.MethodPut, urlAssoc, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Associate failure - invalid tag UUID", func(t *testing.T) { + urlAssoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/%s/tags/invalid-id", projectID, snippetID) + resp := app.DoRequest(t, http.MethodPut, urlAssoc, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestItemTagHandler_Disassociate(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed Project + respProj := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Parent Project"}`), true) + var proj map[string]interface{} + _ = json.NewDecoder(respProj.Body).Decode(&proj) + projectID := proj["id"].(string) + + // Seed Snippet + urlSnippet := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + respSnippet := app.DoRequest(t, http.MethodPost, urlSnippet, []byte(`{"title":"Sample Snippet","content":"code","language":"GO","snippetType":"CODE"}`), true) + var snippet map[string]interface{} + _ = json.NewDecoder(respSnippet.Body).Decode(&snippet) + snippetID := snippet["id"].(string) + + // Seed Tag + urlTag := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + respTag := app.DoRequest(t, http.MethodPost, urlTag, []byte(`{"name":"Backend","color":"#FF0000"}`), true) + var tag map[string]interface{} + _ = json.NewDecoder(respTag.Body).Decode(&tag) + tagID := tag["id"].(string) + + // Associate first + urlAssoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/%s/tags/%s", projectID, snippetID, tagID) + respAssoc := app.DoRequest(t, http.MethodPut, urlAssoc, nil, true) + require.Equal(t, http.StatusNoContent, respAssoc.StatusCode) + + t.Run("Disassociate success", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodDelete, urlAssoc, nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + }) + + t.Run("Disassociate failure - project not found", func(t *testing.T) { + urlDisassoc := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/items/snippet/%s/tags/%s", snippetID, tagID) + resp := app.DoRequest(t, http.MethodDelete, urlDisassoc, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Disassociate failure - tag not found", func(t *testing.T) { + urlDisassoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/%s/tags/00000000-0000-0000-0000-000000000000", projectID, snippetID) + resp := app.DoRequest(t, http.MethodDelete, urlDisassoc, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Disassociate failure - item not found", func(t *testing.T) { + urlDisassoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/00000000-0000-0000-0000-000000000000/tags/%s", projectID, tagID) + resp := app.DoRequest(t, http.MethodDelete, urlDisassoc, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Disassociate failure - unsupported item type", func(t *testing.T) { + urlDisassoc := fmt.Sprintf("/api/v1/projects/%s/items/invalid_type/%s/tags/%s", projectID, snippetID, tagID) + resp := app.DoRequest(t, http.MethodDelete, urlDisassoc, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Disassociate failure - invalid project UUID", func(t *testing.T) { + urlDisassoc := fmt.Sprintf("/api/v1/projects/invalid-id/items/snippet/%s/tags/%s", snippetID, tagID) + resp := app.DoRequest(t, http.MethodDelete, urlDisassoc, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Disassociate failure - invalid item UUID", func(t *testing.T) { + urlDisassoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/invalid-id/tags/%s", projectID, tagID) + resp := app.DoRequest(t, http.MethodDelete, urlDisassoc, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Disassociate failure - invalid tag UUID", func(t *testing.T) { + urlDisassoc := fmt.Sprintf("/api/v1/projects/%s/items/snippet/%s/tags/invalid-id", projectID, snippetID) + resp := app.DoRequest(t, http.MethodDelete, urlDisassoc, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/backend/internal/adapter/in/web/handler/link_handler.go b/backend/internal/adapter/in/web/handler/link_handler.go new file mode 100644 index 0000000..5a60e00 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/link_handler.go @@ -0,0 +1,166 @@ +package handler + +import ( + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + "errors" + "fmt" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +type LinkHandler struct { + linkUseCase *usecase.LinkUseCase +} + +func NewLinkHandler(linkUseCase *usecase.LinkUseCase) *LinkHandler { + return &LinkHandler{linkUseCase: linkUseCase} +} + +func (h *LinkHandler) Create(c *gin.Context) { + var cmd dto.CreateLinkCommand + err := c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + + link, err := h.linkUseCase.Create(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + log.Printf("[LinkHandler.Create] %v", err) + return + } + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, link.ID) + c.Header("Location", location) + c.JSON(http.StatusCreated, link) +} + +func (h *LinkHandler) GetAll(c *gin.Context) { + var query common.PaginationQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + pagedLinks, err := h.linkUseCase.GetAllByProjectID(c.Request.Context(), projectID, query.PageNumber, query.PageSize) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + log.Printf("[LinkHandler.GetAll] %v", err) + return + } + c.JSON(http.StatusOK, pagedLinks) +} + +func (h *LinkHandler) Get(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + linkID, err := common.ExtractUUIDParam(c, "link_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + link, err := h.linkUseCase.GetByID(c.Request.Context(), projectID, linkID) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + log.Printf("[LinkHandler.Get] %v", err) + return + } + c.JSON(http.StatusOK, link) +} + +func (h *LinkHandler) Update(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + linkID, err := common.ExtractUUIDParam(c, "link_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd dto.UpdateLinkCommand + err = c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + cmd.ID = linkID + + link, err := h.linkUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + log.Printf("[LinkHandler.Update] %v", err) + return + } + c.JSON(http.StatusOK, link) +} + +func (h *LinkHandler) Delete(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := common.ExtractUUIDParam(c, "link_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.linkUseCase.Delete(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[LinkHandler.Delete] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/adapter/in/web/handler/link_handler_test.go b/backend/internal/adapter/in/web/handler/link_handler_test.go new file mode 100644 index 0000000..bb1408a --- /dev/null +++ b/backend/internal/adapter/in/web/handler/link_handler_test.go @@ -0,0 +1,381 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLinkHandler_Create(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project first + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + require.Equal(t, http.StatusCreated, respProject.StatusCode) + + var createdProject map[string]interface{} + err := json.NewDecoder(respProject.Body).Decode(&createdProject) + require.NoError(t, err) + projectID := createdProject["id"].(string) + + t.Run("Create success", func(t *testing.T) { + linkBody := []byte(`{ + "title": "Go Documentation", + "url": "https://go.dev/doc", + "description": "Official Go Documentation" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Location")) + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "Go Documentation", result["title"]) + assert.Equal(t, "https://go.dev/doc", result["url"]) + assert.Equal(t, "Official Go Documentation", result["description"]) + assert.Equal(t, projectID, result["projectId"]) + assert.NotEmpty(t, result["id"]) + }) + + t.Run("Create failure - project not found", func(t *testing.T) { + linkBody := []byte(`{ + "title": "Go Documentation", + "url": "https://go.dev/doc" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000000/links", linkBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Create failure - invalid project UUID format", func(t *testing.T) { + linkBody := []byte(`{ + "title": "Go Documentation", + "url": "https://go.dev/doc" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/invalid-project-id/links", linkBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - missing required fields", func(t *testing.T) { + linkBody := []byte(`{"description": "No title or url"}`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - title too short", func(t *testing.T) { + linkBody := []byte(`{ + "title": "A", + "url": "https://go.dev/doc" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - invalid URL format", func(t *testing.T) { + linkBody := []byte(`{ + "title": "Invalid Link", + "url": "not-a-valid-url" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - unauthorized", func(t *testing.T) { + linkBody := []byte(`{ + "title": "Go Documentation", + "url": "https://go.dev/doc" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, linkBody, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestLinkHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project and a link + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + linkBody := []byte(`{ + "title": "Seeded Link", + "url": "https://example.com" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + respLink := app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + var createdLink map[string]interface{} + _ = json.NewDecoder(respLink.Body).Decode(&createdLink) + linkID := createdLink["id"].(string) + + t.Run("Get success", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/links/%s", projectID, linkID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, linkID, result["id"]) + assert.Equal(t, "Seeded Link", result["title"]) + assert.Equal(t, "https://example.com", result["url"]) + }) + + t.Run("Get failure - link not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/links/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - project not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/links/%s", linkID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - invalid project UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/invalid-project-id/links/%s", linkID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Get failure - invalid link UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/links/invalid-link-id", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestLinkHandler_GetAll(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Create Project A + respProjA := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project A"}`), true) + var projA map[string]interface{} + _ = json.NewDecoder(respProjA.Body).Decode(&projA) + projectAID := projA["id"].(string) + + // Create Project B (for isolation checks) + respProjB := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project B"}`), true) + var projB map[string]interface{} + _ = json.NewDecoder(respProjB.Body).Decode(&projB) + projectBID := projB["id"].(string) + + // Seed 12 links in Project A + for i := 1; i <= 12; i++ { + linkBody := []byte(fmt.Sprintf(`{ + "title": "Project A Link %d", + "url": "https://example.com/a/%d" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectAID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + } + + // Seed 3 links in Project B + for i := 1; i <= 3; i++ { + linkBody := []byte(fmt.Sprintf(`{ + "title": "Project B Link %d", + "url": "https://example.com/b/%d" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectBID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + } + + t.Run("GetAll success - default pagination", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/links", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 10) // default page size is 10 + assert.Equal(t, float64(0), page["number"]) + assert.Equal(t, float64(10), page["size"]) + assert.Equal(t, float64(12), page["totalElements"]) + assert.Equal(t, float64(2), page["totalPages"]) + + // Ensure project isolation: all items belong to projectAID + for _, item := range content { + linkMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, linkMap["projectId"]) + } + }) + + t.Run("GetAll success - custom page and size", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/links?page=1&size=5", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 5) // page 1 with size 5 + assert.Equal(t, float64(1), page["number"]) + assert.Equal(t, float64(5), page["size"]) + assert.Equal(t, float64(12), page["totalElements"]) + assert.Equal(t, float64(3), page["totalPages"]) + + for _, item := range content { + linkMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, linkMap["projectId"]) + } + }) + + t.Run("GetAll failure - project not found", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/00000000-0000-0000-0000-000000000000/links", nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("GetAll failure - invalid project UUID", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/invalid-id/links", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetAll failure - size greater than 100", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/links?page=0&size=101", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestLinkHandler_Update(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + linkBody := []byte(`{ + "title": "Original Title", + "url": "https://original.com" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + respLink := app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + var createdLink map[string]interface{} + _ = json.NewDecoder(respLink.Body).Decode(&createdLink) + linkID := createdLink["id"].(string) + + t.Run("Update success", func(t *testing.T) { + updateBody := []byte(`{ + "title": "Updated Title", + "url": "https://updated.com" + }`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/links/%s", projectID, linkID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "Updated Title", result["title"]) + assert.Equal(t, "https://updated.com", result["url"]) + }) + + t.Run("Update failure - link not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/links/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - project not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/links/%s", linkID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - invalid link UUID format", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/links/invalid-link-id", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Update failure - invalid project UUID format", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/invalid-project-id/links/%s", linkID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Update failure - invalid URL format", func(t *testing.T) { + updateBody := []byte(`{"url": "not-a-valid-url"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/links/%s", projectID, linkID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestLinkHandler_Delete(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + linkBody := []byte(`{ + "title": "Link To Delete", + "url": "https://delete-me.com" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/links", projectID) + respLink := app.DoRequest(t, http.MethodPost, urlCreate, linkBody, true) + var createdLink map[string]interface{} + _ = json.NewDecoder(respLink.Body).Decode(&createdLink) + linkID := createdLink["id"].(string) + + t.Run("Delete success", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/links/%s", projectID, linkID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Assert persistence: issuing a GET request should now return 404 Not Found + respGet := app.DoRequest(t, http.MethodGet, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, respGet.StatusCode) + }) + + t.Run("Delete failure - link not found", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/links/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Delete failure - invalid link UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/links/invalid-link-id", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Delete failure - invalid project UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/invalid-project-id/links/%s", linkID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/backend/internal/adapter/in/web/handler/note_handler.go b/backend/internal/adapter/in/web/handler/note_handler.go new file mode 100644 index 0000000..280536b --- /dev/null +++ b/backend/internal/adapter/in/web/handler/note_handler.go @@ -0,0 +1,171 @@ +package handler + +import ( + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + "errors" + "fmt" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +type NoteHandler struct { + noteUseCase *usecase.NoteUseCase +} + +func NewNoteHandler(noteUseCase *usecase.NoteUseCase) *NoteHandler { + return &NoteHandler{noteUseCase: noteUseCase} +} + +func (h *NoteHandler) Create(c *gin.Context) { + var cmd dto.CreateNoteCommand + err := c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + + note, err := h.noteUseCase.Create(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[NoteHandler.Create] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, note.ID) + c.Header("Location", location) + c.JSON(http.StatusCreated, note) +} + +func (h *NoteHandler) GetAll(c *gin.Context) { + var query common.PaginationQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + paginatedNotes, err := h.noteUseCase.GetAllByProjectID(c.Request.Context(), projectID, query.PageNumber, query.PageSize) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[NoteHandler.GetAll] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, paginatedNotes) +} + +func (h *NoteHandler) Get(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := common.ExtractUUIDParam(c, "note_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + note, err := h.noteUseCase.GetByID(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrNoteNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[NoteHandler.Get] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if note == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"}) + return + } + + c.JSON(http.StatusOK, note) +} + +func (h *NoteHandler) Update(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := common.ExtractUUIDParam(c, "note_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd dto.UpdateNoteCommand + if err := c.ShouldBindJSON(&cmd); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + cmd.ID = id + + note, err := h.noteUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrNoteNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[NoteHandler.Update] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, note) +} + +func (h *NoteHandler) Delete(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := common.ExtractUUIDParam(c, "note_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.noteUseCase.Delete(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrNoteNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[NoteHandler.Delete] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/adapter/in/web/handler/note_handler_test.go b/backend/internal/adapter/in/web/handler/note_handler_test.go new file mode 100644 index 0000000..95660fc --- /dev/null +++ b/backend/internal/adapter/in/web/handler/note_handler_test.go @@ -0,0 +1,316 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNoteHandler_Create(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed project + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + require.Equal(t, http.StatusCreated, respProject.StatusCode) + + var createdProject map[string]interface{} + err := json.NewDecoder(respProject.Body).Decode(&createdProject) + require.NoError(t, err) + projectID := createdProject["id"].(string) + + t.Run("Create success", func(t *testing.T) { + noteBody := []byte(`{ + "title": "Meeting Notes", + "content": "Discussed system architecture and hexagonal pattern." + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/notes", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, noteBody, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Location")) + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "Meeting Notes", result["title"]) + assert.Equal(t, "Discussed system architecture and hexagonal pattern.", result["content"]) + assert.Equal(t, projectID, result["projectId"]) + assert.NotEmpty(t, result["id"]) + assert.Equal(t, false, result["archived"]) + }) + + t.Run("Create failure - project not found", func(t *testing.T) { + noteBody := []byte(`{ + "title": "Orphan Note", + "content": "Content for non-existing project" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000000/notes", noteBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Create failure - invalid project UUID format", func(t *testing.T) { + noteBody := []byte(`{ + "title": "Invalid UUID Note", + "content": "Content" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/invalid-uuid/notes", noteBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - title too short", func(t *testing.T) { + noteBody := []byte(`{ + "title": "A", + "content": "Content" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/notes", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, noteBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - missing content", func(t *testing.T) { + noteBody := []byte(`{ + "title": "Note Without Content" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/notes", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, noteBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - unauthorized", func(t *testing.T) { + noteBody := []byte(`{ + "title": "Unauthorized Note", + "content": "Content" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/notes", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, noteBody, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestNoteHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed project and note + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + noteBody := []byte(`{ + "title": "Seeded Note", + "content": "Seeded Content" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/notes", projectID) + respNote := app.DoRequest(t, http.MethodPost, urlCreate, noteBody, true) + var createdNote map[string]interface{} + _ = json.NewDecoder(respNote.Body).Decode(&createdNote) + noteID := createdNote["id"].(string) + + t.Run("Get success", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/notes/%s", projectID, noteID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, noteID, result["id"]) + assert.Equal(t, "Seeded Note", result["title"]) + assert.Equal(t, "Seeded Content", result["content"]) + }) + + t.Run("Get failure - note not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/notes/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - project not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/notes/%s", noteID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - invalid project UUID", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/invalid-uuid/notes/%s", noteID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Get failure - invalid note UUID", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/notes/invalid-uuid", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestNoteHandler_GetAll(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + respProjA := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project A"}`), true) + var projA map[string]interface{} + _ = json.NewDecoder(respProjA.Body).Decode(&projA) + projectAID := projA["id"].(string) + + respProjB := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project B"}`), true) + var projB map[string]interface{} + _ = json.NewDecoder(respProjB.Body).Decode(&projB) + projectBID := projB["id"].(string) + + // Seed 2 notes in Project A + for i := 1; i <= 2; i++ { + body := []byte(fmt.Sprintf(`{"title":"Note A%d","content":"Content A%d"}`, i, i)) + urlPath := fmt.Sprintf("/api/v1/projects/%s/notes", projectAID) + _ = app.DoRequest(t, http.MethodPost, urlPath, body, true) + } + + t.Run("GetAll success - returns paginated content", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/notes?page=0&size=10", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 2) + }) + + t.Run("GetAll success - empty page for project with no notes", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/notes?page=0&size=10", projectBID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 0) + }) + + t.Run("GetAll failure - project not found", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/00000000-0000-0000-0000-000000000000/notes", nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("GetAll failure - invalid project UUID", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/invalid-uuid/notes", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestNoteHandler_Update(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + noteBody := []byte(`{"title":"Original Title","content":"Original Content"}`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/notes", projectID) + respNote := app.DoRequest(t, http.MethodPost, urlCreate, noteBody, true) + var createdNote map[string]interface{} + _ = json.NewDecoder(respNote.Body).Decode(&createdNote) + noteID := createdNote["id"].(string) + + t.Run("Update success", func(t *testing.T) { + updateBody := []byte(`{"title":"Updated Title","content":"Updated Content"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/notes/%s", projectID, noteID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "Updated Title", result["title"]) + assert.Equal(t, "Updated Content", result["content"]) + }) + + t.Run("Update failure - note not found", func(t *testing.T) { + updateBody := []byte(`{"title":"Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/notes/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - project not found", func(t *testing.T) { + updateBody := []byte(`{"title":"Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/notes/%s", noteID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - title too short", func(t *testing.T) { + updateBody := []byte(`{"title":"A"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/notes/%s", projectID, noteID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Update failure - invalid note UUID", func(t *testing.T) { + updateBody := []byte(`{"title":"Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/notes/invalid-uuid", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestNoteHandler_Delete(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + noteBody := []byte(`{"title":"Note To Delete","content":"Content"}`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/notes", projectID) + respNote := app.DoRequest(t, http.MethodPost, urlCreate, noteBody, true) + var createdNote map[string]interface{} + _ = json.NewDecoder(respNote.Body).Decode(&createdNote) + noteID := createdNote["id"].(string) + + t.Run("Delete success", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/notes/%s", projectID, noteID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Assert persistence: GET request should return 404 + respGet := app.DoRequest(t, http.MethodGet, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, respGet.StatusCode) + }) + + t.Run("Delete failure - note not found", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/notes/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Delete failure - project not found", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/notes/%s", noteID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Delete failure - invalid note UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/notes/invalid-uuid", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/backend/internal/adapter/in/web/handler/problem_handler.go b/backend/internal/adapter/in/web/handler/problem_handler.go new file mode 100644 index 0000000..4cc6b31 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/problem_handler.go @@ -0,0 +1,205 @@ +package handler + +import ( + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + "errors" + "fmt" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +type ProblemHandler struct { + problemUseCase *usecase.ProblemUseCase +} + +func NewProblemHandler(problemUseCase *usecase.ProblemUseCase) *ProblemHandler { + return &ProblemHandler{problemUseCase: problemUseCase} +} + +func (h *ProblemHandler) Create(c *gin.Context) { + var cmd dto.CreateProblemCommand + err := c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + + problem, err := h.problemUseCase.Create(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, problem.ID) + c.Header("Location", location) + c.JSON(http.StatusCreated, problem) +} + +func (h *ProblemHandler) GetAll(c *gin.Context) { + var query common.PaginationQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + paginatedProblems, err := h.problemUseCase.GetAllByProjectID(c.Request.Context(), projectID, query.PageNumber, query.PageSize) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.GetAll] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, paginatedProblems) +} + +func (h *ProblemHandler) Get(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := common.ExtractUUIDParam(c, "problem_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + problem, err := h.problemUseCase.GetByID(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrProblemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.Get] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + if problem == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Problem not found"}) + return + } + + c.JSON(http.StatusOK, problem) +} + +func (h *ProblemHandler) Update(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "problem_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd dto.UpdateProblemCommand + if err := c.ShouldBindJSON(&cmd); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + cmd.ID = id + + problem, err := h.problemUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrProblemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.Update] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, problem) +} + +func (h *ProblemHandler) UpdateStatus(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := common.ExtractUUIDParam(c, "problem_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd dto.UpdateProblemStatusCommand + if err := c.ShouldBindJSON(&cmd); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + cmd.ID = id + + problem, err := h.problemUseCase.UpdateStatus(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrProblemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.UpdateStatus] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, problem) +} + +func (h *ProblemHandler) Delete(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "problem_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.problemUseCase.Delete(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrProblemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProblemHandler.Delete] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/adapter/in/web/handler/problem_handler_test.go b/backend/internal/adapter/in/web/handler/problem_handler_test.go new file mode 100644 index 0000000..748bcc9 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/problem_handler_test.go @@ -0,0 +1,450 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProblemHandler_Create(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project first + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + require.Equal(t, http.StatusCreated, respProject.StatusCode) + + var createdProject map[string]interface{} + err := json.NewDecoder(respProject.Body).Decode(&createdProject) + require.NoError(t, err) + projectID := createdProject["id"].(string) + + t.Run("Create success", func(t *testing.T) { + problemBody := []byte(`{ + "title": "Database Connection Timeout", + "errorDescription": "Failed to connect to database at localhost:5432 after 30s", + "solution": "Restart PostgreSQL container", + "status": "OPEN", + "severity": "HIGH" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, problemBody, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Location")) + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "Database Connection Timeout", result["title"]) + assert.Equal(t, "Failed to connect to database at localhost:5432 after 30s", result["errorDescription"]) + assert.Equal(t, "Restart PostgreSQL container", result["solution"]) + assert.Equal(t, "OPEN", result["status"]) + assert.Equal(t, "HIGH", result["severity"]) + assert.Equal(t, projectID, result["projectId"]) + assert.NotEmpty(t, result["id"]) + }) + + t.Run("Create failure - project not found", func(t *testing.T) { + problemBody := []byte(`{ + "title": "Valid Title", + "errorDescription": "Valid Error Description", + "status": "OPEN", + "severity": "LOW" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000000/problems", problemBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Create failure - invalid project UUID format", func(t *testing.T) { + problemBody := []byte(`{ + "title": "Valid Title", + "errorDescription": "Valid Error Description", + "status": "OPEN", + "severity": "LOW" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/invalid-project-id/problems", problemBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - missing required fields", func(t *testing.T) { + problemBody := []byte(`{"title": "Only Title"}`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, problemBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - title too short", func(t *testing.T) { + problemBody := []byte(`{ + "title": "A", + "errorDescription": "Valid Error Description", + "status": "OPEN", + "severity": "LOW" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, problemBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - unauthorized", func(t *testing.T) { + problemBody := []byte(`{ + "title": "Valid Title", + "errorDescription": "Valid Error Description", + "status": "OPEN", + "severity": "LOW" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, problemBody, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestProblemHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project and a problem + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + problemBody := []byte(`{ + "title": "Seeded Problem", + "errorDescription": "Seeded Error Description", + "status": "OPEN", + "severity": "MEDIUM" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + respProblem := app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + var createdProblem map[string]interface{} + _ = json.NewDecoder(respProblem.Body).Decode(&createdProblem) + problemID := createdProblem["id"].(string) + + t.Run("Get success", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/problems/%s", projectID, problemID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, problemID, result["id"]) + assert.Equal(t, "Seeded Problem", result["title"]) + assert.Equal(t, "OPEN", result["status"]) + assert.Equal(t, "MEDIUM", result["severity"]) + }) + + t.Run("Get failure - problem not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/problems/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - project not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - invalid project UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/invalid-project-id/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Get failure - invalid problem UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/problems/invalid-problem-id", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProblemHandler_GetAll(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Create Project A + respProjA := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project A"}`), true) + var projA map[string]interface{} + _ = json.NewDecoder(respProjA.Body).Decode(&projA) + projectAID := projA["id"].(string) + + // Create Project B (for isolation checks) + respProjB := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project B"}`), true) + var projB map[string]interface{} + _ = json.NewDecoder(respProjB.Body).Decode(&projB) + projectBID := projB["id"].(string) + + // Seed 12 problems in Project A + for i := 1; i <= 12; i++ { + problemBody := []byte(fmt.Sprintf(`{ + "title": "Project A Problem %d", + "errorDescription": "Error description %d", + "status": "OPEN", + "severity": "LOW" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectAID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + } + + // Seed 3 problems in Project B + for i := 1; i <= 3; i++ { + problemBody := []byte(fmt.Sprintf(`{ + "title": "Project B Problem %d", + "errorDescription": "Error description %d", + "status": "OPEN", + "severity": "HIGH" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectBID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + } + + t.Run("GetAll success - default pagination", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 10) // default page size is 10 + assert.Equal(t, float64(0), page["number"]) + assert.Equal(t, float64(10), page["size"]) + assert.Equal(t, float64(12), page["totalElements"]) + assert.Equal(t, float64(2), page["totalPages"]) + + // Ensure project isolation: all items belong to projectAID + for _, item := range content { + problemMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, problemMap["projectId"]) + } + }) + + t.Run("GetAll success - custom page and size", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems?page=1&size=5", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 5) // page 1 with size 5 + assert.Equal(t, float64(1), page["number"]) + assert.Equal(t, float64(5), page["size"]) + assert.Equal(t, float64(12), page["totalElements"]) + assert.Equal(t, float64(3), page["totalPages"]) + + for _, item := range content { + problemMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, problemMap["projectId"]) + } + }) + + t.Run("GetAll failure - project not found", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/00000000-0000-0000-0000-000000000000/problems", nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("GetAll failure - invalid project UUID", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/invalid-id/problems", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetAll failure - size greater than 100", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/problems?page=0&size=101", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProblemHandler_Update(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + problemBody := []byte(`{ + "title": "Original Title", + "errorDescription": "Original Error", + "status": "OPEN", + "severity": "LOW" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + respProblem := app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + var createdProblem map[string]interface{} + _ = json.NewDecoder(respProblem.Body).Decode(&createdProblem) + problemID := createdProblem["id"].(string) + + t.Run("Update success", func(t *testing.T) { + updateBody := []byte(`{ + "title": "Updated Title", + "severity": "CRITICAL" + }`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/problems/%s", projectID, problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "Updated Title", result["title"]) + assert.Equal(t, "CRITICAL", result["severity"]) + }) + + t.Run("Update failure - problem not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/problems/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - project not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - invalid problem UUID format", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/problems/invalid-problem-id", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Update failure - invalid project UUID format", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/invalid-project-id/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProblemHandler_UpdateStatus(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + problemBody := []byte(`{ + "title": "Problem To Update Status", + "errorDescription": "Error description", + "status": "OPEN", + "severity": "MEDIUM" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + respProblem := app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + var createdProblem map[string]interface{} + _ = json.NewDecoder(respProblem.Body).Decode(&createdProblem) + problemID := createdProblem["id"].(string) + + t.Run("UpdateStatus success", func(t *testing.T) { + updateBody := []byte(`{"status": "RESOLVED"}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/%s/problems/%s/status", projectID, problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "RESOLVED", result["status"]) + }) + + t.Run("UpdateStatus failure - problem not found", func(t *testing.T) { + updateBody := []byte(`{"status": "RESOLVED"}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/%s/problems/00000000-0000-0000-0000-000000000000/status", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("UpdateStatus failure - project not found", func(t *testing.T) { + updateBody := []byte(`{"status": "RESOLVED"}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/problems/%s/status", problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("UpdateStatus failure - missing status field", func(t *testing.T) { + updateBody := []byte(`{}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/%s/problems/%s/status", projectID, problemID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("UpdateStatus failure - invalid problem UUID format", func(t *testing.T) { + updateBody := []byte(`{"status": "RESOLVED"}`) + urlUpdateStatus := fmt.Sprintf("/api/v1/projects/%s/problems/invalid-problem-id/status", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdateStatus, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProblemHandler_Delete(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + problemBody := []byte(`{ + "title": "Problem To Delete", + "errorDescription": "Error description", + "status": "OPEN", + "severity": "LOW" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/problems", projectID) + respProblem := app.DoRequest(t, http.MethodPost, urlCreate, problemBody, true) + var createdProblem map[string]interface{} + _ = json.NewDecoder(respProblem.Body).Decode(&createdProblem) + problemID := createdProblem["id"].(string) + + t.Run("Delete success", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/problems/%s", projectID, problemID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Assert persistence: issuing a GET request should now return 404 Not Found + respGet := app.DoRequest(t, http.MethodGet, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, respGet.StatusCode) + }) + + t.Run("Delete failure - problem not found", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/problems/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Delete failure - invalid problem UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/problems/invalid-problem-id", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Delete failure - invalid project UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/invalid-project-id/problems/%s", problemID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/backend/internal/adapter/in/web/handler/project_handler.go b/backend/internal/adapter/in/web/handler/project_handler.go new file mode 100644 index 0000000..d2682bf --- /dev/null +++ b/backend/internal/adapter/in/web/handler/project_handler.go @@ -0,0 +1,184 @@ +package handler + +import ( + "errors" + "fmt" + "log" + "net/http" + + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + + "github.com/gin-gonic/gin" +) + +type ProjectHandler struct { + projectUseCase *usecase.ProjectUseCase +} + +func NewProjectHandler(projectUseCase *usecase.ProjectUseCase) *ProjectHandler { + return &ProjectHandler{projectUseCase: projectUseCase} +} + +func (h *ProjectHandler) Create(c *gin.Context) { + var cmd dto.CreateProjectCommand + err := c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + project, err := h.projectUseCase.Create(c.Request.Context(), cmd) + if err != nil { + log.Printf("[ProjectHandler.Create] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, project.ID) + + c.Header("Location", location) + c.JSON(http.StatusCreated, project) +} + +func (h *ProjectHandler) GetAll(c *gin.Context) { + var query common.PaginationQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + pagedProjects, err := h.projectUseCase.GetAll(c.Request.Context(), query.PageNumber, query.PageSize) + if err != nil { + log.Printf("[ProjectHandler.GetAll] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, pagedProjects) +} + +func (h *ProjectHandler) Get(c *gin.Context) { + id, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + project, err := h.projectUseCase.GetByID(c.Request.Context(), id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProjectHandler.Get] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + if project == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return + } + + c.JSON(http.StatusOK, project) +} + +func (h *ProjectHandler) Update(c *gin.Context) { + id, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd dto.UpdateProjectCommand + err = c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + cmd.ID = id + + project, err := h.projectUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProjectHandler.Update] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, project) +} + +func (h *ProjectHandler) Archive(c *gin.Context) { + id, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.projectUseCase.Archive(c.Request.Context(), id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrProjectAlreadyArchived) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProjectHandler.Archive] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Project archived"}) +} + +func (h *ProjectHandler) Unarchive(c *gin.Context) { + id, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.projectUseCase.Unarchive(c.Request.Context(), id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrProjectAlreadyUnarchived) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProjectHandler.Unarchive] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Project unarchived"}) +} + +func (h *ProjectHandler) Delete(c *gin.Context) { + id, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.projectUseCase.Delete(c.Request.Context(), id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[ProjectHandler.Delete] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/adapter/in/web/handler/project_handler_test.go b/backend/internal/adapter/in/web/handler/project_handler_test.go new file mode 100644 index 0000000..1c8ab1c --- /dev/null +++ b/backend/internal/adapter/in/web/handler/project_handler_test.go @@ -0,0 +1,212 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProjectHandler_Create(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + t.Run("Create success", func(t *testing.T) { + body := []byte(`{"name":"New Project","description":"Valid desc","icon":"rocket","color":"#4A90E2"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects", body, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Location")) + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "New Project", result["name"]) + assert.NotEmpty(t, result["id"]) + }) + + t.Run("Create failure - missing required name", func(t *testing.T) { + body := []byte(`{"description":"No name"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects", body, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - name too short", func(t *testing.T) { + body := []byte(`{"name":"A"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects", body, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - invalid hex color", func(t *testing.T) { + body := []byte(`{"name":"Valid Name","color":"invalid-color"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects", body, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - unauthorized", func(t *testing.T) { + body := []byte(`{"name":"Valid Name"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects", body, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestProjectHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + body := []byte(`{"name":"Seeded Project"}`) + respSeed := app.DoRequest(t, http.MethodPost, "/api/v1/projects", body, true) + var created map[string]interface{} + _ = json.NewDecoder(respSeed.Body).Decode(&created) + projectID := created["id"].(string) + + t.Run("Get success", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/"+projectID, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, projectID, result["id"]) + }) + + t.Run("Get failure - invalid UUID format", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/not-a-uuid", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Get failure - not found", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/00000000-0000-0000-0000-000000000000", nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +func TestProjectHandler_GetAll(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + t.Run("GetAll success - default pagination", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects", nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("GetAll success - custom query page and size", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects?page=0&size=5", nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("GetAll failure - page less than 0", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects?page=-1&size=10", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetAll failure - size less than 1", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects?page=0&size=0", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetAll failure - size greater than 100", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects?page=0&size=101", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProjectHandler_Update(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + body := []byte(`{"name":"Original Name"}`) + respSeed := app.DoRequest(t, http.MethodPost, "/api/v1/projects", body, true) + var created map[string]interface{} + _ = json.NewDecoder(respSeed.Body).Decode(&created) + projectID := created["id"].(string) + + t.Run("Update success", func(t *testing.T) { + updateBody := []byte(`{"name":"Updated Name"}`) + resp := app.DoRequest(t, http.MethodPatch, "/api/v1/projects/"+projectID, updateBody, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "Updated Name", result["name"]) + }) + + t.Run("Update failure - invalid UUID format", func(t *testing.T) { + updateBody := []byte(`{"name":"Updated Name"}`) + resp := app.DoRequest(t, http.MethodPatch, "/api/v1/projects/invalid-id", updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Update failure - project not found", func(t *testing.T) { + updateBody := []byte(`{"name":"Updated Name"}`) + resp := app.DoRequest(t, http.MethodPatch, "/api/v1/projects/00000000-0000-0000-0000-000000000000", updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +func TestProjectHandler_ArchiveAndUnarchive(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + body := []byte(`{"name":"Archive Test Project"}`) + respSeed := app.DoRequest(t, http.MethodPost, "/api/v1/projects", body, true) + var created map[string]interface{} + _ = json.NewDecoder(respSeed.Body).Decode(&created) + projectID := created["id"].(string) + + t.Run("Archive success", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodPatch, "/api/v1/projects/"+projectID+"/archive", nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("Archive failure - already archived", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodPatch, "/api/v1/projects/"+projectID+"/archive", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Unarchive success", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodPatch, "/api/v1/projects/"+projectID+"/unarchive", nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("Unarchive failure - not archived", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodPatch, "/api/v1/projects/"+projectID+"/unarchive", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Archive failure - invalid UUID", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodPatch, "/api/v1/projects/bad-uuid/archive", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestProjectHandler_Delete(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + body := []byte(`{"name":"Delete Test Project"}`) + respSeed := app.DoRequest(t, http.MethodPost, "/api/v1/projects", body, true) + var created map[string]interface{} + _ = json.NewDecoder(respSeed.Body).Decode(&created) + projectID := created["id"].(string) + + t.Run("Delete success", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodDelete, "/api/v1/projects/"+projectID, nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Assert persistence: GET on deleted project should return 404 Not Found + respGet := app.DoRequest(t, http.MethodGet, "/api/v1/projects/"+projectID, nil, true) + assert.Equal(t, http.StatusNotFound, respGet.StatusCode) + }) + + t.Run("Delete failure - project not found", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodDelete, "/api/v1/projects/00000000-0000-0000-0000-000000000000", nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Delete failure - invalid UUID", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodDelete, "/api/v1/projects/invalid-id", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/backend/internal/adapter/in/web/handler/security_handler.go b/backend/internal/adapter/in/web/handler/security_handler.go new file mode 100644 index 0000000..9ca00ce --- /dev/null +++ b/backend/internal/adapter/in/web/handler/security_handler.go @@ -0,0 +1,92 @@ +package handler + +import ( + "errors" + "log" + "net/http" + + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + + "github.com/gin-gonic/gin" +) + +type SecurityHandler struct { + vaultUseCase *usecase.VaultUseCase +} + +func NewSecurityHandler(vaultUseCase *usecase.VaultUseCase) *SecurityHandler { + return &SecurityHandler{vaultUseCase: vaultUseCase} +} + +func (h *SecurityHandler) SetupMasterPassword(c *gin.Context) { + var masterPassword dto.MasterPassword + err := c.ShouldBindJSON(&masterPassword) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + password := []byte(masterPassword.MasterPassword) + masterPassword.MasterPassword = "" // clear reference to string DTO (ownership of password slice transfers to VaultUseCase) + + err = h.vaultUseCase.SetupMasterPassword(c.Request.Context(), password) + if err != nil { + if errors.Is(err, usecase.ErrMasterPasswordAlreadyConfigured) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[SecurityHandler.SetupMasterPassword] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.Status(http.StatusNoContent) +} + +func (h *SecurityHandler) CheckMasterPasswordSetup(g *gin.Context) { + isRequired, err := h.vaultUseCase.CheckIfMasterSetupIsRequired(g.Request.Context()) + if err != nil { + log.Printf("[SecurityHandler.CheckMasterPasswordSetup] %v", err) + g.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + g.JSON(http.StatusOK, gin.H{"isRequired": isRequired}) +} + +func (h *SecurityHandler) GetSessionStatus(c *gin.Context) { + vaultStatus := h.vaultUseCase.GetSessionStatus() + c.JSON(http.StatusOK, vaultStatus) +} + +func (h *SecurityHandler) UnlockVault(c *gin.Context) { + var masterPassword dto.MasterPassword + err := c.ShouldBindJSON(&masterPassword) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + password := []byte(masterPassword.MasterPassword) + masterPassword.MasterPassword = "" // clear reference to string DTO (ownership of password slice transfers to VaultUseCase) + + result, err := h.vaultUseCase.UnlockVault(c.Request.Context(), password) + if err != nil { + if errors.Is(err, usecase.ErrInvalidMasterPassword) { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrMasterPasswordNotConfigured) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[SecurityHandler.UnlockVault] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, result) +} + +func (h *SecurityHandler) LockVault(c *gin.Context) { + h.vaultUseCase.LockVault() + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/adapter/in/web/handler/security_handler_test.go b/backend/internal/adapter/in/web/handler/security_handler_test.go new file mode 100644 index 0000000..0e89441 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/security_handler_test.go @@ -0,0 +1,137 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecurityHandler_SetupMasterPassword(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + t.Run("Setup success - first time", func(t *testing.T) { + body := []byte(`{"masterPassword":"mySuperSecretPassword123"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/security/master-password", body, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Verify that setup is no longer required + respReq := app.DoRequest(t, http.MethodGet, "/api/v1/security/master-password/required-status", nil, true) + assert.Equal(t, http.StatusOK, respReq.StatusCode) + var result map[string]interface{} + err := json.NewDecoder(respReq.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, false, result["isRequired"]) + }) + + t.Run("Setup failure - password too short", func(t *testing.T) { + body := []byte(`{"masterPassword":"short"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/security/master-password", body, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Setup failure - already configured", func(t *testing.T) { + // Second setup attempt on already initialized vault + body := []byte(`{"masterPassword":"anotherPassword123"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/security/master-password", body, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Setup failure - unauthorized", func(t *testing.T) { + body := []byte(`{"masterPassword":"mySuperSecretPassword123"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/security/master-password", body, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestSecurityHandler_CheckMasterPasswordSetup(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + t.Run("Returns true when not configured", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/security/master-password/required-status", nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, true, result["isRequired"]) + }) +} + +func TestSecurityHandler_SessionStatusAndLockUnlock(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + t.Run("Unlock failure - not configured", func(t *testing.T) { + body := []byte(`{"masterPassword":"mySuperSecretPassword123"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/security/vault/unlock", body, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Session status - locked by default", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/security/vault/status", nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var status map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&status) + require.NoError(t, err) + assert.Equal(t, false, status["active"]) + assert.Equal(t, float64(0), status["secondsLeft"]) + }) + + // Setup master password for unlock/lock tests + setupBody := []byte(`{"masterPassword":"mySuperSecretPassword123"}`) + respSetup := app.DoRequest(t, http.MethodPost, "/api/v1/security/master-password", setupBody, true) + require.Equal(t, http.StatusNoContent, respSetup.StatusCode) + + t.Run("Unlock failure - wrong password", func(t *testing.T) { + body := []byte(`{"masterPassword":"wrongPassword123"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/security/vault/unlock", body, true) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("Unlock failure - password too short", func(t *testing.T) { + body := []byte(`{"masterPassword":"short"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/security/vault/unlock", body, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Unlock success - correct password", func(t *testing.T) { + // First, lock vault so we test unlocking + _ = app.DoRequest(t, http.MethodPost, "/api/v1/security/vault/lock", nil, true) + + body := []byte(`{"masterPassword":"mySuperSecretPassword123"}`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/security/vault/unlock", body, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result bool + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.True(t, result) + + // Verify session status is active + respStatus := app.DoRequest(t, http.MethodGet, "/api/v1/security/vault/status", nil, true) + assert.Equal(t, http.StatusOK, respStatus.StatusCode) + var status map[string]interface{} + _ = json.NewDecoder(respStatus.Body).Decode(&status) + assert.Equal(t, true, status["active"]) + assert.Greater(t, status["secondsLeft"].(float64), float64(0)) + }) + + t.Run("Lock success - clears active session", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodPost, "/api/v1/security/vault/lock", nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Verify session status is inactive + respStatus := app.DoRequest(t, http.MethodGet, "/api/v1/security/vault/status", nil, true) + assert.Equal(t, http.StatusOK, respStatus.StatusCode) + var status map[string]interface{} + _ = json.NewDecoder(respStatus.Body).Decode(&status) + assert.Equal(t, false, status["active"]) + assert.Equal(t, float64(0), status["secondsLeft"]) + }) +} diff --git a/backend/internal/adapter/in/web/handler/snippet_handler.go b/backend/internal/adapter/in/web/handler/snippet_handler.go new file mode 100644 index 0000000..162bbcd --- /dev/null +++ b/backend/internal/adapter/in/web/handler/snippet_handler.go @@ -0,0 +1,175 @@ +package handler + +import ( + "errors" + "fmt" + "log" + "net/http" + + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + + "github.com/gin-gonic/gin" +) + +type SnippetHandler struct { + snippetUseCase *usecase.SnippetUseCase +} + +func NewSnippetHandler(snippetUseCase *usecase.SnippetUseCase) *SnippetHandler { + return &SnippetHandler{snippetUseCase: snippetUseCase} +} + +func (h *SnippetHandler) Create(c *gin.Context) { + var cmd dto.CreateSnippetCommand + err := c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + + snippet, err := h.snippetUseCase.Create(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[SnippetHandler.Create] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, snippet.ID) + c.Header("Location", location) + c.JSON(http.StatusCreated, snippet) +} + +func (h *SnippetHandler) GetAll(c *gin.Context) { + var query common.PaginationQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + pagedSnippets, err := h.snippetUseCase.GetAllByProjectID(c.Request.Context(), projectID, query.PageNumber, query.PageSize) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[SnippetHandler.GetAll] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, pagedSnippets) +} + +func (h *SnippetHandler) Get(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "snippet_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + snippet, err := h.snippetUseCase.GetByID(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrSnippetNotFound) || errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[SnippetHandler.Get] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if snippet == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Snippet not found"}) + return + } + + c.JSON(http.StatusOK, snippet) +} + +func (h *SnippetHandler) Update(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "snippet_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cmd dto.UpdateSnippetCommand + err = c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ID = id + cmd.ProjectID = projectID + + snippet, err := h.snippetUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrSnippetNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[SnippetHandler.Update] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + c.JSON(http.StatusOK, snippet) +} + +func (h *SnippetHandler) Delete(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "snippet_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.snippetUseCase.Delete(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrSnippetNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[SnippetHandler.Delete] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/adapter/in/web/handler/snippet_handler_test.go b/backend/internal/adapter/in/web/handler/snippet_handler_test.go new file mode 100644 index 0000000..e38eaea --- /dev/null +++ b/backend/internal/adapter/in/web/handler/snippet_handler_test.go @@ -0,0 +1,364 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSnippetHandler_Create(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project first + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + require.Equal(t, http.StatusCreated, respProject.StatusCode) + + var createdProject map[string]interface{} + err := json.NewDecoder(respProject.Body).Decode(&createdProject) + require.NoError(t, err) + projectID := createdProject["id"].(string) + + t.Run("Create success", func(t *testing.T) { + snippetBody := []byte(`{ + "title": "Print Hello World", + "description": "Simple Go print snippet", + "content": "fmt.Println(\"Hello World\")", + "language": "GO", + "snippetType": "CODE" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, snippetBody, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Location")) + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "Print Hello World", result["title"]) + assert.Equal(t, projectID, result["projectId"]) + assert.NotEmpty(t, result["id"]) + }) + + t.Run("Create failure - project not found", func(t *testing.T) { + snippetBody := []byte(`{ + "title": "Valid Title", + "content": "some content", + "language": "BASH", + "snippetType": "COMMAND" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000000/snippets", snippetBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Create failure - invalid project UUID format", func(t *testing.T) { + snippetBody := []byte(`{ + "title": "Valid Title", + "content": "some content", + "language": "BASH", + "snippetType": "COMMAND" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/invalid-project-id/snippets", snippetBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - missing required fields", func(t *testing.T) { + snippetBody := []byte(`{"description": "No title or content"}`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, snippetBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - title too short", func(t *testing.T) { + snippetBody := []byte(`{ + "title": "A", + "content": "some content", + "language": "BASH", + "snippetType": "COMMAND" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, snippetBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - unauthorized", func(t *testing.T) { + snippetBody := []byte(`{ + "title": "Valid Title", + "content": "some content", + "language": "BASH", + "snippetType": "COMMAND" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, snippetBody, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestSnippetHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project and a snippet + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + snippetBody := []byte(`{ + "title": "Seeded Snippet", + "content": "echo 'hello'", + "language": "BASH", + "snippetType": "COMMAND" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + respSnippet := app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + var createdSnippet map[string]interface{} + _ = json.NewDecoder(respSnippet.Body).Decode(&createdSnippet) + snippetID := createdSnippet["id"].(string) + + t.Run("Get success", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/snippets/%s", projectID, snippetID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, snippetID, result["id"]) + assert.Equal(t, "Seeded Snippet", result["title"]) + }) + + t.Run("Get failure - snippet not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/snippets/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - invalid project UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/invalid-project-id/snippets/%s", snippetID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Get failure - invalid snippet UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/snippets/invalid-snippet-id", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestSnippetHandler_GetAll(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Create Project A + respProjA := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project A"}`), true) + var projA map[string]interface{} + _ = json.NewDecoder(respProjA.Body).Decode(&projA) + projectAID := projA["id"].(string) + + // Create Project B (for isolation checks) + respProjB := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project B"}`), true) + var projB map[string]interface{} + _ = json.NewDecoder(respProjB.Body).Decode(&projB) + projectBID := projB["id"].(string) + + // Seed 12 snippets in Project A + for i := 1; i <= 12; i++ { + snippetBody := []byte(fmt.Sprintf(`{ + "title": "Project A Snippet %d", + "content": "echo %d", + "language": "BASH", + "snippetType": "COMMAND" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectAID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + } + + // Seed 3 snippets in Project B + for i := 1; i <= 3; i++ { + snippetBody := []byte(fmt.Sprintf(`{ + "title": "Project B Snippet %d", + "content": "echo %d", + "language": "GO", + "snippetType": "CODE" + }`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectBID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + } + + t.Run("GetAll success - default pagination", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 10) // default page size is 10 + assert.Equal(t, float64(0), page["number"]) + assert.Equal(t, float64(10), page["size"]) + assert.Equal(t, float64(12), page["totalElements"]) + assert.Equal(t, float64(2), page["totalPages"]) + + // Ensure project isolation: all items belong to projectAID + for _, item := range content { + snippetMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, snippetMap["projectId"]) + } + }) + + t.Run("GetAll success - custom page and size", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets?page=1&size=5", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var page map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&page) + require.NoError(t, err) + + content := page["content"].([]interface{}) + assert.Len(t, content, 5) // page 1 with size 5 + assert.Equal(t, float64(1), page["number"]) + assert.Equal(t, float64(5), page["size"]) + assert.Equal(t, float64(12), page["totalElements"]) + assert.Equal(t, float64(3), page["totalPages"]) + + for _, item := range content { + snippetMap := item.(map[string]interface{}) + assert.Equal(t, projectAID, snippetMap["projectId"]) + } + }) + + t.Run("GetAll failure - project not found", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/00000000-0000-0000-0000-000000000000/snippets", nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("GetAll failure - invalid project UUID", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/invalid-id/snippets", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetAll failure - size greater than 100", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/snippets?page=0&size=101", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestSnippetHandler_Update(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + snippetBody := []byte(`{ + "title": "Original Title", + "content": "Original Content", + "language": "GO", + "snippetType": "CODE" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + respSnippet := app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + var createdSnippet map[string]interface{} + _ = json.NewDecoder(respSnippet.Body).Decode(&createdSnippet) + snippetID := createdSnippet["id"].(string) + + t.Run("Update success", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/snippets/%s", projectID, snippetID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "Updated Title", result["title"]) + }) + + t.Run("Update failure - snippet not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/snippets/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - project not found", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/snippets/%s", snippetID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - invalid snippet UUID format", func(t *testing.T) { + updateBody := []byte(`{"title": "Updated Title"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/snippets/invalid-snippet-id", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestSnippetHandler_Delete(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + snippetBody := []byte(`{ + "title": "Snippet To Delete", + "content": "rm -rf /tmp", + "language": "BASH", + "snippetType": "COMMAND" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/snippets", projectID) + respSnippet := app.DoRequest(t, http.MethodPost, urlCreate, snippetBody, true) + var createdSnippet map[string]interface{} + _ = json.NewDecoder(respSnippet.Body).Decode(&createdSnippet) + snippetID := createdSnippet["id"].(string) + + t.Run("Delete success", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/snippets/%s", projectID, snippetID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Assert persistence: issuing a GET request should now return 404 Not Found + respGet := app.DoRequest(t, http.MethodGet, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, respGet.StatusCode) + }) + + t.Run("Delete failure - snippet not found", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/snippets/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Delete failure - invalid snippet UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/snippets/invalid-snippet-id", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Delete failure - invalid project UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/invalid-project-id/snippets/%s", snippetID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/backend/internal/adapter/in/web/handler/tag_handler.go b/backend/internal/adapter/in/web/handler/tag_handler.go new file mode 100644 index 0000000..6494267 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/tag_handler.go @@ -0,0 +1,193 @@ +package handler + +import ( + "devaulty-backend/internal/adapter/in/web/common" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + "errors" + "fmt" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +type TagHandler struct { + tagUseCase *usecase.TagUseCase +} + +func NewTagHandler(tagUseCase *usecase.TagUseCase) *TagHandler { + return &TagHandler{tagUseCase: tagUseCase} +} + +func (h *TagHandler) Create(c *gin.Context) { + var cmd dto.CreateTagCommand + err := c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ProjectID = projectID + + tag, err := h.tagUseCase.Create(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrTagAlreadyExists) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[TagHandler.Create] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + location := fmt.Sprintf("%s/%s", c.Request.URL.Path, tag.ID) + c.Header("Location", location) + c.JSON(http.StatusCreated, tag) +} + +func (h *TagHandler) GetAll(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tags, err := h.tagUseCase.GetAllByProjectID(c.Request.Context(), projectID) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[TagHandler.GetAll] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, tags) +} + +func (h *TagHandler) Get(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "tag_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tag, err := h.tagUseCase.GetByID(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrTagNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[TagHandler.Get] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if tag == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Tag not found"}) + return + } + + c.JSON(http.StatusOK, tag) +} + +func (h *TagHandler) SearchByName(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + name := c.Query("tag_name") + tags, err := h.tagUseCase.SearchByName(c.Request.Context(), projectID, name) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[TagHandler.SearchByName] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, tags) +} + +func (h *TagHandler) Update(c *gin.Context) { + var cmd dto.UpdateTagCommand + err := c.ShouldBindJSON(&cmd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := common.ExtractUUIDParam(c, "tag_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cmd.ID = id + cmd.ProjectID = projectID + + tag, err := h.tagUseCase.Update(c.Request.Context(), cmd) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrTagNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, usecase.ErrTagAlreadyExists) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("[TagHandler.Update] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, tag) +} + +func (h *TagHandler) Delete(c *gin.Context) { + projectID, err := common.ExtractUUIDParam(c, "project_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := common.ExtractUUIDParam(c, "tag_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.tagUseCase.Delete(c.Request.Context(), projectID, id) + if err != nil { + if errors.Is(err, usecase.ErrProjectNotFound) || errors.Is(err, usecase.ErrTagNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[TagHandler.Delete] %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/adapter/in/web/handler/tag_handler_test.go b/backend/internal/adapter/in/web/handler/tag_handler_test.go new file mode 100644 index 0000000..ef1fb28 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/tag_handler_test.go @@ -0,0 +1,393 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTagHandler_Create(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project first + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + require.Equal(t, http.StatusCreated, respProject.StatusCode) + + var createdProject map[string]interface{} + err := json.NewDecoder(respProject.Body).Decode(&createdProject) + require.NoError(t, err) + projectID := createdProject["id"].(string) + + t.Run("Create success", func(t *testing.T) { + tagBody := []byte(`{ + "name": "Backend", + "color": "#FF0000" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, tagBody, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Location")) + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "Backend", result["name"]) + assert.Equal(t, "#FF0000", result["color"]) + assert.Equal(t, projectID, result["projectId"]) + assert.NotEmpty(t, result["id"]) + }) + + t.Run("Create success - without color", func(t *testing.T) { + tagBody := []byte(`{ + "name": "NoColorTag" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, tagBody, true) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var result map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "NoColorTag", result["name"]) + assert.Nil(t, result["color"]) + }) + + t.Run("Create failure - tag name already exists", func(t *testing.T) { + tagBody := []byte(`{ + "name": "Backend", + "color": "#00FF00" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, tagBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - project not found", func(t *testing.T) { + tagBody := []byte(`{ + "name": "Frontend", + "color": "#0000FF" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/00000000-0000-0000-0000-000000000000/tags", tagBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Create failure - invalid project UUID format", func(t *testing.T) { + tagBody := []byte(`{ + "name": "Frontend", + "color": "#0000FF" + }`) + resp := app.DoRequest(t, http.MethodPost, "/api/v1/projects/invalid-project-id/tags", tagBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - name too short", func(t *testing.T) { + tagBody := []byte(`{ + "name": "A", + "color": "#0000FF" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, tagBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - invalid hex color", func(t *testing.T) { + tagBody := []byte(`{ + "name": "Database", + "color": "invalid-color" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, tagBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Create failure - unauthorized", func(t *testing.T) { + tagBody := []byte(`{ + "name": "DevOps", + "color": "#123456" + }`) + urlPath := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + resp := app.DoRequest(t, http.MethodPost, urlPath, tagBody, false) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestTagHandler_Get(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Seed a project and a tag + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + tagBody := []byte(`{ + "name": "Seeded Tag", + "color": "#112233" + }`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + respTag := app.DoRequest(t, http.MethodPost, urlCreate, tagBody, true) + var createdTag map[string]interface{} + _ = json.NewDecoder(respTag.Body).Decode(&createdTag) + tagID := createdTag["id"].(string) + + t.Run("Get success", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/tags/%s", projectID, tagID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, tagID, result["id"]) + assert.Equal(t, "Seeded Tag", result["name"]) + assert.Equal(t, "#112233", result["color"]) + }) + + t.Run("Get failure - tag not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/tags/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - project not found", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/tags/%s", tagID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Get failure - invalid project UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/invalid-project-id/tags/%s", tagID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Get failure - invalid tag UUID format", func(t *testing.T) { + urlGet := fmt.Sprintf("/api/v1/projects/%s/tags/invalid-tag-id", projectID) + resp := app.DoRequest(t, http.MethodGet, urlGet, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestTagHandler_GetAll(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + // Create Project A + respProjA := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project A"}`), true) + var projA map[string]interface{} + _ = json.NewDecoder(respProjA.Body).Decode(&projA) + projectAID := projA["id"].(string) + + // Create Project B (isolation check) + respProjB := app.DoRequest(t, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Project B"}`), true) + var projB map[string]interface{} + _ = json.NewDecoder(respProjB.Body).Decode(&projB) + projectBID := projB["id"].(string) + + // Seed 3 tags in Project A + for i := 1; i <= 3; i++ { + tagBody := []byte(fmt.Sprintf(`{"name": "Tag A%d", "color": "#00000%d"}`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/tags", projectAID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, tagBody, true) + } + + // Seed 2 tags in Project B + for i := 1; i <= 2; i++ { + tagBody := []byte(fmt.Sprintf(`{"name": "Tag B%d", "color": "#00000%d"}`, i, i)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/tags", projectBID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, tagBody, true) + } + + t.Run("GetAll success", func(t *testing.T) { + urlPath := fmt.Sprintf("/api/v1/projects/%s/tags", projectAID) + resp := app.DoRequest(t, http.MethodGet, urlPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var tags []map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&tags) + require.NoError(t, err) + + assert.Len(t, tags, 3) + for _, tagMap := range tags { + assert.Equal(t, projectAID, tagMap["projectId"]) + } + }) + + t.Run("GetAll failure - project not found", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/00000000-0000-0000-0000-000000000000/tags", nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("GetAll failure - invalid project UUID", func(t *testing.T) { + resp := app.DoRequest(t, http.MethodGet, "/api/v1/projects/invalid-id/tags", nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestTagHandler_SearchByName(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + // Seed tags + tagNames := []string{"Golang", "Go-Fiber", "Python", "Rust"} + for _, name := range tagNames { + tagBody := []byte(fmt.Sprintf(`{"name": "%s", "color": "#123456"}`, name)) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + _ = app.DoRequest(t, http.MethodPost, urlCreate, tagBody, true) + } + + t.Run("SearchByName success - matching term", func(t *testing.T) { + urlSearch := fmt.Sprintf("/api/v1/projects/%s/tags/search?tag_name=go", projectID) + resp := app.DoRequest(t, http.MethodGet, urlSearch, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var tags []map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&tags) + require.NoError(t, err) + + assert.Len(t, tags, 2) // Golang, Go-Fiber + }) + + t.Run("SearchByName failure - project not found", func(t *testing.T) { + urlSearch := "/api/v1/projects/00000000-0000-0000-0000-000000000000/tags/search?tag_name=go" + resp := app.DoRequest(t, http.MethodGet, urlSearch, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("SearchByName failure - invalid project UUID", func(t *testing.T) { + urlSearch := "/api/v1/projects/invalid-id/tags/search?tag_name=go" + resp := app.DoRequest(t, http.MethodGet, urlSearch, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestTagHandler_Update(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + tagBody := []byte(`{"name": "Original Name", "color": "#111111"}`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + respTag := app.DoRequest(t, http.MethodPost, urlCreate, tagBody, true) + var createdTag map[string]interface{} + _ = json.NewDecoder(respTag.Body).Decode(&createdTag) + tagID := createdTag["id"].(string) + + // Create another tag to test name collision + _ = app.DoRequest(t, http.MethodPost, urlCreate, []byte(`{"name": "Existing Tag", "color": "#222222"}`), true) + + t.Run("Update success", func(t *testing.T) { + updateBody := []byte(`{"name": "Updated Name", "color": "#333333"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/tags/%s", projectID, tagID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "Updated Name", result["name"]) + assert.Equal(t, "#333333", result["color"]) + }) + + t.Run("Update failure - name collision", func(t *testing.T) { + updateBody := []byte(`{"name": "Existing Tag"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/tags/%s", projectID, tagID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Update failure - tag not found", func(t *testing.T) { + updateBody := []byte(`{"name": "New Name"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/tags/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - project not found", func(t *testing.T) { + updateBody := []byte(`{"name": "New Name"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/00000000-0000-0000-0000-000000000000/tags/%s", tagID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Update failure - invalid tag UUID format", func(t *testing.T) { + updateBody := []byte(`{"name": "New Name"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/%s/tags/invalid-tag-id", projectID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Update failure - invalid project UUID format", func(t *testing.T) { + updateBody := []byte(`{"name": "New Name"}`) + urlUpdate := fmt.Sprintf("/api/v1/projects/invalid-project-id/tags/%s", tagID) + resp := app.DoRequest(t, http.MethodPatch, urlUpdate, updateBody, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestTagHandler_Delete(t *testing.T) { + app := SetupTestApp(t) + defer app.Server.Close() + + projectBody := []byte(`{"name":"Parent Project"}`) + respProject := app.DoRequest(t, http.MethodPost, "/api/v1/projects", projectBody, true) + var createdProject map[string]interface{} + _ = json.NewDecoder(respProject.Body).Decode(&createdProject) + projectID := createdProject["id"].(string) + + tagBody := []byte(`{"name": "Tag To Delete", "color": "#000000"}`) + urlCreate := fmt.Sprintf("/api/v1/projects/%s/tags", projectID) + respTag := app.DoRequest(t, http.MethodPost, urlCreate, tagBody, true) + var createdTag map[string]interface{} + _ = json.NewDecoder(respTag.Body).Decode(&createdTag) + tagID := createdTag["id"].(string) + + t.Run("Delete success", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/tags/%s", projectID, tagID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Assert persistence: GET request should return 404 + respGet := app.DoRequest(t, http.MethodGet, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, respGet.StatusCode) + }) + + t.Run("Delete failure - tag not found", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/tags/00000000-0000-0000-0000-000000000000", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Delete failure - invalid tag UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/%s/tags/invalid-tag-id", projectID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Delete failure - invalid project UUID", func(t *testing.T) { + urlDelete := fmt.Sprintf("/api/v1/projects/invalid-project-id/tags/%s", tagID) + resp := app.DoRequest(t, http.MethodDelete, urlDelete, nil, true) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/backend/internal/adapter/in/web/handler/test_helper_test.go b/backend/internal/adapter/in/web/handler/test_helper_test.go new file mode 100644 index 0000000..cb3d972 --- /dev/null +++ b/backend/internal/adapter/in/web/handler/test_helper_test.go @@ -0,0 +1,114 @@ +package handler_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "devaulty-backend/internal/adapter/in/web" + "devaulty-backend/internal/adapter/in/web/handler" + "devaulty-backend/internal/adapter/out/persistence" + "devaulty-backend/internal/adapter/out/security" + "devaulty-backend/internal/usecase" + + "github.com/stretchr/testify/require" +) + +type TestApp struct { + Server *httptest.Server + Token string +} + +func SetupTestApp(t *testing.T) *TestApp { + db, err := persistence.InitDB(":memory:", "../../../../../migrations") + require.NoError(t, err) + + itemTagRepo := persistence.NewItemTagRepository(db) + + projectRepo := persistence.NewProjectRepository(db) + projectUseCase := usecase.NewProjectUseCase(projectRepo) + projectHandler := handler.NewProjectHandler(projectUseCase) + + snippetRepo := persistence.NewSnippetRepository(db) + snippetUseCase := usecase.NewSnippetUseCase(snippetRepo, projectRepo, itemTagRepo) + snippetHandler := handler.NewSnippetHandler(snippetUseCase) + + linkRepo := persistence.NewLinkRepository(db) + linkUseCase := usecase.NewLinkUseCase(linkRepo, projectRepo, itemTagRepo) + linkHandler := handler.NewLinkHandler(linkUseCase) + + problemRepo := persistence.NewProblemRepository(db) + problemUseCase := usecase.NewProblemUseCase(problemRepo, projectRepo, itemTagRepo) + problemHandler := handler.NewProblemHandler(problemUseCase) + + tagRepo := persistence.NewTagRepository(db) + tagUseCase := usecase.NewTagUseCase(tagRepo, projectRepo) + tagHandler := handler.NewTagHandler(tagUseCase) + + noteRepo := persistence.NewNoteRepository(db) + noteUseCase := usecase.NewNoteUseCase(noteRepo, projectRepo, itemTagRepo) + noteHandler := handler.NewNoteHandler(noteUseCase) + + appSettingRepo := persistence.NewAppSettingRepository(db) + masterKeySession := security.NewMasterKeySessionHolder() + keyDeriver := security.NewArgon2KeyDeriver() + vaultUseCase := usecase.NewVaultUseCase(keyDeriver, masterKeySession, appSettingRepo) + securityHandler := handler.NewSecurityHandler(vaultUseCase) + + cryptoAdapter := security.NewAESGCMCrypto() + credentialRepo := persistence.NewCredentialRepository(db) + credentialUseCase := usecase.NewCredentialUseCase(credentialRepo, projectRepo, itemTagRepo, cryptoAdapter, masterKeySession, *vaultUseCase) + credentialHandler := handler.NewCredentialHandler(credentialUseCase) + + itemTagUseCase := usecase.NewItemTagUseCase(itemTagRepo, tagRepo, projectRepo, snippetRepo, credentialRepo, linkRepo, problemRepo, noteRepo) + itemTagHandler := handler.NewItemTagHandler(itemTagUseCase, tagUseCase, projectUseCase) + + handlers := &web.Handlers{ + Project: projectHandler, + Snippet: snippetHandler, + Credential: credentialHandler, + Link: linkHandler, + Problem: problemHandler, + Note: noteHandler, + Tag: tagHandler, + ItemTag: itemTagHandler, + Security: securityHandler, + } + + token := "test-internal-token-12345" + router := web.SetupRouter(handlers, token) + + ts := httptest.NewServer(router) + + return &TestApp{ + Server: ts, + Token: token, + } +} + +func (app *TestApp) DoRequest(t *testing.T, method, path string, body []byte, sendToken bool) *http.Response { + var bodyReader *bytes.Buffer + if body != nil { + bodyReader = bytes.NewBuffer(body) + } else { + bodyReader = bytes.NewBuffer([]byte{}) + } + + req, err := http.NewRequest(method, app.Server.URL+path, bodyReader) + require.NoError(t, err) + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if sendToken { + req.Header.Set("DEVAULTY_INTERNAL_TOKEN", app.Token) + } + + resp, err := app.Server.Client().Do(req) + require.NoError(t, err) + t.Cleanup(func() { + _ = resp.Body.Close() + }) + return resp +} diff --git a/backend/internal/adapter/in/web/middleware/auth_middleware.go b/backend/internal/adapter/in/web/middleware/auth_middleware.go new file mode 100644 index 0000000..78b187d --- /dev/null +++ b/backend/internal/adapter/in/web/middleware/auth_middleware.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + + "github.com/gin-gonic/gin" +) + +func AuthMiddleware(apiToken string) gin.HandlerFunc { + return func(c *gin.Context) { + if apiToken == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + clientToken := c.GetHeader("DEVAULTY_INTERNAL_TOKEN") + if subtle.ConstantTimeCompare([]byte(clientToken), []byte(apiToken)) != 1 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + c.Next() + } +} diff --git a/backend/internal/adapter/in/web/middleware/cors_middleware.go b/backend/internal/adapter/in/web/middleware/cors_middleware.go new file mode 100644 index 0000000..ab00d19 --- /dev/null +++ b/backend/internal/adapter/in/web/middleware/cors_middleware.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +var allowedOrigins = []string{ + "tauri://localhost", + "http://tauri.localhost", + "http://localhost:1420", + "http://localhost:5173", + "http://localhost:8080", +} + +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + isAllowed := false + for _, allowedOrigin := range allowedOrigins { + if strings.EqualFold(origin, allowedOrigin) { + isAllowed = true + break + } + } + if isAllowed { + c.Header("Access-Control-Allow-Origin", origin) + } + c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, "+ + "X-CSRF-Token, Authorization, accept,origin, Cache-Control, X-Requested-With, DEVAULTY_INTERNAL_TOKEN") + c.Header("Access-Control-Allow-Methods", "POST, HEAD, PATCH, OPTIONS, GET, PUT, DELETE") + + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/backend/internal/adapter/in/web/router.go b/backend/internal/adapter/in/web/router.go new file mode 100644 index 0000000..78c7c51 --- /dev/null +++ b/backend/internal/adapter/in/web/router.go @@ -0,0 +1,185 @@ +package web + +import ( + "devaulty-backend/internal/adapter/in/web/handler" + "devaulty-backend/internal/adapter/in/web/middleware" + "net/http" + "os" + + "github.com/MarceloPetrucio/go-scalar-api-reference" + "github.com/gin-gonic/gin" +) + +type Handlers struct { + Project *handler.ProjectHandler + Snippet *handler.SnippetHandler + Credential *handler.CredentialHandler + Link *handler.LinkHandler + Problem *handler.ProblemHandler + Note *handler.NoteHandler + Tag *handler.TagHandler + ItemTag *handler.ItemTagHandler + Security *handler.SecurityHandler +} + +func SetupRouter(h *Handlers, apiToken string) *gin.Engine { + if os.Getenv("APP_ENV") != "dev" { + gin.SetMode(gin.ReleaseMode) + } + r := gin.New() + r.Use(gin.Recovery()) + r.Use(middleware.CORSMiddleware()) + + registerDocsRoutes(r) + + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "UP"}) + }) + + v1 := r.Group("/api/v1") + { + protected := v1.Group("") + protected.Use(middleware.AuthMiddleware(apiToken)) + { + mapProjectRoutes(protected, h.Project) + mapSnippetRoutes(protected, h.Snippet) + mapCredentialRoutes(protected, h.Credential) + mapLinkRoutes(protected, h.Link) + mapProblemRoutes(protected, h.Problem) + mapNoteRoutes(protected, h.Note) + mapTagRoutes(protected, h.Tag) + mapItemTagRoutes(protected, h.ItemTag) + mapSecurityRoutes(protected, h.Security) + } + } + return r +} + +func mapProjectRoutes(rg *gin.RouterGroup, h *handler.ProjectHandler) { + projects := rg.Group("/projects") + { + projects.POST("", h.Create) + projects.GET("", h.GetAll) + projects.GET("/:project_id", h.Get) + projects.PATCH("/:project_id", h.Update) + projects.PATCH("/:project_id/archive", h.Archive) + projects.PATCH("/:project_id/unarchive", h.Unarchive) + projects.DELETE("/:project_id", h.Delete) + } +} + +func mapSnippetRoutes(rg *gin.RouterGroup, h *handler.SnippetHandler) { + snippets := rg.Group("/projects/:project_id/snippets") + { + snippets.POST("", h.Create) + snippets.GET("", h.GetAll) + snippets.GET("/:snippet_id", h.Get) + snippets.PATCH("/:snippet_id", h.Update) + snippets.DELETE("/:snippet_id", h.Delete) + } +} + +func mapCredentialRoutes(rg *gin.RouterGroup, h *handler.CredentialHandler) { + credentials := rg.Group("/projects/:project_id/credentials") + { + credentials.POST("", h.Create) + credentials.GET("", h.GetAll) + credentials.GET("/:credential_id", h.Get) + credentials.PATCH("/:credential_id", h.Update) + credentials.DELETE("/:credential_id", h.Delete) + } +} + +func mapLinkRoutes(rg *gin.RouterGroup, h *handler.LinkHandler) { + links := rg.Group("/projects/:project_id/links") + { + links.POST("", h.Create) + links.GET("", h.GetAll) + links.GET("/:link_id", h.Get) + links.PATCH("/:link_id", h.Update) + links.DELETE("/:link_id", h.Delete) + } +} + +func mapProblemRoutes(rg *gin.RouterGroup, h *handler.ProblemHandler) { + problems := rg.Group("/projects/:project_id/problems") + { + problems.POST("", h.Create) + problems.GET("", h.GetAll) + problems.GET("/:problem_id", h.Get) + problems.PATCH("/:problem_id", h.Update) + problems.PATCH("/:problem_id/status", h.UpdateStatus) + problems.DELETE("/:problem_id", h.Delete) + } +} + +func mapNoteRoutes(rg *gin.RouterGroup, h *handler.NoteHandler) { + notes := rg.Group("/projects/:project_id/notes") + { + notes.POST("", h.Create) + notes.GET("", h.GetAll) + notes.GET("/:note_id", h.Get) + notes.PATCH("/:note_id", h.Update) + notes.DELETE("/:note_id", h.Delete) + } +} + +func mapTagRoutes(rg *gin.RouterGroup, h *handler.TagHandler) { + tags := rg.Group("/projects/:project_id/tags") + { + tags.POST("", h.Create) + tags.GET("", h.GetAll) + tags.GET("/search", h.SearchByName) + tags.GET("/:tag_id", h.Get) + tags.PATCH("/:tag_id", h.Update) + tags.DELETE("/:tag_id", h.Delete) + } +} + +func mapItemTagRoutes(rg *gin.RouterGroup, h *handler.ItemTagHandler) { + itemTags := rg.Group("/projects/:project_id/items/:item_type/:item_id/tags") + { + itemTags.PUT("/:tag_id", h.Associate) + itemTags.DELETE("/:tag_id", h.Disassociate) + } +} + +func mapSecurityRoutes(rg *gin.RouterGroup, h *handler.SecurityHandler) { + security := rg.Group("/security") + { + security.POST("/master-password", h.SetupMasterPassword) + security.GET("/master-password/required-status", h.CheckMasterPasswordSetup) + security.GET("/vault/status", h.GetSessionStatus) + security.POST("/vault/unlock", h.UnlockVault) + security.POST("/vault/lock", h.LockVault) + } +} + +func registerDocsRoutes(r *gin.Engine) { + if os.Getenv("APP_ENV") != "dev" { + return + } + + r.StaticFile("/openapi.yaml", "./docs/openapi.yaml") + + r.GET("/docs", func(c *gin.Context) { + specContent, err := os.ReadFile("./docs/openapi.yaml") + if err != nil { + c.String(http.StatusInternalServerError, "Failed to read openapi.yaml: "+err.Error()) + return + } + + htmlContent, err := scalar.ApiReferenceHTML(&scalar.Options{ + SpecContent: string(specContent), + CustomOptions: scalar.CustomOptions{ + PageTitle: "Devaulty API Documentation", + }, + DarkMode: true, + }) + if err != nil { + c.String(http.StatusInternalServerError, err.Error()) + return + } + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent)) + }) +} diff --git a/backend/internal/adapter/out/persistence/app_setting_repository.go b/backend/internal/adapter/out/persistence/app_setting_repository.go new file mode 100644 index 0000000..211582b --- /dev/null +++ b/backend/internal/adapter/out/persistence/app_setting_repository.go @@ -0,0 +1,83 @@ +package persistence + +import ( + "context" + "database/sql" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/usecase" + "errors" + "fmt" + + "github.com/jmoiron/sqlx" +) + +type AppSettingRepositoryAdapter struct { + db *sqlx.DB +} + +func NewAppSettingRepository(db *sqlx.DB) port.AppSettingRepository { + return &AppSettingRepositoryAdapter{db: db} +} + +func (r *AppSettingRepositoryAdapter) Save(ctx context.Context, setting *model.AppSetting) (*model.AppSetting, error) { + query := ` INSERT INTO app_settings (key, value) + VALUES (:key, :value) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + _, err := r.db.NamedExecContext(ctx, query, setting) + if err != nil { + return nil, fmt.Errorf("error trying to save app setting: %w", err) + } + + return setting, nil +} + +func (r *AppSettingRepositoryAdapter) FindByKey(ctx context.Context, key string) (*model.AppSetting, error) { + query := `SELECT * FROM app_settings WHERE key = ?` + var setting model.AppSetting + err := r.db.GetContext(ctx, &setting, query, key) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil // not found, returns nil + } + + return nil, fmt.Errorf("error trying to find app setting: %w", err) + } + return &setting, nil +} + +func (r *AppSettingRepositoryAdapter) ExistsByKey(ctx context.Context, key string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM app_settings WHERE key = ?)` + var exits bool + err := r.db.GetContext(ctx, &exits, query, key) + if err != nil { + return false, fmt.Errorf("error trying to check if app setting exists: %w", err) + } + return exits, nil +} + +func (r *AppSettingRepositoryAdapter) SaveMasterPasswordSettings(ctx context.Context, hashValue, saltValue string) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("error starting transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var count int + queryCheck := `SELECT COUNT(*) FROM app_settings WHERE key IN ('master_password_hash', 'master_password_salt')` + err = tx.GetContext(ctx, &count, queryCheck) + if err != nil { + return fmt.Errorf("error checking existing master password keys: %w", err) + } + if count > 0 { + return usecase.ErrMasterPasswordAlreadyConfigured + } + + queryInsert := `INSERT INTO app_settings (key, value) VALUES ('master_password_hash', ?), ('master_password_salt', ?)` + _, err = tx.ExecContext(ctx, queryInsert, hashValue, saltValue) + if err != nil { + return fmt.Errorf("error inserting master password settings: %w", err) + } + + return tx.Commit() +} diff --git a/backend/internal/adapter/out/persistence/credential_repository.go b/backend/internal/adapter/out/persistence/credential_repository.go new file mode 100644 index 0000000..b91d340 --- /dev/null +++ b/backend/internal/adapter/out/persistence/credential_repository.go @@ -0,0 +1,80 @@ +package persistence + +import ( + "context" + "database/sql" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type CredentialRepositoryAdapter struct { + db *sqlx.DB +} + +func NewCredentialRepository(db *sqlx.DB) port.CredentialRepository { + return &CredentialRepositoryAdapter{db: db} +} + +func (r *CredentialRepositoryAdapter) Save(ctx context.Context, credential *model.Credential) (*model.Credential, error) { + query := ` INSERT INTO credentials (id, project_id, title, secret_type, payload_encrypted, + encryption_iv, encryption_auth_tag, notes, + related_url, created_at, updated_at) + VALUES (:id , :project_id, :title, + :secret_type, :payload_encrypted, :encryption_iv, :encryption_auth_tag, + :notes, :related_url, :created_at, :updated_at) + ON CONFLICT(id) DO UPDATE SET title = excluded.title, secret_type = excluded.secret_type, + payload_encrypted = excluded.payload_encrypted, encryption_iv = excluded.encryption_iv, + encryption_auth_tag = excluded.encryption_auth_tag, notes = excluded.notes, + related_url = excluded.related_url, updated_at = excluded.updated_at` + _, err := r.db.NamedExecContext(ctx, query, credential) + if err != nil { + return nil, fmt.Errorf("error trying to save credential: %w", err) + } + return credential, nil +} + +func (r *CredentialRepositoryAdapter) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Credential, error) { + query := `SELECT * FROM credentials WHERE id = ? AND project_id = ?` + var credential model.Credential + err := r.db.GetContext(ctx, &credential, query, id, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("error trying to find credential: %w", err) + } + return &credential, nil +} + +func (r *CredentialRepositoryAdapter) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Credential], error) { + countQuery := `SELECT COUNT(*) FROM credentials WHERE project_id = ?` + selectQuery := `SELECT id, project_id, title, secret_type, related_url, created_at, updated_at + FROM credentials WHERE project_id = ? ORDER BY created_at DESC` + return PaginateExec[model.Credential](ctx, r.db, countQuery, selectQuery, page, size, projectID) +} + +func (r *CredentialRepositoryAdapter) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + query := `DELETE FROM credentials WHERE id = ? AND project_id = ?` + res, err := r.db.ExecContext(ctx, query, id, projectID) + if err != nil { + return false, fmt.Errorf("error trying to delete credential: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("error checking deleted rows: %w", err) + } + return rows > 0, nil +} + +func (r *CredentialRepositoryAdapter) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { + return ExistsByIDAndProjectIDExec(ctx, r.db, "credentials", id, projectID) +} + +func (r *CredentialRepositoryAdapter) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + return FindExistingIDsByProjectIDExec(ctx, r.db, "credentials", ids, projectID) +} diff --git a/backend/internal/adapter/out/persistence/db.go b/backend/internal/adapter/out/persistence/db.go new file mode 100644 index 0000000..9a55f6e --- /dev/null +++ b/backend/internal/adapter/out/persistence/db.go @@ -0,0 +1,112 @@ +package persistence + +import ( + "database/sql" + "errors" + "fmt" + "io/fs" + "log" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/sqlite3" + "github.com/golang-migrate/migrate/v4/source/iofs" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +// InitDB opens a SQLite connection and applies migrations from a filesystem path. +// Used in development and tests where migrations live on disk. +func InitDB(dbPath string, migrationsPath string) (*sqlx.DB, error) { + db, err := openDB(dbPath) + if err != nil { + return nil, err + } + + if err := runMigrationsFromPath(db.DB, migrationsPath); err != nil { + _ = db.Close() + return nil, fmt.Errorf("error trying to apply migrations: %w", err) + } + + log.Println("SQLite database connected and migrations applied") + return db, nil +} + +// InitDBWithFS opens a SQLite connection and applies migrations from an embedded filesystem. +// Used in production where migrations are compiled into the binary via go:embed. +func InitDBWithFS(dbPath string, migrationsFS fs.FS) (*sqlx.DB, error) { + db, err := openDB(dbPath) + if err != nil { + return nil, err + } + + if err := runMigrationsFromFS(db.DB, migrationsFS); err != nil { + _ = db.Close() + return nil, fmt.Errorf("error trying to apply migrations: %w", err) + } + + log.Println("SQLite database connected and migrations applied") + return db, nil +} + +func openDB(dbPath string) (*sqlx.DB, error) { + dsn := fmt.Sprintf("%s?_foreign_keys=on", dbPath) + db, err := sqlx.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("error trying to open Sqlite connection: %w", err) + } + + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("error to connect to Sqlite: %w", err) + } + + return db, nil +} + +func runMigrationsFromPath(db *sql.DB, migrationsPath string) error { + driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) + if err != nil { + return fmt.Errorf("error trying to create Sqlite driver for migrations: %w", err) + } + + sourceURL := fmt.Sprintf("file://%s", migrationsPath) + m, err := migrate.NewWithDatabaseInstance( + sourceURL, + "sqlite3", + driver, + ) + + if err != nil { + return fmt.Errorf("error trying to create migrations: %w", err) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("error trying to run migrations: %w", err) + } + + return nil +} + +func runMigrationsFromFS(db *sql.DB, migrationsFS fs.FS) error { + dbDriver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) + if err != nil { + return fmt.Errorf("error trying to create Sqlite driver for migrations: %w", err) + } + + sourceDriver, err := iofs.New(migrationsFS, ".") + if err != nil { + return fmt.Errorf("error trying to create embedded migrations source: %w", err) + } + + m, err := migrate.NewWithInstance("iofs", sourceDriver, "sqlite3", dbDriver) + if err != nil { + return fmt.Errorf("error trying to create migrations: %w", err) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("error trying to run migrations: %w", err) + } + + return nil +} diff --git a/backend/internal/adapter/out/persistence/item_tag_repository.go b/backend/internal/adapter/out/persistence/item_tag_repository.go new file mode 100644 index 0000000..50bdce1 --- /dev/null +++ b/backend/internal/adapter/out/persistence/item_tag_repository.go @@ -0,0 +1,108 @@ +package persistence + +import ( + "context" + "fmt" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type ItemTagRepositoryAdapter struct { + db *sqlx.DB +} + +func NewItemTagRepository(db *sqlx.DB) port.ItemTagRepository { + return &ItemTagRepositoryAdapter{db: db} +} + +func (r *ItemTagRepositoryAdapter) AssociateTagToItem(ctx context.Context, projectID, tagID uuid.UUID, itemType model.ItemType, itemID uuid.UUID) error { + query := ` + INSERT INTO item_tags (tag_id, item_type, item_id) + SELECT ?, ?, ? + WHERE EXISTS (SELECT 1 FROM tags WHERE id = ? AND project_id = ?) + ON CONFLICT(tag_id, item_type, item_id) DO NOTHING + ` + _, err := r.db.ExecContext(ctx, query, tagID, itemType, itemID, tagID, projectID) + if err != nil { + return fmt.Errorf("error trying to associate tag to item: %w", err) + } + return nil +} + +func (r *ItemTagRepositoryAdapter) DisassembleTagFromItem(ctx context.Context, projectID, tagID uuid.UUID, itemType model.ItemType, itemID uuid.UUID) error { + query := `DELETE FROM item_tags WHERE tag_id = ? AND item_type = ? AND item_id = ? AND tag_id IN (SELECT id FROM tags WHERE project_id = ?)` + _, err := r.db.ExecContext(ctx, query, tagID, itemType, itemID, projectID) + if err != nil { + return fmt.Errorf("error trying to disassemble tag from item: %w", err) + } + return nil +} + +func (r *ItemTagRepositoryAdapter) RemoveAllTagsFromItem(ctx context.Context, itemType model.ItemType, itemID uuid.UUID) error { + query := `DELETE FROM item_tags WHERE item_type = ? AND item_id = ?` + _, err := r.db.ExecContext(ctx, query, itemType, itemID) + if err != nil { + return fmt.Errorf("error trying to remove all tags from item: %w", err) + } + return nil +} + +func (r *ItemTagRepositoryAdapter) FindTagsForItem(ctx context.Context, itemType model.ItemType, projectID, itemID uuid.UUID) ([]model.Tag, error) { + query := ` + SELECT t.id, t.project_id, t.name, t.color, t.created_at, t.updated_at + FROM tags t + INNER JOIN item_tags it ON t.id = it.tag_id + WHERE it.item_type = ? AND it.item_id = ? AND t.project_id = ? + ORDER BY t.name ASC + ` + var tags []model.Tag + err := r.db.SelectContext(ctx, &tags, query, itemType, itemID, projectID) + if err != nil { + return nil, fmt.Errorf("error trying to find tags for item: %w", err) + } + if tags == nil { + tags = []model.Tag{} + } + return tags, nil +} + +type itemTagJoinRow struct { + ItemID uuid.UUID `db:"item_id"` + model.Tag +} + +func (r *ItemTagRepositoryAdapter) FindTagsForItems(ctx context.Context, itemType model.ItemType, projectID uuid.UUID, itemIDs []uuid.UUID) (map[uuid.UUID][]model.Tag, error) { + result := make(map[uuid.UUID][]model.Tag) + if len(itemIDs) == 0 { + return result, nil + } + + query := ` + SELECT it.item_id, t.id, t.project_id, t.name, t.color, t.created_at, t.updated_at + FROM tags t + INNER JOIN item_tags it ON t.id = it.tag_id + WHERE it.item_type = ? AND it.item_id IN (?) AND t.project_id = ? + ORDER BY t.name ASC + ` + query, args, err := sqlx.In(query, itemType, itemIDs, projectID) + if err != nil { + return nil, fmt.Errorf("error trying to build query for item tags: %w", err) + } + query = r.db.Rebind(query) + + var rows []itemTagJoinRow + err = r.db.SelectContext(ctx, &rows, query, args...) + if err != nil { + return nil, fmt.Errorf("error trying to find tags for items: %w", err) + } + + for _, row := range rows { + result[row.ItemID] = append(result[row.ItemID], row.Tag) + } + + return result, nil +} diff --git a/backend/internal/adapter/out/persistence/link_repository.go b/backend/internal/adapter/out/persistence/link_repository.go new file mode 100644 index 0000000..04c9df6 --- /dev/null +++ b/backend/internal/adapter/out/persistence/link_repository.go @@ -0,0 +1,75 @@ +package persistence + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type LinkRepositoryAdapter struct { + db *sqlx.DB +} + +func NewLinkRepository(db *sqlx.DB) port.LinkRepository { + return &LinkRepositoryAdapter{db: db} +} + +func (r *LinkRepositoryAdapter) Save(ctx context.Context, link *model.Link) (*model.Link, error) { + query := ` INSERT INTO links (id, project_id, title, url, description, created_at, updated_at) + VALUES (:id, :project_id, :title, :url, :description, :created_at, :updated_at) + ON CONFLICT(id) DO UPDATE SET title = excluded.title, url = excluded.url, description = excluded.description, updated_at = excluded.updated_at` + _, err := r.db.NamedExecContext(ctx, query, link) + if err != nil { + return nil, fmt.Errorf("error trying to save link: %w", err) + } + + return link, nil +} + +func (r *LinkRepositoryAdapter) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Link, error) { + query := `SELECT * FROM links WHERE id = ? AND project_id = ?` + var link model.Link + err := r.db.GetContext(ctx, &link, query, id, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("error trying to find link: %w", err) + } + + return &link, nil +} + +func (r *LinkRepositoryAdapter) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Link], error) { + countQuery := `SELECT COUNT(*) FROM links WHERE project_id = ?` + selectQuery := `SELECT * FROM links WHERE project_id = ? ORDER BY created_at DESC` + return PaginateExec[model.Link](ctx, r.db, countQuery, selectQuery, page, size, projectID) +} + +func (r *LinkRepositoryAdapter) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + query := `DELETE FROM links WHERE id = ? AND project_id = ?` + res, err := r.db.ExecContext(ctx, query, id, projectID) + if err != nil { + return false, fmt.Errorf("error trying to delete link: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("error checking deleted rows: %w", err) + } + return rows > 0, nil +} + +func (r *LinkRepositoryAdapter) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { + return ExistsByIDAndProjectIDExec(ctx, r.db, "links", id, projectID) +} + +func (r *LinkRepositoryAdapter) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + return FindExistingIDsByProjectIDExec(ctx, r.db, "links", ids, projectID) +} diff --git a/backend/internal/adapter/out/persistence/note_repository.go b/backend/internal/adapter/out/persistence/note_repository.go new file mode 100644 index 0000000..59548c4 --- /dev/null +++ b/backend/internal/adapter/out/persistence/note_repository.go @@ -0,0 +1,73 @@ +package persistence + +import ( + "context" + "database/sql" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type NoteRepositoryAdapter struct { + db *sqlx.DB +} + +func NewNoteRepository(db *sqlx.DB) port.NoteRepository { + return &NoteRepositoryAdapter{db: db} +} + +func (r *NoteRepositoryAdapter) Save(ctx context.Context, note *model.Note) (*model.Note, error) { + query := ` INSERT INTO notes (id, project_id, title, content, archived, created_at, updated_at) + VALUES (:id, :project_id, :title, :content, :archived, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET title = excluded.title, content = excluded.content, + archived = excluded.archived, updated_at = excluded.updated_at` + _, err := r.db.NamedExecContext(ctx, query, note) + if err != nil { + return nil, err + } + return note, nil +} + +func (r *NoteRepositoryAdapter) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Note, error) { + query := `SELECT * FROM notes WHERE id = ? AND project_id = ?` + var note model.Note + err := r.db.GetContext(ctx, ¬e, query, id, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("error trying to find note: %w", err) + } + return ¬e, nil +} + +func (r *NoteRepositoryAdapter) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Note], error) { + countQuery := `SELECT COUNT(*) FROM notes WHERE project_id = ?` + selectQuery := `SELECT * FROM notes WHERE project_id = ? ORDER BY created_at DESC` + return PaginateExec[model.Note](ctx, r.db, countQuery, selectQuery, page, size, projectID) +} + +func (r *NoteRepositoryAdapter) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + query := `DELETE FROM notes WHERE id = ? AND project_id = ?` + res, err := r.db.ExecContext(ctx, query, id, projectID) + if err != nil { + return false, fmt.Errorf("error trying to delete note: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("error checking deleted rows: %w", err) + } + return rows > 0, nil +} + +func (r *NoteRepositoryAdapter) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { + return ExistsByIDAndProjectIDExec(ctx, r.db, "notes", id, projectID) +} + +func (r *NoteRepositoryAdapter) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + return FindExistingIDsByProjectIDExec(ctx, r.db, "notes", ids, projectID) +} diff --git a/backend/internal/adapter/out/persistence/pagination.go b/backend/internal/adapter/out/persistence/pagination.go new file mode 100644 index 0000000..e096719 --- /dev/null +++ b/backend/internal/adapter/out/persistence/pagination.go @@ -0,0 +1,44 @@ +package persistence + +import ( + "context" + "devaulty-backend/internal/domain/model" + "fmt" + + "github.com/jmoiron/sqlx" +) + +func PaginateExec[T any]( + ctx context.Context, + db *sqlx.DB, + countQuery string, + selectQuery string, + page, size int, + args ...any) (model.Page[T], error) { + if page < 0 { + page = 0 + } + + if size <= 0 { + size = 10 + } + + offset := page * size + var totalElements int64 + err := db.GetContext(ctx, &totalElements, countQuery, args...) + if err != nil { + return model.Page[T]{}, fmt.Errorf("error trying to count items: %w", err) + } + if totalElements == 0 { + return model.NewPage([]T{}, page, size, 0), nil + } + + queryWithPagination := fmt.Sprintf("%s LIMIT ? OFFSET ?", selectQuery) + fullArgs := append(args, size, offset) + var items []T + err = db.SelectContext(ctx, &items, queryWithPagination, fullArgs...) + if err != nil { + return model.Page[T]{}, fmt.Errorf("error trying to find items: %w", err) + } + return model.NewPage(items, page, size, totalElements), nil +} diff --git a/backend/internal/adapter/out/persistence/problem_repository.go b/backend/internal/adapter/out/persistence/problem_repository.go new file mode 100644 index 0000000..c7d647c --- /dev/null +++ b/backend/internal/adapter/out/persistence/problem_repository.go @@ -0,0 +1,78 @@ +package persistence + +import ( + "context" + "database/sql" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type ProblemRepositoryAdapter struct { + db *sqlx.DB +} + +func NewProblemRepository(db *sqlx.DB) port.ProblemRepository { + return &ProblemRepositoryAdapter{db: db} +} + +func (r *ProblemRepositoryAdapter) Save(ctx context.Context, problem *model.Problem) (*model.Problem, error) { + query := ` INSERT into problems ( + id, project_id, title, error_description, solution, + status, severity, created_at, updated_at + ) VALUES (:id , :project_id, :title, :error_description, :solution, :status, :severity, :created_at, :updated_at) + ON CONFLICT(id) DO UPDATE SET title = excluded.title, error_description = excluded.error_description, + solution = excluded.solution, status = excluded.status, + severity = excluded.severity, updated_at = excluded.updated_at` + _, err := r.db.NamedExecContext(ctx, query, problem) + if err != nil { + return nil, fmt.Errorf("error trying to save problem: %w", err) + } + return problem, nil +} + +func (r *ProblemRepositoryAdapter) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Problem, error) { + query := `SELECT * FROM problems WHERE id = ? AND project_id = ?` + var problem model.Problem + err := r.db.GetContext(ctx, &problem, query, id, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("error trying to find problem: %w", err) + } + return &problem, nil +} + +func (r *ProblemRepositoryAdapter) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[port.ProblemSummary], error) { + countQuery := `SELECT COUNT(*) FROM problems WHERE project_id = ?` + selectQuery := `SELECT id, project_id, title, status, severity, created_at, updated_at FROM problems + WHERE project_id = ? ORDER BY created_at DESC` + return PaginateExec[port.ProblemSummary](ctx, r.db, countQuery, selectQuery, page, size, projectID) +} + +func (r *ProblemRepositoryAdapter) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + query := `DELETE FROM problems WHERE id = ? AND project_id = ?` + res, err := r.db.ExecContext(ctx, query, id, projectID) + if err != nil { + return false, fmt.Errorf("error trying to delete problem: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("error checking deleted rows: %w", err) + } + + return rows > 0, nil +} + +func (r *ProblemRepositoryAdapter) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { + return ExistsByIDAndProjectIDExec(ctx, r.db, "problems", id, projectID) +} + +func (r *ProblemRepositoryAdapter) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + return FindExistingIDsByProjectIDExec(ctx, r.db, "problems", ids, projectID) +} diff --git a/backend/internal/adapter/out/persistence/project_repository.go b/backend/internal/adapter/out/persistence/project_repository.go new file mode 100644 index 0000000..8f4292c --- /dev/null +++ b/backend/internal/adapter/out/persistence/project_repository.go @@ -0,0 +1,75 @@ +package persistence + +import ( + "context" + "database/sql" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type ProjectRepositoryAdapter struct { + db *sqlx.DB +} + +func NewProjectRepository(db *sqlx.DB) port.ProjectRepository { + return &ProjectRepositoryAdapter{db: db} +} + +func (r *ProjectRepositoryAdapter) Save(ctx context.Context, project *model.Project) (*model.Project, error) { + query := ` INSERT INTO projects (id, name, description, icon, color, archived, created_at, updated_at) + VALUES (:id, :name, :description, :icon, :color, :archived, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET name = excluded.name, description = excluded.description, + icon = excluded.icon, color = excluded.color, + archived = excluded.archived, updated_at = excluded.updated_at` + _, err := r.db.NamedExecContext(ctx, query, project) + if err != nil { + return nil, err + } + return project, nil +} + +func (r *ProjectRepositoryAdapter) FindAll(ctx context.Context, page int, size int) (model.Page[model.Project], error) { + countQuery := `SELECT COUNT(*) FROM projects` + selectQuery := `SELECT * FROM projects ORDER BY created_at DESC` + return PaginateExec[model.Project](ctx, r.db, countQuery, selectQuery, page, size) +} + +func (r *ProjectRepositoryAdapter) FindByID(ctx context.Context, id uuid.UUID) (*model.Project, error) { + query := `SELECT * FROM projects WHERE id = ?` + var project model.Project + err := r.db.GetContext(ctx, &project, query, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &project, nil +} + +func (r *ProjectRepositoryAdapter) DeleteByID(ctx context.Context, id uuid.UUID) (bool, error) { + query := `DELETE FROM projects WHERE id = ?` + res, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return false, err + } + rows, err := res.RowsAffected() + if err != nil { + return false, err + } + return rows > 0, nil +} + +func (r *ProjectRepositoryAdapter) ExistsByID(ctx context.Context, id uuid.UUID) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM projects WHERE id = ?)` + var exists bool + err := r.db.GetContext(ctx, &exists, query, id) + if err != nil { + return false, err + } + return exists, nil +} diff --git a/backend/internal/adapter/out/persistence/project_scoped_helper.go b/backend/internal/adapter/out/persistence/project_scoped_helper.go new file mode 100644 index 0000000..7c05195 --- /dev/null +++ b/backend/internal/adapter/out/persistence/project_scoped_helper.go @@ -0,0 +1,39 @@ +package persistence + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +func ExistsByIDAndProjectIDExec(ctx context.Context, db *sqlx.DB, tableName string, id, projectID uuid.UUID) (bool, error) { + query := fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM %s WHERE id = ? AND project_id = ?)", tableName) + var exists bool + err := db.GetContext(ctx, &exists, query, id, projectID) + if err != nil { + return false, fmt.Errorf("error checking existence in %s: %w", tableName, err) + } + return exists, nil +} + +func FindExistingIDsByProjectIDExec(ctx context.Context, db *sqlx.DB, tableName string, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + if len(ids) == 0 { + return []uuid.UUID{}, nil + } + + query := fmt.Sprintf("SELECT id FROM %s WHERE id IN (?) AND project_id = ?", tableName) + query, args, err := sqlx.In(query, ids, projectID) + if err != nil { + return nil, fmt.Errorf("error trying to build query: %w", err) + } + query = db.Rebind(query) + var existingIDs []uuid.UUID + err = db.SelectContext(ctx, &existingIDs, query, args...) + if err != nil { + return nil, fmt.Errorf("error trying to find existing ids in %s: %w", tableName, err) + } + + return existingIDs, nil +} diff --git a/backend/internal/adapter/out/persistence/snippet_repository.go b/backend/internal/adapter/out/persistence/snippet_repository.go new file mode 100644 index 0000000..b24877e --- /dev/null +++ b/backend/internal/adapter/out/persistence/snippet_repository.go @@ -0,0 +1,76 @@ +package persistence + +import ( + "context" + "database/sql" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type SnippetRepositoryAdapter struct { + db *sqlx.DB +} + +func NewSnippetRepository(db *sqlx.DB) port.SnippetRepository { + return &SnippetRepositoryAdapter{db: db} +} + +func (r *SnippetRepositoryAdapter) Save(ctx context.Context, snippet *model.Snippet) (*model.Snippet, error) { + query := ` INSERT INTO snippets (id, project_id, title, description, content, language, + snippet_type, created_at, updated_at) + VALUES (:id, :project_id, :title, :description, :content, :language, + :snippet_type, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET title = excluded.title, description = excluded.description, + content = excluded.content, language = excluded.language, + snippet_type = excluded.snippet_type, updated_at = excluded.updated_at` + _, err := r.db.NamedExecContext(ctx, query, snippet) + if err != nil { + return nil, fmt.Errorf("error trying to save snippet: %w", err) + } + return snippet, nil +} + +func (r *SnippetRepositoryAdapter) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Snippet, error) { + query := `SELECT * FROM snippets WHERE id = ? AND project_id = ?` + var snippet model.Snippet + err := r.db.GetContext(ctx, &snippet, query, id, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("error trying to find snippet: %w", err) + } + return &snippet, nil +} + +func (r *SnippetRepositoryAdapter) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Snippet], error) { + countQuery := `SELECT COUNT(*) FROM snippets WHERE project_id = ?` + selectQuery := `SELECT * FROM snippets WHERE project_id = ? ORDER BY created_at DESC` + return PaginateExec[model.Snippet](ctx, r.db, countQuery, selectQuery, page, size, projectID) +} + +func (r *SnippetRepositoryAdapter) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + query := `DELETE FROM snippets WHERE id = ? AND project_id = ?` + res, err := r.db.ExecContext(ctx, query, id, projectID) + if err != nil { + return false, fmt.Errorf("error trying to delete snippet: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("error checking deleted rows: %w", err) + } + return rows > 0, nil +} + +func (r *SnippetRepositoryAdapter) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { + return ExistsByIDAndProjectIDExec(ctx, r.db, "snippets", id, projectID) +} + +func (r *SnippetRepositoryAdapter) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + return FindExistingIDsByProjectIDExec(ctx, r.db, "snippets", ids, projectID) +} diff --git a/backend/internal/adapter/out/persistence/tag_repository.go b/backend/internal/adapter/out/persistence/tag_repository.go new file mode 100644 index 0000000..49385df --- /dev/null +++ b/backend/internal/adapter/out/persistence/tag_repository.go @@ -0,0 +1,99 @@ +package persistence + +import ( + "context" + "database/sql" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type TagRepositoryAdapter struct { + db *sqlx.DB +} + +func NewTagRepository(db *sqlx.DB) port.TagRepository { + return &TagRepositoryAdapter{db: db} +} + +func (r *TagRepositoryAdapter) Save(ctx context.Context, tag *model.Tag) (*model.Tag, error) { + query := ` INSERT INTO tags (id, project_id, name, color, created_at, updated_at) + VALUES (:id, :project_id, :name, :color, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET name = excluded.name, color = excluded.color, + updated_at = excluded.updated_at` + _, err := r.db.NamedExecContext(ctx, query, tag) + if err != nil { + return nil, err + } + return tag, nil +} + +func (r *TagRepositoryAdapter) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Tag, error) { + query := `SELECT * FROM tags WHERE id = ? AND project_id = ?` + var tag model.Tag + err := r.db.GetContext(ctx, &tag, query, id, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &tag, nil +} + +func (r *TagRepositoryAdapter) FindAllByProjectID(ctx context.Context, projectID uuid.UUID) ([]model.Tag, error) { + query := `SELECT * FROM tags WHERE project_id = ? ORDER BY created_at DESC` + var tags []model.Tag + err := r.db.SelectContext(ctx, &tags, query, projectID) + if err != nil { + return nil, fmt.Errorf("error trying to find tags: %w", err) + } + return tags, nil +} + +func (r *TagRepositoryAdapter) SearchByNameAndProjectID(ctx context.Context, name string, projectID uuid.UUID) ([]model.Tag, error) { + query := `SELECT * FROM tags WHERE project_id = ? AND (? = '' OR LOWER(name) LIKE LOWER(?)) ORDER BY name ASC` + var tags []model.Tag + err := r.db.SelectContext(ctx, &tags, query, projectID, "%"+name+"%", "%"+name+"%") + if err != nil { + return nil, fmt.Errorf("error trying to find tags: %w", err) + } + return tags, nil +} + +func (r *TagRepositoryAdapter) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + query := `DELETE FROM tags WHERE id = ? AND project_id = ?` + res, err := r.db.ExecContext(ctx, query, id, projectID) + if err != nil { + return false, fmt.Errorf("error trying to delete tag: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("error checking deleted rows: %w", err) + } + return rows > 0, nil +} + +func (r *TagRepositoryAdapter) ExistsByNameAndProjectID(ctx context.Context, name string, projectID uuid.UUID) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?))` + var exists bool + err := r.db.GetContext(ctx, &exists, query, projectID, name) + if err != nil { + return false, err + } + return exists, nil +} + +func (r *TagRepositoryAdapter) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM tags WHERE id = ? AND project_id = ?)` + var exists bool + err := r.db.GetContext(ctx, &exists, query, id, projectID) + if err != nil { + return false, err + } + return exists, nil +} diff --git a/backend/internal/adapter/out/security/aes_gcm_crypto.go b/backend/internal/adapter/out/security/aes_gcm_crypto.go new file mode 100644 index 0000000..59b0da8 --- /dev/null +++ b/backend/internal/adapter/out/security/aes_gcm_crypto.go @@ -0,0 +1,75 @@ +package security + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "devaulty-backend/internal/domain/port" + "errors" + "fmt" +) + +var ( + ErrDecryptionFailed = errors.New("decryption failed") +) + +const ( + TagBitLength = 128 // 16 bytes + IvByteLength = 12 // 12 bytes +) + +type AESGCMCryptoAdapter struct{} + +func NewAESGCMCrypto() port.Crypto { + return &AESGCMCryptoAdapter{} +} + +func (A *AESGCMCryptoAdapter) Encrypt(plainData, secretKey, aad []byte) (cipherText, iv, authTag []byte, err error) { + iv = make([]byte, IvByteLength) + if _, err := rand.Read(iv); err != nil { + return nil, nil, nil, err + } + + cipherBlock, err := aes.NewCipher(secretKey) + if err != nil { + return nil, nil, nil, err + } + + gcm, err := cipher.NewGCM(cipherBlock) + if err != nil { + return nil, nil, nil, err + } + + sealed := gcm.Seal(nil, iv, plainData, aad) + + tagLen := TagBitLength / 8 + cipherTextLen := len(sealed) - tagLen + + cipherText = sealed[:cipherTextLen] + authTag = sealed[cipherTextLen:] + + return cipherText, iv, authTag, nil +} + +func (A *AESGCMCryptoAdapter) Decrypt(cipherText, iv, authTag, secretKey, aad []byte) (plainData []byte, err error) { + cipherBlock, err := aes.NewCipher(secretKey) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(cipherBlock) + if err != nil { + return nil, err + } + + sealed := make([]byte, 0, len(cipherText)+len(authTag)) + sealed = append(sealed, cipherText...) + sealed = append(sealed, authTag...) + + plainData, err = gcm.Open(nil, iv, sealed, aad) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err) + } + + return plainData, nil +} diff --git a/backend/internal/adapter/out/security/aes_gcm_crypto_test.go b/backend/internal/adapter/out/security/aes_gcm_crypto_test.go new file mode 100644 index 0000000..f641b9e --- /dev/null +++ b/backend/internal/adapter/out/security/aes_gcm_crypto_test.go @@ -0,0 +1,67 @@ +package security_test + +import ( + "bytes" + "crypto/rand" + "testing" + + "devaulty-backend/internal/adapter/out/security" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAESGCMCryptoAdapter_EncryptAndDecrypt(t *testing.T) { + crypto := security.NewAESGCMCrypto() + + key := make([]byte, 32) + _, _ = rand.Read(key) + + aad := []byte("32BytesLongAADProjectIDAndCredID") + plainData := []byte(`{"username":"admin","password":"mySecretPassword123"}`) + + t.Run("Encrypt and decrypt success", func(t *testing.T) { + cipherText, iv, authTag, err := crypto.Encrypt(plainData, key, aad) + require.NoError(t, err) + assert.NotEmpty(t, cipherText) + assert.Len(t, iv, 12) + assert.Len(t, authTag, 16) + + decrypted, err := crypto.Decrypt(cipherText, iv, authTag, key, aad) + require.NoError(t, err) + assert.True(t, bytes.Equal(plainData, decrypted)) + }) + + t.Run("Decrypt failure with wrong key", func(t *testing.T) { + cipherText, iv, authTag, err := crypto.Encrypt(plainData, key, aad) + require.NoError(t, err) + + wrongKey := make([]byte, 32) + _, _ = rand.Read(wrongKey) + + _, err = crypto.Decrypt(cipherText, iv, authTag, wrongKey, aad) + assert.Error(t, err) + }) + + t.Run("Decrypt failure with wrong AAD", func(t *testing.T) { + cipherText, iv, authTag, err := crypto.Encrypt(plainData, key, aad) + require.NoError(t, err) + + wrongAAD := []byte("Wrong32BytesLongAADString1234567") + + _, err = crypto.Decrypt(cipherText, iv, authTag, key, wrongAAD) + assert.Error(t, err) + }) + + t.Run("Decrypt failure with tampered authTag", func(t *testing.T) { + cipherText, iv, authTag, err := crypto.Encrypt(plainData, key, aad) + require.NoError(t, err) + + tamperedAuthTag := make([]byte, len(authTag)) + copy(tamperedAuthTag, authTag) + tamperedAuthTag[0] ^= 0xFF + + _, err = crypto.Decrypt(cipherText, iv, tamperedAuthTag, key, aad) + assert.Error(t, err) + }) +} diff --git a/backend/internal/adapter/out/security/argon2_key_deriver.go b/backend/internal/adapter/out/security/argon2_key_deriver.go new file mode 100644 index 0000000..ddaebc8 --- /dev/null +++ b/backend/internal/adapter/out/security/argon2_key_deriver.go @@ -0,0 +1,58 @@ +package security + +import ( + "crypto/rand" + "crypto/subtle" + "devaulty-backend/internal/domain/port" + "fmt" + + "golang.org/x/crypto/argon2" +) + +const ( + ArgonIterations uint32 = 3 + ArgonMemory uint32 = 65536 // this is 64MB, A.K.A. 64 * 1024 * 1024 + ArgonParallelism uint8 = 2 + KeyLength uint32 = 32 +) + +type Argon2KeyDeriverAdapter struct{} + +func NewArgon2KeyDeriver() port.KeyDeriver { + return &Argon2KeyDeriverAdapter{} +} + +func (a *Argon2KeyDeriverAdapter) GenerateSalt(size int) ([]byte, error) { + if size <= 0 { + return nil, fmt.Errorf("invalid salt size: %d", size) + } + salt := make([]byte, size) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("error generating salt: %w", err) + } + return salt, nil +} + +func (a *Argon2KeyDeriverAdapter) DeriveKey(password, salt []byte) ([]byte, error) { + if password == nil || salt == nil { + return nil, fmt.Errorf("password and salt must not be nil") + } + key := argon2.IDKey(password, salt, ArgonIterations, ArgonMemory, ArgonParallelism, KeyLength) + return key, nil +} + +func (a *Argon2KeyDeriverAdapter) HashPassword(password, salt []byte) ([]byte, error) { + hashedPassword, err := a.DeriveKey(password, salt) + if err != nil { + return nil, fmt.Errorf("error hashing password: %w", err) + } + return hashedPassword, nil +} + +func (a *Argon2KeyDeriverAdapter) VerifyPassword(password, salt, expectedHash []byte) bool { + hashedPassword, err := a.DeriveKey(password, salt) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(hashedPassword, expectedHash) == 1 +} diff --git a/backend/internal/adapter/out/security/argon2_key_deriver_test.go b/backend/internal/adapter/out/security/argon2_key_deriver_test.go new file mode 100644 index 0000000..3885b4c --- /dev/null +++ b/backend/internal/adapter/out/security/argon2_key_deriver_test.go @@ -0,0 +1,90 @@ +package security_test + +import ( + "bytes" + "testing" + + "devaulty-backend/internal/adapter/out/security" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestArgon2KeyDeriver_GenerateSalt(t *testing.T) { + deriver := security.NewArgon2KeyDeriver() + + t.Run("Generates salt of specified length", func(t *testing.T) { + salt, err := deriver.GenerateSalt(16) + require.NoError(t, err) + assert.Len(t, salt, 16) + }) + + t.Run("Generates distinct random salts", func(t *testing.T) { + salt1, err1 := deriver.GenerateSalt(16) + salt2, err2 := deriver.GenerateSalt(16) + require.NoError(t, err1) + require.NoError(t, err2) + assert.False(t, bytes.Equal(salt1, salt2)) + }) +} + +func TestArgon2KeyDeriver_DeriveKey(t *testing.T) { + deriver := security.NewArgon2KeyDeriver() + password := []byte("mySecretMasterPassword123") + salt := []byte("0123456789abcdef") + + t.Run("Derives valid 32-byte key", func(t *testing.T) { + key, err := deriver.DeriveKey(password, salt) + require.NoError(t, err) + assert.Len(t, key, 32) + }) + + t.Run("Derives identical key for identical inputs", func(t *testing.T) { + key1, err1 := deriver.DeriveKey(password, salt) + key2, err2 := deriver.DeriveKey(password, salt) + require.NoError(t, err1) + require.NoError(t, err2) + assert.True(t, bytes.Equal(key1, key2)) + }) + + t.Run("Derives different key for different passwords", func(t *testing.T) { + differentPassword := []byte("differentMasterPassword123") + key1, _ := deriver.DeriveKey(password, salt) + key2, _ := deriver.DeriveKey(differentPassword, salt) + assert.False(t, bytes.Equal(key1, key2)) + }) + + t.Run("Derives different key for different salts", func(t *testing.T) { + differentSalt := []byte("fedcba9876543210") + key1, _ := deriver.DeriveKey(password, salt) + key2, _ := deriver.DeriveKey(password, differentSalt) + assert.False(t, bytes.Equal(key1, key2)) + }) +} + +func TestArgon2KeyDeriver_HashAndPasswordVerification(t *testing.T) { + deriver := security.NewArgon2KeyDeriver() + password := []byte("mySecretMasterPassword123") + wrongPassword := []byte("wrongMasterPassword123") + + salt, err := deriver.GenerateSalt(16) + require.NoError(t, err) + + t.Run("HashPassword and VerifyPassword success", func(t *testing.T) { + hash, err := deriver.HashPassword(password, salt) + require.NoError(t, err) + assert.Len(t, hash, 32) + + // Verification with correct password + match := deriver.VerifyPassword(password, salt, hash) + assert.True(t, match) + }) + + t.Run("VerifyPassword failure with wrong password", func(t *testing.T) { + hash, err := deriver.HashPassword(password, salt) + require.NoError(t, err) + + match := deriver.VerifyPassword(wrongPassword, salt, hash) + assert.False(t, match) + }) +} diff --git a/backend/internal/adapter/out/security/master_key_session_holder.go b/backend/internal/adapter/out/security/master_key_session_holder.go new file mode 100644 index 0000000..57b7ecc --- /dev/null +++ b/backend/internal/adapter/out/security/master_key_session_holder.go @@ -0,0 +1,126 @@ +package security + +import ( + "devaulty-backend/internal/domain/port" + "sync" + "time" +) + +const ( + DefaultTimeout = 15 * time.Minute +) + +type MasterKeySessionHolderAdapter struct { + mu sync.RWMutex + masterKey []byte + lastActivityAt *time.Time +} + +func NewMasterKeySessionHolder() port.MasterKeySession { + return &MasterKeySessionHolderAdapter{} +} + +func (m *MasterKeySessionHolderAdapter) SetKey(key []byte) { + m.mu.Lock() + defer m.mu.Unlock() + + m.clearLocked() + + if len(key) == 0 { + return + } + + m.masterKey = make([]byte, len(key)) + copy(m.masterKey, key) + + now := time.Now() + m.lastActivityAt = &now +} + +func (m *MasterKeySessionHolderAdapter) GetKey() []byte { + m.mu.Lock() + defer m.mu.Unlock() + if m.hasKeyLocked() { + if m.isExpired(DefaultTimeout) { + m.clearLocked() + return nil + } + m.touchLocked() + } + if m.masterKey == nil { + return nil + } + keyCopy := make([]byte, len(m.masterKey)) + copy(keyCopy, m.masterKey) + return keyCopy +} + +func (m *MasterKeySessionHolderAdapter) HasKey() bool { + m.mu.Lock() + defer m.mu.Unlock() + if !m.hasKeyLocked() { + return false + } + if m.isExpired(DefaultTimeout) { + m.clearLocked() + return false + } + return true +} + +func (m *MasterKeySessionHolderAdapter) hasKeyLocked() bool { + return m.masterKey != nil +} + +func (m *MasterKeySessionHolderAdapter) Clear() { + m.mu.Lock() + defer m.mu.Unlock() + m.clearLocked() +} + +func (m *MasterKeySessionHolderAdapter) clearLocked() { + clear(m.masterKey) + m.masterKey = nil + m.lastActivityAt = nil +} + +func (m *MasterKeySessionHolderAdapter) Touch() { + m.mu.Lock() + defer m.mu.Unlock() + m.touchLocked() +} + +func (m *MasterKeySessionHolderAdapter) touchLocked() { + if m.hasKeyLocked() { + if m.isExpired(DefaultTimeout) { + m.clearLocked() + return + } + now := time.Now() + m.lastActivityAt = &now + } +} + +func (m *MasterKeySessionHolderAdapter) GetSecondsRemaining(timeout time.Duration) int64 { + m.mu.RLock() + defer m.mu.RUnlock() + if m.masterKey == nil || m.lastActivityAt == nil { + return 0 + } + elapsedSeconds := time.Since(*m.lastActivityAt).Seconds() + timeoutSeconds := timeout.Seconds() + remaining := timeoutSeconds - elapsedSeconds + if remaining < 0 { + return 0 + } + return int64(remaining) +} + +// helper methods +func (m *MasterKeySessionHolderAdapter) isExpired(timeout time.Duration) bool { + if (m.lastActivityAt == nil) || (timeout == 0) { + return true + } + elapsedSeconds := time.Since(*m.lastActivityAt).Seconds() + return elapsedSeconds > timeout.Seconds() +} diff --git a/backend/internal/adapter/out/security/master_key_session_holder_test.go b/backend/internal/adapter/out/security/master_key_session_holder_test.go new file mode 100644 index 0000000..6e8da1f --- /dev/null +++ b/backend/internal/adapter/out/security/master_key_session_holder_test.go @@ -0,0 +1,86 @@ +package security_test + +import ( + "testing" + "time" + + "devaulty-backend/internal/adapter/out/security" + + "github.com/stretchr/testify/assert" +) + +func TestMasterKeySessionHolder_Initialization(t *testing.T) { + session := security.NewMasterKeySessionHolder() + + assert.False(t, session.HasKey()) + assert.Nil(t, session.GetKey()) + assert.Equal(t, int64(0), session.GetSecondsRemaining(15*time.Minute)) +} + +func TestMasterKeySessionHolder_SetAndGetKey(t *testing.T) { + session := security.NewMasterKeySessionHolder() + keyBytes := []byte("12345678901234567890123456789012") // 32 bytes + + session.SetKey(keyBytes) + + assert.True(t, session.HasKey()) + assert.Equal(t, keyBytes, session.GetKey()) + assert.Greater(t, session.GetSecondsRemaining(15*time.Minute), int64(0)) +} + +func TestMasterKeySessionHolder_DefensiveCopy(t *testing.T) { + session := security.NewMasterKeySessionHolder() + originalKey := []byte("12345678901234567890123456789012") + + session.SetKey(originalKey) + + // Retrieve key and mutate returned slice + retrievedKey := session.GetKey() + retrievedKey[0] = 'X' + + // Verify that internal session key was NOT mutated by the caller + secondRetrieval := session.GetKey() + assert.Equal(t, originalKey, secondRetrieval) + assert.NotEqual(t, retrievedKey, secondRetrieval) +} + +func TestMasterKeySessionHolder_Clear(t *testing.T) { + session := security.NewMasterKeySessionHolder() + keyBytes := []byte("12345678901234567890123456789012") + + session.SetKey(keyBytes) + assert.True(t, session.HasKey()) + + session.Clear() + + assert.False(t, session.HasKey()) + assert.Nil(t, session.GetKey()) + assert.Equal(t, int64(0), session.GetSecondsRemaining(15*time.Minute)) +} + +func TestMasterKeySessionHolder_SetEmptyKey(t *testing.T) { + session := security.NewMasterKeySessionHolder() + keyBytes := []byte("12345678901234567890123456789012") + + session.SetKey(keyBytes) + assert.True(t, session.HasKey()) + + // Setting empty key should clear the session + session.SetKey([]byte{}) + assert.False(t, session.HasKey()) + assert.Nil(t, session.GetKey()) +} + +func TestMasterKeySessionHolder_Touch(t *testing.T) { + session := security.NewMasterKeySessionHolder() + keyBytes := []byte("12345678901234567890123456789012") + + session.SetKey(keyBytes) + initialRemaining := session.GetSecondsRemaining(15 * time.Minute) + + session.Touch() + touchedRemaining := session.GetSecondsRemaining(15 * time.Minute) + + assert.True(t, session.HasKey()) + assert.GreaterOrEqual(t, touchedRemaining, initialRemaining-2) +} diff --git a/backend/internal/domain/model/app_setting.go b/backend/internal/domain/model/app_setting.go new file mode 100644 index 0000000..6abcf18 --- /dev/null +++ b/backend/internal/domain/model/app_setting.go @@ -0,0 +1,6 @@ +package model + +type AppSetting struct { + Key string `json:"key" db:"key"` + Value string `json:"value" db:"value"` +} diff --git a/backend/internal/domain/model/base.go b/backend/internal/domain/model/base.go new file mode 100644 index 0000000..993284f --- /dev/null +++ b/backend/internal/domain/model/base.go @@ -0,0 +1,8 @@ +package model + +import "time" + +type BaseEntity struct { + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" db:"updated_at"` +} diff --git a/backend/internal/domain/model/credential.go b/backend/internal/domain/model/credential.go new file mode 100644 index 0000000..cc649cc --- /dev/null +++ b/backend/internal/domain/model/credential.go @@ -0,0 +1,24 @@ +package model + +import "github.com/google/uuid" + +type CredentialSecretType string + +const ( + CredentialSecretTypeLogin CredentialSecretType = "LOGIN" + CredentialSecretTypeApiKey CredentialSecretType = "API_KEY" + CredentialSecretTypeRawText CredentialSecretType = "RAW_TEXT" +) + +type Credential struct { + ID uuid.UUID `json:"id" db:"id"` + ProjectID uuid.UUID `json:"projectId" db:"project_id"` + Title string `json:"title" db:"title"` + SecretType CredentialSecretType `json:"secretType" db:"secret_type"` + PayloadEncrypted []byte `json:"payloadEncrypted" db:"payload_encrypted"` + EncryptionIv []byte `json:"encryptionIv" db:"encryption_iv"` + EncryptionAuthTag []byte `json:"encryptionAuthTag" db:"encryption_auth_tag"` + Notes *string `json:"notes,omitempty" db:"notes"` + RelatedUrl *string `json:"relatedUrl,omitempty" db:"related_url"` + BaseEntity +} diff --git a/backend/internal/domain/model/link.go b/backend/internal/domain/model/link.go new file mode 100644 index 0000000..e0c9181 --- /dev/null +++ b/backend/internal/domain/model/link.go @@ -0,0 +1,12 @@ +package model + +import "github.com/google/uuid" + +type Link struct { + ID uuid.UUID `json:"id" db:"id"` + ProjectID uuid.UUID `json:"projectId" db:"project_id"` + Title string `json:"title" db:"title"` + Url string `json:"url" db:"url"` + Description *string `json:"description,omitempty" db:"description"` + BaseEntity +} diff --git a/backend/internal/domain/model/note.go b/backend/internal/domain/model/note.go new file mode 100644 index 0000000..19d41d9 --- /dev/null +++ b/backend/internal/domain/model/note.go @@ -0,0 +1,12 @@ +package model + +import "github.com/google/uuid" + +type Note struct { + ID uuid.UUID `json:"id" db:"id"` + ProjectID uuid.UUID `json:"projectId" db:"project_id"` + Title string `json:"title" db:"title"` + Content *string `json:"content,omitempty" db:"content"` + Archived bool `json:"archived" db:"archived"` + BaseEntity +} diff --git a/backend/internal/domain/model/page.go b/backend/internal/domain/model/page.go new file mode 100644 index 0000000..11bc3e2 --- /dev/null +++ b/backend/internal/domain/model/page.go @@ -0,0 +1,24 @@ +package model + +type Page[T any] struct { + Content []T `json:"content"` + Size int `json:"size"` + Number int `json:"number"` + TotalElements int64 `json:"totalElements"` + TotalPages int `json:"totalPages"` +} + +func NewPage[T any](content []T, pageNumber, pageSize int, totalElements int64) Page[T] { + totalPages := 0 + if pageSize > 0 { + totalPages = int((totalElements + int64(pageSize) - 1) / int64(pageSize)) + } + + return Page[T]{ + Content: content, + Size: pageSize, + Number: pageNumber, + TotalElements: totalElements, + TotalPages: totalPages, + } +} diff --git a/backend/internal/domain/model/problem.go b/backend/internal/domain/model/problem.go new file mode 100644 index 0000000..177dab2 --- /dev/null +++ b/backend/internal/domain/model/problem.go @@ -0,0 +1,32 @@ +package model + +import "github.com/google/uuid" + +type ProblemStatus string + +const ( + ProblemStatusOpen ProblemStatus = "OPEN" + ProblemStatusWorkingOnIt ProblemStatus = "WORKING_ON" + ProblemStatusResolved ProblemStatus = "RESOLVED" + ProblemStatusWontFix ProblemStatus = "WONT_FIX" +) + +type ProblemSeverity string + +const ( + ProblemSeverityLow ProblemSeverity = "LOW" + ProblemSeverityMedium ProblemSeverity = "MEDIUM" + ProblemSeverityHigh ProblemSeverity = "HIGH" + ProblemSeverityCritical ProblemSeverity = "CRITICAL" +) + +type Problem struct { + ID uuid.UUID `json:"id" db:"id"` + ProjectID uuid.UUID `json:"projectId" db:"project_id"` + Title string `json:"title" db:"title"` + ErrorDescription string `json:"errorDescription" db:"error_description"` + Solution *string `json:"solution,omitempty" db:"solution"` + Status ProblemStatus `json:"status" db:"status"` + Severity ProblemSeverity `json:"severity" db:"severity"` + BaseEntity +} diff --git a/backend/internal/domain/model/project.go b/backend/internal/domain/model/project.go new file mode 100644 index 0000000..2f8ee4e --- /dev/null +++ b/backend/internal/domain/model/project.go @@ -0,0 +1,13 @@ +package model + +import "github.com/google/uuid" + +type Project struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description *string `json:"description,omitempty" db:"description"` + Icon *string `json:"icon,omitempty" db:"icon"` + Color *string `json:"color,omitempty" db:"color"` + Archived bool `json:"archived" db:"archived"` + BaseEntity +} diff --git a/backend/internal/domain/model/snippet.go b/backend/internal/domain/model/snippet.go new file mode 100644 index 0000000..276d9f2 --- /dev/null +++ b/backend/internal/domain/model/snippet.go @@ -0,0 +1,109 @@ +package model + +import "github.com/google/uuid" + +type SnippetType string + +const ( + SnippetTypeCommand SnippetType = "COMMAND" + SnippetTypeCode SnippetType = "CODE" +) + +type SnippetLanguage string + +const ( + // Shells / terminal + SnippetLangBash SnippetLanguage = "BASH" + SnippetLangFish SnippetLanguage = "FISH" + SnippetLangZsh SnippetLanguage = "ZSH" + SnippetLangSh SnippetLanguage = "SH" + SnippetLangPowershell SnippetLanguage = "POWERSHELL" + SnippetLangBatch SnippetLanguage = "BATCH" + + // Code Languages + SnippetLangJava SnippetLanguage = "JAVA" + SnippetLangKotlin SnippetLanguage = "KOTLIN" + SnippetLangJavascript SnippetLanguage = "JAVASCRIPT" + SnippetLangTypescript SnippetLanguage = "TYPESCRIPT" + SnippetLangPython SnippetLanguage = "PYTHON" + SnippetLangGo SnippetLanguage = "GO" + SnippetLangRust SnippetLanguage = "RUST" + SnippetLangC SnippetLanguage = "C" + SnippetLangCpp SnippetLanguage = "CPP" + SnippetLangCsharp SnippetLanguage = "CSHARP" + SnippetLangPhp SnippetLanguage = "PHP" + SnippetLangRuby SnippetLanguage = "RUBY" + SnippetLangSwift SnippetLanguage = "SWIFT" + SnippetLangDart SnippetLanguage = "DART" + SnippetLangScala SnippetLanguage = "SCALA" + SnippetLangLua SnippetLanguage = "LUA" + SnippetLangPerl SnippetLanguage = "PERL" + SnippetLangR SnippetLanguage = "R" + SnippetLangElixir SnippetLanguage = "ELIXIR" + SnippetLangHaskell SnippetLanguage = "HASKELL" + SnippetLangClojure SnippetLanguage = "CLOJURE" + SnippetLangGroovy SnippetLanguage = "GROOVY" + + // Web / frontend + SnippetLangHtml SnippetLanguage = "HTML" + SnippetLangCss SnippetLanguage = "CSS" + SnippetLangScss SnippetLanguage = "SCSS" + SnippetLangLess SnippetLanguage = "LESS" + SnippetLangJsx SnippetLanguage = "JSX" + SnippetLangTsx SnippetLanguage = "TSX" + SnippetLangVue SnippetLanguage = "VUE" + SnippetLangSvelte SnippetLanguage = "SVELTE" + + // Data / config / markup + SnippetLangJson SnippetLanguage = "JSON" + SnippetLangYaml SnippetLanguage = "YAML" + SnippetLangXml SnippetLanguage = "XML" + SnippetLangToml SnippetLanguage = "TOML" + SnippetLangIni SnippetLanguage = "INI" + SnippetLangEnv SnippetLanguage = "ENV" + SnippetLangCsv SnippetLanguage = "CSV" + SnippetLangMarkdown SnippetLanguage = "MARKDOWN" + SnippetLangProperties SnippetLanguage = "PROPERTIES" + + // Infra / DevOps + SnippetLangDockerfile SnippetLanguage = "DOCKERFILE" + SnippetLangDockerCompose SnippetLanguage = "DOCKER_COMPOSE" + SnippetLangNginx SnippetLanguage = "NGINX" + SnippetLangApache SnippetLanguage = "APACHE" + SnippetLangTerraform SnippetLanguage = "TERRAFORM" + SnippetLangAnsible SnippetLanguage = "ANSIBLE" + SnippetLangKubernetesYaml SnippetLanguage = "KUBERNETES_YAML" + SnippetLangHelm SnippetLanguage = "HELM" + SnippetLangMakefile SnippetLanguage = "MAKEFILE" + SnippetLangCmake SnippetLanguage = "CMAKE" + SnippetLangGradle SnippetLanguage = "GRADLE" + SnippetLangMavenPom SnippetLanguage = "MAVEN_POM" + + // Databases + SnippetLangSql SnippetLanguage = "SQL" + SnippetLangPlsql SnippetLanguage = "PLSQL" + SnippetLangGraphql SnippetLanguage = "GRAPHQL" + SnippetLangMongodb SnippetLanguage = "MONGODB" + + // CI/CD + SnippetLangGithubActions SnippetLanguage = "GITHUB_ACTIONS" + SnippetLangGitlabCi SnippetLanguage = "GITLAB_CI" + SnippetLangJenkinsfile SnippetLanguage = "JENKINSFILE" + + // Others / Generics + SnippetLangRegex SnippetLanguage = "REGEX" + SnippetLangDiff SnippetLanguage = "DIFF" + SnippetLangLog SnippetLanguage = "LOG" + SnippetLangPlainText SnippetLanguage = "PLAIN_TEXT" +) + +type Snippet struct { + ID uuid.UUID `json:"id" db:"id"` + ProjectID uuid.UUID `json:"projectId" db:"project_id"` + Title string `json:"title" db:"title"` + Description *string `json:"description,omitempty" db:"description"` + Content string `json:"content" db:"content"` + Language *SnippetLanguage `json:"language,omitempty" db:"language"` + SnippetType SnippetType `json:"snippetType" db:"snippet_type"` + BaseEntity +} diff --git a/backend/internal/domain/model/tag.go b/backend/internal/domain/model/tag.go new file mode 100644 index 0000000..8ab9470 --- /dev/null +++ b/backend/internal/domain/model/tag.go @@ -0,0 +1,27 @@ +package model + +import "github.com/google/uuid" + +type ItemType string + +const ( + ItemTypeSnippet ItemType = "SNIPPET" + ItemTypeNote ItemType = "NOTE" + ItemTypeLink ItemType = "LINK" + ItemTypeProblem ItemType = "PROBLEM" + ItemTypeCredential ItemType = "CREDENTIAL" +) + +type Tag struct { + ID uuid.UUID `json:"id" db:"id"` + ProjectID uuid.UUID `json:"projectId" db:"project_id"` + Name string `json:"name" db:"name"` + Color *string `json:"color,omitempty" db:"color"` + BaseEntity +} + +type ItemTag struct { + TagID uuid.UUID `json:"tagId" db:"tag_id"` + ItemType ItemType `json:"itemType" db:"item_type"` + ItemID uuid.UUID `json:"itemId" db:"item_id"` +} diff --git a/backend/internal/domain/port/app_setting_repository.go b/backend/internal/domain/port/app_setting_repository.go new file mode 100644 index 0000000..6a037a9 --- /dev/null +++ b/backend/internal/domain/port/app_setting_repository.go @@ -0,0 +1,13 @@ +package port + +import ( + "context" + "devaulty-backend/internal/domain/model" +) + +type AppSettingRepository interface { + Save(ctx context.Context, setting *model.AppSetting) (*model.AppSetting, error) + FindByKey(ctx context.Context, key string) (*model.AppSetting, error) + ExistsByKey(ctx context.Context, key string) (bool, error) + SaveMasterPasswordSettings(ctx context.Context, hashValue, saltValue string) error +} diff --git a/backend/internal/domain/port/credential_repository.go b/backend/internal/domain/port/credential_repository.go new file mode 100644 index 0000000..2e36b7d --- /dev/null +++ b/backend/internal/domain/port/credential_repository.go @@ -0,0 +1,16 @@ +package port + +import ( + "context" + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type CredentialRepository interface { + Save(ctx context.Context, credential *model.Credential) (*model.Credential, error) + FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Credential, error) + FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Credential], error) + DeleteByIDAndProjectID(ctx context.Context, ProjectID, id uuid.UUID) (bool, error) + ProjectScopedRepository +} diff --git a/backend/internal/domain/port/crypto.go b/backend/internal/domain/port/crypto.go new file mode 100644 index 0000000..79bffd4 --- /dev/null +++ b/backend/internal/domain/port/crypto.go @@ -0,0 +1,6 @@ +package port + +type Crypto interface { + Encrypt(plainData, secretKey, aad []byte) (cipherText, iv, authTag []byte, err error) + Decrypt(cipherText, iv, authTag, secretKey, aad []byte) (plainData []byte, err error) +} diff --git a/backend/internal/domain/port/item_tag_repository.go b/backend/internal/domain/port/item_tag_repository.go new file mode 100644 index 0000000..9e93347 --- /dev/null +++ b/backend/internal/domain/port/item_tag_repository.go @@ -0,0 +1,16 @@ +package port + +import ( + "context" + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type ItemTagRepository interface { + AssociateTagToItem(ctx context.Context, projectID, tagID uuid.UUID, itemType model.ItemType, itemID uuid.UUID) error + DisassembleTagFromItem(ctx context.Context, projectID, tagID uuid.UUID, itemType model.ItemType, itemID uuid.UUID) error + RemoveAllTagsFromItem(ctx context.Context, itemType model.ItemType, itemID uuid.UUID) error + FindTagsForItem(ctx context.Context, itemType model.ItemType, projectID, itemID uuid.UUID) ([]model.Tag, error) + FindTagsForItems(ctx context.Context, itemType model.ItemType, projectID uuid.UUID, itemIDs []uuid.UUID) (map[uuid.UUID][]model.Tag, error) +} diff --git a/backend/internal/domain/port/key_deriver.go b/backend/internal/domain/port/key_deriver.go new file mode 100644 index 0000000..f28e33a --- /dev/null +++ b/backend/internal/domain/port/key_deriver.go @@ -0,0 +1,8 @@ +package port + +type KeyDeriver interface { + GenerateSalt(size int) ([]byte, error) + DeriveKey(password, salt []byte) ([]byte, error) + HashPassword(password, salt []byte) ([]byte, error) + VerifyPassword(password, salt, expectedHash []byte) bool +} diff --git a/backend/internal/domain/port/link_repository.go b/backend/internal/domain/port/link_repository.go new file mode 100644 index 0000000..84c3f64 --- /dev/null +++ b/backend/internal/domain/port/link_repository.go @@ -0,0 +1,16 @@ +package port + +import ( + "context" + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type LinkRepository interface { + Save(ctx context.Context, link *model.Link) (*model.Link, error) + FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Link, error) + FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Link], error) + DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) + ProjectScopedRepository +} diff --git a/backend/internal/domain/port/master_key_session.go b/backend/internal/domain/port/master_key_session.go new file mode 100644 index 0000000..ca6bb52 --- /dev/null +++ b/backend/internal/domain/port/master_key_session.go @@ -0,0 +1,12 @@ +package port + +import "time" + +type MasterKeySession interface { + SetKey(key []byte) + GetKey() []byte + HasKey() bool + Clear() + Touch() + GetSecondsRemaining(timeout time.Duration) int64 +} diff --git a/backend/internal/domain/port/note_repository.go b/backend/internal/domain/port/note_repository.go new file mode 100644 index 0000000..787e02c --- /dev/null +++ b/backend/internal/domain/port/note_repository.go @@ -0,0 +1,16 @@ +package port + +import ( + "context" + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type NoteRepository interface { + Save(ctx context.Context, note *model.Note) (*model.Note, error) + FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Note, error) + FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Note], error) + DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) + ProjectScopedRepository +} diff --git a/backend/internal/domain/port/problem_repository.go b/backend/internal/domain/port/problem_repository.go new file mode 100644 index 0000000..e68168c --- /dev/null +++ b/backend/internal/domain/port/problem_repository.go @@ -0,0 +1,25 @@ +package port + +import ( + "context" + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type ProblemSummary struct { + ID uuid.UUID `json:"id" db:"id"` + ProjectID uuid.UUID `json:"projectId" db:"project_id"` + Title string `json:"title" db:"title"` + Status model.ProblemStatus `json:"status" db:"status"` + Severity model.ProblemSeverity `json:"severity" db:"severity"` + model.BaseEntity +} + +type ProblemRepository interface { + Save(ctx context.Context, problem *model.Problem) (*model.Problem, error) + FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Problem, error) + FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[ProblemSummary], error) + DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) + ProjectScopedRepository +} diff --git a/backend/internal/domain/port/project_repository.go b/backend/internal/domain/port/project_repository.go new file mode 100644 index 0000000..25c9268 --- /dev/null +++ b/backend/internal/domain/port/project_repository.go @@ -0,0 +1,16 @@ +package port + +import ( + "context" + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type ProjectRepository interface { + Save(ctx context.Context, project *model.Project) (*model.Project, error) + FindAll(ctx context.Context, page int, size int) (model.Page[model.Project], error) + FindByID(ctx context.Context, id uuid.UUID) (*model.Project, error) + DeleteByID(ctx context.Context, id uuid.UUID) (bool, error) + ExistsByID(ctx context.Context, id uuid.UUID) (bool, error) +} diff --git a/backend/internal/domain/port/project_scoped_repository.go b/backend/internal/domain/port/project_scoped_repository.go new file mode 100644 index 0000000..24ceeb6 --- /dev/null +++ b/backend/internal/domain/port/project_scoped_repository.go @@ -0,0 +1,12 @@ +package port + +import ( + "context" + + "github.com/google/uuid" +) + +type ProjectScopedRepository interface { + ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) + FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) +} diff --git a/backend/internal/domain/port/snippet_repository.go b/backend/internal/domain/port/snippet_repository.go new file mode 100644 index 0000000..56f8d1b --- /dev/null +++ b/backend/internal/domain/port/snippet_repository.go @@ -0,0 +1,16 @@ +package port + +import ( + "context" + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type SnippetRepository interface { + Save(ctx context.Context, snippet *model.Snippet) (*model.Snippet, error) + FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Snippet, error) + FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page int, size int) (model.Page[model.Snippet], error) + DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) + ProjectScopedRepository +} diff --git a/backend/internal/domain/port/tag_repository.go b/backend/internal/domain/port/tag_repository.go new file mode 100644 index 0000000..df2d1a5 --- /dev/null +++ b/backend/internal/domain/port/tag_repository.go @@ -0,0 +1,18 @@ +package port + +import ( + "context" + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type TagRepository interface { + Save(ctx context.Context, tag *model.Tag) (*model.Tag, error) + FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Tag, error) + FindAllByProjectID(ctx context.Context, projectID uuid.UUID) ([]model.Tag, error) + SearchByNameAndProjectID(ctx context.Context, name string, projectID uuid.UUID) ([]model.Tag, error) + DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) + ExistsByNameAndProjectID(ctx context.Context, name string, projectID uuid.UUID) (bool, error) + ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) +} diff --git a/backend/internal/dto/app_setting_dto.go b/backend/internal/dto/app_setting_dto.go new file mode 100644 index 0000000..fc8f488 --- /dev/null +++ b/backend/internal/dto/app_setting_dto.go @@ -0,0 +1,11 @@ +package dto + +type AppSettingView struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type UpdateAppSettingCommand struct { + Key string `json:"key" binding:"required"` + Value string `json:"value" binding:"required"` +} diff --git a/backend/internal/dto/credential_dto.go b/backend/internal/dto/credential_dto.go new file mode 100644 index 0000000..05b1f0d --- /dev/null +++ b/backend/internal/dto/credential_dto.go @@ -0,0 +1,69 @@ +package dto + +import ( + "devaulty-backend/internal/domain/model" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type SecretBytes []byte + +type CreateCredentialCommand struct { + ProjectID uuid.UUID `json:"-"` + Title string `json:"title" binding:"required,min=2,max=255"` + SecretType model.CredentialSecretType `json:"secretType" binding:"required"` + Username SecretBytes `json:"username,omitempty"` + Password SecretBytes `json:"password,omitempty"` + APIKey SecretBytes `json:"apiKey,omitempty"` + RawTextContent SecretBytes `json:"rawTextContent,omitempty"` + Notes *string `json:"notes,omitempty"` + RelatedURL *string `json:"relatedUrl,omitempty"` +} + +type UpdateCredentialCommand struct { + ID uuid.UUID `json:"-"` + ProjectID uuid.UUID `json:"-"` + Title *string `json:"title,omitempty" binding:"omitempty,min=2,max=255"` + SecretType *model.CredentialSecretType `json:"secretType,omitempty"` + Username SecretBytes `json:"username,omitempty"` + Password SecretBytes `json:"password,omitempty"` + APIKey SecretBytes `json:"apiKey,omitempty"` + RawTextContent SecretBytes `json:"rawTextContent,omitempty"` + Notes *string `json:"notes,omitempty"` + RelatedURL *string `json:"relatedUrl,omitempty"` +} + +type CredentialSummaryView struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"projectId"` + Title string `json:"title"` + SecretType model.CredentialSecretType `json:"secretType"` + RelatedURL *string `json:"relatedUrl"` + Tags []TagSummary `json:"tags"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type CredentialView struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"projectId"` + Title string `json:"title"` + SecretType model.CredentialSecretType `json:"secretType"` + DecryptedPayload map[string]string `json:"decryptedPayload"` + Notes *string `json:"notes"` + RelatedURL *string `json:"relatedUrl"` + Tags []TagSummary `json:"tags"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +func (sb *SecretBytes) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *sb = SecretBytes(s) + return nil +} diff --git a/backend/internal/dto/link_dto.go b/backend/internal/dto/link_dto.go new file mode 100644 index 0000000..1fe3448 --- /dev/null +++ b/backend/internal/dto/link_dto.go @@ -0,0 +1,33 @@ +package dto + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateLinkCommand struct { + ProjectID uuid.UUID `json:"-"` + Title string `json:"title" binding:"required,min=2,max=255"` + URL string `json:"url" binding:"required,url"` + Description *string `json:"description,omitempty" binding:"omitempty"` +} + +type UpdateLinkCommand struct { + ID uuid.UUID `json:"-"` + ProjectID uuid.UUID `json:"-"` + Title *string `json:"title,omitempty" binding:"omitempty,min=2,max=255"` + URL *string `json:"url,omitempty" binding:"omitempty,url"` + Description *string `json:"description,omitempty" binding:"omitempty"` +} + +type LinkView struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"projectId"` + Title string `json:"title"` + URL string `json:"url"` + Description *string `json:"description,omitempty"` + Tags []TagSummary `json:"tags"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} diff --git a/backend/internal/dto/note_dto.go b/backend/internal/dto/note_dto.go new file mode 100644 index 0000000..e6a17bd --- /dev/null +++ b/backend/internal/dto/note_dto.go @@ -0,0 +1,41 @@ +package dto + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateNoteCommand struct { + ProjectID uuid.UUID `json:"-"` + Title string `json:"title" binding:"required,min=2,max=255"` + Content string `json:"content" binding:"required"` +} + +type UpdateNoteCommand struct { + ProjectID uuid.UUID `json:"-"` + ID uuid.UUID `json:"-"` + Title *string `json:"title,omitempty" binding:"omitempty,min=2,max=255"` + Content *string `json:"content,omitempty" binding:"omitempty"` +} + +type NoteView struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"projectId"` + Title string `json:"title"` + Content string `json:"content"` + Archived bool `json:"archived"` + Tags []TagSummary `json:"tags"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type NoteSummary struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"projectId"` + Title string `json:"title"` + Archived bool `json:"archived"` + Tags []TagSummary `json:"tags"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} diff --git a/backend/internal/dto/problem_dto.go b/backend/internal/dto/problem_dto.go new file mode 100644 index 0000000..a7b0275 --- /dev/null +++ b/backend/internal/dto/problem_dto.go @@ -0,0 +1,57 @@ +package dto + +import ( + "time" + + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type CreateProblemCommand struct { + ProjectID uuid.UUID `json:"-"` + Title string `json:"title" binding:"required,min=2,max=255"` + ErrorDescription string `json:"errorDescription" binding:"required,min=2,max=255"` + Solution *string `json:"solution,omitempty" binding:"omitempty,min=2,max=255"` + Status model.ProblemStatus `json:"status" binding:"required"` + Severity model.ProblemSeverity `json:"severity" binding:"required"` +} + +type UpdateProblemCommand struct { + ProjectID uuid.UUID `json:"-"` + ID uuid.UUID `json:"-"` + Title *string `json:"title,omitempty" binding:"omitempty,min=2,max=255"` + ErrorDescription *string `json:"errorDescription,omitempty" binding:"omitempty,min=2,max=255"` + Solution *string `json:"solution,omitempty" binding:"omitempty,min=2,max=255"` + Severity *model.ProblemSeverity `json:"severity,omitempty" binding:"omitempty"` +} + +type UpdateProblemStatusCommand struct { + ProjectID uuid.UUID `json:"-"` + ID uuid.UUID `json:"-"` + Status model.ProblemStatus `json:"status" binding:"required"` +} + +type ProblemSummary struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"projectId"` + Title string `json:"title"` + Status model.ProblemStatus `json:"status"` + Severity model.ProblemSeverity `json:"severity"` + Tags []TagSummary `json:"tags"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type ProblemView struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"projectId"` + Title string `json:"title"` + ErrorDescription string `json:"errorDescription"` + Solution *string `json:"solution,omitempty"` + Status model.ProblemStatus `json:"status"` + Severity model.ProblemSeverity `json:"severity"` + Tags []TagSummary `json:"tags"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} diff --git a/backend/internal/dto/project_dto.go b/backend/internal/dto/project_dto.go new file mode 100644 index 0000000..2e52b95 --- /dev/null +++ b/backend/internal/dto/project_dto.go @@ -0,0 +1,18 @@ +package dto + +import "github.com/google/uuid" + +type CreateProjectCommand struct { + Name string `json:"name" binding:"required,min=2,max=255"` + Description *string `json:"description,omitempty" binding:"omitempty,min=1,max=255"` + Icon *string `json:"icon,omitempty" binding:"omitempty,max=100"` + Color *string `json:"color,omitempty" binding:"omitempty,hexcolor"` +} + +type UpdateProjectCommand struct { + ID uuid.UUID `json:"-"` + Name *string `json:"name,omitempty" binding:"omitempty,min=2,max=255"` + Description *string `json:"description,omitempty" binding:"omitempty,min=1,max=255"` + Icon *string `json:"icon,omitempty" binding:"omitempty,max=100"` + Color *string `json:"color,omitempty" binding:"omitempty,hexcolor"` +} diff --git a/backend/internal/dto/security_dto.go b/backend/internal/dto/security_dto.go new file mode 100644 index 0000000..862e1af --- /dev/null +++ b/backend/internal/dto/security_dto.go @@ -0,0 +1,10 @@ +package dto + +type MasterPassword struct { + MasterPassword string `json:"masterPassword" binding:"required,min=8,max=255"` +} + +type VaultStatusView struct { + Active bool `json:"active"` + SecondsLeft int64 `json:"secondsLeft"` +} diff --git a/backend/internal/dto/snippet_dto.go b/backend/internal/dto/snippet_dto.go new file mode 100644 index 0000000..de7c4b9 --- /dev/null +++ b/backend/internal/dto/snippet_dto.go @@ -0,0 +1,41 @@ +package dto + +import ( + "time" + + "devaulty-backend/internal/domain/model" + + "github.com/google/uuid" +) + +type CreateSnippetCommand struct { + ProjectID uuid.UUID `json:"-"` + Title string `json:"title" binding:"required,min=2,max=255"` + Description *string `json:"description,omitempty" binding:"omitempty,min=1,max=255"` + Content string `json:"content" binding:"required,min=1"` + Language model.SnippetLanguage `json:"language" binding:"required"` + SnippetType model.SnippetType `json:"snippetType" binding:"required"` +} + +type UpdateSnippetCommand struct { + ProjectID uuid.UUID `json:"-"` + ID uuid.UUID `json:"-"` + Title *string `json:"title,omitempty" binding:"omitempty,min=2,max=255"` + Description *string `json:"description,omitempty" binding:"omitempty,min=1,max=255"` + Content *string `json:"content,omitempty" binding:"omitempty,min=1"` + Language *model.SnippetLanguage `json:"language,omitempty" binding:"omitempty"` + SnippetType *model.SnippetType `json:"snippetType,omitempty" binding:"omitempty"` +} + +type SnippetView struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"projectId"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + Content string `json:"content"` + Language *model.SnippetLanguage `json:"language,omitempty"` + SnippetType model.SnippetType `json:"snippetType"` + Tags []TagSummary `json:"tags"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} diff --git a/backend/internal/dto/tag_dto.go b/backend/internal/dto/tag_dto.go new file mode 100644 index 0000000..1f1cb15 --- /dev/null +++ b/backend/internal/dto/tag_dto.go @@ -0,0 +1,35 @@ +package dto + +import ( + "time" + + "github.com/google/uuid" +) + +type TagSummary struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Color *string `json:"color,omitempty"` +} + +type TagView struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"projectId"` + Name string `json:"name"` + Color *string `json:"color,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type CreateTagCommand struct { + ProjectID uuid.UUID `json:"-"` + Name string `json:"name" binding:"required,min=2,max=40"` + Color *string `json:"color,omitempty" binding:"omitempty,hexcolor"` +} + +type UpdateTagCommand struct { + ID uuid.UUID `json:"-"` + ProjectID uuid.UUID `json:"-"` + Name *string `json:"name,omitempty" binding:"omitempty,min=1,max=40"` + Color *string `json:"color,omitempty" binding:"omitempty,hexcolor"` +} diff --git a/backend/internal/usecase/credential_usecase.go b/backend/internal/usecase/credential_usecase.go new file mode 100644 index 0000000..fc7bf7b --- /dev/null +++ b/backend/internal/usecase/credential_usecase.go @@ -0,0 +1,455 @@ +package usecase + +import ( + "bytes" + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/dto" + "encoding/json" + "errors" + "fmt" + "log" + "time" + + "github.com/google/uuid" +) + +var ( + ErrCredentialNotFound = errors.New("credential not found") + ErrInvalidSecretPayload = errors.New("invalid secret payload") +) + +type CredentialUseCase struct { + credentialRepo port.CredentialRepository + projectRepo port.ProjectRepository + itemTagRepo port.ItemTagRepository + crypto port.Crypto + masterKeySession port.MasterKeySession + VaultUseCase +} + +func NewCredentialUseCase(credentialRepo port.CredentialRepository, projectRepo port.ProjectRepository, itemTagRepo port.ItemTagRepository, crypto port.Crypto, masterKeySession port.MasterKeySession, vaultUseCase VaultUseCase) *CredentialUseCase { + return &CredentialUseCase{ + credentialRepo: credentialRepo, + projectRepo: projectRepo, + itemTagRepo: itemTagRepo, + crypto: crypto, + masterKeySession: masterKeySession, + VaultUseCase: vaultUseCase, + } +} + +func (uc *CredentialUseCase) Create(ctx context.Context, cmd dto.CreateCredentialCommand) (*dto.CredentialView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + isRequired, err := uc.CheckIfMasterSetupIsRequired(ctx) + if err != nil { + return nil, fmt.Errorf("error checking if master setup is required: %w", err) + } + if isRequired { + return nil, ErrMasterPasswordNotConfigured + } + secretKey := uc.masterKeySession.GetKey() + if secretKey == nil { + return nil, ErrVaultLocked + } + defer clear(secretKey) + + payloadBytes, err := buildSecretPayloadBytes(cmd.SecretType, cmd.Username, cmd.Password, cmd.APIKey, cmd.RawTextContent) + if err != nil { + return nil, err + } + defer clear(payloadBytes) + + credentialID := uuid.New() + aad := uc.computeAad(cmd.ProjectID, credentialID) + + cipherText, iv, authTag, err := uc.crypto.Encrypt(payloadBytes, secretKey, aad) + if err != nil { + return nil, fmt.Errorf("error encrypting credential: %w", err) + } + + credential := model.Credential{ + ID: credentialID, + ProjectID: cmd.ProjectID, + Title: cmd.Title, + SecretType: cmd.SecretType, + PayloadEncrypted: cipherText, + EncryptionIv: iv, + EncryptionAuthTag: authTag, + Notes: cmd.Notes, + RelatedUrl: cmd.RelatedURL, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + savedCredential, err := uc.credentialRepo.Save(ctx, &credential) + if err != nil { + return nil, fmt.Errorf("error saving credential: %w", err) + } + + decryptedBytes, err := uc.crypto.Decrypt(savedCredential.PayloadEncrypted, savedCredential.EncryptionIv, savedCredential.EncryptionAuthTag, secretKey, aad) + if err != nil { + return nil, fmt.Errorf("error decrypting credential: %w", err) + } + defer clear(decryptedBytes) + + var payloadMap map[string]string + if err := json.Unmarshal(decryptedBytes, &payloadMap); err != nil { + return nil, fmt.Errorf("error unmarshalling decrypted credential payload: %w", err) + } + + return &dto.CredentialView{ + ID: savedCredential.ID, + ProjectID: savedCredential.ProjectID, + Title: savedCredential.Title, + SecretType: savedCredential.SecretType, + DecryptedPayload: payloadMap, + Notes: savedCredential.Notes, + RelatedURL: savedCredential.RelatedUrl, + Tags: []dto.TagSummary{}, + CreatedAt: savedCredential.CreatedAt, + UpdatedAt: savedCredential.UpdatedAt, + }, nil +} + +func (uc *CredentialUseCase) GetById(ctx context.Context, projectID, id uuid.UUID) (*dto.CredentialView, error) { + isRequired, err := uc.CheckIfMasterSetupIsRequired(ctx) + if err != nil { + return nil, fmt.Errorf("error checking if master setup is required: %w", err) + } + if isRequired { + return nil, ErrMasterPasswordNotConfigured + } + secretKey := uc.masterKeySession.GetKey() + if secretKey == nil { + return nil, ErrVaultLocked + } + defer clear(secretKey) + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + + credential, err := uc.credentialRepo.FindByIDAndProjectID(ctx, projectID, id) + if err != nil { + return nil, fmt.Errorf("error checking if credential exists: %w", err) + } + if credential == nil { + return nil, ErrCredentialNotFound + } + + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeCredential, projectID, id) + if err != nil { + return nil, fmt.Errorf("error fetching tags for credential: %w", err) + } + + aad := uc.computeAad(projectID, id) + decryptedBytes, err := uc.crypto.Decrypt(credential.PayloadEncrypted, credential.EncryptionIv, credential.EncryptionAuthTag, secretKey, aad) + if err != nil { + return nil, fmt.Errorf("error decrypting credential: %w", err) + } + defer clear(decryptedBytes) + + var payloadMap map[string]string + if err := json.Unmarshal(decryptedBytes, &payloadMap); err != nil { + return nil, fmt.Errorf("error unmarshalling decrypted credential payload: %w", err) + } + + return mapCredentialToView(credential, payloadMap, tags), nil +} + +func (uc *CredentialUseCase) GetAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[dto.CredentialSummaryView], error) { + isRequired, err := uc.CheckIfMasterSetupIsRequired(ctx) + if err != nil { + return model.Page[dto.CredentialSummaryView]{}, fmt.Errorf("error checking if master setup is required: %w", err) + } + if isRequired { + return model.Page[dto.CredentialSummaryView]{}, ErrMasterPasswordNotConfigured + } + secretKey := uc.masterKeySession.GetKey() + if secretKey == nil { + return model.Page[dto.CredentialSummaryView]{}, ErrVaultLocked + } + defer clear(secretKey) + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return model.Page[dto.CredentialSummaryView]{}, err + } + + credentialPage, err := uc.credentialRepo.FindAllByProjectID(ctx, projectID, page, size) + if err != nil { + return model.Page[dto.CredentialSummaryView]{}, fmt.Errorf("error fetching credentials: %w", err) + } + if len(credentialPage.Content) == 0 { + return model.NewPage([]dto.CredentialSummaryView{}, credentialPage.Number, credentialPage.Size, credentialPage.TotalElements), nil + } + + credentialIDs := make([]uuid.UUID, len(credentialPage.Content)) + for i, c := range credentialPage.Content { + credentialIDs[i] = c.ID + } + + tagsMap, err := uc.itemTagRepo.FindTagsForItems(ctx, model.ItemTypeCredential, projectID, credentialIDs) + if err != nil { + return model.Page[dto.CredentialSummaryView]{}, fmt.Errorf("error fetching tags for credentials: %w", err) + } + + summaries := make([]dto.CredentialSummaryView, len(credentialPage.Content)) + for i, c := range credentialPage.Content { + tags := tagsMap[c.ID] + tagSummaries := make([]dto.TagSummary, len(tags)) + for j, t := range tags { + tagSummaries[j] = dto.TagSummary{ + ID: t.ID, + Name: t.Name, + Color: t.Color, + } + } + summaries[i] = dto.CredentialSummaryView{ + ID: c.ID, + ProjectID: c.ProjectID, + Title: c.Title, + SecretType: c.SecretType, + RelatedURL: c.RelatedUrl, + Tags: tagSummaries, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + } + + return model.NewPage(summaries, credentialPage.Number, credentialPage.Size, credentialPage.TotalElements), nil +} + +func (uc *CredentialUseCase) Update(ctx context.Context, cmd dto.UpdateCredentialCommand) (*dto.CredentialView, error) { + isRequired, err := uc.CheckIfMasterSetupIsRequired(ctx) + if err != nil { + return nil, fmt.Errorf("error checking if master setup is required: %w", err) + } + if isRequired { + return nil, ErrMasterPasswordNotConfigured + } + secretKey := uc.masterKeySession.GetKey() + if secretKey == nil { + return nil, ErrVaultLocked + } + defer clear(secretKey) + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + credential, err := uc.credentialRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error checking if credential exists: %w", err) + } + if credential == nil { + return nil, ErrCredentialNotFound + } + if cmd.Title != nil { + credential.Title = *cmd.Title + } + if cmd.Notes != nil { + credential.Notes = cmd.Notes + } + if cmd.RelatedURL != nil { + credential.RelatedUrl = cmd.RelatedURL + } + aad := uc.computeAad(cmd.ProjectID, cmd.ID) + + secretTypeChanged := cmd.SecretType != nil && *cmd.SecretType != credential.SecretType + secretFieldsProvided := cmd.APIKey != nil || cmd.Username != nil || cmd.Password != nil || cmd.RawTextContent != nil + + if secretTypeChanged || secretFieldsProvided { + targetSecretType := credential.SecretType + if cmd.SecretType != nil { + targetSecretType = *cmd.SecretType + } + + var mergedMap map[string]string + if secretTypeChanged { + mergedMap = make(map[string]string) + } else { + decryptedBytes, err := uc.crypto.Decrypt(credential.PayloadEncrypted, credential.EncryptionIv, credential.EncryptionAuthTag, secretKey, aad) + if err != nil { + return nil, fmt.Errorf("error decrypting existing credential for update: %w", err) + } + _ = json.Unmarshal(decryptedBytes, &mergedMap) + clear(decryptedBytes) + if mergedMap == nil { + mergedMap = make(map[string]string) + } + } + + switch targetSecretType { + case model.CredentialSecretTypeLogin: + if len(cmd.Username) > 0 { + mergedMap["username"] = string(cmd.Username) + } + if len(cmd.Password) > 0 { + mergedMap["password"] = string(cmd.Password) + } + if len(bytes.TrimSpace([]byte(mergedMap["password"]))) == 0 { + return nil, fmt.Errorf("%w: password is required for LOGIN secret type", ErrInvalidSecretPayload) + } + case model.CredentialSecretTypeApiKey: + if len(cmd.APIKey) > 0 { + mergedMap["apiKey"] = string(cmd.APIKey) + } + if len(bytes.TrimSpace([]byte(mergedMap["apiKey"]))) == 0 { + return nil, fmt.Errorf("%w: apikey is required for APIKEY secret type", ErrInvalidSecretPayload) + } + case model.CredentialSecretTypeRawText: + if len(cmd.RawTextContent) > 0 { + mergedMap["rawText"] = string(cmd.RawTextContent) + } + if len(bytes.TrimSpace([]byte(mergedMap["rawText"]))) == 0 { + return nil, fmt.Errorf("%w: raw text is required for RAWTEXT secret type", ErrInvalidSecretPayload) + } + default: + return nil, ErrInvalidSecretPayload + } + + payloadBytes, err := json.Marshal(mergedMap) + if err != nil { + return nil, fmt.Errorf("error marshalling updated credential payload: %w", err) + } + defer clear(payloadBytes) + + cipherText, iv, authTag, err := uc.crypto.Encrypt(payloadBytes, secretKey, aad) + if err != nil { + return nil, fmt.Errorf("error encrypting updated credential: %w", err) + } + credential.SecretType = targetSecretType + credential.PayloadEncrypted = cipherText + credential.EncryptionIv = iv + credential.EncryptionAuthTag = authTag + } + + now := time.Now() + credential.UpdatedAt = &now + saved, err := uc.credentialRepo.Save(ctx, credential) + if err != nil { + return nil, err + } + + decryptedBytes, err := uc.crypto.Decrypt(saved.PayloadEncrypted, saved.EncryptionIv, saved.EncryptionAuthTag, secretKey, aad) + if err != nil { + return nil, fmt.Errorf("error decrypting credential: %w", err) + } + defer clear(decryptedBytes) + + var payloadMap map[string]string + if err := json.Unmarshal(decryptedBytes, &payloadMap); err != nil { + return nil, fmt.Errorf("error unmarshalling decrypted credential payload: %w", err) + } + + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeCredential, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error fetching tags for credential: %w", err) + } + + return mapCredentialToView(saved, payloadMap, tags), nil +} + +func (uc *CredentialUseCase) Delete(ctx context.Context, projectID, id uuid.UUID) error { + isRequired, err := uc.CheckIfMasterSetupIsRequired(ctx) + if err != nil { + return fmt.Errorf("error checking if master setup is required: %w", err) + } + if isRequired { + return ErrMasterPasswordNotConfigured + } + secretKey := uc.masterKeySession.GetKey() + if secretKey == nil { + return ErrVaultLocked + } + defer clear(secretKey) + + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + + deleted, err := uc.credentialRepo.DeleteByIDAndProjectID(ctx, projectID, id) + if err != nil { + return fmt.Errorf("error deleting credential: %w", err) + } + if !deleted { + return ErrCredentialNotFound + } + + if err = uc.itemTagRepo.RemoveAllTagsFromItem(ctx, model.ItemTypeCredential, id); err != nil { + log.Printf("warning: failed to remove tags from credential %s: %v", id, err) + } + return nil +} + +// --- helper methods --- + +func (uc *CredentialUseCase) computeAad(projectID, CredentialID uuid.UUID) []byte { + aad := make([]byte, 0, 32) + aad = append(aad, projectID[:]...) + aad = append(aad, CredentialID[:]...) + return aad +} + +func buildSecretPayloadBytes(secretType model.CredentialSecretType, username, password, apikey, rawText []byte) ([]byte, error) { + var secretMap map[string]string + + switch secretType { + case model.CredentialSecretTypeLogin: + if len(bytes.TrimSpace(password)) == 0 { + return nil, fmt.Errorf("%w: password is required for LOGIN secret type", ErrInvalidSecretPayload) + } + secretMap = map[string]string{ + "username": string(username), + "password": string(password), + } + case model.CredentialSecretTypeApiKey: + if len(bytes.TrimSpace(apikey)) == 0 { + return nil, fmt.Errorf("%w: apikey is required for APIKEY secret type", ErrInvalidSecretPayload) + } + secretMap = map[string]string{ + "apiKey": string(apikey), + } + case model.CredentialSecretTypeRawText: + if len(bytes.TrimSpace(rawText)) == 0 { + return nil, fmt.Errorf("%w: raw text is required for RAWTEXT secret type", ErrInvalidSecretPayload) + } + secretMap = map[string]string{ + "rawText": string(rawText), + } + default: + return nil, ErrInvalidSecretPayload + } + + payloadBytes, err := json.Marshal(secretMap) + if err != nil { + return nil, fmt.Errorf("error marshalling secret payload: %w", err) + } + + return payloadBytes, nil +} + +func mapCredentialToView(credential *model.Credential, decryptedPayload map[string]string, tags []model.Tag) *dto.CredentialView { + tagSummaries := make([]dto.TagSummary, len(tags)) + for i, t := range tags { + tagSummaries[i] = dto.TagSummary{ + ID: t.ID, + Name: t.Name, + Color: t.Color, + } + } + return &dto.CredentialView{ + ID: credential.ID, + ProjectID: credential.ProjectID, + Title: credential.Title, + SecretType: credential.SecretType, + DecryptedPayload: decryptedPayload, + Notes: credential.Notes, + RelatedURL: credential.RelatedUrl, + Tags: tagSummaries, + CreatedAt: credential.CreatedAt, + UpdatedAt: credential.UpdatedAt, + } +} diff --git a/backend/internal/usecase/credential_usecase_test.go b/backend/internal/usecase/credential_usecase_test.go new file mode 100644 index 0000000..b20467c --- /dev/null +++ b/backend/internal/usecase/credential_usecase_test.go @@ -0,0 +1,455 @@ +package usecase_test + +import ( + "bytes" + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockCredentialRepository struct { + mock.Mock +} + +func (m *MockCredentialRepository) Save(ctx context.Context, credential *model.Credential) (*model.Credential, error) { + args := m.Called(ctx, credential) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Credential), args.Error(1) +} + +func (m *MockCredentialRepository) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Credential, error) { + args := m.Called(ctx, projectID, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Credential), args.Error(1) +} + +func (m *MockCredentialRepository) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[model.Credential], error) { + args := m.Called(ctx, projectID, page, size) + return args.Get(0).(model.Page[model.Credential]), args.Error(1) +} + +func (m *MockCredentialRepository) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + args := m.Called(ctx, projectID, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockCredentialRepository) ExistsByIDAndProjectID(ctx context.Context, id, projectID uuid.UUID) (bool, error) { + args := m.Called(ctx, id, projectID) + return args.Bool(0), args.Error(1) +} + +func (m *MockCredentialRepository) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + args := m.Called(ctx, ids, projectID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]uuid.UUID), args.Error(1) +} + +type MockCrypto struct { + mock.Mock +} + +func (m *MockCrypto) Encrypt(plainData, secretKey, aad []byte) (cipherText, iv, authTag []byte, err error) { + args := m.Called(plainData, secretKey, aad) + if args.Get(0) == nil { + return nil, nil, nil, args.Error(3) + } + return args.Get(0).([]byte), args.Get(1).([]byte), args.Get(2).([]byte), args.Error(3) +} + +func (m *MockCrypto) Decrypt(cipherText, iv, authTag, secretKey, aad []byte) (plainData []byte, err error) { + args := m.Called(cipherText, iv, authTag, secretKey, aad) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]byte), args.Error(1) +} + +func setupCredentialUseCaseTest() ( + *usecase.CredentialUseCase, + *MockCredentialRepository, + *MockProjectRepository, + *MockItemTagRepository, + *MockCrypto, + *MockMasterKeySession, + *MockAppSettingRepository, +) { + credRepo := new(MockCredentialRepository) + projRepo := new(MockProjectRepository) + tagRepo := new(MockItemTagRepository) + crypto := new(MockCrypto) + session := new(MockMasterKeySession) + appSettingRepo := new(MockAppSettingRepository) + + vaultUC := usecase.NewVaultUseCase(new(MockKeyDeriver), session, appSettingRepo) + uc := usecase.NewCredentialUseCase(credRepo, projRepo, tagRepo, crypto, session, *vaultUC) + + return uc, credRepo, projRepo, tagRepo, crypto, session, appSettingRepo +} + +func TestCredentialUseCase_Create(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + + t.Run("Create LOGIN Credential Success", func(t *testing.T) { + uc, credRepo, projRepo, _, crypto, session, appSettingRepo := setupCredentialUseCaseTest() + subtestMasterKey := []byte("12345678901234567890123456789012") + + cmd := dto.CreateCredentialCommand{ + ProjectID: projectID, + Title: "My AWS Login", + SecretType: model.CredentialSecretTypeLogin, + Username: []byte("admin"), + Password: []byte("secretPassword123"), + } + + cipherText := []byte("encryptedCipherBytes") + iv := []byte("12BytesIvData") + authTag := []byte("16BytesAuthTag") + + payloadMap := map[string]string{"username": "admin", "password": "secretPassword123"} + decryptedPayloadBytes, _ := json.Marshal(payloadMap) + + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(subtestMasterKey) + + crypto.On("Encrypt", mock.Anything, subtestMasterKey, mock.Anything).Return(cipherText, iv, authTag, nil) + credRepo.On("Save", ctx, mock.Anything).Return(&model.Credential{ + ID: uuid.New(), + ProjectID: projectID, + Title: cmd.Title, + SecretType: cmd.SecretType, + PayloadEncrypted: cipherText, + EncryptionIv: iv, + EncryptionAuthTag: authTag, + BaseEntity: model.BaseEntity{CreatedAt: time.Now()}, + }, nil) + crypto.On("Decrypt", cipherText, iv, authTag, subtestMasterKey, mock.Anything).Return(decryptedPayloadBytes, nil) + + view, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, view) + assert.Equal(t, "My AWS Login", view.Title) + assert.Equal(t, model.CredentialSecretTypeLogin, view.SecretType) + assert.Equal(t, "admin", view.DecryptedPayload["username"]) + assert.Equal(t, "secretPassword123", view.DecryptedPayload["password"]) + + // Assert subtest key was zeroed in RAM after operation + assert.True(t, bytes.Equal(subtestMasterKey, make([]byte, 32))) + + projRepo.AssertExpectations(t) + crypto.AssertExpectations(t) + credRepo.AssertExpectations(t) + }) + + t.Run("Create API_KEY Credential Success", func(t *testing.T) { + uc, credRepo, projRepo, _, crypto, session, appSettingRepo := setupCredentialUseCaseTest() + subtestMasterKey := []byte("12345678901234567890123456789012") + + cmd := dto.CreateCredentialCommand{ + ProjectID: projectID, + Title: "Stripe API Key", + SecretType: model.CredentialSecretTypeApiKey, + APIKey: []byte("sk_test_123456789"), + } + + cipherText := []byte("encryptedCipherBytes") + iv := []byte("12BytesIvData") + authTag := []byte("16BytesAuthTag") + + payloadMap := map[string]string{"apiKey": "sk_test_123456789"} + decryptedPayloadBytes, _ := json.Marshal(payloadMap) + + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(subtestMasterKey) + + crypto.On("Encrypt", mock.Anything, subtestMasterKey, mock.Anything).Return(cipherText, iv, authTag, nil) + credRepo.On("Save", ctx, mock.Anything).Return(&model.Credential{ + ID: uuid.New(), + ProjectID: projectID, + Title: cmd.Title, + SecretType: cmd.SecretType, + PayloadEncrypted: cipherText, + EncryptionIv: iv, + EncryptionAuthTag: authTag, + BaseEntity: model.BaseEntity{CreatedAt: time.Now()}, + }, nil) + crypto.On("Decrypt", cipherText, iv, authTag, subtestMasterKey, mock.Anything).Return(decryptedPayloadBytes, nil) + + view, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, view) + assert.Equal(t, "sk_test_123456789", view.DecryptedPayload["apiKey"]) + + // Assert subtest key was zeroed in RAM after operation + assert.True(t, bytes.Equal(subtestMasterKey, make([]byte, 32))) + }) + + t.Run("Create Failure - Project Not Found", func(t *testing.T) { + uc, _, projRepo, _, _, _, _ := setupCredentialUseCaseTest() + cmd := dto.CreateCredentialCommand{ProjectID: projectID, Title: "Test"} + + projRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + view, err := uc.Create(ctx, cmd) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + assert.Nil(t, view) + }) + + t.Run("Create Failure - Master Password Not Configured", func(t *testing.T) { + uc, _, projRepo, _, _, _, appSettingRepo := setupCredentialUseCaseTest() + cmd := dto.CreateCredentialCommand{ProjectID: projectID, Title: "Test"} + + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(false, nil) + + view, err := uc.Create(ctx, cmd) + assert.ErrorIs(t, err, usecase.ErrMasterPasswordNotConfigured) + assert.Nil(t, view) + }) + + t.Run("Create Failure - Vault Locked", func(t *testing.T) { + uc, _, projRepo, _, _, session, appSettingRepo := setupCredentialUseCaseTest() + cmd := dto.CreateCredentialCommand{ProjectID: projectID, Title: "Test"} + + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(nil) + + view, err := uc.Create(ctx, cmd) + assert.ErrorIs(t, err, usecase.ErrVaultLocked) + assert.Nil(t, view) + }) + + t.Run("Create Failure - Missing Password for LOGIN", func(t *testing.T) { + uc, _, projRepo, _, _, session, appSettingRepo := setupCredentialUseCaseTest() + subtestMasterKey := []byte("12345678901234567890123456789012") + + cmd := dto.CreateCredentialCommand{ + ProjectID: projectID, + Title: "Missing Password", + SecretType: model.CredentialSecretTypeLogin, + Username: []byte("admin"), + Password: []byte(""), + } + + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(subtestMasterKey) + + view, err := uc.Create(ctx, cmd) + assert.Error(t, err) + assert.Nil(t, view) + + assert.True(t, bytes.Equal(subtestMasterKey, make([]byte, 32))) + }) +} + +func TestCredentialUseCase_GetById(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + credentialID := uuid.New() + + t.Run("GetById Success", func(t *testing.T) { + uc, credRepo, projRepo, tagRepo, crypto, session, appSettingRepo := setupCredentialUseCaseTest() + subtestMasterKey := []byte("12345678901234567890123456789012") + + cipherText := []byte("encryptedCipherBytes") + iv := []byte("12BytesIvData") + authTag := []byte("16BytesAuthTag") + + payloadMap := map[string]string{"username": "user1", "password": "pass1"} + decryptedPayloadBytes, _ := json.Marshal(payloadMap) + + credModel := &model.Credential{ + ID: credentialID, + ProjectID: projectID, + Title: "DB Credentials", + SecretType: model.CredentialSecretTypeLogin, + PayloadEncrypted: cipherText, + EncryptionIv: iv, + EncryptionAuthTag: authTag, + BaseEntity: model.BaseEntity{CreatedAt: time.Now()}, + } + + tagColor := "#ff0000" + tags := []model.Tag{{ID: uuid.New(), Name: "prod", Color: &tagColor}} + + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(subtestMasterKey) + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + credRepo.On("FindByIDAndProjectID", ctx, projectID, credentialID).Return(credModel, nil) + tagRepo.On("FindTagsForItem", ctx, model.ItemTypeCredential, projectID, credentialID).Return(tags, nil) + crypto.On("Decrypt", cipherText, iv, authTag, subtestMasterKey, mock.Anything).Return(decryptedPayloadBytes, nil) + + view, err := uc.GetById(ctx, projectID, credentialID) + + assert.NoError(t, err) + assert.NotNil(t, view) + assert.Equal(t, credentialID, view.ID) + assert.Equal(t, "DB Credentials", view.Title) + assert.Len(t, view.Tags, 1) + assert.Equal(t, "prod", view.Tags[0].Name) + assert.Equal(t, "user1", view.DecryptedPayload["username"]) + + assert.True(t, bytes.Equal(subtestMasterKey, make([]byte, 32))) + }) + + t.Run("GetById Failure - Credential Not Found", func(t *testing.T) { + uc, credRepo, projRepo, _, _, session, appSettingRepo := setupCredentialUseCaseTest() + subtestMasterKey := []byte("12345678901234567890123456789012") + + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(subtestMasterKey) + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + credRepo.On("FindByIDAndProjectID", ctx, projectID, credentialID).Return(nil, nil) + + view, err := uc.GetById(ctx, projectID, credentialID) + + assert.ErrorIs(t, err, usecase.ErrCredentialNotFound) + assert.Nil(t, view) + + assert.True(t, bytes.Equal(subtestMasterKey, make([]byte, 32))) + }) +} + +func TestCredentialUseCase_GetAllByProjectID(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + + t.Run("GetAllByProjectID Success", func(t *testing.T) { + uc, credRepo, projRepo, tagRepo, _, session, appSettingRepo := setupCredentialUseCaseTest() + subtestMasterKey := []byte("12345678901234567890123456789012") + + cred1 := model.Credential{ID: uuid.New(), ProjectID: projectID, Title: "Cred 1", SecretType: model.CredentialSecretTypeLogin} + cred2 := model.Credential{ID: uuid.New(), ProjectID: projectID, Title: "Cred 2", SecretType: model.CredentialSecretTypeApiKey} + + pageModel := model.NewPage([]model.Credential{cred1, cred2}, 1, 10, 2) + + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(subtestMasterKey) + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + credRepo.On("FindAllByProjectID", ctx, projectID, 1, 10).Return(pageModel, nil) + tagRepo.On("FindTagsForItems", ctx, model.ItemTypeCredential, projectID, []uuid.UUID{cred1.ID, cred2.ID}). + Return(map[uuid.UUID][]model.Tag{}, nil) + + pageResult, err := uc.GetAllByProjectID(ctx, projectID, 1, 10) + + assert.NoError(t, err) + assert.Equal(t, int64(2), pageResult.TotalElements) + assert.Len(t, pageResult.Content, 2) + assert.Equal(t, "Cred 1", pageResult.Content[0].Title) + assert.Equal(t, "Cred 2", pageResult.Content[1].Title) + + assert.True(t, bytes.Equal(subtestMasterKey, make([]byte, 32))) + }) +} + +func TestCredentialUseCase_Update(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + credentialID := uuid.New() + + t.Run("Update Title Only Success", func(t *testing.T) { + uc, credRepo, projRepo, tagRepo, crypto, session, appSettingRepo := setupCredentialUseCaseTest() + subtestMasterKey := []byte("12345678901234567890123456789012") + + newTitle := "Updated AWS Title" + cmd := dto.UpdateCredentialCommand{ + ID: credentialID, + ProjectID: projectID, + Title: &newTitle, + } + + cipherText := []byte("existingCipherText") + iv := []byte("12BytesIvData") + authTag := []byte("16BytesAuthTag") + + existingCred := &model.Credential{ + ID: credentialID, + ProjectID: projectID, + Title: "Old Title", + SecretType: model.CredentialSecretTypeLogin, + PayloadEncrypted: cipherText, + EncryptionIv: iv, + EncryptionAuthTag: authTag, + } + + payloadMap := map[string]string{"username": "admin", "password": "secretPassword123"} + decryptedPayloadBytes, _ := json.Marshal(payloadMap) + + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(subtestMasterKey) + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + credRepo.On("FindByIDAndProjectID", ctx, projectID, credentialID).Return(existingCred, nil) + credRepo.On("Save", ctx, mock.Anything).Return(existingCred, nil) + tagRepo.On("FindTagsForItem", ctx, model.ItemTypeCredential, projectID, credentialID).Return([]model.Tag{}, nil) + crypto.On("Decrypt", cipherText, iv, authTag, subtestMasterKey, mock.Anything).Return(decryptedPayloadBytes, nil) + + view, err := uc.Update(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, view) + assert.Equal(t, "Updated AWS Title", view.Title) + + assert.True(t, bytes.Equal(subtestMasterKey, make([]byte, 32))) + }) +} + +func TestCredentialUseCase_Delete(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + credentialID := uuid.New() + + t.Run("Delete Success", func(t *testing.T) { + uc, credRepo, projRepo, tagRepo, _, session, appSettingRepo := setupCredentialUseCaseTest() + subtestMasterKey := []byte("12345678901234567890123456789012") + + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(subtestMasterKey) + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + credRepo.On("DeleteByIDAndProjectID", ctx, projectID, credentialID).Return(true, nil) + tagRepo.On("RemoveAllTagsFromItem", ctx, model.ItemTypeCredential, credentialID).Return(nil) + + err := uc.Delete(ctx, projectID, credentialID) + + assert.NoError(t, err) + credRepo.AssertExpectations(t) + tagRepo.AssertExpectations(t) + + assert.True(t, bytes.Equal(subtestMasterKey, make([]byte, 32))) + }) + + t.Run("Delete Failure - Credential Not Found", func(t *testing.T) { + uc, credRepo, projRepo, _, _, session, appSettingRepo := setupCredentialUseCaseTest() + subtestMasterKey := []byte("12345678901234567890123456789012") + + appSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + session.On("GetKey").Return(subtestMasterKey) + projRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + credRepo.On("DeleteByIDAndProjectID", ctx, projectID, credentialID).Return(false, nil) + + err := uc.Delete(ctx, projectID, credentialID) + + assert.ErrorIs(t, err, usecase.ErrCredentialNotFound) + + assert.True(t, bytes.Equal(subtestMasterKey, make([]byte, 32))) + }) +} diff --git a/backend/internal/usecase/item_tag_usecase.go b/backend/internal/usecase/item_tag_usecase.go new file mode 100644 index 0000000..57439b1 --- /dev/null +++ b/backend/internal/usecase/item_tag_usecase.go @@ -0,0 +1,145 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "errors" + + "github.com/google/uuid" +) + +var ( + ErrUnsupportedItemType = errors.New("unsupported item type") + ErrItemNotFound = errors.New("item not found") +) + +type ItemTagUseCase struct { + itemTagRepo port.ItemTagRepository + tagRepo port.TagRepository + projectRepo port.ProjectRepository + itemRepos map[model.ItemType]port.ProjectScopedRepository +} + +func NewItemTagUseCase( + itemTagRepo port.ItemTagRepository, + tagRepo port.TagRepository, + projectRepo port.ProjectRepository, + snippetRepo port.SnippetRepository, + credentialRepo port.CredentialRepository, + linkRepo port.LinkRepository, + problemRepo port.ProblemRepository, + noteRepo port.NoteRepository, +) *ItemTagUseCase { + return &ItemTagUseCase{ + itemTagRepo: itemTagRepo, + tagRepo: tagRepo, + projectRepo: projectRepo, + itemRepos: map[model.ItemType]port.ProjectScopedRepository{ + model.ItemTypeSnippet: snippetRepo, + model.ItemTypeLink: linkRepo, + model.ItemTypeProblem: problemRepo, + model.ItemTypeNote: noteRepo, + model.ItemTypeCredential: credentialRepo, + }, + } +} + +func (uc *ItemTagUseCase) AssociateTagToItem(ctx context.Context, projectID uuid.UUID, itemType model.ItemType, itemID, tagID uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + tagExists, err := uc.tagRepo.ExistsByIDAndProjectID(ctx, tagID, projectID) + if err != nil { + return err + } + if !tagExists { + return ErrTagNotFound + } + if err := uc.validateItemOwnership(ctx, projectID, itemType, itemID); err != nil { + return err + } + + return uc.itemTagRepo.AssociateTagToItem(ctx, projectID, tagID, itemType, itemID) +} + +func (uc *ItemTagUseCase) DisassociateTagFromItem(ctx context.Context, projectID uuid.UUID, itemType model.ItemType, itemID, tagID uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + tagExists, err := uc.tagRepo.ExistsByIDAndProjectID(ctx, tagID, projectID) + if err != nil { + return err + } + if !tagExists { + return ErrTagNotFound + } + if err := uc.validateItemOwnership(ctx, projectID, itemType, itemID); err != nil { + return err + } + return uc.itemTagRepo.DisassembleTagFromItem(ctx, projectID, tagID, itemType, itemID) + +} + +// useful methods for listing tags for a project +func (uc *ItemTagUseCase) GetTagsForItem(ctx context.Context, projectID uuid.UUID, itemType model.ItemType, itemID uuid.UUID) ([]model.Tag, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + if err := uc.validateItemOwnership(ctx, projectID, itemType, itemID); err != nil { + return nil, err + } + + return uc.itemTagRepo.FindTagsForItem(ctx, itemType, projectID, itemID) +} + +func (uc *ItemTagUseCase) GetTagsForItems(ctx context.Context, projectID uuid.UUID, itemType model.ItemType, itemIDs []uuid.UUID) (map[uuid.UUID][]model.Tag, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + if len(itemIDs) == 0 { + return map[uuid.UUID][]model.Tag{}, nil + } + if err := uc.validateItemsOwnership(ctx, projectID, itemType, itemIDs); err != nil { + return nil, err + } + + return uc.itemTagRepo.FindTagsForItems(ctx, itemType, projectID, itemIDs) +} + +// helper methods +func (uc *ItemTagUseCase) validateItemOwnership(ctx context.Context, projectID uuid.UUID, itemType model.ItemType, itemID uuid.UUID) error { + repo, ok := uc.itemRepos[itemType] + if !ok { + return ErrUnsupportedItemType + } + exists, err := repo.ExistsByIDAndProjectID(ctx, itemID, projectID) + if err != nil { + return err + } + if !exists { + return ErrItemNotFound + } + return nil +} + +func (uc *ItemTagUseCase) validateItemsOwnership(ctx context.Context, projectID uuid.UUID, itemType model.ItemType, itemIDs []uuid.UUID) error { + repo, ok := uc.itemRepos[itemType] + if !ok { + return ErrUnsupportedItemType + } + existingIDs, err := repo.FindExistingIDsByProjectID(ctx, itemIDs, projectID) + if err != nil { + return err + } + existingMap := make(map[uuid.UUID]bool, len(existingIDs)) + for _, id := range existingIDs { + existingMap[id] = true + } + for _, id := range itemIDs { + if !existingMap[id] { + return ErrItemNotFound + } + } + return nil +} diff --git a/backend/internal/usecase/item_tag_usecase_test.go b/backend/internal/usecase/item_tag_usecase_test.go new file mode 100644 index 0000000..717f182 --- /dev/null +++ b/backend/internal/usecase/item_tag_usecase_test.go @@ -0,0 +1,465 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/usecase" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockItemTagRepository struct { + mock.Mock +} + +func (m *MockItemTagRepository) AssociateTagToItem(ctx context.Context, projectID, tagID uuid.UUID, itemType model.ItemType, itemID uuid.UUID) error { + args := m.Called(ctx, projectID, tagID, itemType, itemID) + return args.Error(0) +} + +func (m *MockItemTagRepository) DisassembleTagFromItem(ctx context.Context, projectID, tagID uuid.UUID, itemType model.ItemType, itemID uuid.UUID) error { + args := m.Called(ctx, projectID, tagID, itemType, itemID) + return args.Error(0) +} + +func (m *MockItemTagRepository) RemoveAllTagsFromItem(ctx context.Context, itemType model.ItemType, itemID uuid.UUID) error { + args := m.Called(ctx, itemType, itemID) + return args.Error(0) +} + +func (m *MockItemTagRepository) FindTagsForItem(ctx context.Context, itemType model.ItemType, projectID, itemID uuid.UUID) ([]model.Tag, error) { + args := m.Called(ctx, itemType, projectID, itemID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]model.Tag), args.Error(1) +} + +func (m *MockItemTagRepository) FindTagsForItems(ctx context.Context, itemType model.ItemType, projectID uuid.UUID, itemIDs []uuid.UUID) (map[uuid.UUID][]model.Tag, error) { + args := m.Called(ctx, itemType, projectID, itemIDs) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[uuid.UUID][]model.Tag), args.Error(1) +} + +type MockNoteRepository struct { + mock.Mock +} + +func (m *MockNoteRepository) Save(ctx context.Context, note *model.Note) (*model.Note, error) { + args := m.Called(ctx, note) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Note), args.Error(1) +} + +func (m *MockNoteRepository) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Note, error) { + args := m.Called(ctx, projectID, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Note), args.Error(1) +} + +func (m *MockNoteRepository) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[model.Note], error) { + args := m.Called(ctx, projectID, page, size) + return args.Get(0).(model.Page[model.Note]), args.Error(1) +} + +func (m *MockNoteRepository) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + args := m.Called(ctx, projectID, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockNoteRepository) ExistsByIDAndProjectID(ctx context.Context, id, projectID uuid.UUID) (bool, error) { + args := m.Called(ctx, id, projectID) + return args.Bool(0), args.Error(1) +} + +func (m *MockNoteRepository) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + args := m.Called(ctx, ids, projectID) + return args.Get(0).([]uuid.UUID), args.Error(1) +} + +// --- UNIT TESTS --- + +func SetupItemTagUseCaseTest() ( + *MockItemTagRepository, + *MockTagRepository, + *MockProjectRepository, + *MockSnippetRepository, + *MockLinkRepository, + *MockProblemRepository, + *usecase.ItemTagUseCase, +) { + mockItemTagRepo := new(MockItemTagRepository) + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + mockSnippetRepo := new(MockSnippetRepository) + mockCredRepo := new(MockCredentialRepository) + mockLinkRepo := new(MockLinkRepository) + mockProblemRepo := new(MockProblemRepository) + mockNoteRepo := new(MockNoteRepository) + + uc := usecase.NewItemTagUseCase( + mockItemTagRepo, + mockTagRepo, + mockProjectRepo, + mockSnippetRepo, + mockCredRepo, + mockLinkRepo, + mockProblemRepo, + mockNoteRepo, + ) + + return mockItemTagRepo, mockTagRepo, mockProjectRepo, mockSnippetRepo, mockLinkRepo, mockProblemRepo, uc +} + +func TestItemTagUseCase_AssociateTagToItem(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + tagID := uuid.New() + snippetID := uuid.New() + + t.Run("Associate_Success", func(t *testing.T) { + mockItemTagRepo, mockTagRepo, mockProjectRepo, mockSnippetRepo, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(true, nil) + mockSnippetRepo.On("ExistsByIDAndProjectID", ctx, snippetID, projectID).Return(true, nil) + mockItemTagRepo.On("AssociateTagToItem", ctx, projectID, tagID, model.ItemTypeSnippet, snippetID).Return(nil) + + err := uc.AssociateTagToItem(ctx, projectID, model.ItemTypeSnippet, snippetID, tagID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockTagRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) + }) + + t.Run("Associate_ProjectNotFound", func(t *testing.T) { + _, _, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + err := uc.AssociateTagToItem(ctx, projectID, model.ItemTypeSnippet, snippetID, tagID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + }) + + t.Run("Associate_TagNotFound", func(t *testing.T) { + _, mockTagRepo, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(false, nil) + + err := uc.AssociateTagToItem(ctx, projectID, model.ItemTypeSnippet, snippetID, tagID) + + assert.ErrorIs(t, err, usecase.ErrTagNotFound) + }) + + t.Run("Associate_UnsupportedItemType", func(t *testing.T) { + _, mockTagRepo, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(true, nil) + + err := uc.AssociateTagToItem(ctx, projectID, model.ItemType("UNKNOWN"), snippetID, tagID) + + assert.ErrorIs(t, err, usecase.ErrUnsupportedItemType) + }) + + t.Run("Associate_ItemNotFound", func(t *testing.T) { + _, mockTagRepo, mockProjectRepo, mockSnippetRepo, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(true, nil) + mockSnippetRepo.On("ExistsByIDAndProjectID", ctx, snippetID, projectID).Return(false, nil) + + err := uc.AssociateTagToItem(ctx, projectID, model.ItemTypeSnippet, snippetID, tagID) + + assert.ErrorIs(t, err, usecase.ErrItemNotFound) + }) + + t.Run("Associate_RepoError", func(t *testing.T) { + mockItemTagRepo, mockTagRepo, mockProjectRepo, mockSnippetRepo, _, _, uc := SetupItemTagUseCaseTest() + + dbErr := errors.New("db insert failure") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(true, nil) + mockSnippetRepo.On("ExistsByIDAndProjectID", ctx, snippetID, projectID).Return(true, nil) + mockItemTagRepo.On("AssociateTagToItem", ctx, projectID, tagID, model.ItemTypeSnippet, snippetID).Return(dbErr) + + err := uc.AssociateTagToItem(ctx, projectID, model.ItemTypeSnippet, snippetID, tagID) + + assert.ErrorIs(t, err, dbErr) + }) +} + +func TestItemTagUseCase_DisassociateTagFromItem(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + tagID := uuid.New() + linkID := uuid.New() + + t.Run("Disassociate_Success", func(t *testing.T) { + mockItemTagRepo, mockTagRepo, mockProjectRepo, _, mockLinkRepo, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(true, nil) + mockLinkRepo.On("ExistsByIDAndProjectID", ctx, linkID, projectID).Return(true, nil) + mockItemTagRepo.On("DisassembleTagFromItem", ctx, projectID, tagID, model.ItemTypeLink, linkID).Return(nil) + + err := uc.DisassociateTagFromItem(ctx, projectID, model.ItemTypeLink, linkID, tagID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockTagRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) + }) + + t.Run("Disassociate_ProjectNotFound", func(t *testing.T) { + _, _, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + err := uc.DisassociateTagFromItem(ctx, projectID, model.ItemTypeLink, linkID, tagID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + }) + + t.Run("Disassociate_TagNotFound", func(t *testing.T) { + _, mockTagRepo, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(false, nil) + + err := uc.DisassociateTagFromItem(ctx, projectID, model.ItemTypeLink, linkID, tagID) + + assert.ErrorIs(t, err, usecase.ErrTagNotFound) + }) + + t.Run("Disassociate_UnsupportedItemType", func(t *testing.T) { + _, mockTagRepo, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(true, nil) + + err := uc.DisassociateTagFromItem(ctx, projectID, model.ItemType("INVALID"), linkID, tagID) + + assert.ErrorIs(t, err, usecase.ErrUnsupportedItemType) + }) + + t.Run("Disassociate_ItemNotFound", func(t *testing.T) { + _, mockTagRepo, mockProjectRepo, _, mockLinkRepo, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(true, nil) + mockLinkRepo.On("ExistsByIDAndProjectID", ctx, linkID, projectID).Return(false, nil) + + err := uc.DisassociateTagFromItem(ctx, projectID, model.ItemTypeLink, linkID, tagID) + + assert.ErrorIs(t, err, usecase.ErrItemNotFound) + }) + + t.Run("Disassociate_RepoError", func(t *testing.T) { + mockItemTagRepo, mockTagRepo, mockProjectRepo, _, mockLinkRepo, _, uc := SetupItemTagUseCaseTest() + + dbErr := errors.New("db delete failure") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByIDAndProjectID", ctx, tagID, projectID).Return(true, nil) + mockLinkRepo.On("ExistsByIDAndProjectID", ctx, linkID, projectID).Return(true, nil) + mockItemTagRepo.On("DisassembleTagFromItem", ctx, projectID, tagID, model.ItemTypeLink, linkID).Return(dbErr) + + err := uc.DisassociateTagFromItem(ctx, projectID, model.ItemTypeLink, linkID, tagID) + + assert.ErrorIs(t, err, dbErr) + }) +} + +func TestItemTagUseCase_GetTagsForItem(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + problemID := uuid.New() + + t.Run("GetTagsForItem_Success", func(t *testing.T) { + mockItemTagRepo, _, mockProjectRepo, _, _, mockProblemRepo, uc := SetupItemTagUseCaseTest() + + expectedTags := []model.Tag{ + {ID: uuid.New(), ProjectID: projectID, Name: "Bug"}, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("ExistsByIDAndProjectID", ctx, problemID, projectID).Return(true, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeProblem, projectID, problemID).Return(expectedTags, nil) + + result, err := uc.GetTagsForItem(ctx, projectID, model.ItemTypeProblem, problemID) + + assert.NoError(t, err) + assert.Equal(t, expectedTags, result) + }) + + t.Run("GetTagsForItem_ProjectNotFound", func(t *testing.T) { + _, _, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetTagsForItem(ctx, projectID, model.ItemTypeProblem, problemID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + assert.Nil(t, result) + }) + + t.Run("GetTagsForItem_UnsupportedItemType", func(t *testing.T) { + _, _, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + + result, err := uc.GetTagsForItem(ctx, projectID, model.ItemType("DUMMY"), problemID) + + assert.ErrorIs(t, err, usecase.ErrUnsupportedItemType) + assert.Nil(t, result) + }) + + t.Run("GetTagsForItem_ItemNotFound", func(t *testing.T) { + _, _, mockProjectRepo, _, _, mockProblemRepo, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("ExistsByIDAndProjectID", ctx, problemID, projectID).Return(false, nil) + + result, err := uc.GetTagsForItem(ctx, projectID, model.ItemTypeProblem, problemID) + + assert.ErrorIs(t, err, usecase.ErrItemNotFound) + assert.Nil(t, result) + }) + + t.Run("GetTagsForItem_RepoError", func(t *testing.T) { + mockItemTagRepo, _, mockProjectRepo, _, _, mockProblemRepo, uc := SetupItemTagUseCaseTest() + + dbErr := errors.New("find tags error") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("ExistsByIDAndProjectID", ctx, problemID, projectID).Return(true, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeProblem, projectID, problemID).Return(nil, dbErr) + + result, err := uc.GetTagsForItem(ctx, projectID, model.ItemTypeProblem, problemID) + + assert.ErrorIs(t, err, dbErr) + assert.Nil(t, result) + }) +} + +func TestItemTagUseCase_GetTagsForItems(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + id1 := uuid.New() + id2 := uuid.New() + itemIDs := []uuid.UUID{id1, id2} + + t.Run("GetTagsForItems_Success", func(t *testing.T) { + mockItemTagRepo, _, mockProjectRepo, mockSnippetRepo, _, _, uc := SetupItemTagUseCaseTest() + + expectedMap := map[uuid.UUID][]model.Tag{ + id1: {{ID: uuid.New(), ProjectID: projectID, Name: "Tag A"}}, + id2: {{ID: uuid.New(), ProjectID: projectID, Name: "Tag B"}}, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindExistingIDsByProjectID", ctx, itemIDs, projectID).Return(itemIDs, nil) + mockItemTagRepo.On("FindTagsForItems", ctx, model.ItemTypeSnippet, projectID, itemIDs).Return(expectedMap, nil) + + result, err := uc.GetTagsForItems(ctx, projectID, model.ItemTypeSnippet, itemIDs) + + assert.NoError(t, err) + assert.Equal(t, expectedMap, result) + }) + + t.Run("GetTagsForItems_Success_EmptyList", func(t *testing.T) { + _, _, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + + result, err := uc.GetTagsForItems(ctx, projectID, model.ItemTypeSnippet, []uuid.UUID{}) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Empty(t, result) + }) + + t.Run("GetTagsForItems_ProjectNotFound", func(t *testing.T) { + _, _, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetTagsForItems(ctx, projectID, model.ItemTypeSnippet, itemIDs) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + assert.Nil(t, result) + }) + + t.Run("GetTagsForItems_UnsupportedItemType", func(t *testing.T) { + _, _, mockProjectRepo, _, _, _, uc := SetupItemTagUseCaseTest() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + + result, err := uc.GetTagsForItems(ctx, projectID, model.ItemType("UNKNOWN"), itemIDs) + + assert.ErrorIs(t, err, usecase.ErrUnsupportedItemType) + assert.Nil(t, result) + }) + + t.Run("GetTagsForItems_ItemNotFound", func(t *testing.T) { + _, _, mockProjectRepo, mockSnippetRepo, _, _, uc := SetupItemTagUseCaseTest() + + // Only id1 is returned from db, so length doesn't match itemIDs (2 != 1) + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindExistingIDsByProjectID", ctx, itemIDs, projectID).Return([]uuid.UUID{id1}, nil) + + result, err := uc.GetTagsForItems(ctx, projectID, model.ItemTypeSnippet, itemIDs) + + assert.ErrorIs(t, err, usecase.ErrItemNotFound) + assert.Nil(t, result) + }) + + t.Run("GetTagsForItems_RepoError", func(t *testing.T) { + mockItemTagRepo, _, mockProjectRepo, mockSnippetRepo, _, _, uc := SetupItemTagUseCaseTest() + + dbErr := errors.New("batch query error") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindExistingIDsByProjectID", ctx, itemIDs, projectID).Return(itemIDs, nil) + mockItemTagRepo.On("FindTagsForItems", ctx, model.ItemTypeSnippet, projectID, itemIDs).Return(nil, dbErr) + + result, err := uc.GetTagsForItems(ctx, projectID, model.ItemTypeSnippet, itemIDs) + + assert.ErrorIs(t, err, dbErr) + assert.Nil(t, result) + }) + + t.Run("GetTagsForItems_DuplicateIDsSuccess", func(t *testing.T) { + mockItemTagRepo, _, mockProjectRepo, mockSnippetRepo, _, _, uc := SetupItemTagUseCaseTest() + + duplicateIDs := []uuid.UUID{id1, id1} + existingIDs := []uuid.UUID{id1} // DB returns unique existing ID + + expectedTags := map[uuid.UUID][]model.Tag{ + id1: {{ID: uuid.New(), Name: "Go"}}, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindExistingIDsByProjectID", ctx, duplicateIDs, projectID).Return(existingIDs, nil) + mockItemTagRepo.On("FindTagsForItems", ctx, model.ItemTypeSnippet, projectID, duplicateIDs).Return(expectedTags, nil) + + result, err := uc.GetTagsForItems(ctx, projectID, model.ItemTypeSnippet, duplicateIDs) + + assert.NoError(t, err) + assert.Equal(t, expectedTags, result) + }) +} diff --git a/backend/internal/usecase/link_usecase.go b/backend/internal/usecase/link_usecase.go new file mode 100644 index 0000000..d0098e5 --- /dev/null +++ b/backend/internal/usecase/link_usecase.go @@ -0,0 +1,181 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/dto" + "errors" + "fmt" + "log" + "time" + + "github.com/google/uuid" +) + +var ( + ErrLinkNotFound = errors.New("link not found") +) + +type LinkUseCase struct { + linkRepo port.LinkRepository + projectRepo port.ProjectRepository + itemTagRepo port.ItemTagRepository +} + +func NewLinkUseCase(linkRepo port.LinkRepository, projectRepo port.ProjectRepository, itemTagRepo port.ItemTagRepository) *LinkUseCase { + return &LinkUseCase{ + linkRepo: linkRepo, + projectRepo: projectRepo, + itemTagRepo: itemTagRepo, + } +} + +func (uc *LinkUseCase) Create(ctx context.Context, cmd dto.CreateLinkCommand) (*dto.LinkView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + link := model.Link{ + ID: uuid.New(), + ProjectID: cmd.ProjectID, + Title: cmd.Title, + Url: cmd.URL, + Description: cmd.Description, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + + saved, err := uc.linkRepo.Save(ctx, &link) + if err != nil { + return nil, err + } + return mapLinkToView(saved, nil), nil +} + +func (uc *LinkUseCase) GetByID(ctx context.Context, projectID, id uuid.UUID) (*dto.LinkView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + link, err := uc.linkRepo.FindByIDAndProjectID(ctx, projectID, id) + if err != nil { + return nil, fmt.Errorf("error trying to find link: %w", err) + } + + if link == nil { + return nil, ErrLinkNotFound + } + + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeLink, projectID, id) + if err != nil { + return nil, fmt.Errorf("error fetching tags for link: %w", err) + } + + return mapLinkToView(link, tags), nil +} + +func (uc *LinkUseCase) GetAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[dto.LinkView], error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return model.Page[dto.LinkView]{}, err + } + linkPage, err := uc.linkRepo.FindAllByProjectID(ctx, projectID, page, size) + if err != nil { + return model.Page[dto.LinkView]{}, err + } + if len(linkPage.Content) == 0 { + return model.NewPage([]dto.LinkView{}, linkPage.Number, linkPage.Size, linkPage.TotalElements), nil + } + + linkIDs := make([]uuid.UUID, len(linkPage.Content)) + for i, l := range linkPage.Content { + linkIDs[i] = l.ID + } + + tagsMap, err := uc.itemTagRepo.FindTagsForItems(ctx, model.ItemTypeLink, projectID, linkIDs) + if err != nil { + return model.Page[dto.LinkView]{}, fmt.Errorf("error fetching tags for links: %w", err) + } + + views := make([]dto.LinkView, len(linkPage.Content)) + for i, l := range linkPage.Content { + tags := tagsMap[l.ID] + views[i] = *mapLinkToView(&l, tags) + } + + return model.NewPage(views, linkPage.Number, linkPage.Size, linkPage.TotalElements), nil +} + +func (uc *LinkUseCase) Update(ctx context.Context, cmd dto.UpdateLinkCommand) (*dto.LinkView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + link, err := uc.linkRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find link: %w", err) + } + if link == nil { + return nil, ErrLinkNotFound + } + + if cmd.Title != nil { + link.Title = *cmd.Title + } + if cmd.URL != nil { + link.Url = *cmd.URL + } + if cmd.Description != nil { + link.Description = cmd.Description + } + now := time.Now() + link.UpdatedAt = &now + saved, err := uc.linkRepo.Save(ctx, link) + if err != nil { + return nil, err + } + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeLink, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error fetching tags for link: %w", err) + } + return mapLinkToView(saved, tags), nil +} + +func (uc *LinkUseCase) Delete(ctx context.Context, projectID, id uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + deleted, err := uc.linkRepo.DeleteByIDAndProjectID(ctx, projectID, id) + if err != nil { + return fmt.Errorf("error deleting link: %w", err) + } + if !deleted { + return ErrLinkNotFound + } + if err := uc.itemTagRepo.RemoveAllTagsFromItem(ctx, model.ItemTypeLink, id); err != nil { + log.Printf("warning: failed to remove tags from link %s: %v", id, err) + } + return nil +} + +func mapLinkToView(link *model.Link, tags []model.Tag) *dto.LinkView { + tagSummaries := make([]dto.TagSummary, len(tags)) + for i, t := range tags { + tagSummaries[i] = dto.TagSummary{ + ID: t.ID, + Name: t.Name, + Color: t.Color, + } + } + return &dto.LinkView{ + ID: link.ID, + ProjectID: link.ProjectID, + Title: link.Title, + URL: link.Url, + Description: link.Description, + Tags: tagSummaries, + CreatedAt: &link.CreatedAt, + UpdatedAt: link.UpdatedAt, + } +} diff --git a/backend/internal/usecase/link_usecase_test.go b/backend/internal/usecase/link_usecase_test.go new file mode 100644 index 0000000..fa56be3 --- /dev/null +++ b/backend/internal/usecase/link_usecase_test.go @@ -0,0 +1,392 @@ +package usecase_test + +import ( + "context" + "testing" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockLinkRepository struct { + mock.Mock +} + +func (m *MockLinkRepository) Save(ctx context.Context, link *model.Link) (*model.Link, error) { + args := m.Called(ctx, link) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Link), args.Error(1) +} + +func (m *MockLinkRepository) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Link, error) { + args := m.Called(ctx, projectID, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Link), args.Error(1) +} + +func (m *MockLinkRepository) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[model.Link], error) { + args := m.Called(ctx, projectID, page, size) + return args.Get(0).(model.Page[model.Link]), args.Error(1) +} + +func (m *MockLinkRepository) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + args := m.Called(ctx, projectID, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockLinkRepository) ExistsByIDAndProjectID(ctx context.Context, id, projectID uuid.UUID) (bool, error) { + args := m.Called(ctx, id, projectID) + return args.Bool(0), args.Error(1) +} + +func (m *MockLinkRepository) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + args := m.Called(ctx, ids, projectID) + return args.Get(0).([]uuid.UUID), args.Error(1) +} + +// --- UNIT TESTS --- + +func TestLinkUseCase_Create_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + desc := "Link description" + + cmd := dto.CreateLinkCommand{ + ProjectID: projectID, + Title: "Go Documentation", + URL: "https://go.dev/doc", + Description: &desc, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("Save", ctx, mock.MatchedBy(func(l *model.Link) bool { + return l.ProjectID == projectID && l.Title == "Go Documentation" && l.Url == "https://go.dev/doc" + })).Return(&model.Link{ + ID: uuid.New(), + ProjectID: projectID, + Title: "Go Documentation", + Url: "https://go.dev/doc", + Description: &desc, + }, nil) + + created, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, created) + assert.Equal(t, "Go Documentation", created.Title) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Create_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + cmd := dto.CreateLinkCommand{ + ProjectID: projectID, + Title: "Go Documentation", + URL: "https://go.dev/doc", + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + created, err := uc.Create(ctx, cmd) + + assert.Nil(t, created) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestLinkUseCase_GetByID_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + expectedLink := &model.Link{ + ID: linkID, + ProjectID: projectID, + Title: "Go Documentation", + Url: "https://go.dev/doc", + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("FindByIDAndProjectID", ctx, projectID, linkID).Return(expectedLink, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeLink, projectID, linkID).Return([]model.Tag{}, nil) + + result, err := uc.GetByID(ctx, projectID, linkID) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, linkID, result.ID) + assert.Equal(t, projectID, result.ProjectID) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestLinkUseCase_GetByID_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetByID(ctx, projectID, linkID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestLinkUseCase_GetByID_NotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("FindByIDAndProjectID", ctx, projectID, linkID).Return(nil, nil) + + result, err := uc.GetByID(ctx, projectID, linkID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrLinkNotFound) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_GetAllByProjectID_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + link1ID := uuid.New() + link2ID := uuid.New() + + expectedPage := model.NewPage([]model.Link{ + {ID: link1ID, ProjectID: projectID, Title: "Link 1", Url: "https://link1.com"}, + {ID: link2ID, ProjectID: projectID, Title: "Link 2", Url: "https://link2.com"}, + }, 0, 10, 2) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("FindAllByProjectID", ctx, projectID, 0, 10).Return(expectedPage, nil) + mockItemTagRepo.On("FindTagsForItems", ctx, model.ItemTypeLink, projectID, []uuid.UUID{link1ID, link2ID}).Return(map[uuid.UUID][]model.Tag{}, nil) + + result, err := uc.GetAllByProjectID(ctx, projectID, 0, 10) + + assert.NoError(t, err) + assert.Equal(t, 2, len(result.Content)) + assert.Equal(t, int64(2), result.TotalElements) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestLinkUseCase_GetAllByProjectID_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + _, err := uc.GetAllByProjectID(ctx, projectID, 0, 10) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Update_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + existingLink := &model.Link{ + ID: linkID, + ProjectID: projectID, + Title: "Old Title", + Url: "https://old-url.com", + } + + newTitle := "Updated Title" + cmd := dto.UpdateLinkCommand{ + ProjectID: projectID, + ID: linkID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("FindByIDAndProjectID", ctx, projectID, linkID).Return(existingLink, nil) + mockLinkRepo.On("Save", ctx, mock.MatchedBy(func(l *model.Link) bool { + return l.Title == "Updated Title" && l.UpdatedAt != nil + })).Return(&model.Link{ + ID: linkID, + ProjectID: projectID, + Title: "Updated Title", + Url: "https://old-url.com", + }, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeLink, projectID, linkID).Return([]model.Tag{}, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, updated) + assert.Equal(t, "Updated Title", updated.Title) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Update_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + newTitle := "Updated Title" + cmd := dto.UpdateLinkCommand{ + ProjectID: projectID, + ID: linkID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Update_LinkNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + newTitle := "Updated Title" + cmd := dto.UpdateLinkCommand{ + ProjectID: projectID, + ID: linkID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("FindByIDAndProjectID", ctx, projectID, linkID).Return(nil, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrLinkNotFound) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Delete_Success(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("DeleteByIDAndProjectID", ctx, projectID, linkID).Return(true, nil) + mockItemTagRepo.On("RemoveAllTagsFromItem", ctx, model.ItemTypeLink, linkID).Return(nil) + + err := uc.Delete(ctx, projectID, linkID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Delete_ProjectNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + err := uc.Delete(ctx, projectID, linkID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestLinkUseCase_Delete_LinkNotFound(t *testing.T) { + mockLinkRepo := new(MockLinkRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewLinkUseCase(mockLinkRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + linkID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockLinkRepo.On("DeleteByIDAndProjectID", ctx, projectID, linkID).Return(false, nil) + + err := uc.Delete(ctx, projectID, linkID) + + assert.ErrorIs(t, err, usecase.ErrLinkNotFound) + mockProjectRepo.AssertExpectations(t) + mockLinkRepo.AssertExpectations(t) +} diff --git a/backend/internal/usecase/note_usecase.go b/backend/internal/usecase/note_usecase.go new file mode 100644 index 0000000..d987a1f --- /dev/null +++ b/backend/internal/usecase/note_usecase.go @@ -0,0 +1,196 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/dto" + "errors" + "fmt" + "log" + "time" + + "github.com/google/uuid" +) + +var ( + ErrNoteNotFound = errors.New("note not found") +) + +type NoteUseCase struct { + noteRepo port.NoteRepository + projectRepo port.ProjectRepository + itemTagRepo port.ItemTagRepository +} + +func NewNoteUseCase(noteRepo port.NoteRepository, projectRepo port.ProjectRepository, itemTagRepo port.ItemTagRepository) *NoteUseCase { + return &NoteUseCase{ + noteRepo: noteRepo, + projectRepo: projectRepo, + itemTagRepo: itemTagRepo, + } +} + +func (uc *NoteUseCase) Create(ctx context.Context, cmd dto.CreateNoteCommand) (*dto.NoteView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + note := model.Note{ + ID: uuid.New(), + ProjectID: cmd.ProjectID, + Title: cmd.Title, + Content: &cmd.Content, + Archived: false, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + saved, err := uc.noteRepo.Save(ctx, ¬e) + if err != nil { + return nil, err + } + return mapNoteToView(saved, nil), nil +} + +func (uc *NoteUseCase) GetByID(ctx context.Context, projectID, id uuid.UUID) (*dto.NoteView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + note, err := uc.noteRepo.FindByIDAndProjectID(ctx, projectID, id) + if err != nil { + return nil, fmt.Errorf("error trying to find note: %w", err) + } + if note == nil { + return nil, ErrNoteNotFound + } + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeNote, projectID, id) + if err != nil { + return nil, fmt.Errorf("error fetching tags for note: %w", err) + } + return mapNoteToView(note, tags), nil +} + +func (uc *NoteUseCase) GetAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[dto.NoteSummary], error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return model.Page[dto.NoteSummary]{}, err + } + + notePage, err := uc.noteRepo.FindAllByProjectID(ctx, projectID, page, size) + if err != nil { + return model.Page[dto.NoteSummary]{}, err + } + if len(notePage.Content) == 0 { + return model.NewPage([]dto.NoteSummary{}, notePage.Number, notePage.Size, notePage.TotalElements), nil + } + + noteIDs := make([]uuid.UUID, len(notePage.Content)) + for i, p := range notePage.Content { + noteIDs[i] = p.ID + } + + notesMap, err := uc.itemTagRepo.FindTagsForItems(ctx, model.ItemTypeNote, projectID, noteIDs) + if err != nil { + return model.Page[dto.NoteSummary]{}, fmt.Errorf("error fetching tags for notes: %w", err) + } + + summaries := make([]dto.NoteSummary, len(notePage.Content)) + for i, p := range notePage.Content { + tags := notesMap[p.ID] + tagSummaries := make([]dto.TagSummary, len(tags)) + for j, t := range tags { + tagSummaries[j] = dto.TagSummary{ + ID: t.ID, + Name: t.Name, + Color: t.Color, + } + } + summaries[i] = dto.NoteSummary{ + ID: p.ID, + ProjectID: p.ProjectID, + Title: p.Title, + Archived: p.Archived, + Tags: tagSummaries, + CreatedAt: &p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } + } + return model.NewPage(summaries, notePage.Number, notePage.Size, notePage.TotalElements), nil +} + +func (uc *NoteUseCase) Update(ctx context.Context, cmd dto.UpdateNoteCommand) (*dto.NoteView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + note, err := uc.noteRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find note: %w", err) + } + if note == nil { + return nil, ErrNoteNotFound + } + + if cmd.Title != nil { + note.Title = *cmd.Title + } + if cmd.Content != nil { + note.Content = cmd.Content + } + now := time.Now() + note.UpdatedAt = &now + savedNote, err := uc.noteRepo.Save(ctx, note) + if err != nil { + return nil, err + } + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeNote, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error fetching tags for note: %w", err) + } + + return mapNoteToView(savedNote, tags), nil + +} + +func (uc *NoteUseCase) Delete(ctx context.Context, projectID, id uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + deleted, err := uc.noteRepo.DeleteByIDAndProjectID(ctx, projectID, id) + if err != nil { + return fmt.Errorf("error deleting note: %w", err) + } + if !deleted { + return ErrNoteNotFound + } + if err := uc.itemTagRepo.RemoveAllTagsFromItem(ctx, model.ItemTypeNote, id); err != nil { + log.Printf("warning: failed to remove tags from note %s: %v", id, err) + } + return nil +} + +// --- auxiliary methods --- +func mapNoteToView(note *model.Note, tags []model.Tag) *dto.NoteView { + tagSummaries := make([]dto.TagSummary, len(tags)) + for i, t := range tags { + tagSummaries[i] = dto.TagSummary{ + ID: t.ID, + Name: t.Name, + Color: t.Color, + } + } + var content string + if note.Content != nil { + content = *note.Content + } + return &dto.NoteView{ + ID: note.ID, + ProjectID: note.ProjectID, + Title: note.Title, + Content: content, + Archived: note.Archived, + Tags: tagSummaries, + CreatedAt: ¬e.CreatedAt, + UpdatedAt: note.UpdatedAt, + } +} diff --git a/backend/internal/usecase/note_usecase_test.go b/backend/internal/usecase/note_usecase_test.go new file mode 100644 index 0000000..2a90a20 --- /dev/null +++ b/backend/internal/usecase/note_usecase_test.go @@ -0,0 +1,177 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestNoteUseCase_Create(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + + cmd := dto.CreateNoteCommand{ + ProjectID: projectID, + Title: "Architecture Notes", + Content: "Hexagonal architecture design notes", + } + + t.Run("Create_Success", func(t *testing.T) { + mockNoteRepo := new(MockNoteRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewNoteUseCase(mockNoteRepo, mockProjectRepo, mockItemTagRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockNoteRepo.On("Save", ctx, mock.MatchedBy(func(n *model.Note) bool { + return n.Title == "Architecture Notes" && *n.Content == "Hexagonal architecture design notes" && n.ProjectID == projectID + })).Return(&model.Note{ + ID: uuid.New(), + ProjectID: projectID, + Title: "Architecture Notes", + Content: &cmd.Content, + Archived: false, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + }, + }, nil) + + result, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "Architecture Notes", result.Title) + assert.Equal(t, "Hexagonal architecture design notes", result.Content) + mockProjectRepo.AssertExpectations(t) + mockNoteRepo.AssertExpectations(t) + }) + + t.Run("Create_ProjectNotFound", func(t *testing.T) { + mockNoteRepo := new(MockNoteRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewNoteUseCase(mockNoteRepo, mockProjectRepo, mockItemTagRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.Create(ctx, cmd) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + assert.Nil(t, result) + }) +} + +func TestNoteUseCase_GetByID(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + noteID := uuid.New() + content := "Sample content" + + t.Run("GetByID_Success", func(t *testing.T) { + mockNoteRepo := new(MockNoteRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewNoteUseCase(mockNoteRepo, mockProjectRepo, mockItemTagRepo) + + expectedNote := &model.Note{ + ID: noteID, + ProjectID: projectID, + Title: "My Note", + Content: &content, + Archived: false, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + }, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockNoteRepo.On("FindByIDAndProjectID", ctx, projectID, noteID).Return(expectedNote, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeNote, projectID, noteID).Return([]model.Tag{}, nil) + + result, err := uc.GetByID(ctx, projectID, noteID) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, noteID, result.ID) + assert.Equal(t, "My Note", result.Title) + assert.Equal(t, "Sample content", result.Content) + }) + + t.Run("GetByID_NoteNotFound", func(t *testing.T) { + mockNoteRepo := new(MockNoteRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewNoteUseCase(mockNoteRepo, mockProjectRepo, mockItemTagRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockNoteRepo.On("FindByIDAndProjectID", ctx, projectID, noteID).Return(nil, nil) + + result, err := uc.GetByID(ctx, projectID, noteID) + + assert.ErrorIs(t, err, usecase.ErrNoteNotFound) + assert.Nil(t, result) + }) + + t.Run("GetByID_RepoError", func(t *testing.T) { + mockNoteRepo := new(MockNoteRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewNoteUseCase(mockNoteRepo, mockProjectRepo, mockItemTagRepo) + + dbErr := errors.New("db error") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockNoteRepo.On("FindByIDAndProjectID", ctx, projectID, noteID).Return(nil, dbErr) + + result, err := uc.GetByID(ctx, projectID, noteID) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestNoteUseCase_Delete(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + noteID := uuid.New() + + t.Run("Delete_Success", func(t *testing.T) { + mockNoteRepo := new(MockNoteRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewNoteUseCase(mockNoteRepo, mockProjectRepo, mockItemTagRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockNoteRepo.On("DeleteByIDAndProjectID", ctx, projectID, noteID).Return(true, nil) + mockItemTagRepo.On("RemoveAllTagsFromItem", ctx, model.ItemTypeNote, noteID).Return(nil) + + err := uc.Delete(ctx, projectID, noteID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockNoteRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) + }) + + t.Run("Delete_NoteNotFound", func(t *testing.T) { + mockNoteRepo := new(MockNoteRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewNoteUseCase(mockNoteRepo, mockProjectRepo, mockItemTagRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockNoteRepo.On("DeleteByIDAndProjectID", ctx, projectID, noteID).Return(false, nil) + + err := uc.Delete(ctx, projectID, noteID) + + assert.ErrorIs(t, err, usecase.ErrNoteNotFound) + }) +} diff --git a/backend/internal/usecase/problem_usecase.go b/backend/internal/usecase/problem_usecase.go new file mode 100644 index 0000000..137ca11 --- /dev/null +++ b/backend/internal/usecase/problem_usecase.go @@ -0,0 +1,229 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/dto" + "errors" + "fmt" + "log" + "time" + + "github.com/google/uuid" +) + +var ( + ErrProblemNotFound = errors.New("problem not found") +) + +type ProblemUseCase struct { + problemRepo port.ProblemRepository + projectRepo port.ProjectRepository + itemTagRepo port.ItemTagRepository +} + +func NewProblemUseCase(problemRepo port.ProblemRepository, projectRepo port.ProjectRepository, itemTagRepo port.ItemTagRepository) *ProblemUseCase { + return &ProblemUseCase{ + problemRepo: problemRepo, + projectRepo: projectRepo, + itemTagRepo: itemTagRepo, + } +} + +func (uc *ProblemUseCase) Create(ctx context.Context, cmd dto.CreateProblemCommand) (*dto.ProblemView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + problem := model.Problem{ + ID: uuid.New(), + ProjectID: cmd.ProjectID, + Title: cmd.Title, + ErrorDescription: cmd.ErrorDescription, + Solution: cmd.Solution, + Status: cmd.Status, + Severity: cmd.Severity, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + + saved, err := uc.problemRepo.Save(ctx, &problem) + if err != nil { + return nil, err + } + return mapProblemToView(saved, nil), nil +} + +func (uc *ProblemUseCase) GetByID(ctx context.Context, projectID, id uuid.UUID) (*dto.ProblemView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + problem, err := uc.problemRepo.FindByIDAndProjectID(ctx, projectID, id) + if err != nil { + return nil, fmt.Errorf("error trying to find problem: %w", err) + } + if problem == nil { + return nil, ErrProblemNotFound + } + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeProblem, projectID, id) + if err != nil { + return nil, fmt.Errorf("error fetching tags for problem: %w", err) + } + return mapProblemToView(problem, tags), nil +} + +func (uc *ProblemUseCase) GetAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[dto.ProblemSummary], error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return model.Page[dto.ProblemSummary]{}, err + } + problemPage, err := uc.problemRepo.FindAllByProjectID(ctx, projectID, page, size) + if err != nil { + return model.Page[dto.ProblemSummary]{}, err + } + if len(problemPage.Content) == 0 { + return model.NewPage([]dto.ProblemSummary{}, problemPage.Number, problemPage.Size, problemPage.TotalElements), nil + } + + problemIDs := make([]uuid.UUID, len(problemPage.Content)) + for i, p := range problemPage.Content { + problemIDs[i] = p.ID + } + + tagsMap, err := uc.itemTagRepo.FindTagsForItems(ctx, model.ItemTypeProblem, projectID, problemIDs) + if err != nil { + return model.Page[dto.ProblemSummary]{}, fmt.Errorf("error fetching tags for problems: %w", err) + } + + summaries := make([]dto.ProblemSummary, len(problemPage.Content)) + for i, p := range problemPage.Content { + tags := tagsMap[p.ID] + tagSummaries := make([]dto.TagSummary, len(tags)) + for j, t := range tags { + tagSummaries[j] = dto.TagSummary{ + ID: t.ID, + Name: t.Name, + Color: t.Color, + } + } + summaries[i] = dto.ProblemSummary{ + ID: p.ID, + ProjectID: p.ProjectID, + Title: p.Title, + Status: p.Status, + Severity: p.Severity, + Tags: tagSummaries, + CreatedAt: &p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } + } + + return model.NewPage(summaries, problemPage.Number, problemPage.Size, problemPage.TotalElements), nil +} + +func (uc *ProblemUseCase) Update(ctx context.Context, cmd dto.UpdateProblemCommand) (*dto.ProblemView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + problem, err := uc.problemRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find problem: %w", err) + } + if problem == nil { + return nil, ErrProblemNotFound + } + + if cmd.Title != nil { + problem.Title = *cmd.Title + } + if cmd.ErrorDescription != nil { + problem.ErrorDescription = *cmd.ErrorDescription + } + if cmd.Solution != nil { + problem.Solution = cmd.Solution + } + if cmd.Severity != nil { + problem.Severity = *cmd.Severity + } + now := time.Now() + problem.UpdatedAt = &now + saved, err := uc.problemRepo.Save(ctx, problem) + if err != nil { + return nil, err + } + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeProblem, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error fetching tags for problem: %w", err) + } + return mapProblemToView(saved, tags), nil +} + +func (uc *ProblemUseCase) UpdateStatus(ctx context.Context, cmd dto.UpdateProblemStatusCommand) (*dto.ProblemView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + problem, err := uc.problemRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find problem: %w", err) + } + if problem == nil { + return nil, ErrProblemNotFound + } + + problem.Status = cmd.Status + now := time.Now() + problem.UpdatedAt = &now + saved, err := uc.problemRepo.Save(ctx, problem) + if err != nil { + return nil, err + } + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeProblem, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error fetching tags for problem: %w", err) + } + return mapProblemToView(saved, tags), nil +} + +func (uc *ProblemUseCase) Delete(ctx context.Context, projectID, id uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + + deleted, err := uc.problemRepo.DeleteByIDAndProjectID(ctx, projectID, id) + if err != nil { + return fmt.Errorf("error deleting problem: %w", err) + } + if !deleted { + return ErrProblemNotFound + } + + if err := uc.itemTagRepo.RemoveAllTagsFromItem(ctx, model.ItemTypeProblem, id); err != nil { + log.Printf("warning: failed to remove tags from problem %s: %v", id, err) + } + return nil +} + +func mapProblemToView(problem *model.Problem, tags []model.Tag) *dto.ProblemView { + tagSummaries := make([]dto.TagSummary, len(tags)) + for i, t := range tags { + tagSummaries[i] = dto.TagSummary{ + ID: t.ID, + Name: t.Name, + Color: t.Color, + } + } + return &dto.ProblemView{ + ID: problem.ID, + ProjectID: problem.ProjectID, + Title: problem.Title, + ErrorDescription: problem.ErrorDescription, + Solution: problem.Solution, + Status: problem.Status, + Severity: problem.Severity, + Tags: tagSummaries, + CreatedAt: &problem.CreatedAt, + UpdatedAt: problem.UpdatedAt, + } +} diff --git a/backend/internal/usecase/problem_usecase_test.go b/backend/internal/usecase/problem_usecase_test.go new file mode 100644 index 0000000..593932f --- /dev/null +++ b/backend/internal/usecase/problem_usecase_test.go @@ -0,0 +1,505 @@ +package usecase_test + +import ( + "context" + "testing" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockProblemRepository struct { + mock.Mock +} + +func (m *MockProblemRepository) Save(ctx context.Context, problem *model.Problem) (*model.Problem, error) { + args := m.Called(ctx, problem) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Problem), args.Error(1) +} + +func (m *MockProblemRepository) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Problem, error) { + args := m.Called(ctx, projectID, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Problem), args.Error(1) +} + +func (m *MockProblemRepository) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[port.ProblemSummary], error) { + args := m.Called(ctx, projectID, page, size) + return args.Get(0).(model.Page[port.ProblemSummary]), args.Error(1) +} + +func (m *MockProblemRepository) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + args := m.Called(ctx, projectID, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockProblemRepository) ExistsByIDAndProjectID(ctx context.Context, id, projectID uuid.UUID) (bool, error) { + args := m.Called(ctx, id, projectID) + return args.Bool(0), args.Error(1) +} + +func (m *MockProblemRepository) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + args := m.Called(ctx, ids, projectID) + return args.Get(0).([]uuid.UUID), args.Error(1) +} + +// --- UNIT TESTS --- + +func TestProblemUseCase_Create_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + solution := "Restart database service" + + cmd := dto.CreateProblemCommand{ + ProjectID: projectID, + Title: "Connection Refused", + ErrorDescription: "Failed to connect to database at localhost:5432", + Solution: &solution, + Status: model.ProblemStatusOpen, + Severity: model.ProblemSeverityHigh, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Problem) bool { + return p.ProjectID == projectID && p.Title == "Connection Refused" && p.Severity == model.ProblemSeverityHigh + })).Return(&model.Problem{ + ID: uuid.New(), + ProjectID: projectID, + Title: "Connection Refused", + ErrorDescription: "Failed to connect to database at localhost:5432", + Solution: &solution, + Status: model.ProblemStatusOpen, + Severity: model.ProblemSeverityHigh, + }, nil) + + created, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, created) + assert.Equal(t, "Connection Refused", created.Title) + assert.Equal(t, model.ProblemSeverityHigh, created.Severity) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Create_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + cmd := dto.CreateProblemCommand{ + ProjectID: projectID, + Title: "Connection Refused", + ErrorDescription: "Failed to connect", + Status: model.ProblemStatusOpen, + Severity: model.ProblemSeverityHigh, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + created, err := uc.Create(ctx, cmd) + + assert.Nil(t, created) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetByID_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + expectedProblem := &model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Memory Leak", + ErrorDescription: "Process OOM killed", + Status: model.ProblemStatusWorkingOnIt, + Severity: model.ProblemSeverityCritical, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(expectedProblem, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeProblem, projectID, problemID).Return([]model.Tag{}, nil) + + result, err := uc.GetByID(ctx, projectID, problemID) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, problemID, result.ID) + assert.Equal(t, projectID, result.ProjectID) + assert.Equal(t, model.ProblemSeverityCritical, result.Severity) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetByID_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetByID(ctx, projectID, problemID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetByID_NotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(nil, nil) + + result, err := uc.GetByID(ctx, projectID, problemID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProblemNotFound) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetAllByProjectID_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + p1ID := uuid.New() + p2ID := uuid.New() + + expectedPage := model.NewPage([]port.ProblemSummary{ + {ID: p1ID, ProjectID: projectID, Title: "Problem 1", Severity: model.ProblemSeverityLow}, + {ID: p2ID, ProjectID: projectID, Title: "Problem 2", Severity: model.ProblemSeverityMedium}, + }, 0, 10, 2) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindAllByProjectID", ctx, projectID, 0, 10).Return(expectedPage, nil) + mockItemTagRepo.On("FindTagsForItems", ctx, model.ItemTypeProblem, projectID, []uuid.UUID{p1ID, p2ID}).Return(map[uuid.UUID][]model.Tag{}, nil) + + result, err := uc.GetAllByProjectID(ctx, projectID, 0, 10) + + assert.NoError(t, err) + assert.Equal(t, 2, len(result.Content)) + assert.Equal(t, int64(2), result.TotalElements) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestProblemUseCase_GetAllByProjectID_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + _, err := uc.GetAllByProjectID(ctx, projectID, 0, 10) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Update_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + existingProblem := &model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Old Title", + ErrorDescription: "Old Desc", + Status: model.ProblemStatusOpen, + Severity: model.ProblemSeverityLow, + } + + newTitle := "Updated Title" + newSeverity := model.ProblemSeverityCritical + cmd := dto.UpdateProblemCommand{ + ProjectID: projectID, + ID: problemID, + Title: &newTitle, + Severity: &newSeverity, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(existingProblem, nil) + mockProblemRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Problem) bool { + return p.Title == "Updated Title" && p.Severity == model.ProblemSeverityCritical && p.UpdatedAt != nil + })).Return(&model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Updated Title", + Severity: model.ProblemSeverityCritical, + }, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeProblem, projectID, problemID).Return([]model.Tag{}, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, updated) + assert.Equal(t, "Updated Title", updated.Title) + assert.Equal(t, model.ProblemSeverityCritical, updated.Severity) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Update_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + newTitle := "Updated Title" + cmd := dto.UpdateProblemCommand{ + ProjectID: projectID, + ID: problemID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Update_ProblemNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + newTitle := "Updated Title" + cmd := dto.UpdateProblemCommand{ + ProjectID: projectID, + ID: problemID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(nil, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProblemNotFound) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_UpdateStatus_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + existingProblem := &model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Sample Problem", + Status: model.ProblemStatusOpen, + } + + cmd := dto.UpdateProblemStatusCommand{ + ProjectID: projectID, + ID: problemID, + Status: model.ProblemStatusResolved, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(existingProblem, nil) + mockProblemRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Problem) bool { + return p.Status == model.ProblemStatusResolved && p.UpdatedAt != nil + })).Return(&model.Problem{ + ID: problemID, + ProjectID: projectID, + Title: "Sample Problem", + Status: model.ProblemStatusResolved, + }, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeProblem, projectID, problemID).Return([]model.Tag{}, nil) + + updated, err := uc.UpdateStatus(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, updated) + assert.Equal(t, model.ProblemStatusResolved, updated.Status) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestProblemUseCase_UpdateStatus_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + cmd := dto.UpdateProblemStatusCommand{ + ProjectID: projectID, + ID: problemID, + Status: model.ProblemStatusResolved, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + updated, err := uc.UpdateStatus(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_UpdateStatus_ProblemNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + cmd := dto.UpdateProblemStatusCommand{ + ProjectID: projectID, + ID: problemID, + Status: model.ProblemStatusResolved, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("FindByIDAndProjectID", ctx, projectID, problemID).Return(nil, nil) + + updated, err := uc.UpdateStatus(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProblemNotFound) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Delete_Success(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("DeleteByIDAndProjectID", ctx, projectID, problemID).Return(true, nil) + mockItemTagRepo.On("RemoveAllTagsFromItem", ctx, model.ItemTypeProblem, problemID).Return(nil) + + err := uc.Delete(ctx, projectID, problemID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Delete_ProjectNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + err := uc.Delete(ctx, projectID, problemID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestProblemUseCase_Delete_ProblemNotFound(t *testing.T) { + mockProblemRepo := new(MockProblemRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewProblemUseCase(mockProblemRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + problemID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockProblemRepo.On("DeleteByIDAndProjectID", ctx, projectID, problemID).Return(false, nil) + + err := uc.Delete(ctx, projectID, problemID) + + assert.ErrorIs(t, err, usecase.ErrProblemNotFound) + mockProjectRepo.AssertExpectations(t) + mockProblemRepo.AssertExpectations(t) +} diff --git a/backend/internal/usecase/project_usecase.go b/backend/internal/usecase/project_usecase.go new file mode 100644 index 0000000..b316b2f --- /dev/null +++ b/backend/internal/usecase/project_usecase.go @@ -0,0 +1,153 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/dto" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +var ( + ErrProjectNotFound = errors.New("project not found") + ErrProjectAlreadyArchived = errors.New("project already archived") + ErrProjectAlreadyUnarchived = errors.New("project already unarchived") +) + +type ProjectUseCase struct { + projectRepo port.ProjectRepository +} + +func NewProjectUseCase(projectRepo port.ProjectRepository) *ProjectUseCase { + return &ProjectUseCase{projectRepo: projectRepo} +} + +func (uc *ProjectUseCase) Create(ctx context.Context, cmd dto.CreateProjectCommand) (*model.Project, error) { + project := model.Project{ + ID: uuid.New(), + Name: cmd.Name, + Description: cmd.Description, + Icon: cmd.Icon, + Color: cmd.Color, + Archived: false, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + return uc.projectRepo.Save(ctx, &project) +} + +func (uc *ProjectUseCase) GetByID(ctx context.Context, id uuid.UUID) (*model.Project, error) { + project, err := uc.projectRepo.FindByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("error trying to find project: %w", err) + } + if project == nil { + return nil, ErrProjectNotFound + } + return project, nil +} + +func (uc *ProjectUseCase) GetAll(ctx context.Context, page, size int) (model.Page[model.Project], error) { + return uc.projectRepo.FindAll(ctx, page, size) +} + +func (uc *ProjectUseCase) Update(ctx context.Context, cmd dto.UpdateProjectCommand) (*model.Project, error) { + project, err := uc.projectRepo.FindByID(ctx, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find project: %w", err) + } + if project == nil { + return nil, ErrProjectNotFound + } + + if cmd.Name != nil { + project.Name = *cmd.Name + } + if cmd.Description != nil { + project.Description = cmd.Description + } + if cmd.Icon != nil { + project.Icon = cmd.Icon + } + if cmd.Color != nil { + project.Color = cmd.Color + } + now := time.Now() + project.UpdatedAt = &now + return uc.projectRepo.Save(ctx, project) +} + +func (uc *ProjectUseCase) Delete(ctx context.Context, id uuid.UUID) error { + deleted, err := uc.projectRepo.DeleteByID(ctx, id) + if err != nil { + return fmt.Errorf("error trying to delete project: %w", err) + } + if !deleted { + return ErrProjectNotFound + } + return nil +} + +func (uc *ProjectUseCase) Archive(ctx context.Context, id uuid.UUID) error { + project, err := uc.projectRepo.FindByID(ctx, id) + if err != nil { + return fmt.Errorf("error trying to find project: %w", err) + } + if project == nil { + return ErrProjectNotFound + } + if project.Archived { + return ErrProjectAlreadyArchived + } + + project.Archived = true + now := time.Now() + project.UpdatedAt = &now + _, err = uc.projectRepo.Save(ctx, project) + if err != nil { + return fmt.Errorf("error trying to archive project: %w", err) + } + return nil +} + +func (uc *ProjectUseCase) Unarchive(ctx context.Context, id uuid.UUID) error { + project, err := uc.projectRepo.FindByID(ctx, id) + if err != nil { + return fmt.Errorf("error trying to find project: %w", err) + } + if project == nil { + return ErrProjectNotFound + } + if !project.Archived { + return ErrProjectAlreadyUnarchived + } + + project.Archived = false + now := time.Now() + project.UpdatedAt = &now + _, err = uc.projectRepo.Save(ctx, project) + if err != nil { + return fmt.Errorf("error trying to unarchive project: %w", err) + } + return nil +} + +// ---- Auxiliary functions + +// Used for other's use cases to check if a project exists +func ensureProjectExists(ctx context.Context, projectRepo port.ProjectRepository, id uuid.UUID) error { + exists, err := projectRepo.ExistsByID(ctx, id) + if err != nil { + return fmt.Errorf("error trying to check if project exists: %w", err) + } + if !exists { + return ErrProjectNotFound + } + return nil +} diff --git a/backend/internal/usecase/project_usecase_test.go b/backend/internal/usecase/project_usecase_test.go new file mode 100644 index 0000000..b35b307 --- /dev/null +++ b/backend/internal/usecase/project_usecase_test.go @@ -0,0 +1,304 @@ +package usecase_test + +import ( + "context" + "testing" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockProjectRepository struct { + mock.Mock +} + +func (m *MockProjectRepository) Save(ctx context.Context, project *model.Project) (*model.Project, error) { + args := m.Called(ctx, project) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Project), args.Error(1) +} + +func (m *MockProjectRepository) FindByID(ctx context.Context, id uuid.UUID) (*model.Project, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Project), args.Error(1) +} + +func (m *MockProjectRepository) FindAll(ctx context.Context, page, size int) (model.Page[model.Project], error) { + args := m.Called(ctx, page, size) + return args.Get(0).(model.Page[model.Project]), args.Error(1) +} + +func (m *MockProjectRepository) DeleteByID(ctx context.Context, id uuid.UUID) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockProjectRepository) ExistsByID(ctx context.Context, id uuid.UUID) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +// --- UNIT TESTS --- + +func TestProjectUseCase_Create_Success(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + + desc := "Project description" + cmd := dto.CreateProjectCommand{ + Name: "Devaulty Go", + Description: &desc, + } + + mockRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Project) bool { + return p.Name == "Devaulty Go" && p.Archived == false + })).Return(&model.Project{ + ID: uuid.New(), + Name: "Devaulty Go", + Description: &desc, + Archived: false, + }, nil) + + created, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, created) + assert.Equal(t, "Devaulty Go", created.Name) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_GetByID_Success(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + expectedProject := &model.Project{ + ID: projectID, + Name: "Existing Project", + } + + mockRepo.On("FindByID", ctx, projectID).Return(expectedProject, nil) + + result, err := uc.GetByID(ctx, projectID) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, projectID, result.ID) + assert.Equal(t, "Existing Project", result.Name) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_GetByID_NotFound(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + mockRepo.On("FindByID", ctx, projectID).Return(nil, nil) + + result, err := uc.GetByID(ctx, projectID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_GetAll_Success(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + + expectedPage := model.NewPage([]model.Project{ + {ID: uuid.New(), Name: "Project 1"}, + {ID: uuid.New(), Name: "Project 2"}, + }, 0, 10, 2) + + mockRepo.On("FindAll", ctx, 0, 10).Return(expectedPage, nil) + + result, err := uc.GetAll(ctx, 0, 10) + + assert.NoError(t, err) + assert.Equal(t, 2, len(result.Content)) + assert.Equal(t, int64(2), result.TotalElements) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_Update_Success(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + existingProject := &model.Project{ + ID: projectID, + Name: "Old Name", + } + + newName := "Updated Name" + cmd := dto.UpdateProjectCommand{ + ID: projectID, + Name: &newName, + } + + mockRepo.On("FindByID", ctx, projectID).Return(existingProject, nil) + mockRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Project) bool { + return p.Name == "Updated Name" && p.UpdatedAt != nil + })).Return(&model.Project{ + ID: projectID, + Name: "Updated Name", + }, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, updated) + assert.Equal(t, "Updated Name", updated.Name) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_Update_NotFound(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + newName := "Updated Name" + cmd := dto.UpdateProjectCommand{ + ID: projectID, + Name: &newName, + } + + mockRepo.On("FindByID", ctx, projectID).Return(nil, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_Delete_Success(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + mockRepo.On("DeleteByID", ctx, projectID).Return(true, nil) + + err := uc.Delete(ctx, projectID) + + assert.NoError(t, err) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_Delete_NotFound(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + mockRepo.On("DeleteByID", ctx, projectID).Return(false, nil) + + err := uc.Delete(ctx, projectID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_Archive_Success(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + existingProject := &model.Project{ + ID: projectID, + Name: "Project To Archive", + Archived: false, + } + + mockRepo.On("FindByID", ctx, projectID).Return(existingProject, nil) + mockRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Project) bool { + return p.Archived == true + })).Return(existingProject, nil) + + err := uc.Archive(ctx, projectID) + + assert.NoError(t, err) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_Archive_AlreadyArchived(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + existingProject := &model.Project{ + ID: projectID, + Name: "Already Archived Project", + Archived: true, + } + + mockRepo.On("FindByID", ctx, projectID).Return(existingProject, nil) + + err := uc.Archive(ctx, projectID) + + assert.ErrorIs(t, err, usecase.ErrProjectAlreadyArchived) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_Unarchive_Success(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + existingProject := &model.Project{ + ID: projectID, + Name: "Archived Project", + Archived: true, + } + + mockRepo.On("FindByID", ctx, projectID).Return(existingProject, nil) + mockRepo.On("Save", ctx, mock.MatchedBy(func(p *model.Project) bool { + return p.Archived == false + })).Return(existingProject, nil) + + err := uc.Unarchive(ctx, projectID) + + assert.NoError(t, err) + mockRepo.AssertExpectations(t) +} + +func TestProjectUseCase_Unarchive_NotArchived(t *testing.T) { + mockRepo := new(MockProjectRepository) + uc := usecase.NewProjectUseCase(mockRepo) + ctx := context.Background() + projectID := uuid.New() + + existingProject := &model.Project{ + ID: projectID, + Name: "Active Project", + Archived: false, + } + + mockRepo.On("FindByID", ctx, projectID).Return(existingProject, nil) + + err := uc.Unarchive(ctx, projectID) + + assert.ErrorIs(t, err, usecase.ErrProjectAlreadyUnarchived) + mockRepo.AssertExpectations(t) +} diff --git a/backend/internal/usecase/snippet_usecase.go b/backend/internal/usecase/snippet_usecase.go new file mode 100644 index 0000000..c01f8aa --- /dev/null +++ b/backend/internal/usecase/snippet_usecase.go @@ -0,0 +1,187 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/dto" + "errors" + "fmt" + "log" + "time" + + "github.com/google/uuid" +) + +var ( + ErrSnippetNotFound = errors.New("snippet not found") +) + +type SnippetUseCase struct { + snippetRepo port.SnippetRepository + projectRepo port.ProjectRepository + itemTagRepo port.ItemTagRepository +} + +func NewSnippetUseCase(snippetRepo port.SnippetRepository, projectRepo port.ProjectRepository, itemTagRepo port.ItemTagRepository) *SnippetUseCase { + return &SnippetUseCase{ + snippetRepo: snippetRepo, + projectRepo: projectRepo, + itemTagRepo: itemTagRepo, + } +} + +func (uc *SnippetUseCase) Create(ctx context.Context, cmd dto.CreateSnippetCommand) (*dto.SnippetView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + snippet := model.Snippet{ + ID: uuid.New(), + ProjectID: cmd.ProjectID, + Title: cmd.Title, + Description: cmd.Description, + Content: cmd.Content, + Language: &cmd.Language, + SnippetType: cmd.SnippetType, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + saved, err := uc.snippetRepo.Save(ctx, &snippet) + if err != nil { + return nil, err + } + return mapSnippetToView(saved, nil), nil +} + +func (uc *SnippetUseCase) GetByID(ctx context.Context, projectID, id uuid.UUID) (*dto.SnippetView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + snippet, err := uc.snippetRepo.FindByIDAndProjectID(ctx, projectID, id) + if err != nil { + return nil, fmt.Errorf("error trying to find snippet: %w", err) + } + if snippet == nil { + return nil, ErrSnippetNotFound + } + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeSnippet, projectID, id) + if err != nil { + return nil, fmt.Errorf("error fetching tags for snippet: %w", err) + } + return mapSnippetToView(snippet, tags), nil +} + +func (uc *SnippetUseCase) GetAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[dto.SnippetView], error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return model.Page[dto.SnippetView]{}, err + } + snippetPage, err := uc.snippetRepo.FindAllByProjectID(ctx, projectID, page, size) + if err != nil { + return model.Page[dto.SnippetView]{}, err + } + if len(snippetPage.Content) == 0 { + return model.NewPage([]dto.SnippetView{}, snippetPage.Number, snippetPage.Size, snippetPage.TotalElements), nil + } + + snippetIDs := make([]uuid.UUID, len(snippetPage.Content)) + for i, s := range snippetPage.Content { + snippetIDs[i] = s.ID + } + + tagsMap, err := uc.itemTagRepo.FindTagsForItems(ctx, model.ItemTypeSnippet, projectID, snippetIDs) + if err != nil { + return model.Page[dto.SnippetView]{}, fmt.Errorf("error fetching tags for snippets: %w", err) + } + + views := make([]dto.SnippetView, len(snippetPage.Content)) + for i, s := range snippetPage.Content { + tags := tagsMap[s.ID] + views[i] = *mapSnippetToView(&s, tags) + } + + return model.NewPage(views, snippetPage.Number, snippetPage.Size, snippetPage.TotalElements), nil +} + +func (uc *SnippetUseCase) Update(ctx context.Context, cmd dto.UpdateSnippetCommand) (*dto.SnippetView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + snippet, err := uc.snippetRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find snippet: %w", err) + } + if snippet == nil { + return nil, ErrSnippetNotFound + } + if cmd.Title != nil { + snippet.Title = *cmd.Title + } + if cmd.Description != nil { + snippet.Description = cmd.Description + } + if cmd.Content != nil { + snippet.Content = *cmd.Content + } + if cmd.Language != nil { + snippet.Language = cmd.Language + } + if cmd.SnippetType != nil { + snippet.SnippetType = *cmd.SnippetType + } + now := time.Now() + snippet.UpdatedAt = &now + saved, err := uc.snippetRepo.Save(ctx, snippet) + if err != nil { + return nil, err + } + tags, err := uc.itemTagRepo.FindTagsForItem(ctx, model.ItemTypeSnippet, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error fetching tags for snippet: %w", err) + } + return mapSnippetToView(saved, tags), nil +} + +func (uc *SnippetUseCase) Delete(ctx context.Context, projectID, id uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + deleted, err := uc.snippetRepo.DeleteByIDAndProjectID(ctx, projectID, id) + if err != nil { + return fmt.Errorf("error deleting snippet: %w", err) + } + if !deleted { + return ErrSnippetNotFound + } + + if err := uc.itemTagRepo.RemoveAllTagsFromItem(ctx, model.ItemTypeSnippet, id); err != nil { + log.Printf("warning: failed to remove tags from snippet %s: %v", id, err) + } + return nil +} + +func mapSnippetToView(snippet *model.Snippet, tags []model.Tag) *dto.SnippetView { + tagSummaries := make([]dto.TagSummary, len(tags)) + for i, t := range tags { + tagSummaries[i] = dto.TagSummary{ + ID: t.ID, + Name: t.Name, + Color: t.Color, + } + } + return &dto.SnippetView{ + ID: snippet.ID, + ProjectID: snippet.ProjectID, + Title: snippet.Title, + Description: snippet.Description, + Content: snippet.Content, + Language: snippet.Language, + SnippetType: snippet.SnippetType, + Tags: tagSummaries, + CreatedAt: &snippet.CreatedAt, + UpdatedAt: snippet.UpdatedAt, + } +} diff --git a/backend/internal/usecase/snippet_usecase_test.go b/backend/internal/usecase/snippet_usecase_test.go new file mode 100644 index 0000000..7548744 --- /dev/null +++ b/backend/internal/usecase/snippet_usecase_test.go @@ -0,0 +1,397 @@ +package usecase_test + +import ( + "context" + "testing" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockSnippetRepository struct { + mock.Mock +} + +func (m *MockSnippetRepository) Save(ctx context.Context, snippet *model.Snippet) (*model.Snippet, error) { + args := m.Called(ctx, snippet) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Snippet), args.Error(1) +} + +func (m *MockSnippetRepository) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Snippet, error) { + args := m.Called(ctx, projectID, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Snippet), args.Error(1) +} + +func (m *MockSnippetRepository) FindAllByProjectID(ctx context.Context, projectID uuid.UUID, page, size int) (model.Page[model.Snippet], error) { + args := m.Called(ctx, projectID, page, size) + return args.Get(0).(model.Page[model.Snippet]), args.Error(1) +} + +func (m *MockSnippetRepository) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + args := m.Called(ctx, projectID, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockSnippetRepository) ExistsByIDAndProjectID(ctx context.Context, id, projectID uuid.UUID) (bool, error) { + args := m.Called(ctx, id, projectID) + return args.Bool(0), args.Error(1) +} + +func (m *MockSnippetRepository) FindExistingIDsByProjectID(ctx context.Context, ids []uuid.UUID, projectID uuid.UUID) ([]uuid.UUID, error) { + args := m.Called(ctx, ids, projectID) + return args.Get(0).([]uuid.UUID), args.Error(1) +} + +// --- UNIT TESTS --- + +func TestSnippetUseCase_Create_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + desc := "Snippet description" + lang := model.SnippetLangGo + + cmd := dto.CreateSnippetCommand{ + ProjectID: projectID, + Title: "Print Hello World", + Description: &desc, + Content: `fmt.Println("Hello World")`, + Language: lang, + SnippetType: model.SnippetTypeCode, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("Save", ctx, mock.MatchedBy(func(s *model.Snippet) bool { + return s.ProjectID == projectID && s.Title == "Print Hello World" && s.Content == `fmt.Println("Hello World")` + })).Return(&model.Snippet{ + ID: uuid.New(), + ProjectID: projectID, + Title: "Print Hello World", + Description: &desc, + Content: `fmt.Println("Hello World")`, + Language: &lang, + SnippetType: model.SnippetTypeCode, + }, nil) + + created, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, created) + assert.Equal(t, "Print Hello World", created.Title) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Create_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + cmd := dto.CreateSnippetCommand{ + ProjectID: projectID, + Title: "Print Hello World", + Content: `fmt.Println("Hello World")`, + Language: model.SnippetLangGo, + SnippetType: model.SnippetTypeCode, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + created, err := uc.Create(ctx, cmd) + + assert.Nil(t, created) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_GetByID_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + expectedSnippet := &model.Snippet{ + ID: snippetID, + ProjectID: projectID, + Title: "Sample Snippet", + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindByIDAndProjectID", ctx, projectID, snippetID).Return(expectedSnippet, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeSnippet, projectID, snippetID).Return([]model.Tag{}, nil) + + result, err := uc.GetByID(ctx, projectID, snippetID) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, snippetID, result.ID) + assert.Equal(t, projectID, result.ProjectID) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_GetByID_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetByID(ctx, projectID, snippetID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_GetByID_NotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindByIDAndProjectID", ctx, projectID, snippetID).Return(nil, nil) + + result, err := uc.GetByID(ctx, projectID, snippetID) + + assert.Nil(t, result) + assert.ErrorIs(t, err, usecase.ErrSnippetNotFound) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_GetAllByProjectID_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snip1ID := uuid.New() + snip2ID := uuid.New() + + expectedPage := model.NewPage([]model.Snippet{ + {ID: snip1ID, ProjectID: projectID, Title: "Snippet 1"}, + {ID: snip2ID, ProjectID: projectID, Title: "Snippet 2"}, + }, 0, 10, 2) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindAllByProjectID", ctx, projectID, 0, 10).Return(expectedPage, nil) + mockItemTagRepo.On("FindTagsForItems", ctx, model.ItemTypeSnippet, projectID, []uuid.UUID{snip1ID, snip2ID}).Return(map[uuid.UUID][]model.Tag{}, nil) + + result, err := uc.GetAllByProjectID(ctx, projectID, 0, 10) + + assert.NoError(t, err) + assert.Equal(t, 2, len(result.Content)) + assert.Equal(t, int64(2), result.TotalElements) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_GetAllByProjectID_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + _, err := uc.GetAllByProjectID(ctx, projectID, 0, 10) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Update_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + existingSnippet := &model.Snippet{ + ID: snippetID, + ProjectID: projectID, + Title: "Old Title", + Content: "Old Content", + } + + newTitle := "Updated Title" + cmd := dto.UpdateSnippetCommand{ + ProjectID: projectID, + ID: snippetID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindByIDAndProjectID", ctx, projectID, snippetID).Return(existingSnippet, nil) + mockSnippetRepo.On("Save", ctx, mock.MatchedBy(func(s *model.Snippet) bool { + return s.Title == "Updated Title" && s.UpdatedAt != nil + })).Return(&model.Snippet{ + ID: snippetID, + ProjectID: projectID, + Title: "Updated Title", + }, nil) + mockItemTagRepo.On("FindTagsForItem", ctx, model.ItemTypeSnippet, projectID, snippetID).Return([]model.Tag{}, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, updated) + assert.Equal(t, "Updated Title", updated.Title) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Update_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + newTitle := "Updated Title" + cmd := dto.UpdateSnippetCommand{ + ProjectID: projectID, + ID: snippetID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Update_SnippetNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + newTitle := "Updated Title" + cmd := dto.UpdateSnippetCommand{ + ProjectID: projectID, + ID: snippetID, + Title: &newTitle, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("FindByIDAndProjectID", ctx, projectID, snippetID).Return(nil, nil) + + updated, err := uc.Update(ctx, cmd) + + assert.Nil(t, updated) + assert.ErrorIs(t, err, usecase.ErrSnippetNotFound) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Delete_Success(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("DeleteByIDAndProjectID", ctx, projectID, snippetID).Return(true, nil) + mockItemTagRepo.On("RemoveAllTagsFromItem", ctx, model.ItemTypeSnippet, snippetID).Return(nil) + + err := uc.Delete(ctx, projectID, snippetID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) + mockItemTagRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Delete_ProjectNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + err := uc.Delete(ctx, projectID, snippetID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + mockProjectRepo.AssertExpectations(t) +} + +func TestSnippetUseCase_Delete_SnippetNotFound(t *testing.T) { + mockSnippetRepo := new(MockSnippetRepository) + mockProjectRepo := new(MockProjectRepository) + mockItemTagRepo := new(MockItemTagRepository) + uc := usecase.NewSnippetUseCase(mockSnippetRepo, mockProjectRepo, mockItemTagRepo) + ctx := context.Background() + + projectID := uuid.New() + snippetID := uuid.New() + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockSnippetRepo.On("DeleteByIDAndProjectID", ctx, projectID, snippetID).Return(false, nil) + + err := uc.Delete(ctx, projectID, snippetID) + + assert.ErrorIs(t, err, usecase.ErrSnippetNotFound) + mockProjectRepo.AssertExpectations(t) + mockSnippetRepo.AssertExpectations(t) +} diff --git a/backend/internal/usecase/tag_usecase.go b/backend/internal/usecase/tag_usecase.go new file mode 100644 index 0000000..fa2eee7 --- /dev/null +++ b/backend/internal/usecase/tag_usecase.go @@ -0,0 +1,180 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/dto" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + ErrTagAlreadyExists = errors.New("tag already exists") + ErrTagNotFound = errors.New("tag not found") +) + +type TagUseCase struct { + tagRepo port.TagRepository + projectRepo port.ProjectRepository +} + +func NewTagUseCase(tagRepo port.TagRepository, projectRepo port.ProjectRepository) *TagUseCase { + return &TagUseCase{ + tagRepo: tagRepo, + projectRepo: projectRepo, + } +} + +func (uc *TagUseCase) Create(ctx context.Context, cmd dto.CreateTagCommand) (*dto.TagView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + sanitizedName := strings.TrimSpace(cmd.Name) + alreadyExists, err := uc.tagRepo.ExistsByNameAndProjectID(ctx, sanitizedName, cmd.ProjectID) + if err != nil { + return nil, err + } + if alreadyExists { + return nil, ErrTagAlreadyExists + } + + tag := model.Tag{ + ID: uuid.New(), + ProjectID: cmd.ProjectID, + Name: sanitizedName, + Color: cmd.Color, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + UpdatedAt: nil, + }, + } + saved, err := uc.tagRepo.Save(ctx, &tag) + if err != nil { + return nil, err + } + return mapTagToView(saved), nil +} + +func (uc *TagUseCase) GetByID(ctx context.Context, projectID, id uuid.UUID) (*dto.TagView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + tag, err := uc.tagRepo.FindByIDAndProjectID(ctx, projectID, id) + if err != nil { + return nil, fmt.Errorf("error trying to find tag: %w", err) + } + if tag == nil { + return nil, ErrTagNotFound + } + return mapTagToView(tag), nil +} + +func (uc *TagUseCase) GetAllByProjectID(ctx context.Context, projectID uuid.UUID) ([]dto.TagView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + tags, err := uc.tagRepo.FindAllByProjectID(ctx, projectID) + if err != nil { + return nil, err + } + return mapTagsToViews(tags), nil +} + +func (uc *TagUseCase) SearchByName(ctx context.Context, projectID uuid.UUID, name string) ([]dto.TagView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return nil, err + } + tags, err := uc.tagRepo.SearchByNameAndProjectID(ctx, name, projectID) + if err != nil { + return nil, err + } + return mapTagsToViews(tags), nil +} + +func (uc *TagUseCase) Update(ctx context.Context, cmd dto.UpdateTagCommand) (*dto.TagView, error) { + if err := ensureProjectExists(ctx, uc.projectRepo, cmd.ProjectID); err != nil { + return nil, err + } + + tag, err := uc.tagRepo.FindByIDAndProjectID(ctx, cmd.ProjectID, cmd.ID) + if err != nil { + return nil, fmt.Errorf("error trying to find tag: %w", err) + } + if tag == nil { + return nil, ErrTagNotFound + } + + if cmd.Name != nil { + sanitizedName := strings.TrimSpace(*cmd.Name) + if !strings.EqualFold(tag.Name, sanitizedName) { + alreadyExists, err := uc.tagRepo.ExistsByNameAndProjectID(ctx, + sanitizedName, cmd.ProjectID) + if err != nil { + return nil, err + } + if alreadyExists { + return nil, ErrTagAlreadyExists + } + } + tag.Name = sanitizedName + } + if cmd.Color != nil { + tag.Color = cmd.Color + } + now := time.Now() + tag.UpdatedAt = &now + saved, err := uc.tagRepo.Save(ctx, tag) + if err != nil { + return nil, err + } + return mapTagToView(saved), nil +} + +func (uc *TagUseCase) Delete(ctx context.Context, projectID, id uuid.UUID) error { + if err := ensureProjectExists(ctx, uc.projectRepo, projectID); err != nil { + return err + } + deleted, err := uc.tagRepo.DeleteByIDAndProjectID(ctx, projectID, id) + if err != nil { + return fmt.Errorf("error deleting tag: %w", err) + } + if !deleted { + return ErrTagNotFound + } + return nil +} + +func mapTagToView(tag *model.Tag) *dto.TagView { + if tag == nil { + return nil + } + var createdAt *time.Time + if !tag.CreatedAt.IsZero() { + createdAt = &tag.CreatedAt + } + return &dto.TagView{ + ID: tag.ID, + ProjectID: tag.ProjectID, + Name: tag.Name, + Color: tag.Color, + CreatedAt: createdAt, + UpdatedAt: tag.UpdatedAt, + } +} + +func mapTagsToViews(tags []model.Tag) []dto.TagView { + if tags == nil { + return []dto.TagView{} + } + views := make([]dto.TagView, len(tags)) + for i, tag := range tags { + views[i] = *mapTagToView(&tag) + } + return views +} diff --git a/backend/internal/usecase/tag_usecase_test.go b/backend/internal/usecase/tag_usecase_test.go new file mode 100644 index 0000000..4475b0d --- /dev/null +++ b/backend/internal/usecase/tag_usecase_test.go @@ -0,0 +1,607 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/dto" + "devaulty-backend/internal/usecase" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockTagRepository struct { + mock.Mock +} + +func (m *MockTagRepository) Save(ctx context.Context, tag *model.Tag) (*model.Tag, error) { + args := m.Called(ctx, tag) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Tag), args.Error(1) +} + +func (m *MockTagRepository) FindByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (*model.Tag, error) { + args := m.Called(ctx, projectID, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Tag), args.Error(1) +} + +func (m *MockTagRepository) FindAllByProjectID(ctx context.Context, projectID uuid.UUID) ([]model.Tag, error) { + args := m.Called(ctx, projectID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]model.Tag), args.Error(1) +} + +func (m *MockTagRepository) SearchByNameAndProjectID(ctx context.Context, name string, projectID uuid.UUID) ([]model.Tag, error) { + args := m.Called(ctx, name, projectID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]model.Tag), args.Error(1) +} + +func (m *MockTagRepository) DeleteByIDAndProjectID(ctx context.Context, projectID, id uuid.UUID) (bool, error) { + args := m.Called(ctx, projectID, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockTagRepository) ExistsByNameAndProjectID(ctx context.Context, name string, projectID uuid.UUID) (bool, error) { + args := m.Called(ctx, name, projectID) + return args.Bool(0), args.Error(1) +} + +func (m *MockTagRepository) ExistsByIDAndProjectID(ctx context.Context, id uuid.UUID, projectID uuid.UUID) (bool, error) { + args := m.Called(ctx, id, projectID) + return args.Bool(0), args.Error(1) +} + +// --- UNIT TESTS --- + +func TestTagUseCase_Create(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + color := "#FF0000" + + cmd := dto.CreateTagCommand{ + ProjectID: projectID, + Name: " Go Backend ", + Color: &color, + } + + t.Run("Create_Success", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByNameAndProjectID", ctx, "Go Backend", projectID).Return(false, nil) + mockTagRepo.On("Save", ctx, mock.MatchedBy(func(tag *model.Tag) bool { + return tag.Name == "Go Backend" && tag.ProjectID == projectID && *tag.Color == color + })).Return(&model.Tag{ + ID: uuid.New(), + ProjectID: projectID, + Name: "Go Backend", + Color: &color, + BaseEntity: model.BaseEntity{ + CreatedAt: time.Now(), + }, + }, nil) + + result, err := uc.Create(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "Go Backend", result.Name) + mockProjectRepo.AssertExpectations(t) + mockTagRepo.AssertExpectations(t) + }) + + t.Run("Create_ProjectNotFound", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.Create(ctx, cmd) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + assert.Nil(t, result) + }) + + t.Run("Create_TagAlreadyExists", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByNameAndProjectID", ctx, "Go Backend", projectID).Return(true, nil) + + result, err := uc.Create(ctx, cmd) + + assert.ErrorIs(t, err, usecase.ErrTagAlreadyExists) + assert.Nil(t, result) + }) + + t.Run("Create_ProjectRepoError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + dbErr := errors.New("database failure") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, dbErr) + + result, err := uc.Create(ctx, cmd) + + assert.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("Create_ExistsCheckError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + dbErr := errors.New("query error") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByNameAndProjectID", ctx, "Go Backend", projectID).Return(false, dbErr) + + result, err := uc.Create(ctx, cmd) + + assert.ErrorIs(t, err, dbErr) + assert.Nil(t, result) + }) + + t.Run("Create_SaveError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + dbErr := errors.New("insert error") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("ExistsByNameAndProjectID", ctx, "Go Backend", projectID).Return(false, nil) + mockTagRepo.On("Save", ctx, mock.Anything).Return(nil, dbErr) + + result, err := uc.Create(ctx, cmd) + + assert.ErrorIs(t, err, dbErr) + assert.Nil(t, result) + }) +} + +func TestTagUseCase_GetByID(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + tagID := uuid.New() + + t.Run("GetByID_Success", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + expectedTag := &model.Tag{ + ID: tagID, + ProjectID: projectID, + Name: "Golang", + } + expectedView := &dto.TagView{ + ID: tagID, + ProjectID: projectID, + Name: "Golang", + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(expectedTag, nil) + + result, err := uc.GetByID(ctx, projectID, tagID) + + assert.NoError(t, err) + assert.Equal(t, expectedView, result) + }) + + t.Run("GetByID_ProjectNotFound", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetByID(ctx, projectID, tagID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + assert.Nil(t, result) + }) + + t.Run("GetByID_TagNotFound", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(nil, nil) + + result, err := uc.GetByID(ctx, projectID, tagID) + + assert.ErrorIs(t, err, usecase.ErrTagNotFound) + assert.Nil(t, result) + }) + + t.Run("GetByID_RepoError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + dbErr := errors.New("db failure") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(nil, dbErr) + + result, err := uc.GetByID(ctx, projectID, tagID) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestTagUseCase_GetAllByProjectID(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + + t.Run("GetAllByProjectID_Success", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + tag1ID := uuid.New() + tag2ID := uuid.New() + expectedTags := []model.Tag{ + {ID: tag1ID, ProjectID: projectID, Name: "Tag 1"}, + {ID: tag2ID, ProjectID: projectID, Name: "Tag 2"}, + } + expectedViews := []dto.TagView{ + {ID: tag1ID, ProjectID: projectID, Name: "Tag 1"}, + {ID: tag2ID, ProjectID: projectID, Name: "Tag 2"}, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindAllByProjectID", ctx, projectID).Return(expectedTags, nil) + + result, err := uc.GetAllByProjectID(ctx, projectID) + + assert.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, expectedViews, result) + }) + + t.Run("GetAllByProjectID_ProjectNotFound", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.GetAllByProjectID(ctx, projectID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + assert.Nil(t, result) + }) + + t.Run("GetAllByProjectID_RepoError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + dbErr := errors.New("connection failed") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindAllByProjectID", ctx, projectID).Return(nil, dbErr) + + result, err := uc.GetAllByProjectID(ctx, projectID) + + assert.ErrorIs(t, err, dbErr) + assert.Nil(t, result) + }) +} + +func TestTagUseCase_SearchByName(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + searchTerm := "go" + + t.Run("SearchByName_Success", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + expectedTags := []model.Tag{ + {ID: uuid.New(), ProjectID: projectID, Name: "Golang"}, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("SearchByNameAndProjectID", ctx, searchTerm, projectID).Return(expectedTags, nil) + + result, err := uc.SearchByName(ctx, projectID, searchTerm) + + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "Golang", result[0].Name) + }) + + t.Run("SearchByName_ProjectNotFound", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.SearchByName(ctx, projectID, searchTerm) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + assert.Nil(t, result) + }) + + t.Run("SearchByName_RepoError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + dbErr := errors.New("search failed") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("SearchByNameAndProjectID", ctx, searchTerm, projectID).Return(nil, dbErr) + + result, err := uc.SearchByName(ctx, projectID, searchTerm) + + assert.ErrorIs(t, err, dbErr) + assert.Nil(t, result) + }) +} + +func TestTagUseCase_Update(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + tagID := uuid.New() + + newName := "Updated Tag Name" + newColor := "#00FF00" + + cmd := dto.UpdateTagCommand{ + ID: tagID, + ProjectID: projectID, + Name: &newName, + Color: &newColor, + } + + t.Run("Update_Success_ChangeNameAndColor", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + existingTag := &model.Tag{ + ID: tagID, + ProjectID: projectID, + Name: "Old Name", + Color: nil, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(existingTag, nil) + mockTagRepo.On("ExistsByNameAndProjectID", ctx, "Updated Tag Name", projectID).Return(false, nil) + mockTagRepo.On("Save", ctx, mock.MatchedBy(func(tag *model.Tag) bool { + return tag.Name == "Updated Tag Name" && *tag.Color == "#00FF00" && tag.UpdatedAt != nil + })).Return(existingTag, nil) + + result, err := uc.Update(ctx, cmd) + + assert.NoError(t, err) + assert.NotNil(t, result) + mockProjectRepo.AssertExpectations(t) + mockTagRepo.AssertExpectations(t) + }) + + t.Run("Update_Success_SameNameDifferentCase", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + existingTag := &model.Tag{ + ID: tagID, + ProjectID: projectID, + Name: "Golang", + } + + sameNameUpper := "GOLANG" + updateCmd := dto.UpdateTagCommand{ + ID: tagID, + ProjectID: projectID, + Name: &sameNameUpper, + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(existingTag, nil) + // Should NOT call ExistsByNameAndProjectID because name equal (ignoring case) + mockTagRepo.On("Save", ctx, mock.Anything).Return(existingTag, nil) + + result, err := uc.Update(ctx, updateCmd) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "GOLANG", result.Name) + }) + + t.Run("Update_ProjectNotFound", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + result, err := uc.Update(ctx, cmd) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + assert.Nil(t, result) + }) + + t.Run("Update_TagNotFound", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(nil, nil) + + result, err := uc.Update(ctx, cmd) + + assert.ErrorIs(t, err, usecase.ErrTagNotFound) + assert.Nil(t, result) + }) + + t.Run("Update_NameAlreadyExists", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + existingTag := &model.Tag{ + ID: tagID, + ProjectID: projectID, + Name: "Old Name", + } + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(existingTag, nil) + mockTagRepo.On("ExistsByNameAndProjectID", ctx, "Updated Tag Name", projectID).Return(true, nil) + + result, err := uc.Update(ctx, cmd) + + assert.ErrorIs(t, err, usecase.ErrTagAlreadyExists) + assert.Nil(t, result) + }) + + t.Run("Update_FindRepoError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + dbErr := errors.New("db error") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(nil, dbErr) + + result, err := uc.Update(ctx, cmd) + + assert.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("Update_ExistsCheckError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + existingTag := &model.Tag{ + ID: tagID, + ProjectID: projectID, + Name: "Old Name", + } + dbErr := errors.New("query failure") + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(existingTag, nil) + mockTagRepo.On("ExistsByNameAndProjectID", ctx, "Updated Tag Name", projectID).Return(false, dbErr) + + result, err := uc.Update(ctx, cmd) + + assert.ErrorIs(t, err, dbErr) + assert.Nil(t, result) + }) + + t.Run("Update_SaveError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + existingTag := &model.Tag{ + ID: tagID, + ProjectID: projectID, + Name: "Old Name", + } + dbErr := errors.New("save failure") + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("FindByIDAndProjectID", ctx, projectID, tagID).Return(existingTag, nil) + mockTagRepo.On("ExistsByNameAndProjectID", ctx, "Updated Tag Name", projectID).Return(false, nil) + mockTagRepo.On("Save", ctx, mock.Anything).Return(nil, dbErr) + + result, err := uc.Update(ctx, cmd) + + assert.ErrorIs(t, err, dbErr) + assert.Nil(t, result) + }) +} + +func TestTagUseCase_Delete(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + tagID := uuid.New() + + t.Run("Delete_Success", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("DeleteByIDAndProjectID", ctx, projectID, tagID).Return(true, nil) + + err := uc.Delete(ctx, projectID, tagID) + + assert.NoError(t, err) + mockProjectRepo.AssertExpectations(t) + mockTagRepo.AssertExpectations(t) + }) + + t.Run("Delete_ProjectNotFound", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(false, nil) + + err := uc.Delete(ctx, projectID, tagID) + + assert.ErrorIs(t, err, usecase.ErrProjectNotFound) + }) + + t.Run("Delete_TagNotFound", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("DeleteByIDAndProjectID", ctx, projectID, tagID).Return(false, nil) + + err := uc.Delete(ctx, projectID, tagID) + + assert.ErrorIs(t, err, usecase.ErrTagNotFound) + }) + + t.Run("Delete_RepoError", func(t *testing.T) { + mockTagRepo := new(MockTagRepository) + mockProjectRepo := new(MockProjectRepository) + uc := usecase.NewTagUseCase(mockTagRepo, mockProjectRepo) + + dbErr := errors.New("delete db error") + mockProjectRepo.On("ExistsByID", ctx, projectID).Return(true, nil) + mockTagRepo.On("DeleteByIDAndProjectID", ctx, projectID, tagID).Return(false, dbErr) + + err := uc.Delete(ctx, projectID, tagID) + + assert.Error(t, err) + }) +} diff --git a/backend/internal/usecase/vault_usecase.go b/backend/internal/usecase/vault_usecase.go new file mode 100644 index 0000000..a5f9633 --- /dev/null +++ b/backend/internal/usecase/vault_usecase.go @@ -0,0 +1,137 @@ +package usecase + +import ( + "context" + "devaulty-backend/internal/domain/port" + "devaulty-backend/internal/dto" + "encoding/base64" + "errors" + "fmt" + "time" +) + +const ( + MasterPasswordHashKey = "master_password_hash" + MasterPasswordSaltKey = "master_password_salt" + SaltLength = 16 // 16-byte + SessionTimeout = 15 * time.Minute +) + +var ( + ErrMasterPasswordAlreadyConfigured = errors.New("master password already configured") + ErrMasterPasswordNotConfigured = errors.New("master password not configured") + ErrInvalidMasterPassword = errors.New("invalid master password") + ErrVaultLocked = errors.New("vault is locked") +) + +type VaultUseCase struct { + keyDeriver port.KeyDeriver + masterKeySession port.MasterKeySession + appSettingRepo port.AppSettingRepository +} + +func NewVaultUseCase(keyDeriver port.KeyDeriver, masterKeySession port.MasterKeySession, appSettingRepo port.AppSettingRepository) *VaultUseCase { + return &VaultUseCase{ + keyDeriver: keyDeriver, + masterKeySession: masterKeySession, + appSettingRepo: appSettingRepo, + } +} + +func (uc *VaultUseCase) SetupMasterPassword(ctx context.Context, password []byte) error { + defer clear(password) + + saltBytes, err := uc.keyDeriver.GenerateSalt(SaltLength) + if err != nil { + return err + } + defer clear(saltBytes) + + hashedPassword, err := uc.keyDeriver.HashPassword(password, saltBytes) + if err != nil { + return err + } + + hashBase64 := base64.StdEncoding.EncodeToString(hashedPassword) + saltBase64 := base64.StdEncoding.EncodeToString(saltBytes) + + err = uc.appSettingRepo.SaveMasterPasswordSettings(ctx, hashBase64, saltBase64) + if err != nil { + return err + } + + secretKey, err := uc.keyDeriver.DeriveKey(password, saltBytes) + if err != nil { + return err + } + defer clear(secretKey) + + uc.masterKeySession.SetKey(secretKey) + return nil +} + +func (uc *VaultUseCase) CheckIfMasterSetupIsRequired(ctx context.Context) (bool, error) { + alreadyConfigured, err := uc.appSettingRepo.ExistsByKey(ctx, MasterPasswordHashKey) + if err != nil { + return false, fmt.Errorf("error checking if master password exists: %w", err) + } + return !alreadyConfigured, nil +} + +func (uc *VaultUseCase) UnlockVault(ctx context.Context, password []byte) (bool, error) { + defer clear(password) + + mapPassword, err := uc.appSettingRepo.FindByKey(ctx, MasterPasswordHashKey) + if err != nil { + return false, fmt.Errorf("error finding master password hash: %w", err) + } + if mapPassword == nil { + return false, ErrMasterPasswordNotConfigured + } + hashedPassword, err := base64.StdEncoding.DecodeString(mapPassword.Value) + if err != nil { + return false, fmt.Errorf("error decoding master password hash: %w", err) + } + defer clear(hashedPassword) + + mapSalt, err := uc.appSettingRepo.FindByKey(ctx, MasterPasswordSaltKey) + if err != nil { + return false, fmt.Errorf("error finding master password salt: %w", err) + } + if mapSalt == nil { + return false, fmt.Errorf("master password not configured") + } + saltBytes, err := base64.StdEncoding.DecodeString(mapSalt.Value) + if err != nil { + return false, fmt.Errorf("error decoding master password salt: %w", err) + } + defer clear(saltBytes) + + if !uc.keyDeriver.VerifyPassword(password, saltBytes, hashedPassword) { + return false, ErrInvalidMasterPassword + } + + secretKey, err := uc.keyDeriver.DeriveKey(password, saltBytes) + if err != nil { + return false, fmt.Errorf("error deriving key: %w", err) + } + defer clear(secretKey) + + uc.masterKeySession.SetKey(secretKey) + + return true, nil +} + +func (uc *VaultUseCase) LockVault() { + // This will purges key bytes completely from RAM + uc.masterKeySession.Clear() +} + +func (uc *VaultUseCase) GetSessionStatus() dto.VaultStatusView { + isActive := uc.masterKeySession.HasKey() + secondsLeft := uc.masterKeySession.GetSecondsRemaining(SessionTimeout) + return dto.VaultStatusView{ + Active: isActive, + SecondsLeft: secondsLeft, + } +} diff --git a/backend/internal/usecase/vault_usecase_test.go b/backend/internal/usecase/vault_usecase_test.go new file mode 100644 index 0000000..4325eed --- /dev/null +++ b/backend/internal/usecase/vault_usecase_test.go @@ -0,0 +1,353 @@ +package usecase_test + +import ( + "context" + "encoding/base64" + "errors" + "testing" + "time" + + "devaulty-backend/internal/domain/model" + "devaulty-backend/internal/usecase" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// --- MOCK DEFINITIONS --- + +type MockKeyDeriver struct { + mock.Mock +} + +func (m *MockKeyDeriver) GenerateSalt(size int) ([]byte, error) { + args := m.Called(size) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]byte), args.Error(1) +} + +func (m *MockKeyDeriver) DeriveKey(password, salt []byte) ([]byte, error) { + args := m.Called(password, salt) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]byte), args.Error(1) +} + +func (m *MockKeyDeriver) HashPassword(password, salt []byte) ([]byte, error) { + args := m.Called(password, salt) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]byte), args.Error(1) +} + +func (m *MockKeyDeriver) VerifyPassword(password, salt, expectedHash []byte) bool { + args := m.Called(password, salt, expectedHash) + return args.Bool(0) +} + +type MockMasterKeySession struct { + mock.Mock +} + +func (m *MockMasterKeySession) SetKey(key []byte) { + m.Called(key) +} + +func (m *MockMasterKeySession) GetKey() []byte { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).([]byte) +} + +func (m *MockMasterKeySession) HasKey() bool { + args := m.Called() + return args.Bool(0) +} + +func (m *MockMasterKeySession) Clear() { + m.Called() +} + +func (m *MockMasterKeySession) Touch() { + m.Called() +} + +func (m *MockMasterKeySession) GetSecondsRemaining(timeout time.Duration) int64 { + args := m.Called(timeout) + return args.Get(0).(int64) +} + +type MockAppSettingRepository struct { + mock.Mock +} + +func (m *MockAppSettingRepository) Save(ctx context.Context, setting *model.AppSetting) (*model.AppSetting, error) { + args := m.Called(ctx, setting) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.AppSetting), args.Error(1) +} + +func (m *MockAppSettingRepository) FindByKey(ctx context.Context, key string) (*model.AppSetting, error) { + args := m.Called(ctx, key) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.AppSetting), args.Error(1) +} + +func (m *MockAppSettingRepository) ExistsByKey(ctx context.Context, key string) (bool, error) { + args := m.Called(ctx, key) + return args.Bool(0), args.Error(1) +} + +func (m *MockAppSettingRepository) SaveMasterPasswordSettings(ctx context.Context, hashValue, saltValue string) error { + args := m.Called(ctx, hashValue, saltValue) + return args.Error(0) +} + +// --- UNIT TESTS --- + +func TestVaultUseCase_SetupMasterPassword(t *testing.T) { + ctx := context.Background() + + t.Run("SetupMasterPassword_Success", func(t *testing.T) { + mockKeyDeriver := new(MockKeyDeriver) + mockSession := new(MockMasterKeySession) + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(mockKeyDeriver, mockSession, mockAppSettingRepo) + + password := []byte("MySuperSecretPassword123") + saltBytes := []byte("0123456789abcdef") + hashedPassword := []byte("32BytesHashedPasswordOutputBytes") + secretKey := []byte("32BytesDerivedKeyAES256SecretBytes") + + hashBase64 := base64.StdEncoding.EncodeToString(hashedPassword) + saltBase64 := base64.StdEncoding.EncodeToString(saltBytes) + + mockKeyDeriver.On("GenerateSalt", usecase.SaltLength).Return(saltBytes, nil) + mockKeyDeriver.On("HashPassword", mock.Anything, mock.Anything).Return(hashedPassword, nil) + mockAppSettingRepo.On("SaveMasterPasswordSettings", ctx, hashBase64, saltBase64).Return(nil) + mockKeyDeriver.On("DeriveKey", mock.Anything, mock.Anything).Return(secretKey, nil) + mockSession.On("SetKey", secretKey).Return() + + err := uc.SetupMasterPassword(ctx, password) + + assert.NoError(t, err) + mockAppSettingRepo.AssertExpectations(t) + mockKeyDeriver.AssertExpectations(t) + mockSession.AssertExpectations(t) + }) + + t.Run("SetupMasterPassword_AlreadyConfigured", func(t *testing.T) { + mockKeyDeriver := new(MockKeyDeriver) + mockSession := new(MockMasterKeySession) + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(mockKeyDeriver, mockSession, mockAppSettingRepo) + + password := []byte("MySuperSecretPassword123") + saltBytes := []byte("0123456789abcdef") + hashedPassword := []byte("32BytesHashedPasswordOutputBytes") + + hashBase64 := base64.StdEncoding.EncodeToString(hashedPassword) + saltBase64 := base64.StdEncoding.EncodeToString(saltBytes) + + mockKeyDeriver.On("GenerateSalt", usecase.SaltLength).Return(saltBytes, nil) + mockKeyDeriver.On("HashPassword", mock.Anything, mock.Anything).Return(hashedPassword, nil) + mockAppSettingRepo.On("SaveMasterPasswordSettings", ctx, hashBase64, saltBase64).Return(usecase.ErrMasterPasswordAlreadyConfigured) + + err := uc.SetupMasterPassword(ctx, password) + + assert.ErrorIs(t, err, usecase.ErrMasterPasswordAlreadyConfigured) + mockAppSettingRepo.AssertExpectations(t) + mockKeyDeriver.AssertNotCalled(t, "DeriveKey", mock.Anything, mock.Anything) + }) + + t.Run("SetupMasterPassword_GenerateSaltError", func(t *testing.T) { + mockKeyDeriver := new(MockKeyDeriver) + mockSession := new(MockMasterKeySession) + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(mockKeyDeriver, mockSession, mockAppSettingRepo) + + password := []byte("MySuperSecretPassword123") + mockKeyDeriver.On("GenerateSalt", usecase.SaltLength).Return(nil, errors.New("entropy error")) + + err := uc.SetupMasterPassword(ctx, password) + + assert.Error(t, err) + assert.Equal(t, "entropy error", err.Error()) + }) + + t.Run("SetupMasterPassword_DBSaveError", func(t *testing.T) { + mockKeyDeriver := new(MockKeyDeriver) + mockSession := new(MockMasterKeySession) + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(mockKeyDeriver, mockSession, mockAppSettingRepo) + + password := []byte("MySuperSecretPassword123") + saltBytes := []byte("0123456789abcdef") + hashedPassword := []byte("32BytesHashedPasswordOutputBytes") + + hashBase64 := base64.StdEncoding.EncodeToString(hashedPassword) + saltBase64 := base64.StdEncoding.EncodeToString(saltBytes) + + mockKeyDeriver.On("GenerateSalt", usecase.SaltLength).Return(saltBytes, nil) + mockKeyDeriver.On("HashPassword", mock.Anything, mock.Anything).Return(hashedPassword, nil) + mockAppSettingRepo.On("SaveMasterPasswordSettings", ctx, hashBase64, saltBase64).Return(errors.New("db error")) + + err := uc.SetupMasterPassword(ctx, password) + + assert.Error(t, err) + mockSession.AssertNotCalled(t, "SetKey", mock.Anything) + }) +} + +func TestVaultUseCase_CheckIfMasterSetupIsRequired(t *testing.T) { + ctx := context.Background() + + t.Run("CheckIfMasterSetupIsRequired_True", func(t *testing.T) { + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(new(MockKeyDeriver), new(MockMasterKeySession), mockAppSettingRepo) + + mockAppSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(false, nil) + + required, err := uc.CheckIfMasterSetupIsRequired(ctx) + + assert.NoError(t, err) + assert.True(t, required) + }) + + t.Run("CheckIfMasterSetupIsRequired_False", func(t *testing.T) { + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(new(MockKeyDeriver), new(MockMasterKeySession), mockAppSettingRepo) + + mockAppSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(true, nil) + + required, err := uc.CheckIfMasterSetupIsRequired(ctx) + + assert.NoError(t, err) + assert.False(t, required) + }) + + t.Run("CheckIfMasterSetupIsRequired_DBError", func(t *testing.T) { + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(new(MockKeyDeriver), new(MockMasterKeySession), mockAppSettingRepo) + + mockAppSettingRepo.On("ExistsByKey", ctx, usecase.MasterPasswordHashKey).Return(false, errors.New("db error")) + + required, err := uc.CheckIfMasterSetupIsRequired(ctx) + + assert.Error(t, err) + assert.False(t, required) + }) +} + +func TestVaultUseCase_UnlockVault(t *testing.T) { + ctx := context.Background() + + password := []byte("MySuperSecretPassword123") + saltBytes := []byte("0123456789abcdef") + hashedPassword := []byte("32BytesHashedPasswordOutputBytes") + secretKey := []byte("32BytesDerivedKeyAES256SecretBytes") + + encodedHash := base64.StdEncoding.EncodeToString(hashedPassword) + encodedSalt := base64.StdEncoding.EncodeToString(saltBytes) + + t.Run("UnlockVault_Success", func(t *testing.T) { + mockKeyDeriver := new(MockKeyDeriver) + mockSession := new(MockMasterKeySession) + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(mockKeyDeriver, mockSession, mockAppSettingRepo) + + mockAppSettingRepo.On("FindByKey", ctx, usecase.MasterPasswordHashKey).Return(&model.AppSetting{Key: usecase.MasterPasswordHashKey, Value: encodedHash}, nil) + mockAppSettingRepo.On("FindByKey", ctx, usecase.MasterPasswordSaltKey).Return(&model.AppSetting{Key: usecase.MasterPasswordSaltKey, Value: encodedSalt}, nil) + mockKeyDeriver.On("VerifyPassword", mock.Anything, saltBytes, hashedPassword).Return(true) + mockKeyDeriver.On("DeriveKey", mock.Anything, saltBytes).Return(secretKey, nil) + mockSession.On("SetKey", secretKey).Return() + + unlocked, err := uc.UnlockVault(ctx, password) + + assert.NoError(t, err) + assert.True(t, unlocked) + mockAppSettingRepo.AssertExpectations(t) + mockKeyDeriver.AssertExpectations(t) + mockSession.AssertExpectations(t) + }) + + t.Run("UnlockVault_NotConfigured_MissingHash", func(t *testing.T) { + mockKeyDeriver := new(MockKeyDeriver) + mockSession := new(MockMasterKeySession) + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(mockKeyDeriver, mockSession, mockAppSettingRepo) + + mockAppSettingRepo.On("FindByKey", ctx, usecase.MasterPasswordHashKey).Return(nil, nil) + + unlocked, err := uc.UnlockVault(ctx, password) + + assert.ErrorIs(t, err, usecase.ErrMasterPasswordNotConfigured) + assert.False(t, unlocked) + }) + + t.Run("UnlockVault_InvalidPassword", func(t *testing.T) { + mockKeyDeriver := new(MockKeyDeriver) + mockSession := new(MockMasterKeySession) + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(mockKeyDeriver, mockSession, mockAppSettingRepo) + + mockAppSettingRepo.On("FindByKey", ctx, usecase.MasterPasswordHashKey).Return(&model.AppSetting{Key: usecase.MasterPasswordHashKey, Value: encodedHash}, nil) + mockAppSettingRepo.On("FindByKey", ctx, usecase.MasterPasswordSaltKey).Return(&model.AppSetting{Key: usecase.MasterPasswordSaltKey, Value: encodedSalt}, nil) + mockKeyDeriver.On("VerifyPassword", mock.Anything, saltBytes, hashedPassword).Return(false) + + unlocked, err := uc.UnlockVault(ctx, password) + + assert.ErrorIs(t, err, usecase.ErrInvalidMasterPassword) + assert.False(t, unlocked) + mockSession.AssertNotCalled(t, "SetKey", mock.Anything) + }) + + t.Run("UnlockVault_DBError", func(t *testing.T) { + mockKeyDeriver := new(MockKeyDeriver) + mockSession := new(MockMasterKeySession) + mockAppSettingRepo := new(MockAppSettingRepository) + uc := usecase.NewVaultUseCase(mockKeyDeriver, mockSession, mockAppSettingRepo) + + mockAppSettingRepo.On("FindByKey", ctx, usecase.MasterPasswordHashKey).Return(nil, errors.New("db connection failure")) + + unlocked, err := uc.UnlockVault(ctx, password) + + assert.Error(t, err) + assert.False(t, unlocked) + }) +} + +func TestVaultUseCase_LockVault(t *testing.T) { + mockSession := new(MockMasterKeySession) + uc := usecase.NewVaultUseCase(new(MockKeyDeriver), mockSession, new(MockAppSettingRepository)) + + mockSession.On("Clear").Return() + + uc.LockVault() + + mockSession.AssertExpectations(t) +} + +func TestVaultUseCase_GetSessionStatus(t *testing.T) { + mockSession := new(MockMasterKeySession) + uc := usecase.NewVaultUseCase(new(MockKeyDeriver), mockSession, new(MockAppSettingRepository)) + + mockSession.On("HasKey").Return(true) + mockSession.On("GetSecondsRemaining", usecase.SessionTimeout).Return(int64(895)) + + status := uc.GetSessionStatus() + + assert.True(t, status.Active) + assert.Equal(t, int64(895), status.SecondsLeft) + mockSession.AssertExpectations(t) +} diff --git a/backend/migrations/000001_create_app_settings_table.up.sql b/backend/migrations/000001_create_app_settings_table.up.sql new file mode 100644 index 0000000..7602739 --- /dev/null +++ b/backend/migrations/000001_create_app_settings_table.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE app_settings ( + key TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT INTO app_settings (key, value) VALUES ('crypto_version', '1'); diff --git a/backend/migrations/000002_create_projects_table.up.sql b/backend/migrations/000002_create_projects_table.up.sql new file mode 100644 index 0000000..072947e --- /dev/null +++ b/backend/migrations/000002_create_projects_table.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE projects ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT, + color TEXT, + archived INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME +); + +CREATE INDEX idx_projects_archived ON projects(archived); diff --git a/backend/migrations/000003_create_snippets_table.up.sql b/backend/migrations/000003_create_snippets_table.up.sql new file mode 100644 index 0000000..43c2308 --- /dev/null +++ b/backend/migrations/000003_create_snippets_table.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE snippets ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + content TEXT NOT NULL, + language TEXT, + snippet_type TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME, + CONSTRAINT fk_snippets_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE INDEX idx_snippets_project ON snippets(project_id); diff --git a/backend/migrations/000004_create_links_table.up.sql b/backend/migrations/000004_create_links_table.up.sql new file mode 100644 index 0000000..dae15a9 --- /dev/null +++ b/backend/migrations/000004_create_links_table.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE links ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL, + description TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME, + CONSTRAINT fk_links_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE INDEX idx_links_project ON links(project_id); diff --git a/backend/migrations/000005_create_problems_table.up.sql b/backend/migrations/000005_create_problems_table.up.sql new file mode 100644 index 0000000..83ab13c --- /dev/null +++ b/backend/migrations/000005_create_problems_table.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE problems ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + error_description TEXT, + solution TEXT, + status TEXT NOT NULL, + severity TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME, + CONSTRAINT fk_problems_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE INDEX idx_problems_project ON problems(project_id); diff --git a/backend/migrations/000006_create_notes_table.up.sql b/backend/migrations/000006_create_notes_table.up.sql new file mode 100644 index 0000000..46dc885 --- /dev/null +++ b/backend/migrations/000006_create_notes_table.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE notes ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT, + archived INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME, + CONSTRAINT fk_notes_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE INDEX idx_notes_project ON notes(project_id); diff --git a/backend/migrations/000007_create_credentials_table.up.sql b/backend/migrations/000007_create_credentials_table.up.sql new file mode 100644 index 0000000..021881e --- /dev/null +++ b/backend/migrations/000007_create_credentials_table.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE credentials ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + secret_type TEXT NOT NULL, + payload_encrypted BLOB NOT NULL, + encryption_iv BLOB NOT NULL, + encryption_auth_tag BLOB NOT NULL, + notes TEXT, + related_url TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME, + CONSTRAINT fk_credentials_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE INDEX idx_credentials_project ON credentials(project_id); diff --git a/backend/migrations/000008_create_tags_table.up.sql b/backend/migrations/000008_create_tags_table.up.sql new file mode 100644 index 0000000..b361752 --- /dev/null +++ b/backend/migrations/000008_create_tags_table.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE tags ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME, + CONSTRAINT fk_tags_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX uk_tags_project_name ON tags(project_id, name); diff --git a/backend/migrations/000009_create_item_tags_table.up.sql b/backend/migrations/000009_create_item_tags_table.up.sql new file mode 100644 index 0000000..f0cd16e --- /dev/null +++ b/backend/migrations/000009_create_item_tags_table.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE item_tags ( + tag_id TEXT NOT NULL, + item_type TEXT NOT NULL, + item_id TEXT NOT NULL, + PRIMARY KEY (tag_id, item_type, item_id), + CONSTRAINT fk_item_tags_tag FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); + +CREATE INDEX idx_item_tags_item ON item_tags(item_type, item_id); diff --git a/backend/migrations/embed.go b/backend/migrations/embed.go new file mode 100644 index 0000000..c5a2a43 --- /dev/null +++ b/backend/migrations/embed.go @@ -0,0 +1,10 @@ +package migrations + +import "embed" + +// FS contains all SQL migration files embedded at compile time. +// This makes the Go binary fully self-contained, eliminating the +// need to ship migration files alongside the executable. +// +//go:embed *.sql +var FS embed.FS diff --git a/backend/settings.gradle.kts b/backend/settings.gradle.kts deleted file mode 100644 index 2401f0a..0000000 --- a/backend/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "backend" diff --git a/backend/src/main/java/com/devaulty/backend/BackendApplication.java b/backend/src/main/java/com/devaulty/backend/BackendApplication.java deleted file mode 100644 index 27704fd..0000000 --- a/backend/src/main/java/com/devaulty/backend/BackendApplication.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.devaulty.backend; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.web.config.EnableSpringDataWebSupport; -import org.springframework.scheduling.annotation.EnableScheduling; - -import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO; - -@SpringBootApplication -@EnableScheduling -@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) -public class BackendApplication { - - public static void main(String[] args) { - SpringApplication.run(BackendApplication.class, args); - } - -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/scheduler/VaultAutoLockScheduler.java b/backend/src/main/java/com/devaulty/backend/adapter/in/scheduler/VaultAutoLockScheduler.java deleted file mode 100644 index f592259..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/scheduler/VaultAutoLockScheduler.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.devaulty.backend.adapter.in.scheduler; - -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.time.Duration; - -@Component -public class VaultAutoLockScheduler { - - private final Duration inactivityTimeout = Duration.ofMinutes(15); - - private static final Logger logger = LoggerFactory.getLogger(VaultAutoLockScheduler.class); - - private final MasterKeySessionPort masterKeySessionPort; - - public VaultAutoLockScheduler(MasterKeySessionPort masterKeySessionPort) { - this.masterKeySessionPort = masterKeySessionPort; - } - - @Scheduled(fixedDelay = 10000) // Runs every 10 seconds - public void purgeExpiredSession() { - if (!masterKeySessionPort.hasKey()) { - return; - } - - if (masterKeySessionPort.getOrClearIfExpired(inactivityTimeout) == null) { - logger.info("Inactivity Timeout reached. Vault locked."); - } - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/common/BackgroundTaskRunner.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/common/BackgroundTaskRunner.java deleted file mode 100644 index dc94e48..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/common/BackgroundTaskRunner.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.devaulty.backend.adapter.in.web.common; - -import jakarta.annotation.PreDestroy; -import org.springframework.stereotype.Component; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -/** - * Runs long-lived, blocking background tasks (e.g. file downloads) outside - * of the servlet request thread pool, so HTTP worker threads are never held - * hostage by slow I/O operations. - */ -@Component -public class BackgroundTaskRunner { - - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - - public void run(Runnable task) { - executor.execute(task); - } - - @PreDestroy - public void shutdown() { - executor.shutdown(); - try { - if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { - executor.shutdownNow(); - } - } catch (InterruptedException e) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/common/UriLocationBuilderHelper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/common/UriLocationBuilderHelper.java deleted file mode 100644 index 4011694..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/common/UriLocationBuilderHelper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.devaulty.backend.adapter.in.web.common; - -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; - -import java.net.URI; -import java.util.UUID; - -@Component -public class UriLocationBuilderHelper { - - public URI buildLocationUri(UUID id){ - return ServletUriComponentsBuilder - .fromCurrentRequest() - .path("/{id}") - .buildAndExpand(id) - .toUri(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialApi.java deleted file mode 100644 index 3800896..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialApi.java +++ /dev/null @@ -1,364 +0,0 @@ -package com.devaulty.backend.adapter.in.web.credential; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import com.devaulty.backend.adapter.in.web.credential.dto.CreateCredentialRequest; -import com.devaulty.backend.adapter.in.web.credential.dto.CredentialSummaryResponse; -import com.devaulty.backend.adapter.in.web.credential.dto.CredentialViewResponse; -import com.devaulty.backend.adapter.in.web.credential.dto.UpdateCredentialRequest; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@Tag(name = "Credentials", description = "Endpoints for managing secure credentials inside projects") -public interface CredentialApi { - - @Schema(name = "CreateCredentialRequest", description = "Payload for creating a new credential") - record CreateCredentialRequestDoc( - @Schema(description = "Title of the credential", minLength = 2, maxLength = 255, example = "My Database Password") - String title, - - @Schema(description = "Type of the credential secret") - CredentialSecretType secretType, - - @Schema(type = "string", description = "Username for LOGIN type", example = "admin") - String username, - - @Schema(type = "string", description = "Password for LOGIN type", example = "supersecret") - String password, - - @Schema(type = "string", description = "API Key for API_KEY type", example = "api_key_12345") - String apiKey, - - @Schema(type = "string", description = "Raw text content for RAW_TEXT type", example = "my secret private key content") - String rawTextContent, - - @Schema(description = "Optional additional notes", example = "Used for staging env") - String notes, - - @Schema(description = "Optional related website URL", example = "https://database.internal") - String relatedUrl - ) {} - - @Schema(name = "UpdateCredentialRequest", description = "Payload for updating a credential") - record UpdateCredentialRequestDoc( - @Schema(description = "Updated title of the credential", minLength = 2, maxLength = 255, example = "My Database Password") - String title, - - @Schema(description = "Updated type of the credential secret") - CredentialSecretType secretType, - - @Schema(type = "string", description = "Updated username for LOGIN type", example = "admin") - String username, - - @Schema(type = "string", description = "Updated password for LOGIN type", example = "supersecret") - String password, - - @Schema(type = "string", description = "Updated API Key for API_KEY type", example = "api_key_12345") - String apiKey, - - @Schema(type = "string", description = "Updated raw text content for RAW_TEXT type", example = "my secret private key content") - String rawTextContent, - - @Schema(description = "Updated notes", example = "Used for staging env") - String notes, - - @Schema(description = "Updated related website URL", example = "https://database.internal") - String relatedUrl - ) {} - - @Operation( - summary = "Create a new credential", - description = "Creates and encrypts a new credential under the specified project ID. The vault must be unlocked." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "201", - description = "Credential created successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = CredentialViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "403", - description = "Master password not configured", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "423", - description = "Vault is locked (master key session is inactive)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error or encryption failure", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping - ResponseEntity create( - @PathVariable UUID projectId, - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "Credential creation details", - required = true, - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = CreateCredentialRequestDoc.class) - ) - ) - @RequestBody @Valid CreateCredentialRequest request - ); - - @Operation( - summary = "Get all credentials by project", - description = "Retrieves a paginated list of credential summaries belonging to the specified project. The vault must be unlocked." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved list of credential summaries" - ), - @ApiResponse( - responseCode = "403", - description = "Master password not configured", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "423", - description = "Vault is locked (master key session is inactive)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping - ResponseEntity> getAllByProject( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ); - - @Operation( - summary = "Get credential by ID", - description = "Retrieves and decrypts a single credential by its ID within a specific project. The vault must be unlocked." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved and decrypted the credential", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = CredentialViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "403", - description = "Master password not configured", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Credential not found (or credential does not belong to the project)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "423", - description = "Vault is locked (master key session is inactive)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error or decryption failure", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/{credentialId}") - ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID credentialId - ); - - @Operation( - summary = "Update a credential", - description = "Updates metadata and/or the secret payload of an existing credential. Re-encrypts the new secret payload if provided. The vault must be unlocked." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Credential updated successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = CredentialViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "403", - description = "Master password not configured", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Credential not found (or credential does not belong to the project)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "423", - description = "Vault is locked (master key session is inactive)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error or encryption failure", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{credentialId}") - ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID credentialId, - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "Credential update details", - required = true, - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = UpdateCredentialRequestDoc.class) - ) - ) - @RequestBody @Valid UpdateCredentialRequest request - ); - - @Operation( - summary = "Delete a credential", - description = "Deletes a credential by its ID within a specific project. The vault must be unlocked." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Credential deleted successfully" - ), - @ApiResponse( - responseCode = "403", - description = "Master password not configured", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Credential not found (or credential does not belong to the project)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "423", - description = "Vault is locked (master key session is inactive)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @DeleteMapping("/{credentialId}") - ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID credentialId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialController.java deleted file mode 100644 index 9795a22..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialController.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.devaulty.backend.adapter.in.web.credential; - -import com.devaulty.backend.adapter.in.web.credential.dto.CreateCredentialRequest; -import com.devaulty.backend.adapter.in.web.credential.dto.CredentialSummaryResponse; -import com.devaulty.backend.adapter.in.web.credential.dto.CredentialViewResponse; -import com.devaulty.backend.adapter.in.web.credential.dto.UpdateCredentialRequest; -import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper; -import com.devaulty.backend.application.port.in.credential.*; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemsUseCase; -import com.devaulty.backend.domain.model.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/projects/{projectId}/credentials") -public class CredentialController implements CredentialApi{ - - private final CreateCredentialUseCase createCredentialUseCase; - private final GetAllCredentialsByProjectUseCase getAllCredentialsByProjectUseCase; - private final GetCredentialByIdUseCase getCredentialByIdUseCase; - private final UpdateCredentialUseCase updateCredentialUseCase; - private final DeleteCredentialUseCase deleteCredentialUseCase; - private final GetTagsForItemUseCase getTagsForItemUseCase; - private final GetTagsForItemsUseCase getTagsForItemsUseCase; - private final CredentialWebMapper mapper; - private final UriLocationBuilderHelper uriLocationBuilderHelper; - - private static final String ITEM_TYPE = "credential"; - - public CredentialController(CreateCredentialUseCase createCredentialUseCase, GetAllCredentialsByProjectUseCase getAllCredentialsByProjectUseCase, GetCredentialByIdUseCase getCredentialByIdUseCase, UpdateCredentialUseCase updateCredentialUseCase, DeleteCredentialUseCase deleteCredentialUseCase, GetTagsForItemUseCase getTagsForItemUseCase, GetTagsForItemsUseCase getTagsForItemsUseCase, CredentialWebMapper mapper, UriLocationBuilderHelper uriLocationBuilderHelper) { - this.createCredentialUseCase = createCredentialUseCase; - this.getAllCredentialsByProjectUseCase = getAllCredentialsByProjectUseCase; - this.getCredentialByIdUseCase = getCredentialByIdUseCase; - this.updateCredentialUseCase = updateCredentialUseCase; - this.deleteCredentialUseCase = deleteCredentialUseCase; - this.getTagsForItemUseCase = getTagsForItemUseCase; - this.getTagsForItemsUseCase = getTagsForItemsUseCase; - this.mapper = mapper; - this.uriLocationBuilderHelper = uriLocationBuilderHelper; - } - - @Override - @PostMapping - public ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateCredentialRequest request - ){ - CreateCredentialCommand command = mapper.toCreateCredentialCommand(request, projectId); - DecryptedCredential decryptedCredential = createCredentialUseCase.execute(command); - CredentialViewResponse response = mapper.toViewResponse(decryptedCredential, List.of()); - URI location = uriLocationBuilderHelper.buildLocationUri(decryptedCredential.id()); - return ResponseEntity.created(location).body(response); - } - - @Override - @GetMapping - public ResponseEntity> getAllByProject( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ){ - Page summary = getAllCredentialsByProjectUseCase.execute(projectId, page, size); - List ids = summary.getContent().stream().map(CredentialSummary::id).toList(); - Map> tagsByItem = getTagsForItemsUseCase.execute(ITEM_TYPE, projectId, ids); - Page response = summary.map(s -> - mapper.toSummaryResponse(s, tagsByItem.getOrDefault(s.id(), List.of()))); - return ResponseEntity.ok(response); - } - - @Override - @GetMapping("/{credentialId}") - public ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID credentialId - ){ - DecryptedCredential credential = getCredentialByIdUseCase.execute(projectId, credentialId); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, credentialId); - return ResponseEntity.ok(mapper.toViewResponse(credential, tags)); - } - - @Override - @PatchMapping("/{credentialId}") - public ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID credentialId, - @RequestBody @Valid UpdateCredentialRequest request - ){ - UpdateCredentialCommand command = mapper.toUpdateCredentialCommand(request, projectId, credentialId); - DecryptedCredential credential = updateCredentialUseCase.execute(command); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, credentialId); - return ResponseEntity.ok(mapper.toViewResponse(credential, tags)); - } - - @Override - @DeleteMapping("/{credentialId}") - public ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID credentialId - ){ - deleteCredentialUseCase.execute(projectId, credentialId); - return ResponseEntity.noContent().build(); - } - -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialWebMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialWebMapper.java deleted file mode 100644 index 123a63e..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialWebMapper.java +++ /dev/null @@ -1,172 +0,0 @@ -package com.devaulty.backend.adapter.in.web.credential; - -import com.devaulty.backend.adapter.in.web.credential.dto.CreateCredentialRequest; -import com.devaulty.backend.adapter.in.web.credential.dto.CredentialSummaryResponse; -import com.devaulty.backend.adapter.in.web.credential.dto.CredentialViewResponse; -import com.devaulty.backend.adapter.in.web.credential.dto.UpdateCredentialRequest; -import com.devaulty.backend.adapter.in.web.tag.TagWebMapper; -import com.devaulty.backend.application.exception.BusinessRuleException; -import com.devaulty.backend.application.exception.JsonProcessingException; -import com.devaulty.backend.application.port.in.credential.CreateCredentialCommand; -import com.devaulty.backend.application.port.in.credential.CredentialSummary; -import com.devaulty.backend.application.port.in.credential.DecryptedCredential; -import com.devaulty.backend.application.port.in.credential.UpdateCredentialCommand; -import com.devaulty.backend.domain.model.Tag; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.Named; -import org.springframework.beans.factory.annotation.Autowired; -import tools.jackson.core.type.TypeReference; -import tools.jackson.databind.json.JsonMapper; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@Mapper(componentModel = "spring", uses = TagWebMapper.class) -public abstract class CredentialWebMapper { - - @Autowired - protected JsonMapper jsonMapper; - - @Mapping(source = "credential.decryptedPayload", target = "decryptedPayload", qualifiedByName = "jsonToMap") - @Mapping(source = "tags", target = "tags") - public abstract CredentialViewResponse toViewResponse(DecryptedCredential credential, List tags); - - @Mapping(source = "tags", target = "tags") - public abstract CredentialSummaryResponse toSummaryResponse(CredentialSummary credential, List tags); - - public CreateCredentialCommand toCreateCredentialCommand(CreateCredentialRequest request, UUID projectId) { - char[] serializedPayload = null; - try { - if (request.secretType() == null) { - throw new BusinessRuleException("secretType is required"); - } - - boolean isValid = switch (request.secretType()) { - case LOGIN -> request.password() != null && request.password().length > 0; - case API_KEY -> request.apiKey() != null && request.apiKey().length > 0; - case RAW_TEXT -> request.rawTextContent() != null && request.rawTextContent().length > 0; - }; - - if (!isValid) { - throw new BusinessRuleException("Required secret fields are missing for the selected secret type: " + request.secretType()); - } - - serializedPayload = serializeSecretStructure( - request.secretType(), - request.username(), - request.password(), - request.apiKey(), - request.rawTextContent() - ); - - return new CreateCredentialCommand( - projectId, - request.title(), - request.secretType(), - serializedPayload, - request.notes(), - request.relatedUrl() - ); - } catch (BusinessRuleException e) { - throw e; - } catch (Exception e) { - throw new JsonProcessingException("Error trying to structure credential secrets.", e); - } finally { - wipeSensitiveFields(request.username(), request.password(), request.apiKey(), request.rawTextContent()); - } - } - - public UpdateCredentialCommand toUpdateCredentialCommand(UpdateCredentialRequest request, UUID projectId, UUID id) { - char[] serializedPayload = null; - try { - if (request.secretType() != null) { - boolean isValid = switch (request.secretType()) { - case LOGIN -> request.password() != null && request.password().length > 0; - case API_KEY -> request.apiKey() != null && request.apiKey().length > 0; - case RAW_TEXT -> request.rawTextContent() != null && request.rawTextContent().length > 0; - }; - - if (!isValid) { - throw new BusinessRuleException("Required secret fields are missing for the selected secret type: " + request.secretType()); - } - - serializedPayload = serializeSecretStructure( - request.secretType(), - request.username(), - request.password(), - request.apiKey(), - request.rawTextContent() - ); - } else { - boolean hasAnySecretField = (request.username() != null && request.username().length > 0) - || (request.password() != null && request.password().length > 0) - || (request.apiKey() != null && request.apiKey().length > 0) - || (request.rawTextContent() != null && request.rawTextContent().length > 0); - if (hasAnySecretField) { - throw new BusinessRuleException("secretType is required when secret fields are provided"); - } - } - - return new UpdateCredentialCommand( - id, - projectId, - request.title(), - request.secretType(), - serializedPayload, - request.notes(), - request.relatedUrl() - ); - } catch (BusinessRuleException e) { - throw e; - } catch (Exception e) { - throw new JsonProcessingException("Error trying to structure credential secrets.", e); - } finally { - wipeSensitiveFields(request.username(), request.password(), request.apiKey(), request.rawTextContent()); - } - } - - @Named("jsonToMap") - protected Map jsonToMap(byte[] decryptedBytes) { - if (decryptedBytes == null || decryptedBytes.length == 0) return Map.of(); - - try { - return jsonMapper.readValue(decryptedBytes, new TypeReference<>() { - }); - } catch (Exception e) { - throw new JsonProcessingException("Failed to parse decrypted payload.", e); - } finally { - // Clears the decryptedBytes from memory - Arrays.fill(decryptedBytes, (byte) 0); - } - } - - // --- REUSABLE PRIVATE METHODS --- - - private char[] serializeSecretStructure(CredentialSecretType type, char[] username, char[] password, char[] apiKey, char[] rawText) { - Object secretStructure = switch (type) { - case LOGIN -> Map.of( - "username", username != null ? String.valueOf(username) : "", - "password", password != null ? String.valueOf(password) : "" - ); - case API_KEY -> Map.of( - "apiKey", apiKey != null ? String.valueOf(apiKey) : "" - ); - case RAW_TEXT -> Map.of( - "rawText", rawText != null ? String.valueOf(rawText) : "" - ); - }; - return jsonMapper.writeValueAsString(secretStructure).toCharArray(); - } - - private void wipeSensitiveFields(char[] username, char[] password, char[] apiKey, char[] rawText) { - if (username != null) Arrays.fill(username, '\0'); - if (password != null) Arrays.fill(password, '\0'); - if (apiKey != null) Arrays.fill(apiKey, '\0'); - if (rawText != null) Arrays.fill(rawText, '\0'); - } - -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/CreateCredentialRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/CreateCredentialRequest.java deleted file mode 100644 index 2676acc..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/CreateCredentialRequest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.devaulty.backend.adapter.in.web.credential.dto; - -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import jakarta.validation.constraints.AssertTrue; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record CreateCredentialRequest( - - @NotBlank(message = "Title is required") - @Size(min = 2, max = 255, message = "Title must be between 2 and 255 characters") - String title, - - @NotNull(message = "Secret type is required") - CredentialSecretType secretType, - - char[] username, - char[] password, - char[] apiKey, - char[] rawTextContent, - - String notes, - String relatedUrl - -) { - @AssertTrue(message = "Required secret fields are missing for the selected secret type") - public boolean isValidSecretPayload() { - if (secretType == null) return false; - - return switch (secretType) { - case LOGIN -> password != null && password.length > 0; - case API_KEY -> apiKey != null && apiKey.length > 0; - case RAW_TEXT -> rawTextContent != null && rawTextContent.length > 0; - }; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/CredentialSummaryResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/CredentialSummaryResponse.java deleted file mode 100644 index f5ca16a..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/CredentialSummaryResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.devaulty.backend.adapter.in.web.credential.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -public record CredentialSummaryResponse( - UUID id, - UUID projectId, - String title, - CredentialSecretType secretType, - String relatedUrl, - @Schema(description = "List of tags associated with this credential") - List tags, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/CredentialViewResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/CredentialViewResponse.java deleted file mode 100644 index 74fa149..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/CredentialViewResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.devaulty.backend.adapter.in.web.credential.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -public record CredentialViewResponse( - UUID id, - UUID projectId, - String title, - CredentialSecretType secretType, - Map decryptedPayload, - String notes, - String relatedUrl, - @Schema(description = "List of tags associated with this credential") - List tags, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/UpdateCredentialRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/UpdateCredentialRequest.java deleted file mode 100644 index 3bf10f0..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/dto/UpdateCredentialRequest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.devaulty.backend.adapter.in.web.credential.dto; - -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import jakarta.validation.constraints.AssertTrue; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record UpdateCredentialRequest( - @Pattern(regexp = "(?s).*\\S.*", message = "Title must not be blank") - @Size(min = 2, max = 255, message = "Title must be between 2 and 255 characters") - String title, - - CredentialSecretType secretType, - - char[] username, - char[] password, - char[] apiKey, - char[] rawTextContent, - - String notes, - String relatedUrl -){ - @AssertTrue(message = "Required secret fields are missing for the selected secret type") - public boolean isValidSecretPayload() { - boolean isAnySecretFieldPresent = secretType != null - || (username != null && username.length > 0) - || (password != null && password.length > 0) - || (apiKey != null && apiKey.length > 0) - || (rawTextContent != null && rawTextContent.length > 0); - - if (!isAnySecretFieldPresent) { - return true; - } - - if (secretType == null) { - return false; - } - - return switch (secretType) { - case LOGIN -> password != null && password.length > 0; - case API_KEY -> apiKey != null && apiKey.length > 0; - case RAW_TEXT -> rawTextContent != null && rawTextContent.length > 0; - }; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/ApiErrorResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/ApiErrorResponse.java deleted file mode 100644 index 45006ac..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/ApiErrorResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.devaulty.backend.adapter.in.web.exception; - -import java.time.LocalDateTime; -import java.util.List; - -public record ApiErrorResponse( - int status, - String message, - LocalDateTime timestamp, - List errors -) { - // Internal record to detail which field has an error - public record ValidationErrors(String field, String message) {} -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/GlobalExceptionHandler.java deleted file mode 100644 index 58e5013..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.devaulty.backend.adapter.in.web.exception; - -import com.devaulty.backend.application.exception.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.time.LocalDateTime; -import java.util.List; - -import com.devaulty.backend.adapter.in.web.security.dto.MasterPasswordRequest; -import com.devaulty.backend.adapter.in.web.credential.dto.CreateCredentialRequest; -import com.devaulty.backend.adapter.in.web.credential.dto.UpdateCredentialRequest; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); - - @ExceptionHandler(ResourceNotFoundException.class) - public ResponseEntity handleResourceNotFoundException(ResourceNotFoundException exception) { - return buildResponse(HttpStatus.NOT_FOUND, exception.getMessage()); - } - - @ExceptionHandler(ResourceAlreadyExistsException.class) - public ResponseEntity handleResourceAlreadyExistsException(ResourceAlreadyExistsException exception) { - return buildResponse(HttpStatus.CONFLICT, exception.getMessage()); - } - - @ExceptionHandler(BusinessRuleException.class) - public ResponseEntity handleBusinessRuleException(BusinessRuleException exception) { - return buildResponse(HttpStatus.BAD_REQUEST, exception.getMessage()); - } - - @ExceptionHandler(CryptoException.class) - public ResponseEntity handleCryptoException(CryptoException exception) { - return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); - } - - @ExceptionHandler(UpdateNotAvailableException.class) - public ResponseEntity handleUpdateNotAvailableException(UpdateNotAvailableException exception) { - return buildResponse(HttpStatus.BAD_REQUEST, exception.getMessage()); - } - - @ExceptionHandler(InvalidMasterPasswordException.class) - public ResponseEntity handleInvalidMasterPasswordException(InvalidMasterPasswordException exception) { - return buildResponse(HttpStatus.UNAUTHORIZED, exception.getMessage()); - } - - @ExceptionHandler(JsonProcessingException.class) - public ResponseEntity handleJsonProcessingException(JsonProcessingException exception) { - return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); - } - - @ExceptionHandler(MasterPasswordAlreadyConfiguredException.class) - public ResponseEntity handleMasterPasswordAlreadyConfiguredException(MasterPasswordAlreadyConfiguredException exception) { - return buildResponse(HttpStatus.CONFLICT, exception.getMessage()); - } - - @ExceptionHandler(MasterPasswordNotConfiguredException.class) - public ResponseEntity handleMasterPasswordNotConfiguredException(MasterPasswordNotConfiguredException exception) { - return buildResponse(HttpStatus.FORBIDDEN, exception.getMessage()); - } - - @ExceptionHandler(VaultLockedException.class) - public ResponseEntity handleVaultLockedException(VaultLockedException exception) { - return buildResponse(HttpStatus.LOCKED, exception.getMessage()); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidation(MethodArgumentNotValidException exception) { - wipeRejectedRequestSecrets(exception.getBindingResult().getTarget()); - - List errors = exception.getBindingResult().getFieldErrors().stream() - .map(err -> new ApiErrorResponse.ValidationErrors(err.getField(), err.getDefaultMessage())) - .toList(); - - ApiErrorResponse response = new ApiErrorResponse( - HttpStatus.BAD_REQUEST.value(), - "Validation error", - LocalDateTime.now(), - errors - ); - - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handleGeral(Exception exception) { - logger.error("Unexpected error: ", exception); - return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error, Please contact support."); - } - - private ResponseEntity buildResponse(HttpStatus status, String message) { - ApiErrorResponse response = new ApiErrorResponse( - status.value(), - message, - LocalDateTime.now(), - null // null because generic exceptions don't have a validation list field - ); - - return ResponseEntity.status(status).body(response); - } - - private void wipeRejectedRequestSecrets(Object target) { - if (target instanceof MasterPasswordRequest(char[] masterPassword)) { - wipe(masterPassword); - } else if (target instanceof CreateCredentialRequest( - var title, var type, char[] username, char[] password, char[] apiKey, char[] rawTextContent, var notes, - var url - )) { - wipe(username, password, apiKey, rawTextContent); - } else if (target instanceof UpdateCredentialRequest( - var title, var type, char[] username, char[] password, char[] apiKey, char[] rawTextContent, var notes, - var url - )) { - wipe(username, password, apiKey, rawTextContent); - } - } - - private void wipe(char[]... arrays) { - for (char[] array : arrays) { - if (array != null) { - java.util.Arrays.fill(array, '\0'); - } - } - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/LinkApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/LinkApi.java deleted file mode 100644 index fb76878..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/LinkApi.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.devaulty.backend.adapter.in.web.link; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import com.devaulty.backend.adapter.in.web.link.dto.CreateLinkRequest; -import com.devaulty.backend.adapter.in.web.link.dto.LinkViewResponse; -import com.devaulty.backend.adapter.in.web.link.dto.UpdateLinkRequest; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@Tag(name = "Links", description = "Endpoints for managing links within a project") -public interface LinkApi { - - @Operation( - summary = "Create a new link", - description = "Creates a new link associated with the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "201", - description = "Link created successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = LinkViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping - ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateLinkRequest request - ); - - @Operation( - summary = "Get all links by project", - description = "Retrieves a paginated list of all links belonging to the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved list of links" - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping - ResponseEntity> getAllByProject( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ); - - @Operation( - summary = "Get link by ID", - description = "Retrieves a single link by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved the link", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = LinkViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Link not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/{linkId}") - ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID linkId - ); - - @Operation( - summary = "Update a link", - description = "Updates an existing link by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Link updated successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = LinkViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Link not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{linkId}") - ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID linkId, - @RequestBody @Valid UpdateLinkRequest request - ); - - @Operation( - summary = "Delete a link", - description = "Deletes an existing link by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Link deleted successfully" - ), - @ApiResponse( - responseCode = "404", - description = "Project or Link not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @DeleteMapping("/{linkId}") - ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID linkId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/LinkController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/LinkController.java deleted file mode 100644 index a29dfcc..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/LinkController.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.devaulty.backend.adapter.in.web.link; - -import com.devaulty.backend.adapter.in.web.link.dto.CreateLinkRequest; -import com.devaulty.backend.adapter.in.web.link.dto.LinkViewResponse; -import com.devaulty.backend.adapter.in.web.link.dto.UpdateLinkRequest; -import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper; -import com.devaulty.backend.application.port.in.link.*; -import com.devaulty.backend.domain.model.Link; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemsUseCase; -import com.devaulty.backend.domain.model.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/projects/{projectId}/links") -public class LinkController implements LinkApi{ - - private final CreateLinkUseCase createLinkUseCase; - private final GetAllLinksByProjectUseCase getAllLinksByProjectUseCase; - private final GetLinkByIdUseCase getLinkByIdUseCase; - private final DeleteLinkUseCase deleteLinkUseCase; - private final UpdateLinkUseCase updateLinkUseCase; - private final GetTagsForItemUseCase getTagsForItemUseCase; - private final GetTagsForItemsUseCase getTagsForItemsUseCase; - private final LinkWebMapper webMapper; - private final UriLocationBuilderHelper uriLocationBuilderHelper; - - private static final String ITEM_TYPE = "link"; - - public LinkController(CreateLinkUseCase createLinkUseCase, GetAllLinksByProjectUseCase getAllLinksByProjectUseCase, GetLinkByIdUseCase getLinkByIdUseCase, DeleteLinkUseCase deleteLinkUseCase, UpdateLinkUseCase updateLinkUseCase, GetTagsForItemUseCase getTagsForItemUseCase, GetTagsForItemsUseCase getTagsForItemsUseCase, LinkWebMapper webMapper, UriLocationBuilderHelper uriLocationBuilderHelper) { - this.createLinkUseCase = createLinkUseCase; - this.getAllLinksByProjectUseCase = getAllLinksByProjectUseCase; - this.getLinkByIdUseCase = getLinkByIdUseCase; - this.deleteLinkUseCase = deleteLinkUseCase; - this.updateLinkUseCase = updateLinkUseCase; - this.getTagsForItemUseCase = getTagsForItemUseCase; - this.getTagsForItemsUseCase = getTagsForItemsUseCase; - this.webMapper = webMapper; - this.uriLocationBuilderHelper = uriLocationBuilderHelper; - } - - @Override - @PostMapping - public ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateLinkRequest request - ){ - CreateLinkCommand command = webMapper.toCreateLinkCommand(request, projectId); - Link link = createLinkUseCase.execute(command); - URI uri = uriLocationBuilderHelper.buildLocationUri(link.getId()); - return ResponseEntity.created(uri).body(webMapper.toViewResponse(link, List.of())); - } - - @Override - @GetMapping - public ResponseEntity> getAllByProject( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ){ - Page links = getAllLinksByProjectUseCase.execute(projectId, page, size); - List ids = links.getContent().stream().map(Link::getId).toList(); - Map> tagsByItem = getTagsForItemsUseCase.execute(ITEM_TYPE, projectId, ids); - Page response = links.map(s -> - webMapper.toViewResponse(s, tagsByItem.getOrDefault(s.getId(), List.of()))); - return ResponseEntity.ok(response); - } - - @Override - @GetMapping("/{linkId}") - public ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID linkId - ){ - Link link = getLinkByIdUseCase.execute(projectId, linkId); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, linkId); - return ResponseEntity.ok(webMapper.toViewResponse(link, tags)); - } - - @Override - @PatchMapping("/{linkId}") - public ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID linkId, - @RequestBody @Valid UpdateLinkRequest request - ){ - UpdateLinkCommand command = webMapper.toUpdateLinkCommand(request, projectId, linkId); - Link link = updateLinkUseCase.execute(command); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, linkId); - return ResponseEntity.ok(webMapper.toViewResponse(link, tags)); - } - - @Override - @DeleteMapping("/{linkId}") - public ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID linkId - ){ - deleteLinkUseCase.execute(projectId, linkId); - return ResponseEntity.noContent().build(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/LinkWebMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/LinkWebMapper.java deleted file mode 100644 index 0c79be1..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/LinkWebMapper.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.devaulty.backend.adapter.in.web.link; - -import com.devaulty.backend.adapter.in.web.link.dto.CreateLinkRequest; -import com.devaulty.backend.adapter.in.web.link.dto.LinkViewResponse; -import com.devaulty.backend.adapter.in.web.link.dto.UpdateLinkRequest; -import com.devaulty.backend.adapter.in.web.tag.TagWebMapper; -import com.devaulty.backend.application.port.in.link.CreateLinkCommand; -import com.devaulty.backend.application.port.in.link.UpdateLinkCommand; -import com.devaulty.backend.domain.model.Link; -import com.devaulty.backend.domain.model.Tag; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -import java.util.List; -import java.util.UUID; - -@Mapper(componentModel = "spring", uses = TagWebMapper.class) -public interface LinkWebMapper { - - @Mapping(target = "tags", source = "tags") - LinkViewResponse toViewResponse(Link link, List tags); - - @Mapping(target = "projectId", source = "projectId") - CreateLinkCommand toCreateLinkCommand(CreateLinkRequest request, UUID projectId); - - @Mapping(target = "projectId", source = "projectId") - @Mapping(target = "id", source = "linkId") - UpdateLinkCommand toUpdateLinkCommand(UpdateLinkRequest request, UUID projectId, UUID linkId); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/dto/CreateLinkRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/dto/CreateLinkRequest.java deleted file mode 100644 index f40f109..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/dto/CreateLinkRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.devaulty.backend.adapter.in.web.link.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record CreateLinkRequest( - - @NotBlank(message = "Title must not be blank") - @Size(min = 2, max = 255, message = "Title must be between 2 and 255 characters") - String title, - - @NotBlank(message = "URL must not be blank") - String url, - - String description -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/dto/LinkViewResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/dto/LinkViewResponse.java deleted file mode 100644 index 1008b6c..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/dto/LinkViewResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.devaulty.backend.adapter.in.web.link.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -public record LinkViewResponse( - UUID id, - UUID projectId, - String title, - String url, - String description, - @Schema(description = "List of tags associated with this link") - List tags, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/dto/UpdateLinkRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/dto/UpdateLinkRequest.java deleted file mode 100644 index d35a44d..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/link/dto/UpdateLinkRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.devaulty.backend.adapter.in.web.link.dto; - -import jakarta.validation.constraints.Pattern; - -public record UpdateLinkRequest( - @Pattern(regexp = "(?s).*\\S.*", message = "Title must not be blank") - String title, - @Pattern(regexp = "(?s).*\\S.*", message = "url must not be blank") - String url, - String description -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/NoteApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/NoteApi.java deleted file mode 100644 index 6b79c4c..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/NoteApi.java +++ /dev/null @@ -1,293 +0,0 @@ -package com.devaulty.backend.adapter.in.web.note; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import com.devaulty.backend.adapter.in.web.note.dto.CreateNoteRequest; -import com.devaulty.backend.adapter.in.web.note.dto.NoteSummaryResponse; -import com.devaulty.backend.adapter.in.web.note.dto.NoteViewResponse; -import com.devaulty.backend.adapter.in.web.note.dto.UpdateNoteRequest; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@Tag(name = "Notes", description = "Endpoints for managing project notes") -public interface NoteApi { - - @Operation( - summary = "Create a new note", - description = "Creates a new note associated with the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "201", - description = "Note created successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = NoteViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping - ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateNoteRequest request - ); - - @Operation( - summary = "Get all notes by project", - description = "Retrieves a paginated list of all note summaries belonging to the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved list of note summaries" - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping - ResponseEntity> getAllByProject( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ); - - @Operation( - summary = "Get note by ID", - description = "Retrieves a single note by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved the note", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = NoteViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Note not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/{noteID}") - ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID noteID - ); - - @Operation( - summary = "Update a note", - description = "Updates an existing note by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Note updated successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = NoteViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Note not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{noteId}") - ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID noteId, - @RequestBody @Valid UpdateNoteRequest request - ); - - @Operation( - summary = "Archive a note", - description = "Archives an existing note by its unique identifier." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Note archived successfully" - ), - @ApiResponse( - responseCode = "400", - description = "Note is already archived", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Note not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{noteId}/archive") - ResponseEntity archive( - @PathVariable UUID projectId, - @PathVariable UUID noteId - ); - - @Operation( - summary = "Unarchive a note", - description = "Unarchives an archived note by its unique identifier." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Note unarchived successfully" - ), - @ApiResponse( - responseCode = "400", - description = "Note is not archived", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Note not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{noteId}/unarchive") - ResponseEntity unarchive( - @PathVariable UUID projectId, - @PathVariable UUID noteId - ); - - @Operation( - summary = "Delete a note", - description = "Deletes an existing note by its unique identifier." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Note deleted successfully" - ), - @ApiResponse( - responseCode = "404", - description = "Project or Note not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @DeleteMapping("/{noteId}") - ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID noteId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/NoteController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/NoteController.java deleted file mode 100644 index 0cd6503..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/NoteController.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.devaulty.backend.adapter.in.web.note; - -import com.devaulty.backend.adapter.in.web.note.dto.CreateNoteRequest; -import com.devaulty.backend.adapter.in.web.note.dto.NoteSummaryResponse; -import com.devaulty.backend.adapter.in.web.note.dto.NoteViewResponse; -import com.devaulty.backend.adapter.in.web.note.dto.UpdateNoteRequest; -import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper; -import com.devaulty.backend.application.port.in.note.*; -import com.devaulty.backend.domain.model.Note; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemsUseCase; -import com.devaulty.backend.domain.model.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/projects/{projectId}/notes") -public class NoteController implements NoteApi{ - - private final CreateNoteUseCase createNoteUseCase; - private final GetAllNotesByProjectUseCase getAllNotesByProjectUseCase; - private final GetNoteByIdUseCase getNoteByIdUseCase; - private final ArchiveNoteUseCase archiveNoteUseCase; - private final UpdateNoteUseCase updateNoteUseCase; - private final UnarchiveNoteUseCase unarchiveNoteUseCase; - private final DeleteNoteUseCase deleteNoteUseCase; - private final GetTagsForItemUseCase getTagsForItemUseCase; - private final GetTagsForItemsUseCase getTagsForItemsUseCase; - private final NoteWebMapper mapper; - private final UriLocationBuilderHelper uriLocationBuilderHelper; - - private static final String ITEM_TYPE = "note"; - - public NoteController(CreateNoteUseCase createNoteUseCase, GetAllNotesByProjectUseCase getAllNotesByProjectUseCase, GetNoteByIdUseCase getNoteByIdUseCase, ArchiveNoteUseCase archiveNoteUseCase, UpdateNoteUseCase updateNoteUseCase, UnarchiveNoteUseCase unarchiveNoteUseCase, DeleteNoteUseCase deleteNoteUseCase, GetTagsForItemUseCase getTagsForItemUseCase, GetTagsForItemsUseCase getTagsForItemsUseCase, NoteWebMapper mapper, UriLocationBuilderHelper uriLocationBuilderHelper) { - this.createNoteUseCase = createNoteUseCase; - this.getAllNotesByProjectUseCase = getAllNotesByProjectUseCase; - this.getNoteByIdUseCase = getNoteByIdUseCase; - this.archiveNoteUseCase = archiveNoteUseCase; - this.updateNoteUseCase = updateNoteUseCase; - this.unarchiveNoteUseCase = unarchiveNoteUseCase; - this.deleteNoteUseCase = deleteNoteUseCase; - this.getTagsForItemUseCase = getTagsForItemUseCase; - this.getTagsForItemsUseCase = getTagsForItemsUseCase; - this.mapper = mapper; - this.uriLocationBuilderHelper = uriLocationBuilderHelper; - } - - @Override - @PostMapping - public ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateNoteRequest request - ) - { - CreateNoteCommand command = mapper.toCreateNoteCommand(request, projectId); - Note note = createNoteUseCase.execute(command); - URI location = uriLocationBuilderHelper.buildLocationUri(note.getId()); - return ResponseEntity.created(location).body(mapper.toViewResponse(note, List.of())); - } - - @Override - @GetMapping - public ResponseEntity> getAllByProject( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ){ - Page notes = getAllNotesByProjectUseCase.execute(projectId, page, size); - List ids = notes.getContent().stream().map(Note::getId).toList(); - Map> tagsByItem = getTagsForItemsUseCase.execute(ITEM_TYPE, projectId, ids); - Page response = notes.map(s -> - mapper.toSummaryResponse(s, tagsByItem.getOrDefault(s.getId(), List.of()))); - return ResponseEntity.ok(response); - } - - @Override - @GetMapping("/{noteID}") - public ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID noteID - ) - { - Note note = getNoteByIdUseCase.execute(projectId, noteID); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, noteID); - return ResponseEntity.ok(mapper.toViewResponse(note, tags)); - } - - @Override - @PatchMapping("/{noteId}") - public ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID noteId, - @RequestBody @Valid UpdateNoteRequest request - ){ - UpdateNoteCommand command = mapper.toUpdateNoteCommand(request, projectId, noteId); - Note note = updateNoteUseCase.execute(command); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, noteId); - return ResponseEntity.ok(mapper.toViewResponse(note, tags)); - } - - @Override - @PatchMapping("/{noteId}/archive") - public ResponseEntity archive( - @PathVariable UUID projectId, - @PathVariable UUID noteId - ){ - archiveNoteUseCase.execute(projectId, noteId); - return ResponseEntity.noContent().build(); - } - - @Override - @PatchMapping("/{noteId}/unarchive") - public ResponseEntity unarchive( - @PathVariable UUID projectId, - @PathVariable UUID noteId - ){ - unarchiveNoteUseCase.execute(projectId, noteId); - return ResponseEntity.noContent().build(); - } - - @Override - @DeleteMapping("/{noteId}") - public ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID noteId - ){ - deleteNoteUseCase.execute(projectId, noteId); - return ResponseEntity.noContent().build(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/NoteWebMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/NoteWebMapper.java deleted file mode 100644 index 3f0d489..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/NoteWebMapper.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.devaulty.backend.adapter.in.web.note; - -import com.devaulty.backend.adapter.in.web.note.dto.CreateNoteRequest; -import com.devaulty.backend.adapter.in.web.note.dto.NoteSummaryResponse; -import com.devaulty.backend.adapter.in.web.note.dto.NoteViewResponse; -import com.devaulty.backend.adapter.in.web.note.dto.UpdateNoteRequest; -import com.devaulty.backend.adapter.in.web.tag.TagWebMapper; -import com.devaulty.backend.application.port.in.note.CreateNoteCommand; -import com.devaulty.backend.application.port.in.note.UpdateNoteCommand; -import com.devaulty.backend.domain.model.Note; -import com.devaulty.backend.domain.model.Tag; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -import java.util.List; -import java.util.UUID; - -@Mapper(componentModel = "spring", uses = TagWebMapper.class) -public interface NoteWebMapper { - - @Mapping(target = "tags", source = "tags") - NoteViewResponse toViewResponse(Note note, List tags); - - @Mapping(target = "tags", source = "tags") - NoteSummaryResponse toSummaryResponse(Note note, List tags); - - @Mapping( target = "projectId", source = "projectId") - CreateNoteCommand toCreateNoteCommand(CreateNoteRequest request, UUID projectId); - - @Mapping( target = "projectId", source = "projectId") - @Mapping( target = "id", source = "noteId") - UpdateNoteCommand toUpdateNoteCommand(UpdateNoteRequest request, UUID projectId, UUID noteId); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/CreateNoteRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/CreateNoteRequest.java deleted file mode 100644 index f4e1ff8..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/CreateNoteRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.devaulty.backend.adapter.in.web.note.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record CreateNoteRequest( - @NotBlank - @Size(min = 2, max = 255, message = "Title must be between 2 and 255 characters") - String title, - - String content -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/NoteSummaryResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/NoteSummaryResponse.java deleted file mode 100644 index 751f9e9..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/NoteSummaryResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.devaulty.backend.adapter.in.web.note.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -public record NoteSummaryResponse( - UUID id, - UUID projectId, - String title, - boolean archived, - @Schema(description = "List of tags associated with this note") - List tags, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/NoteViewResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/NoteViewResponse.java deleted file mode 100644 index 47a5186..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/NoteViewResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.devaulty.backend.adapter.in.web.note.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -public record NoteViewResponse( - UUID id, - UUID projectId, - String title, - String content, - boolean archived, - @Schema(description = "List of tags associated with this note") - List tags, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/UpdateNoteRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/UpdateNoteRequest.java deleted file mode 100644 index 1469977..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/note/dto/UpdateNoteRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.devaulty.backend.adapter.in.web.note.dto; - -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record UpdateNoteRequest( - @Pattern(regexp = "(?s).*\\S.*", message = "Title must not be blank") - @Size(min = 2, max = 255, message = "Title must be between 2 and 255 characters") - String title, - String content -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/ProblemApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/ProblemApi.java deleted file mode 100644 index 3062b50..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/ProblemApi.java +++ /dev/null @@ -1,255 +0,0 @@ -package com.devaulty.backend.adapter.in.web.problem; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import com.devaulty.backend.adapter.in.web.problem.dto.*; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@Tag(name = "Problems", description = "Endpoints for managing problems/errors encountered within a project") -public interface ProblemApi { - - @Operation( - summary = "Create a new problem log", - description = "Logs a new problem associated with the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "201", - description = "Problem logged successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ProblemViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping - ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateProblemRequest request - ); - - @Operation( - summary = "Get all problems by project", - description = "Retrieves a paginated list of all problem summaries belonging to the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved list of problem summaries" - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping - ResponseEntity> getAllByProject( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ); - - @Operation( - summary = "Get problem details by ID", - description = "Retrieves details of a single problem by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved the problem details", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ProblemViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Problem not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/{problemId}") - ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID problemId - ); - - @Operation( - summary = "Update a problem", - description = "Updates details of an existing problem by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Problem updated successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ProblemViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Problem not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{problemId}") - ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID problemId, - @RequestBody @Valid UpdateProblemRequest request - ); - - @Operation( - summary = "Update problem status", - description = "Updates status of an existing problem by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Problem status updated successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ProblemViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Problem not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{problemId}/status") - ResponseEntity updateStatus( - @PathVariable UUID projectId, - @PathVariable UUID problemId, - @RequestBody @Valid UpdateProblemStatusRequest request - ); - - @Operation( - summary = "Delete a problem log", - description = "Deletes an existing problem log by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Problem deleted successfully" - ), - @ApiResponse( - responseCode = "404", - description = "Project or Problem not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @DeleteMapping("/{problemId}") - ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID problemId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/ProblemController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/ProblemController.java deleted file mode 100644 index 2d78ef3..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/ProblemController.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.devaulty.backend.adapter.in.web.problem; - -import com.devaulty.backend.adapter.in.web.problem.dto.*; -import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper; -import com.devaulty.backend.application.port.in.problem.*; -import com.devaulty.backend.domain.model.Problem; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemsUseCase; -import com.devaulty.backend.domain.model.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/projects/{projectId}/problems") -public class ProblemController implements ProblemApi{ - - private final CreateProblemUseCase createProblemUseCase; - private final GetAllProblemsByProjectUseCase getAllProblemsByProjectUseCase; - private final GetProblemByIdUseCase getProblemByIdUseCase; - private final UpdateProblemUseCase updateProblemUseCase; - private final UpdateProblemStatusUseCase updateProblemStatusUseCase; - private final DeleteProblemUseCase deleteProblemUseCase; - private final GetTagsForItemUseCase getTagsForItemUseCase; - private final GetTagsForItemsUseCase getTagsForItemsUseCase; - private final ProblemWebMapper mapper; - private final UriLocationBuilderHelper uriLocationBuilderHelper; - - private static final String ITEM_TYPE = "problem"; - - public ProblemController(CreateProblemUseCase createProblemUseCase, GetAllProblemsByProjectUseCase getAllProblemsByProjectUseCase, GetProblemByIdUseCase getProblemByIdUseCase, UpdateProblemUseCase updateProblemUseCase, UpdateProblemStatusUseCase updateProblemStatusUseCase, DeleteProblemUseCase deleteProblemUseCase, GetTagsForItemUseCase getTagsForItemUseCase, GetTagsForItemsUseCase getTagsForItemsUseCase, ProblemWebMapper mapper, UriLocationBuilderHelper uriLocationBuilderHelper) { - this.createProblemUseCase = createProblemUseCase; - this.getAllProblemsByProjectUseCase = getAllProblemsByProjectUseCase; - this.getProblemByIdUseCase = getProblemByIdUseCase; - this.updateProblemUseCase = updateProblemUseCase; - this.updateProblemStatusUseCase = updateProblemStatusUseCase; - this.deleteProblemUseCase = deleteProblemUseCase; - this.getTagsForItemUseCase = getTagsForItemUseCase; - this.getTagsForItemsUseCase = getTagsForItemsUseCase; - this.mapper = mapper; - this.uriLocationBuilderHelper = uriLocationBuilderHelper; - } - - @Override - @PostMapping - public ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateProblemRequest request) - { - CreateProblemCommand command = mapper.toCreateProblemCommand(request, projectId); - Problem problem = createProblemUseCase.execute(command); - URI location = uriLocationBuilderHelper.buildLocationUri(problem.getId()); - return ResponseEntity.created(location).body(mapper.toViewResponse(problem, List.of())); - } - - @Override - @GetMapping - public ResponseEntity> getAllByProject( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ){ - Page problems = getAllProblemsByProjectUseCase.execute(projectId, page, size); - List ids = problems.getContent().stream().map(Problem::getId).toList(); - Map> tagsByItem = getTagsForItemsUseCase.execute(ITEM_TYPE, projectId, ids); - Page response = problems.map(s -> - mapper.toSummaryResponse(s, tagsByItem.getOrDefault(s.getId(), List.of()))); - return ResponseEntity.ok(response); - } - - @Override - @GetMapping("/{problemId}") - public ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID problemId - ){ - Problem problem = getProblemByIdUseCase.execute(projectId, problemId); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, problemId); - return ResponseEntity.ok(mapper.toViewResponse(problem, tags)); - } - - @Override - @PatchMapping("/{problemId}") - public ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID problemId, - @RequestBody @Valid UpdateProblemRequest request - ){ - UpdateProblemCommand command = mapper.toUpdateProblemCommand(request, projectId, problemId); - Problem problem = updateProblemUseCase.execute(command); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, problemId); - return ResponseEntity.ok(mapper.toViewResponse(problem, tags)); - } - - @Override - @PatchMapping("/{problemId}/status") - public ResponseEntity updateStatus( - @PathVariable UUID projectId, - @PathVariable UUID problemId, - @RequestBody @Valid UpdateProblemStatusRequest request - ){ - UpdateProblemStatusCommand command = mapper.toUpdateProblemStatusCommand(request, projectId, problemId); - Problem problem = updateProblemStatusUseCase.execute(command); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, problemId); - return ResponseEntity.ok(mapper.toViewResponse(problem, tags)); - } - - @Override - @DeleteMapping("/{problemId}") - public ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID problemId - ){ - deleteProblemUseCase.execute(projectId, problemId); - return ResponseEntity.noContent().build(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/ProblemWebMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/ProblemWebMapper.java deleted file mode 100644 index 308b5e9..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/ProblemWebMapper.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.devaulty.backend.adapter.in.web.problem; - -import com.devaulty.backend.adapter.in.web.problem.dto.*; -import com.devaulty.backend.adapter.in.web.tag.TagWebMapper; -import com.devaulty.backend.application.port.in.problem.CreateProblemCommand; -import com.devaulty.backend.application.port.in.problem.UpdateProblemCommand; -import com.devaulty.backend.application.port.in.problem.UpdateProblemStatusCommand; -import com.devaulty.backend.domain.model.Problem; -import com.devaulty.backend.domain.model.Tag; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -import java.util.List; -import java.util.UUID; - -@Mapper(componentModel = "spring", uses = TagWebMapper.class) -public interface ProblemWebMapper { - - @Mapping(target = "tags", source = "tags") - ProblemViewResponse toViewResponse(Problem problem, List tags); - - @Mapping(target = "tags", source = "tags") - ProblemSummaryResponse toSummaryResponse(Problem problem, List tags); - - @Mapping(target = "projectId", source = "projectId") - CreateProblemCommand toCreateProblemCommand(CreateProblemRequest request, UUID projectId); - - @Mapping(target = "projectId", source = "projectId") - @Mapping(target = "id", source = "problemId") - UpdateProblemCommand toUpdateProblemCommand(UpdateProblemRequest request, UUID projectId, UUID problemId); - - @Mapping(target = "projectId", source = "projectId") - @Mapping(target = "id", source = "problemId") - UpdateProblemStatusCommand toUpdateProblemStatusCommand(UpdateProblemStatusRequest request, UUID projectId, UUID problemId); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/CreateProblemRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/CreateProblemRequest.java deleted file mode 100644 index e7cbec8..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/CreateProblemRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.devaulty.backend.adapter.in.web.problem.dto; - -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record CreateProblemRequest( - @NotBlank(message = "Title must not be blank") - @Size(min = 2, max = 255, message = "Title must be between 2 and 255 characters") - String title, - String errorDescription, - String solution, - @NotNull(message = "Status must not be null") - ProblemStatus status, - @NotNull(message = "Severity must not be null") - ProblemSeverity severity -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/ProblemSummaryResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/ProblemSummaryResponse.java deleted file mode 100644 index a21e38d..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/ProblemSummaryResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.devaulty.backend.adapter.in.web.problem.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -public record ProblemSummaryResponse( - UUID id, - UUID projectId, - String title, - ProblemStatus status, - ProblemSeverity severity, - @Schema(description = "List of tags associated with this problem") - List tags, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/ProblemViewResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/ProblemViewResponse.java deleted file mode 100644 index 8255374..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/ProblemViewResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.devaulty.backend.adapter.in.web.problem.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -public record ProblemViewResponse( - UUID id, - UUID projectId, - String title, - String errorDescription, - String solution, - ProblemStatus status, - ProblemSeverity severity, - @Schema(description = "List of tags associated with this problem") - List tags, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/UpdateProblemRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/UpdateProblemRequest.java deleted file mode 100644 index 30c290f..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/UpdateProblemRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.adapter.in.web.problem.dto; - -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record UpdateProblemRequest( - @Pattern(regexp = "(?s).*\\S.*", message = "Title must not be blank") - @Size(min = 2, max = 255, message = "Title must be between 2 and 255 characters") - String title, - String errorDescription, - String solution, - ProblemSeverity severity -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/UpdateProblemStatusRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/UpdateProblemStatusRequest.java deleted file mode 100644 index 73ab4ad..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/problem/dto/UpdateProblemStatusRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.adapter.in.web.problem.dto; - -import com.devaulty.backend.domain.model.enums.ProblemStatus; -import jakarta.validation.constraints.NotNull; - -public record UpdateProblemStatusRequest( - @NotNull(message = "Status must not be null") - ProblemStatus status -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/ProjectApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/ProjectApi.java deleted file mode 100644 index 58a6bd2..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/ProjectApi.java +++ /dev/null @@ -1,243 +0,0 @@ -package com.devaulty.backend.adapter.in.web.project; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import com.devaulty.backend.adapter.in.web.project.dto.CreateProjectRequest; -import com.devaulty.backend.adapter.in.web.project.dto.ProjectViewResponse; -import com.devaulty.backend.adapter.in.web.project.dto.UpdateProjectRequest; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@Tag(name = "Projects", description = "Endpoints for managing projects") -public interface ProjectApi { - - @Operation( - summary = "Create a new project", - description = "Creates a new project with the specified name, description, icon, and hex color code." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "201", - description = "Project created successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ProjectViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping - ResponseEntity create(@RequestBody @Valid CreateProjectRequest request); - - @Operation( - summary = "Get all projects", - description = "Retrieves a paginated list of projects." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved list of projects" - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping - ResponseEntity> getAll( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ); - - @Operation( - summary = "Get project by ID", - description = "Retrieves a single project by its unique identifier." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved the project", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ProjectViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/{id}") - ResponseEntity getById(@PathVariable UUID id); - - @Operation( - summary = "Update a project", - description = "Updates an existing project by its unique identifier with the specified name, description, icon, and hex color code." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Project updated successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ProjectViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{id}") - ResponseEntity update( - @PathVariable UUID id, - @RequestBody @Valid UpdateProjectRequest request - ); - - @Operation( - summary = "Archive a project", - description = "Archives an existing project by its unique identifier." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Project archived successfully" - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{id}/archive") - ResponseEntity archive(@PathVariable UUID id); - - @Operation( - summary = "Unarchive a project", - description = "Unarchives an archived project by its unique identifier." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Project unarchived successfully" - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{id}/unarchive") - ResponseEntity unarchive(@PathVariable UUID id); - - @Operation( - summary = "Delete a project", - description = "Deletes an existing project by its unique identifier." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Project deleted successfully" - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @DeleteMapping("/{id}") - ResponseEntity delete(@PathVariable UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/ProjectController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/ProjectController.java deleted file mode 100644 index 9d30672..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/ProjectController.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.devaulty.backend.adapter.in.web.project; - -import com.devaulty.backend.adapter.in.web.project.dto.CreateProjectRequest; -import com.devaulty.backend.adapter.in.web.project.dto.ProjectViewResponse; -import com.devaulty.backend.adapter.in.web.project.dto.UpdateProjectRequest; -import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper; -import com.devaulty.backend.application.port.in.project.*; -import com.devaulty.backend.domain.model.Project; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.net.URI; -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/projects") -public class ProjectController implements ProjectApi { - - private final CreateProjectUseCase createProjectUseCase; - private final GetAllProjectsUseCase getAllProjectsUseCase; - private final GetProjectByIdUseCase getProjectByIdUseCase; - private final UpdateProjectUseCase updateProjectUseCase; - private final ArchiveProjectUseCase archiveProjectUseCase; - private final UnarchiveProjectUseCase unarchiveProjectUseCase; - private final DeleteProjectUseCase deleteProjectUseCase; - private final ProjectWebMapper webMapper; - private final UriLocationBuilderHelper uriLocationBuilderHelper; - - public ProjectController(CreateProjectUseCase createProjectUseCase, GetAllProjectsUseCase getAllProjectsUseCase, GetProjectByIdUseCase getProjectByIdUseCase, UpdateProjectUseCase updateProjectUseCase, ArchiveProjectUseCase archiveProjectUseCase, UnarchiveProjectUseCase unarchiveProjectUseCase, DeleteProjectUseCase deleteProjectUseCase, ProjectWebMapper webMapper, UriLocationBuilderHelper uriLocationBuilderHelper) { - this.createProjectUseCase = createProjectUseCase; - this.getAllProjectsUseCase = getAllProjectsUseCase; - this.getProjectByIdUseCase = getProjectByIdUseCase; - this.updateProjectUseCase = updateProjectUseCase; - this.archiveProjectUseCase = archiveProjectUseCase; - this.unarchiveProjectUseCase = unarchiveProjectUseCase; - this.deleteProjectUseCase = deleteProjectUseCase; - this.webMapper = webMapper; - this.uriLocationBuilderHelper = uriLocationBuilderHelper; - } - - @Override - @PostMapping - public ResponseEntity create(@RequestBody @Valid CreateProjectRequest request){ - CreateProjectCommand command = webMapper.toCreateProjectCommand(request); - Project project = createProjectUseCase.execute(command); - URI location = uriLocationBuilderHelper.buildLocationUri(project.getId()); - return ResponseEntity.created(location).body(webMapper.toViewResponse(project)); - } - - @Override - @GetMapping - public ResponseEntity> getAll( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size) - { - Page projects = getAllProjectsUseCase.execute(page, size); - return ResponseEntity.ok(projects.map(webMapper::toViewResponse)); - } - - @Override - @GetMapping("/{id}") - public ResponseEntity getById(@PathVariable UUID id){ - Project project = getProjectByIdUseCase.execute(id); - return ResponseEntity.ok(webMapper.toViewResponse(project)); - } - - @PatchMapping("/{id}") - @Override - public ResponseEntity update(@PathVariable UUID id, - @RequestBody @Valid UpdateProjectRequest request) - { - CreateProjectCommand command = webMapper.toUpdateProjectCommand(request); - Project project = updateProjectUseCase.execute(id, command); - return ResponseEntity.ok(webMapper.toViewResponse(project)); - } - - @PatchMapping("/{id}/archive") - @Override - public ResponseEntity archive(@PathVariable UUID id){ - archiveProjectUseCase.execute(id); - return ResponseEntity.noContent().build(); - } - - @PatchMapping("/{id}/unarchive") - @Override - public ResponseEntity unarchive(@PathVariable UUID id){ - unarchiveProjectUseCase.execute(id); - return ResponseEntity.noContent().build(); - } - - @DeleteMapping("/{id}") - @Override - public ResponseEntity delete(@PathVariable UUID id){ - deleteProjectUseCase.execute(id); - return ResponseEntity.noContent().build(); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/ProjectWebMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/ProjectWebMapper.java deleted file mode 100644 index 9eb9eae..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/ProjectWebMapper.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.devaulty.backend.adapter.in.web.project; - -import com.devaulty.backend.adapter.in.web.project.dto.CreateProjectRequest; -import com.devaulty.backend.adapter.in.web.project.dto.ProjectViewResponse; -import com.devaulty.backend.adapter.in.web.project.dto.UpdateProjectRequest; -import com.devaulty.backend.application.port.in.project.CreateProjectCommand; -import com.devaulty.backend.domain.model.Project; -import org.mapstruct.Mapper; - -@Mapper(componentModel = "spring") -public interface ProjectWebMapper { - - ProjectViewResponse toViewResponse(Project project); - - CreateProjectCommand toCreateProjectCommand(CreateProjectRequest request); - - CreateProjectCommand toUpdateProjectCommand(UpdateProjectRequest request); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/dto/CreateProjectRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/dto/CreateProjectRequest.java deleted file mode 100644 index 6f5e66e..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/dto/CreateProjectRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.devaulty.backend.adapter.in.web.project.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record CreateProjectRequest( - @NotBlank - @Size(min = 2, max = 255, message = "Name must be between 2 and 255 characters") - String name, - - @Size(max = 255) - String description, - - @Size(max = 100) - String icon, - - @Pattern(regexp = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", message = "Color must be a valid hex code (e.g. #1A1A2E or #FFF)") - String color -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/dto/ProjectViewResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/dto/ProjectViewResponse.java deleted file mode 100644 index affbee4..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/dto/ProjectViewResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.devaulty.backend.adapter.in.web.project.dto; - -import java.time.LocalDateTime; -import java.util.UUID; - -public record ProjectViewResponse( - UUID id, - String name, - String description, - String icon, - String color, - boolean archived, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/dto/UpdateProjectRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/dto/UpdateProjectRequest.java deleted file mode 100644 index 89ce6da..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/project/dto/UpdateProjectRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.devaulty.backend.adapter.in.web.project.dto; - -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record UpdateProjectRequest( - @Size(min = 2, max = 255, message = "Name must be between 2 and 255 characters") - String name, - - @Size(max = 255) - String description, - - @Size(max = 100) - String icon, - - @Pattern(regexp = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", message = "Color must be a valid hex code (e.g. #1A1A2E or #FFF)") - String color -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseApi.java deleted file mode 100644 index 6be2c49..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseApi.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.devaulty.backend.adapter.in.web.release; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import com.devaulty.backend.adapter.in.web.release.dto.AppUpdateInfoResponse; -import com.devaulty.backend.adapter.in.web.release.dto.CurrentVersionResponse; -import com.devaulty.backend.adapter.in.web.release.dto.UpdateDownloadProgressResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -@Tag(name = "Releases", description = "Endpoints for checking desktop application updates, streaming asset downloads, and invoking native OS installation") -@RequestMapping("/api/v1/releases") -public interface ReleaseApi { - - @Operation( - summary = "Check for application updates", - description = "Fetches the latest official relea" + - "se published on GitHub Releases and compares its tag version with the local running application version (`app.version`). Dynamically detects the current host OS (Linux .deb vs .rpm, Windows .msi, macOS .dmg) and resolves the matching asset download URL and file size." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Update check completed successfully. Returns update availability status, current vs latest version info, changelog notes, and host-specific download metadata.", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = AppUpdateInfoResponse.class)) - ), - @ApiResponse( - responseCode = "403", - description = "Forbidden. Request missing or containing an invalid `X-Devaulty-Internal-Token` header.", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) - ), - @ApiResponse( - responseCode = "500", - description = "Internal Server Error. Unexpected error occurred while querying GitHub API or reading local system properties.", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) - ) - }) - @GetMapping("/check") - ResponseEntity checkUpdates(); - - @Operation( - summary = "Stream update download and trigger native OS installation", - description = "Establishes a Server-Sent Events (SSE) HTTP stream (`text/event-stream`) to download the latest installer binary into the local platform temp directory (`~/.config/devaulty/temp` on Linux, `%LOCALAPPDATA%\\devaulty\\temp` on Windows, `~/Library/Caches/devaulty/temp` on macOS). Emits real-time progress events (`DOWNLOADING` status with percentage and byte counts). Once download reaches 100%, transitions to `INSTALLING` status, launches the native OS installer/restart script detached from the current process, and gracefully shuts down the running Devaulty application instance. " + - "Backed by a blocking download pipeline (JDK `HttpClient`) executed on a dedicated background thread, decoupled from the servlet request thread pool." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Server-Sent Events (SSE) stream established successfully. Emits real-time `UpdateDownloadProgressResponse` events until the stream completes or errors out.", - content = @Content(mediaType = MediaType.TEXT_EVENT_STREAM_VALUE, schema = @Schema(implementation = UpdateDownloadProgressResponse.class)) - ), - @ApiResponse( - responseCode = "403", - description = "Forbidden. Request missing or containing an invalid `X-Devaulty-Internal-Token` header.", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) - ), - @ApiResponse( - responseCode = "500", - description = "Internal Server Error. Streaming network failure, file I/O error during installer save, or native process execution failure. Reported as a terminal SSE error event, since HTTP headers are already committed by the time streaming begins.", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) - ) - }) - @PostMapping(value = "/download-and-install", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - SseEmitter downloadUpdate(); - - @Operation( - summary = "Get current running application version", - description = "Returns the local application version (`app.version`) instantly from DevaultyProperties without querying external services." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Returns current application version string.", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = CurrentVersionResponse.class)) - ), - @ApiResponse( - responseCode = "403", - description = "Forbidden. Request missing or containing an invalid `X-Devaulty-Internal-Token` header.", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) - ) - }) - @GetMapping("/current-app-version") - ResponseEntity getAppInfo(); -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseController.java deleted file mode 100644 index 29cbd8a..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseController.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.devaulty.backend.adapter.in.web.release; - -import com.devaulty.backend.adapter.in.web.common.BackgroundTaskRunner; -import com.devaulty.backend.adapter.in.web.release.dto.AppUpdateInfoResponse; -import com.devaulty.backend.adapter.in.web.release.dto.CurrentVersionResponse; -import com.devaulty.backend.application.port.in.release.CheckForUpdatesUseCase; -import com.devaulty.backend.application.port.in.release.DownloadUpdateUseCase; -import com.devaulty.backend.application.port.in.release.GetCurrentVersionUseCase; -import com.devaulty.backend.application.port.in.release.UpdateProgressInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.io.IOException; -import java.util.concurrent.atomic.AtomicBoolean; - -@RestController -@RequestMapping("/api/v1/releases") -public class ReleaseController implements ReleaseApi { - - private static final Logger logger = LoggerFactory.getLogger(ReleaseController.class); - - private final CheckForUpdatesUseCase checkForUpdatesUseCase; - private final DownloadUpdateUseCase downloadUpdateUseCase; - private final GetCurrentVersionUseCase getCurrentVersionUseCase; - private final ReleaseWebMapper releaseWebMapper; - private final BackgroundTaskRunner backgroundTaskRunner; - - private static final long SSE_TIMEOUT_MS = 20 * 60 * 1000L; - - public ReleaseController(CheckForUpdatesUseCase checkForUpdatesUseCase, DownloadUpdateUseCase downloadUpdateUseCase, GetCurrentVersionUseCase getCurrentVersionUseCase, ReleaseWebMapper releaseWebMapper, BackgroundTaskRunner backgroundTaskRunner) { - this.checkForUpdatesUseCase = checkForUpdatesUseCase; - this.downloadUpdateUseCase = downloadUpdateUseCase; - this.getCurrentVersionUseCase = getCurrentVersionUseCase; - this.releaseWebMapper = releaseWebMapper; - this.backgroundTaskRunner = backgroundTaskRunner; - } - - @Override - @GetMapping("/check") - public ResponseEntity checkUpdates() { - return ResponseEntity.ok(releaseWebMapper.toAppUpdateInfoResponse(checkForUpdatesUseCase.execute())); - } - - @Override - @PostMapping("/download-and-install") - public SseEmitter downloadUpdate() { - SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); - AtomicBoolean isCancelled = new AtomicBoolean(false); - - emitter.onCompletion(() -> isCancelled.set(true)); - emitter.onTimeout(() -> isCancelled.set(true)); - emitter.onError(ex -> isCancelled.set(true)); - - backgroundTaskRunner.run(() -> runDownload(emitter, isCancelled)); - return emitter; - } - - @Override - @GetMapping("/current-app-version") - public ResponseEntity getAppInfo() { - return ResponseEntity.ok(new CurrentVersionResponse(getCurrentVersionUseCase.execute())); - } - - private void runDownload(SseEmitter emitter, AtomicBoolean isCancelled) { - try { - downloadUpdateUseCase.execute(progress -> sendProgress(emitter, progress, isCancelled)); - if (!isCancelled.get()) { - emitter.complete(); - } - } catch (Exception ex) { - if (!isCancelled.get()) { - emitter.completeWithError(ex); - } - } - } - - private void sendProgress(SseEmitter emitter, UpdateProgressInfo progress, AtomicBoolean isCancelled) { - if (isCancelled.get()) { - throw new IllegalStateException("Client disconnected or SSE stream cancelled."); - } - try { - emitter.send(releaseWebMapper.toProgressResponse(progress)); - } catch (IOException | IllegalStateException e) { - isCancelled.set(true); - logger.debug("Failed to send SSE progress, client likely disconnected: {}", e.getMessage()); - } - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseWebMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseWebMapper.java deleted file mode 100644 index 82a0b35..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseWebMapper.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.devaulty.backend.adapter.in.web.release; - -import com.devaulty.backend.adapter.in.web.release.dto.AppUpdateInfoResponse; -import com.devaulty.backend.adapter.in.web.release.dto.UpdateDownloadProgressResponse; -import com.devaulty.backend.application.port.in.release.AppUpdateInfo; -import com.devaulty.backend.application.port.in.release.UpdateProgressInfo; -import org.mapstruct.Mapper; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; - -@Mapper(componentModel = "spring") -public interface ReleaseWebMapper { - - AppUpdateInfoResponse toAppUpdateInfoResponse(AppUpdateInfo appUpdateInfo); - - UpdateDownloadProgressResponse toProgressResponse(UpdateProgressInfo info); - - default LocalDateTime toLocalDateTime(Instant instant) { - if(instant == null) return null; - return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/AppUpdateInfoResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/AppUpdateInfoResponse.java deleted file mode 100644 index 311b2c9..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/AppUpdateInfoResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.adapter.in.web.release.dto; - -import java.time.LocalDateTime; - -public record AppUpdateInfoResponse( - boolean updateAvailable, - String currentVersion, - String latestVersion, - String releaseTitle, - String releaseNotes, - String downloadUrl, - Long downloadSizeInBytes, - LocalDateTime publishedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/CurrentVersionResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/CurrentVersionResponse.java deleted file mode 100644 index 1b1f4c5..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/CurrentVersionResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.devaulty.backend.adapter.in.web.release.dto; - -public record CurrentVersionResponse( - String actualVersion -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/UpdateDownloadProgressResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/UpdateDownloadProgressResponse.java deleted file mode 100644 index 13fb913..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/UpdateDownloadProgressResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.devaulty.backend.adapter.in.web.release.dto; - -import com.devaulty.backend.application.port.in.release.enums.UpdateStatus; - -public record UpdateDownloadProgressResponse( - UpdateStatus status, - int percentage, - long downloadedBytes, - long totalBytes, - String errorMessage -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityApi.java deleted file mode 100644 index 6b79c58..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityApi.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.devaulty.backend.adapter.in.web.security; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import com.devaulty.backend.adapter.in.web.security.dto.MasterPasswordRequest; -import com.devaulty.backend.application.port.in.security.SessionStatus; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@Tag(name = "Security", description = "Endpoints for master password configuration, vault locking, and unlocking") -public interface SecurityApi { - - @Schema(name = "MasterPasswordRequest", description = "Payload containing the master password") - record MasterPasswordRequestDoc( - @Schema(type = "string", minLength = 8, maxLength = 255, description = "The master password", example = "mySuperSecurePassword123") - String masterPassword - ) {} - - @Operation( - summary = "Configure master password", - description = "Initializes the master password for the vault if it has not been configured yet." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Master password configured successfully" - ), - @ApiResponse( - responseCode = "400", - description = "Validation failed (password too short or null)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "409", - description = "Master password is already configured", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping("/master-password") - ResponseEntity setupMasterPassword( - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "Master password details", - required = true, - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = MasterPasswordRequestDoc.class) - ) - ) - @RequestBody @Valid MasterPasswordRequest request - ); - - @Operation( - summary = "Check master password setup status", - description = "Checks if the master password has not been configured yet (returns true if setup is required, false if already configured)." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Status check completed successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(type = "boolean") - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/master-password/required-status") - ResponseEntity checkMasterPasswordSetup(); - - @Operation( - summary = "Get vault session status", - description = "Retrieves the status of the cryptographic vault session (active status and remaining seconds)." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Session status retrieved successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = SessionStatus.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/vault/status") - ResponseEntity getSessionStatus(); - - @Operation( - summary = "Unlock the vault", - description = "Unlocks the vault with the master password, activating the cryptographic session in RAM." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Vault unlocked successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(type = "boolean", example = "true") - ) - ), - @ApiResponse( - responseCode = "400", - description = "Validation failed (password too short or null)", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "401", - description = "Invalid master password", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "403", - description = "Master password has not been configured yet", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping("/vault/unlock") - ResponseEntity unlockVault( - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "Master password details", - required = true, - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = MasterPasswordRequestDoc.class) - ) - ) - @RequestBody @Valid MasterPasswordRequest request - ); - - @Operation( - summary = "Lock the vault", - description = "Locks the vault, clearing all cryptographic keys from memory." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Vault locked successfully (session cleared)" - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping("/vault/lock") - ResponseEntity lockVault(); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityController.java deleted file mode 100644 index a4f7f92..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityController.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.devaulty.backend.adapter.in.web.security; - -import com.devaulty.backend.adapter.in.web.security.dto.MasterPasswordRequest; -import com.devaulty.backend.application.port.in.security.*; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.Arrays; - -@RestController -@RequestMapping("/api/v1/security") -public class SecurityController implements SecurityApi{ - - private final SetupMasterPasswordUseCase setupMasterPasswordUseCase; - private final CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - private final LockVaultUseCase lockVaultUseCase; - private final UnlockVaultUseCase unlockVaultUseCase; - private final GetSessionStatusUseCase getSessionStatusUseCase; - - public SecurityController(SetupMasterPasswordUseCase setupMasterPasswordUseCase, CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase, LockVaultUseCase lockVaultUseCase, UnlockVaultUseCase unlockVaultUseCase, GetSessionStatusUseCase getSessionStatusUseCase) { - this.setupMasterPasswordUseCase = setupMasterPasswordUseCase; - this.checkMasterPasswordSetupUseCase = checkMasterPasswordSetupUseCase; - this.lockVaultUseCase = lockVaultUseCase; - this.unlockVaultUseCase = unlockVaultUseCase; - this.getSessionStatusUseCase = getSessionStatusUseCase; - } - - @Override - @PostMapping("/master-password") - public ResponseEntity setupMasterPassword(@RequestBody @Valid MasterPasswordRequest request) { - setupMasterPasswordUseCase.execute(request.masterPassword()); - Arrays.fill(request.masterPassword(),'\0'); - return ResponseEntity.noContent().build(); - } - - @Override - @GetMapping("/master-password/required-status") - public ResponseEntity checkMasterPasswordSetup(){ - return ResponseEntity.ok(checkMasterPasswordSetupUseCase.isSetupRequired()); - } - - @Override - @GetMapping("/vault/status") - public ResponseEntity getSessionStatus(){ - return ResponseEntity.ok(getSessionStatusUseCase.execute()); - } - - @Override - @PostMapping("/vault/unlock") - public ResponseEntity unlockVault(@RequestBody @Valid MasterPasswordRequest request){ - boolean result = unlockVaultUseCase.execute(request.masterPassword()); - Arrays.fill(request.masterPassword(),'\0'); - return ResponseEntity.ok(result); - } - - @Override - @PostMapping("/vault/lock") - public ResponseEntity lockVault(){ - lockVaultUseCase.execute(); - return ResponseEntity.noContent().build(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/dto/MasterPasswordRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/dto/MasterPasswordRequest.java deleted file mode 100644 index e95ffb2..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/dto/MasterPasswordRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.devaulty.backend.adapter.in.web.security.dto; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record MasterPasswordRequest( - @NotNull(message = "Password cannot be null") - @Size(min = 8, max = 255, message = "Password must be between 8 and 255 characters") - char[] masterPassword -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/SnippetApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/SnippetApi.java deleted file mode 100644 index 504db75..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/SnippetApi.java +++ /dev/null @@ -1,213 +0,0 @@ -package com.devaulty.backend.adapter.in.web.snippet; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import com.devaulty.backend.adapter.in.web.snippet.dto.CreateSnippetRequest; -import com.devaulty.backend.adapter.in.web.snippet.dto.SnippetSummaryResponse; -import com.devaulty.backend.adapter.in.web.snippet.dto.SnippetViewResponse; -import com.devaulty.backend.adapter.in.web.snippet.dto.UpdateSnippetRequest; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@Tag(name = "Snippets", description = "Endpoints for managing snippets within a project") -public interface SnippetApi { - - @Operation( - summary = "Create a new snippet", - description = "Creates a new snippet associated with the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "201", - description = "Snippet created successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = SnippetViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping - ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateSnippetRequest request - ); - - @Operation( - summary = "Get all snippets by project", - description = "Retrieves a paginated list of all snippets belonging to the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved list of snippets" - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping - ResponseEntity> getAllByProject( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ); - - @Operation( - summary = "Get snippet by ID", - description = "Retrieves a single snippet by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved the snippet", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = SnippetViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Snippet not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/{snippetId}") - ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID snippetId - ); - - @Operation( - summary = "Update a snippet", - description = "Updates an existing snippet by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Snippet updated successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = SnippetViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Snippet not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{snippetId}") - ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID snippetId, - @RequestBody @Valid UpdateSnippetRequest request - ); - - @Operation( - summary = "Delete a snippet", - description = "Deletes an existing snippet by its unique identifier and its project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Snippet deleted successfully" - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @DeleteMapping("/{snippetId}") - ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID snippetId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/SnippetController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/SnippetController.java deleted file mode 100644 index be942aa..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/SnippetController.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.devaulty.backend.adapter.in.web.snippet; - -import com.devaulty.backend.adapter.in.web.snippet.dto.CreateSnippetRequest; -import com.devaulty.backend.adapter.in.web.snippet.dto.SnippetSummaryResponse; -import com.devaulty.backend.adapter.in.web.snippet.dto.SnippetViewResponse; -import com.devaulty.backend.adapter.in.web.snippet.dto.UpdateSnippetRequest; -import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper; -import com.devaulty.backend.application.port.in.snippet.*; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemsUseCase; -import com.devaulty.backend.domain.model.Snippet; -import com.devaulty.backend.domain.model.Tag; -import jakarta.validation.Valid; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/projects/{projectId}/snippets") -public class SnippetController implements SnippetApi{ - - private final CreateSnippetUseCase createSnippetUseCase; - private final GetAllSnippetsByProjectUseCase getAllSnippetsByProjectUseCase; - private final GetSnippetByIdUseCase getSnippetByIdUseCase; - private final UpdateSnippetUseCase updateSnippetUseCase; - private final DeleteSnippetUseCase deleteSnippetUseCase; - private final GetTagsForItemUseCase getTagsForItemUseCase; - private final GetTagsForItemsUseCase getTagsForItemsUseCase; - private final SnippetWebMapper webMapper; - private final UriLocationBuilderHelper uriLocationBuilderHelper; - - private static final String ITEM_TYPE = "snippet"; - - public SnippetController(CreateSnippetUseCase createSnippetUseCase, GetAllSnippetsByProjectUseCase getAllSnippetsByProjectUseCase, GetSnippetByIdUseCase getSnippetByIdUseCase, UpdateSnippetUseCase updateSnippetUseCase, DeleteSnippetUseCase deleteSnippetUseCase, GetTagsForItemUseCase getTagsForItemUseCase, GetTagsForItemsUseCase getTagsForItemsUseCase, SnippetWebMapper webMapper, UriLocationBuilderHelper uriLocationBuilderHelper) { - this.createSnippetUseCase = createSnippetUseCase; - this.getAllSnippetsByProjectUseCase = getAllSnippetsByProjectUseCase; - this.getSnippetByIdUseCase = getSnippetByIdUseCase; - this.updateSnippetUseCase = updateSnippetUseCase; - this.deleteSnippetUseCase = deleteSnippetUseCase; - this.getTagsForItemUseCase = getTagsForItemUseCase; - this.getTagsForItemsUseCase = getTagsForItemsUseCase; - this.webMapper = webMapper; - this.uriLocationBuilderHelper = uriLocationBuilderHelper; - } - - @Override - @PostMapping - public ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateSnippetRequest request - - ) { - CreateSnippetCommand command = webMapper.toCreateSnippetCommand(request, projectId); - Snippet snippet = createSnippetUseCase.execute(command); - URI location = uriLocationBuilderHelper.buildLocationUri(snippet.getId()); - return ResponseEntity.created(location).body(webMapper.toViewResponse(snippet, List.of())); - } - - @Override - @GetMapping - public ResponseEntity> getAllByProject ( - @PathVariable UUID projectId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ){ - Page snippets = getAllSnippetsByProjectUseCase.execute(projectId, page, size); - List ids = snippets.getContent().stream().map(Snippet::getId).toList(); - Map> tagsByItem = getTagsForItemsUseCase.execute(ITEM_TYPE, projectId, ids); - Page response = snippets.map(s -> - webMapper.toSummaryResponse(s, tagsByItem.getOrDefault(s.getId(), List.of()))); - return ResponseEntity.ok(response); - } - - @Override - @GetMapping("/{snippetId}") - public ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID snippetId - ){ - Snippet snippet = getSnippetByIdUseCase.execute(projectId, snippetId); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, snippetId); - return ResponseEntity.ok(webMapper.toViewResponse(snippet, tags)); - } - - @Override - @PatchMapping("/{snippetId}") - public ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID snippetId, - @RequestBody @Valid UpdateSnippetRequest request - ){ - UpdateSnippetCommand command = webMapper.toUpdateSnippetCommand(request, projectId, snippetId); - Snippet snippet = updateSnippetUseCase.execute(command); - List tags = getTagsForItemUseCase.execute(ITEM_TYPE, projectId, snippetId); - return ResponseEntity.ok(webMapper.toViewResponse(snippet, tags)); - } - - @Override - @DeleteMapping("/{snippetId}") - public ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID snippetId - ){ - deleteSnippetUseCase.execute(projectId, snippetId); - return ResponseEntity.noContent().build(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/SnippetWebMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/SnippetWebMapper.java deleted file mode 100644 index 8aea277..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/SnippetWebMapper.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.devaulty.backend.adapter.in.web.snippet; - -import com.devaulty.backend.adapter.in.web.snippet.dto.CreateSnippetRequest; -import com.devaulty.backend.adapter.in.web.snippet.dto.SnippetSummaryResponse; -import com.devaulty.backend.adapter.in.web.snippet.dto.SnippetViewResponse; -import com.devaulty.backend.adapter.in.web.snippet.dto.UpdateSnippetRequest; -import com.devaulty.backend.adapter.in.web.tag.TagWebMapper; -import com.devaulty.backend.application.port.in.snippet.CreateSnippetCommand; -import com.devaulty.backend.application.port.in.snippet.UpdateSnippetCommand; -import com.devaulty.backend.domain.model.Snippet; -import com.devaulty.backend.domain.model.Tag; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -import java.util.List; -import java.util.UUID; - -@Mapper(componentModel = "spring", uses = TagWebMapper.class) -public interface SnippetWebMapper { - - @Mapping(target = "tags", source = "tags") - SnippetViewResponse toViewResponse(Snippet snippet, List tags); - - @Mapping(target = "tags", source = "tags") - SnippetSummaryResponse toSummaryResponse(Snippet snippet, List tags); - - @Mapping(target = "projectId", source = "projectId") - CreateSnippetCommand toCreateSnippetCommand(CreateSnippetRequest request, UUID projectId); - - @Mapping(target = "projectId", source = "projectId") - @Mapping(target = "id", source = "snippetId") - UpdateSnippetCommand toUpdateSnippetCommand(UpdateSnippetRequest request, UUID projectId, UUID snippetId); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/CreateSnippetRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/CreateSnippetRequest.java deleted file mode 100644 index 240fa71..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/CreateSnippetRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.devaulty.backend.adapter.in.web.snippet.dto; - -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record CreateSnippetRequest( - @NotBlank - @Size(min = 2, max = 255, message = "Title must be between 2 and 255 characters") - String title, - String description, - @NotBlank - String content, - @NotNull - SnippetLanguage language, - @NotNull - SnippetType snippetType -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/SnippetSummaryResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/SnippetSummaryResponse.java deleted file mode 100644 index 0b2d89e..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/SnippetSummaryResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.devaulty.backend.adapter.in.web.snippet.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -public record SnippetSummaryResponse( - UUID id, - UUID projectId, - String title, - String description, - SnippetLanguage language, - SnippetType snippetType, - @Schema(description = "List of tags associated with this snippet") - List tags, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/SnippetViewResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/SnippetViewResponse.java deleted file mode 100644 index 89fa177..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/SnippetViewResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.devaulty.backend.adapter.in.web.snippet.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -public record SnippetViewResponse( - UUID id, - UUID projectId, - String title, - String description, - String content, - SnippetLanguage language, - SnippetType snippetType, - @Schema(description = "List of tags associated with this snippet") - List tags, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/UpdateSnippetRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/UpdateSnippetRequest.java deleted file mode 100644 index 700c695..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/snippet/dto/UpdateSnippetRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.devaulty.backend.adapter.in.web.snippet.dto; - -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record UpdateSnippetRequest( - @Pattern(regexp = "(?s).*\\S.*", message = "Title must not be blank") - @Size(min = 2, max = 255, message = "Title must be between 2 and 255 characters") - String title, - String description, - String content, - SnippetLanguage language, - SnippetType snippetType -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/TagApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/TagApi.java deleted file mode 100644 index 58d0f27..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/TagApi.java +++ /dev/null @@ -1,250 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import com.devaulty.backend.adapter.in.web.tag.dto.CreateTagRequest; -import com.devaulty.backend.adapter.in.web.tag.dto.TagViewResponse; -import com.devaulty.backend.adapter.in.web.tag.dto.UpdateTagRequest; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; - -@Tag(name = "Tags", description = "Endpoints for managing tags within a project") -public interface TagApi { - - @Operation( - summary = "Create a new tag", - description = "Creates a new tag associated with the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "201", - description = "Tag created successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = TagViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PostMapping - ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateTagRequest request - ); - - @Operation( - summary = "Get all tags by project", - description = "Retrieves a list of all tags belonging to the specified project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved tags list", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = TagViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping - ResponseEntity> getAll( - @PathVariable UUID projectId - ); - - @Operation( - summary = "Get tag by ID", - description = "Retrieves a single tag by its ID and project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Tag retrieved successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = TagViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Tag not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/{tagId}") - ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID tagId - ); - - @Operation( - summary = "Search tag by name", - description = "Searches for tags belonging to the project that contain the given name string." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Successfully performed search", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = TagViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @GetMapping("/search") - ResponseEntity> searchByName( - @PathVariable UUID projectId, - @RequestParam String name - ); - - @Operation( - summary = "Update tag", - description = "Updates the fields of an existing tag within a project." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Tag updated successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = TagViewResponse.class) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request body or validation constraints failed", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project or Tag not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PatchMapping("/{tagId}") - ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID tagId, - @RequestBody @Valid UpdateTagRequest request - ); - - @Operation( - summary = "Delete tag", - description = "Deletes a tag by its ID and project ID." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Tag deleted successfully" - ), - @ApiResponse( - responseCode = "404", - description = "Project or Tag not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @DeleteMapping("/{tagId}") - ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID tagId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/TagController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/TagController.java deleted file mode 100644 index d624628..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/TagController.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag; - -import com.devaulty.backend.adapter.in.web.tag.dto.CreateTagRequest; -import com.devaulty.backend.adapter.in.web.tag.dto.TagViewResponse; -import com.devaulty.backend.adapter.in.web.tag.dto.UpdateTagRequest; -import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper; -import com.devaulty.backend.application.port.in.tag.*; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.net.URI; -import java.util.List; -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/projects/{projectId}/tags") -public class TagController implements TagApi { - - private final CreateTagUseCase createTagUseCase; - private final GetAllTagsByProjectUseCase getAllTagsByProjectUseCase; - private final GetTagByIdUseCase getTagByIdUseCase; - private final SearchTagByNameUseCase searchTagByNameUseCase; - private final UpdateTagUseCase updateTagUseCase; - private final DeleteTagUseCase deleteTagUseCase; - private final TagWebMapper mapper; - private final UriLocationBuilderHelper uriLocationBuilderHelper; - - public TagController(CreateTagUseCase createTagUseCase, GetAllTagsByProjectUseCase getAllTagsByProjectUseCase, GetTagByIdUseCase getTagByIdUseCase, SearchTagByNameUseCase searchTagByNameUseCase, UpdateTagUseCase updateTagUseCase, DeleteTagUseCase deleteTagUseCase, TagWebMapper mapper, UriLocationBuilderHelper uriLocationBuilderHelper) { - this.createTagUseCase = createTagUseCase; - this.getAllTagsByProjectUseCase = getAllTagsByProjectUseCase; - this.getTagByIdUseCase = getTagByIdUseCase; - this.searchTagByNameUseCase = searchTagByNameUseCase; - this.updateTagUseCase = updateTagUseCase; - this.deleteTagUseCase = deleteTagUseCase; - this.mapper = mapper; - this.uriLocationBuilderHelper = uriLocationBuilderHelper; - } - - @Override - @PostMapping - public ResponseEntity create( - @PathVariable UUID projectId, - @RequestBody @Valid CreateTagRequest request) - { - CreateTagCommand command = mapper.toCreateTagCommand(request, projectId); - TagViewResponse response = mapper.toViewResponse(createTagUseCase.execute(command)); - URI location = uriLocationBuilderHelper.buildLocationUri(response.id()); - return ResponseEntity.created(location).body(response); - } - - @Override - @GetMapping - public ResponseEntity> getAll( - @PathVariable UUID projectId - ){ - List responses = getAllTagsByProjectUseCase.execute(projectId) - .stream() - .map(mapper::toViewResponse) - .toList(); - return ResponseEntity.ok(responses); - } - - @Override - @GetMapping("/{tagId}") - public ResponseEntity getById( - @PathVariable UUID projectId, - @PathVariable UUID tagId - ){ - TagViewResponse response = mapper.toViewResponse(getTagByIdUseCase.execute(projectId, tagId)); - return ResponseEntity.ok(response); - } - - @Override - @GetMapping("/search") - public ResponseEntity> searchByName( - @PathVariable UUID projectId, - @RequestParam String name) - { - List responses = searchTagByNameUseCase.execute(projectId, name) - .stream() - .map(mapper::toViewResponse) - .toList(); - return ResponseEntity.ok(responses); - } - - @Override - @PatchMapping("/{tagId}") - public ResponseEntity update( - @PathVariable UUID projectId, - @PathVariable UUID tagId, - @RequestBody @Valid UpdateTagRequest request - ){ - UpdateTagCommand command = mapper.toUpdateTagCommand(request, projectId, tagId); - TagViewResponse response = mapper.toViewResponse(updateTagUseCase.execute(command)); - return ResponseEntity.ok(response); - } - - @Override - @DeleteMapping("/{tagId}") - public ResponseEntity delete( - @PathVariable UUID projectId, - @PathVariable UUID tagId - ){ - deleteTagUseCase.execute(projectId, tagId); - return ResponseEntity.noContent().build(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/TagWebMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/TagWebMapper.java deleted file mode 100644 index a15f7f2..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/TagWebMapper.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag; - -import com.devaulty.backend.adapter.in.web.tag.dto.CreateTagRequest; -import com.devaulty.backend.adapter.in.web.tag.dto.TagSummaryResponse; -import com.devaulty.backend.adapter.in.web.tag.dto.TagViewResponse; -import com.devaulty.backend.adapter.in.web.tag.dto.UpdateTagRequest; -import com.devaulty.backend.application.port.in.tag.CreateTagCommand; -import com.devaulty.backend.application.port.in.tag.UpdateTagCommand; -import com.devaulty.backend.domain.model.Tag; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -import java.util.UUID; - -@Mapper(componentModel = "spring") -public interface TagWebMapper { - - TagViewResponse toViewResponse(Tag tag); - - TagSummaryResponse toSummaryResponse(Tag tag); - - @Mapping(target = "projectId", source = "projectId") - CreateTagCommand toCreateTagCommand(CreateTagRequest request, UUID projectId); - - @Mapping(target = "projectId", source = "projectId") - @Mapping(target = "id", source = "tagId") - UpdateTagCommand toUpdateTagCommand(UpdateTagRequest request, UUID projectId, UUID tagId); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/CreateTagRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/CreateTagRequest.java deleted file mode 100644 index b30cdd8..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/CreateTagRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record CreateTagRequest( - @NotBlank(message = "Name cannot be blank") - @Size(min = 1, max = 40, message = "Name must be between 1 and 40 characters") - String name, - @Pattern(regexp = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", message = "Color must be a valid hex code (e.g. #1A1A2E or #FFF)") - String color -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/TagSummaryResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/TagSummaryResponse.java deleted file mode 100644 index 00ffdab..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/TagSummaryResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag.dto; - -import java.util.UUID; - -public record TagSummaryResponse( - UUID id, - String name, - String color -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/TagViewResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/TagViewResponse.java deleted file mode 100644 index 6fa4efc..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/TagViewResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag.dto; - -import java.time.LocalDateTime; -import java.util.UUID; - -public record TagViewResponse( - UUID id, - UUID projectId, - String name, - String color, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/UpdateTagRequest.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/UpdateTagRequest.java deleted file mode 100644 index 108d919..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/dto/UpdateTagRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag.dto; - -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record UpdateTagRequest( - @Pattern(regexp = "(?s).*\\S.*", message = "Name must not be blank") - @Size(min = 1, max = 40, message = "Name must be between 1 and 40 characters") - String name, - - @Pattern(regexp = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", message = "Color must be a valid hex code (e.g. #1A1A2E or #FFF)") - String color -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/item/ItemTagApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/item/ItemTagApi.java deleted file mode 100644 index d79da78..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/item/ItemTagApi.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag.item; - -import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@Tag(name = "Item Tags", description = "Endpoints for associating and disassociating tags with project items") -public interface ItemTagApi { - - @Operation( - summary = "Associate a tag with an item", - description = "Associates the specified tag ID with a project item (snippet, credential, link, note, problem)." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Association completed (returns 204 No Content)", - content = @Content - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request or unsupported item type", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project, Item, or Tag not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @PutMapping("/{tagId}") - ResponseEntity associate( - @PathVariable UUID projectId, - @PathVariable String itemType, - @PathVariable UUID itemId, - @PathVariable UUID tagId - ); - - @Operation( - summary = "Disassociate a tag from an item", - description = "Removes the association between the specified tag ID and a project item." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "204", - description = "Association removed successfully", - content = @Content - ), - @ApiResponse( - responseCode = "400", - description = "Invalid request or unsupported item type", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Project, Item, or Tag association not found", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ), - @ApiResponse( - responseCode = "500", - description = "Internal server error occurred", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorResponse.class) - ) - ) - }) - @DeleteMapping("/{tagId}") - ResponseEntity disassociate( - @PathVariable UUID projectId, - @PathVariable String itemType, - @PathVariable UUID itemId, - @PathVariable UUID tagId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/item/ItemTagController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/item/ItemTagController.java deleted file mode 100644 index 1ef3089..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/tag/item/ItemTagController.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag.item; - -import com.devaulty.backend.application.port.in.tag.item.AssociateTagToItemUseCase; -import com.devaulty.backend.application.port.in.tag.item.RemoveTagFromItemUseCase; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/projects/{projectId}/items/{itemType}/{itemId}/tags") -public class ItemTagController implements ItemTagApi{ - - private final AssociateTagToItemUseCase associateTagToItemUseCase; - private final RemoveTagFromItemUseCase removeTagFromItemUseCase; - - public ItemTagController(AssociateTagToItemUseCase associateTagToItemUseCase, RemoveTagFromItemUseCase removeTagFromItemUseCase) { - this.associateTagToItemUseCase = associateTagToItemUseCase; - this.removeTagFromItemUseCase = removeTagFromItemUseCase; - } - - @Override - @PutMapping("/{tagId}") - public ResponseEntity associate( - @PathVariable UUID projectId, - @PathVariable String itemType, - @PathVariable UUID itemId, - @PathVariable UUID tagId - ){ - associateTagToItemUseCase.execute(projectId, itemType, itemId, tagId); - return ResponseEntity.noContent().build(); - } - - @Override - @DeleteMapping("/{tagId}") - public ResponseEntity disassociate( - @PathVariable UUID projectId, - @PathVariable String itemType, - @PathVariable UUID itemId, - @PathVariable UUID tagId - ){ - removeTagFromItemUseCase.execute(projectId, itemType, itemId, tagId); - return ResponseEntity.noContent().build(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/crypto/Argon2KeyDeriverAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/crypto/Argon2KeyDeriverAdapter.java deleted file mode 100644 index 96ecbc5..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/crypto/Argon2KeyDeriverAdapter.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.devaulty.backend.adapter.out.crypto; - -import com.devaulty.backend.application.port.out.security.KeyDerivationPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.bouncycastle.crypto.generators.Argon2BytesGenerator; -import org.bouncycastle.crypto.params.Argon2Parameters; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; - -@Component -public class Argon2KeyDeriverAdapter implements KeyDerivationPort { - - /** - * Derives a secure 256-bit AES key from the master password using the memory-hard Argon2id KDF. - *

- * This method configures Argon2id with 3 iterations, 64MB of memory, and 2 parallel threads - * to significantly elevate the computational cost of brute-force and hardware-accelerated (GPU/ASIC) attacks. - * The resulting key bytes are safely encapsulated into a {@link SecretKey} and dispatched to the - * {@link MasterKeySessionPort} to back subsequent cryptographic operations without repetitive key re-derivation. - *

- * - * @param password the plain-text master password provided by the user - * @param salt the cryptographically secure global application salt - */ - - @Override - public SecretKey deriveKey(char[] password, byte[] salt) { - Argon2BytesGenerator gen = new Argon2BytesGenerator(); - Argon2Parameters.Builder builder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) - .withVersion(Argon2Parameters.ARGON2_VERSION_13) - .withIterations(3) - .withMemoryAsKB(65536) - .withParallelism(2) - .withSalt(salt); - - gen.init(builder.build()); - byte[] keyBytes = new byte[32]; - - ByteBuffer buffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(password)); - byte[] passwordBytes = new byte[buffer.remaining()]; - buffer.get(passwordBytes); - - try { - gen.generateBytes(passwordBytes, keyBytes, 0, keyBytes.length); - return new SecretKeySpec(keyBytes, "AES"); - } finally { - Arrays.fill(passwordBytes, (byte) 0); - Arrays.fill(keyBytes, (byte) 0); - } - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/crypto/BouncyCastleCryptoAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/crypto/BouncyCastleCryptoAdapter.java deleted file mode 100644 index 93855b6..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/crypto/BouncyCastleCryptoAdapter.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.devaulty.backend.adapter.out.crypto; - -import com.devaulty.backend.application.exception.CryptoException; -import com.devaulty.backend.application.exception.InvalidMasterPasswordException; -import com.devaulty.backend.application.port.out.security.CryptoPort; -import com.devaulty.backend.application.port.out.security.dto.CryptoResultDto; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.springframework.stereotype.Component; - -import javax.crypto.AEADBadTagException; -import javax.crypto.Cipher; -import javax.crypto.SecretKey; -import javax.crypto.spec.GCMParameterSpec; -import java.security.SecureRandom; -import java.security.Security; -import java.util.Arrays; - -@Component -public class BouncyCastleCryptoAdapter implements CryptoPort { - - private static final String ALGORITHM = "AES/GCM/NoPadding"; - private static final int TAG_BIT_LENGTH = 128; // 16 bytes - private static final int IV_BYTE_LENGTH = 12; // 12 bytes - - private final SecureRandom secureRandom; - - public BouncyCastleCryptoAdapter() { - // Registers Bouncy Castle as a security provider in the Java Runtime Environment - Security.addProvider(new BouncyCastleProvider()); - this.secureRandom = new SecureRandom(); - } - - @Override - public CryptoResultDto encrypt(byte[] plainData, SecretKey secretKey, byte[] aad) { - try { - // 1. Generates a unique random 12-byte IV for THIS encryption operation - byte[] iv = new byte[IV_BYTE_LENGTH]; - secureRandom.nextBytes(iv); - - // 2. Initializes the Bouncy Castle Cipher in ENCRYPT mode - Cipher cipher = Cipher.getInstance(ALGORITHM, "BC"); - GCMParameterSpec parameterSpec = new GCMParameterSpec(TAG_BIT_LENGTH, iv); - cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec); - if (aad != null && aad.length > 0) { - cipher.updateAAD(aad); - } - - // 3. Encrypts the plaintext - byte[] cipherTextWithTag = cipher.doFinal(plainData); - - // 4. Separates the ciphertext and the authentication tag (the last 16 bytes). - int cipherTextLength = cipherTextWithTag.length - 16; - byte[] cipherText = Arrays.copyOfRange(cipherTextWithTag, 0, cipherTextLength); - byte[] authTag = Arrays.copyOfRange(cipherTextWithTag, cipherTextLength, cipherTextWithTag.length); - - return new CryptoResultDto(cipherText, iv, authTag); - - } catch (Exception e) { - throw new CryptoException("Critical failure when attempting to encrypt payload", e); - } - } - - @Override - public byte[] decrypt(byte[] cipherText, byte[] iv, byte[] authTag, SecretKey secretKey, byte[] aad) { - try { - // 1. Recombines the ciphertext and authentication tag into a single byte array - byte[] cipherTextWithTag = new byte[cipherText.length + authTag.length]; - System.arraycopy(cipherText, 0, cipherTextWithTag, 0, cipherText.length); - System.arraycopy(authTag, 0, cipherTextWithTag, cipherText.length, authTag.length); - - // 2. Initializes the Bouncy Castle Cipher in DECRYPT mode - Cipher cipher = Cipher.getInstance(ALGORITHM, "BC"); - GCMParameterSpec parameterSpec = new GCMParameterSpec(TAG_BIT_LENGTH, iv); - cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec); - if (aad != null && aad.length > 0) { - cipher.updateAAD(aad); - } - - // 3. Performs the reverse operation and verifies the authentication tag - return cipher.doFinal(cipherTextWithTag); - - } catch (AEADBadTagException e) { - throw new InvalidMasterPasswordException("Incorrect master password or corrupted data (invalid authentication tag)"); - } catch (Exception e) { - throw new CryptoException("Critical failure when attempting to decrypt payload", e); - } - } - -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/crypto/MasterKeySessionHolder.java b/backend/src/main/java/com/devaulty/backend/adapter/out/crypto/MasterKeySessionHolder.java deleted file mode 100644 index cc6503f..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/crypto/MasterKeySessionHolder.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.devaulty.backend.adapter.out.crypto; - -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.time.Duration; -import java.time.Instant; -import java.util.Arrays; - -@Component -public class MasterKeySessionHolder implements MasterKeySessionPort { - - private SecretKey masterKey = null; - private byte[] rawKeyBytes = null; - private volatile Instant lastActivityAt; - - private static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(15); - - public synchronized void setKey(SecretKey key) { - if (this.rawKeyBytes != null) { - Arrays.fill(this.rawKeyBytes, (byte) 0); - this.rawKeyBytes = null; - } - this.masterKey = key; - if (key != null && key.getEncoded() != null) { - this.rawKeyBytes = key.getEncoded(); - } - if (key != null) { - this.lastActivityAt = Instant.now(); - } else { - this.lastActivityAt = null; - } - } - - /** - * Retrieves the active master key and automatically resets the inactivity timer. - * - *

⚠ SIDE EFFECT: Calling this method triggers a {@link #touch()}, - * sliding the session expiration window forward.

- */ - - public synchronized SecretKey getKey() { - if (hasKey()) { - if (isExpired(DEFAULT_TIMEOUT)) { - clear(); - return null; - } - touch(); - } - return this.masterKey; - } - - public synchronized boolean hasKey() { - return this.masterKey != null; - } - - public synchronized void clear() { - if (rawKeyBytes != null) { - // Best-effort reference clearing; does not clear internal immutable copies in SecretKey - Arrays.fill(rawKeyBytes, (byte) 0); - } - this.masterKey = null; - this.rawKeyBytes = null; - this.lastActivityAt = null; - } - - public synchronized void touch() { - if (hasKey()) { - if (isExpired(DEFAULT_TIMEOUT)) { - clear(); - return; - } - this.lastActivityAt = Instant.now(); - } - } - - public synchronized SecretKey getOrClearIfExpired(Duration timeout) { - if (hasKey() && isExpired(timeout)) { - clear(); - return null; - } - - return this.masterKey; - } - - public synchronized Long getSecondsRemaining(Duration timeout) { - if (this.masterKey == null || lastActivityAt == null) { - return 0L; - } - long elapsedSeconds = Duration.between(lastActivityAt, Instant.now()).toSeconds(); - long remaining = timeout.toSeconds() - elapsedSeconds; - - // Return 0 if time is expired - return Math.max(0L, remaining); - } - - private boolean isExpired(Duration timeout) { - if (lastActivityAt == null) { - return true; - } - long elapsedSeconds = Duration.between(lastActivityAt, Instant.now()).toSeconds(); - return elapsedSeconds >= timeout.toSeconds(); - } - -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubClient.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubClient.java deleted file mode 100644 index 45486b6..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubClient.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.devaulty.backend.adapter.out.external.github; - -import com.devaulty.backend.adapter.out.external.github.dto.GitHubReleaseResponse; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.RestClientResponseException; - -import java.io.InputStream; -import java.net.URI; - -@Component -public class GitHubClient { - - private final RestClient githubRestClient; - private final RestClient downloadRestClient; - - private static final String LATEST_RELEASES_URL = "/repos/MathCunha16/Devaulty/releases/latest"; - - public GitHubClient(@Qualifier("githubRestClient") RestClient githubRestClient, - @Qualifier("downloadRestClient") RestClient downloadRestClient) { - this.githubRestClient = githubRestClient; - this.downloadRestClient = downloadRestClient; - } - - public GitHubReleaseResponse getLatestRelease() { - try { - return githubRestClient.get() - .uri(LATEST_RELEASES_URL) - .retrieve() - .body(GitHubReleaseResponse.class); - } catch (RestClientResponseException ex) { - if (ex.getStatusCode() == HttpStatus.NOT_FOUND) { - return null; - } - throw ex; - } - } - - /** - * Downloads a binary release asset from an absolute URL (e.g. GitHub CDN). - * Returns the response body as a live InputStream (streaming) — the caller - * MUST consume it inside a try-with-resources block, otherwise the - * underlying HTTP connection will leak. - */ - public InputStream downloadAsset(String downloadUrl) { - return downloadRestClient.get() - .uri(URI.create(downloadUrl)) - .exchange((request, response) -> { - if (response.getStatusCode().isError()) { - throw new RestClientResponseException( - "Failed to download asset: HTTP " + response.getStatusCode(), - response.getStatusCode().value(), - response.getStatusText(), - response.getHeaders(), - null, - null - ); - } - return response.getBody(); - }, false); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseAdapter.java deleted file mode 100644 index 9b9f222..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseAdapter.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.devaulty.backend.adapter.out.external.github; - -import com.devaulty.backend.application.port.out.external.release.ReleasePort; -import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; -import org.springframework.stereotype.Component; - -import java.io.InputStream; - -@Component -public class GitHubReleaseAdapter implements ReleasePort { - - private final GitHubClient gitHubClient; - private final GitHubReleaseMapper mapper; - - public GitHubReleaseAdapter(GitHubClient gitHubClient, GitHubReleaseMapper mapper) { - this.gitHubClient = gitHubClient; - this.mapper = mapper; - } - - @Override - public LatestReleaseInfo getLatestRelease() { - return mapper.toDomain(gitHubClient.getLatestRelease()); - } - - @Override - public InputStream downloadAsset(String downloadUrl) { - return gitHubClient.downloadAsset(downloadUrl); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseMapper.java deleted file mode 100644 index 157aec3..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseMapper.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.devaulty.backend.adapter.out.external.github; - -import com.devaulty.backend.adapter.out.external.github.dto.GitHubAssetResponse; -import com.devaulty.backend.adapter.out.external.github.dto.GitHubReleaseResponse; -import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; -import com.devaulty.backend.application.port.out.external.release.dto.ReleaseAssetInfo; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -@Mapper(componentModel = "spring") -public interface GitHubReleaseMapper { - - @Mapping(target = "isPreRelease", source = "preRelease") - @Mapping(target = "name", source = "name", defaultValue = "") - @Mapping(target = "body", source = "body", defaultValue = "Version notes not informed") - LatestReleaseInfo toDomain(GitHubReleaseResponse dto); - - @Mapping(target = "fileName", source = "name") - @Mapping(target = "downloadUrl", source = "browserDownloadUrl") - @Mapping(target = "sizeInBytes", source = "size") - ReleaseAssetInfo toAssetDomain(GitHubAssetResponse dto); - -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/common/GitHubConfig.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/common/GitHubConfig.java deleted file mode 100644 index 7e7d22f..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/common/GitHubConfig.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.devaulty.backend.adapter.out.external.github.common; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.JdkClientHttpRequestFactory; -import org.springframework.web.client.RestClient; - -import java.net.http.HttpClient; -import java.time.Duration; - -@Configuration -public class GitHubConfig { - - private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); - private static final Duration API_READ_TIMEOUT = Duration.ofSeconds(15); - /** - * Generous timeout for downloading large binary assets (e.g. .deb, .rpm, .msi). - */ - private static final Duration DOWNLOAD_READ_TIMEOUT = Duration.ofMinutes(10); - private static final String USER_AGENT_HEADER = "Devaulty-Desktop-App"; - private static final String GITHUB_API_BASE_URL = "https://api.github.com"; - private static final String GITHUB_ACCEPT_HEADER = "application/vnd.github+json"; - private static final String GITHUB_API_VERSION_HEADER = "2026-03-10"; - - - /** - * Default RestClient for GitHub API requests. - */ - @Bean - public HttpClient sharedHttpClient() { - return HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); - } - - @Bean(name = "githubRestClient") - public RestClient githubRestClient(HttpClient sharedHttpClient) { - JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(sharedHttpClient); - requestFactory.setReadTimeout(API_READ_TIMEOUT); - - return RestClient.builder() - .requestFactory(requestFactory) - .baseUrl(GITHUB_API_BASE_URL) - .defaultHeader("User-Agent", USER_AGENT_HEADER) - .defaultHeader("Accept", GITHUB_ACCEPT_HEADER) - .defaultHeader("X-GitHub-Api-Version", GITHUB_API_VERSION_HEADER) - .build(); - } - - - /** - * RestClient for downloading large binary assets (e.g. .deb, .rpm, .msi). - */ - @Bean(name = "downloadRestClient") - public RestClient downloadRestClient(HttpClient sharedHttpClient) { - JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(sharedHttpClient); - requestFactory.setReadTimeout(DOWNLOAD_READ_TIMEOUT); - - return RestClient.builder() - .requestFactory(requestFactory) - .defaultHeader("User-Agent", USER_AGENT_HEADER) - .build(); - } - -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubAssetResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubAssetResponse.java deleted file mode 100644 index fe6d054..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubAssetResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.devaulty.backend.adapter.out.external.github.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public record GitHubAssetResponse( - String name, - @JsonProperty("browser_download_url") - String browserDownloadUrl, - long size, - @JsonProperty("content_type") - String contentType, - String digest -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubReleaseResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubReleaseResponse.java deleted file mode 100644 index b43f4e1..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubReleaseResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.devaulty.backend.adapter.out.external.github.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.time.Instant; -import java.util.List; - -public record GitHubReleaseResponse( - @JsonProperty("tag_name") - String tagName, - String name, - String body, - @JsonProperty("html_url") - String htmlUrl, - @JsonProperty("published_at") - Instant publishedAt, - @JsonProperty("prerelease") - boolean preRelease, - List assets -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/base/BaseJpaEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/base/BaseJpaEntity.java deleted file mode 100644 index d7dfcc1..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/base/BaseJpaEntity.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.base; - -import com.devaulty.backend.adapter.out.persistence.converter.LocalDateTimeToStringConverter; -import jakarta.persistence.Column; -import jakarta.persistence.Convert; -import jakarta.persistence.MappedSuperclass; - -import java.time.LocalDateTime; - -@MappedSuperclass -public class BaseJpaEntity { - - @Column(name = "created_at", nullable = false, updatable = false) - @Convert(converter = LocalDateTimeToStringConverter.class) - private LocalDateTime createdAt; - - @Column(name = "updated_at") - @Convert(converter = LocalDateTimeToStringConverter.class) - private LocalDateTime updatedAt; - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(LocalDateTime createdAt) { - this.createdAt = createdAt; - } - - public LocalDateTime getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(LocalDateTime updatedAt) { - this.updatedAt = updatedAt; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/converter/LocalDateTimeToStringConverter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/converter/LocalDateTimeToStringConverter.java deleted file mode 100644 index 65cc2fd..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/converter/LocalDateTimeToStringConverter.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.converter; - -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -@Converter(autoApply = false) -public class LocalDateTimeToStringConverter implements AttributeConverter { - - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; - - @Override - public String convertToDatabaseColumn(LocalDateTime attribute) { - return attribute != null ? attribute.format(FORMATTER) : null; - } - - @Override - public LocalDateTime convertToEntityAttribute(String dbData) { - return dbData != null ? LocalDateTime.parse(dbData, FORMATTER) : null; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/CredentialEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/CredentialEntity.java deleted file mode 100644 index 1428862..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/CredentialEntity.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.credential; - -import com.devaulty.backend.adapter.out.persistence.base.BaseJpaEntity; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import jakarta.persistence.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.util.Objects; -import java.util.UUID; - -@Table(name = "credentials") -@Entity -public class CredentialEntity extends BaseJpaEntity { - - @Id - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "project_id", nullable = false) - private ProjectEntity project; - - @Column(nullable = false) - private String title; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private CredentialSecretType secretType; - - @Column(name = "payload_encrypted", nullable = false) - private byte[] payloadEncrypted; - - @Column(name = "encryption_iv", nullable = false) - private byte[] encryptionIv; - - @Column(name = "encryption_auth_tag", nullable = false) - private byte[] encryptionAuthTag; - - private String notes; - - @Column(name = "related_url") - private String relatedUrl; - - public CredentialEntity() { - // Empty constructor - } - - public CredentialEntity(UUID id, ProjectEntity project, String title, CredentialSecretType secretType, byte[] payloadEncrypted, byte[] encryptionIv, byte[] encryptionAuthTag, String notes, String relatedUrl) { - this.id = id; - this.project = project; - this.title = title; - this.secretType = secretType; - this.payloadEncrypted = payloadEncrypted; - this.encryptionIv = encryptionIv; - this.encryptionAuthTag = encryptionAuthTag; - this.notes = notes; - this.relatedUrl = relatedUrl; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public ProjectEntity getProject() { - return project; - } - - public void setProject(ProjectEntity project) { - this.project = project; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public CredentialSecretType getSecretType() { - return secretType; - } - - public void setSecretType(CredentialSecretType secretType) { - this.secretType = secretType; - } - - public byte[] getPayloadEncrypted() { - return payloadEncrypted; - } - - public void setPayloadEncrypted(byte[] payloadEncrypted) { - this.payloadEncrypted = payloadEncrypted; - } - - public byte[] getEncryptionIv() { - return encryptionIv; - } - - public void setEncryptionIv(byte[] encryptionIv) { - this.encryptionIv = encryptionIv; - } - - public byte[] getEncryptionAuthTag() { - return encryptionAuthTag; - } - - public void setEncryptionAuthTag(byte[] encryptionAuthTag) { - this.encryptionAuthTag = encryptionAuthTag; - } - - public String getNotes() { - return notes; - } - - public void setNotes(String notes) { - this.notes = notes; - } - - public String getRelatedUrl() { - return relatedUrl; - } - - public void setRelatedUrl(String relatedUrl) { - this.relatedUrl = relatedUrl; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - CredentialEntity that = (CredentialEntity) o; - return Objects.equals(id, that.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/CredentialMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/CredentialMapper.java deleted file mode 100644 index e370780..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/CredentialMapper.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.credential; - -import com.devaulty.backend.domain.model.Credential; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -@Mapper(componentModel = "spring") -public interface CredentialMapper { - - @Mapping( target = "project", ignore = true) - CredentialEntity toEntity(Credential credential); - - @Mapping( target = "projectId", source = "project.id") - Credential toDomain(CredentialEntity credentialEntity); -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/CredentialPersistenceAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/CredentialPersistenceAdapter.java deleted file mode 100644 index 58e840f..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/CredentialPersistenceAdapter.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.credential; - -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.application.port.in.credential.CredentialSummary; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.domain.model.Credential; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Component -public class CredentialPersistenceAdapter implements CredentialRepositoryPort { - - private final SpringDataCredentialRepository credentialRepository; - private final SpringDataProjectRepository projectRepository; - private final CredentialMapper credentialMapper; - - public CredentialPersistenceAdapter(SpringDataCredentialRepository credentialRepository, SpringDataProjectRepository projectRepository, CredentialMapper credentialMapper) { - this.credentialRepository = credentialRepository; - this.projectRepository = projectRepository; - this.credentialMapper = credentialMapper; - } - - @Override - public Credential save(Credential credential) { - CredentialEntity entity = credentialMapper.toEntity(credential); - entity.setProject(projectRepository.getReferenceById(credential.getProjectId())); - CredentialEntity savedEntity = credentialRepository.save(entity); - return credentialMapper.toDomain(savedEntity); - } - - @Override - public Optional findById(UUID id) { - Optional entity = credentialRepository.findById(id); - return entity.map(credentialMapper::toDomain); - } - - @Override - public Page findAllByProject(UUID projectId, int page, int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - return credentialRepository.findAllSummaryByProjectId(projectId, pageable); - } - - @Override - public void deleteById(UUID id) { - credentialRepository.deleteById(id); - } - - @Override - public boolean existsByIdAndProjectId(UUID id, UUID projectId) { - return credentialRepository.existsByIdAndProject_Id(id, projectId); - } - - @Override - public String getSupportedType() { - return "credential"; - } - - @Override - public List findExistingIdsByProject(List ids, UUID projectId) { - if (ids == null || ids.isEmpty()) { - return List.of(); - } - return credentialRepository.findExistingIdsByProjectId(ids, projectId); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/SpringDataCredentialRepository.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/SpringDataCredentialRepository.java deleted file mode 100644 index e741f9c..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/credential/SpringDataCredentialRepository.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.credential; - -import com.devaulty.backend.application.port.in.credential.CredentialSummary; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.UUID; - -public interface SpringDataCredentialRepository extends JpaRepository { - @Query(""" - SELECT new com.devaulty.backend.application.port.in.credential.CredentialSummary( - c.id, - c.project.id, - c.title, - c.secretType, - c.relatedUrl, - c.createdAt, - c.updatedAt - ) - FROM CredentialEntity c - WHERE c.project.id = :projectId - """) - Page findAllSummaryByProjectId( - @Param("projectId") UUID projectId, - Pageable pageable - ); - - boolean existsByIdAndProject_Id(UUID id, UUID projectId); - - @Query("SELECT c.id FROM CredentialEntity c WHERE c.id IN :ids AND c.project.id = :projectId") - List findExistingIdsByProjectId( - @Param("ids") List ids, - @Param("projectId") UUID projectId - ); -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/LinkEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/LinkEntity.java deleted file mode 100644 index a5f99c8..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/LinkEntity.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.link; - -import com.devaulty.backend.adapter.out.persistence.base.BaseJpaEntity; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import jakarta.persistence.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.util.Objects; -import java.util.UUID; - -@Table(name = "links") -@Entity -public class LinkEntity extends BaseJpaEntity { - - @Id - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "project_id", nullable = false) - private ProjectEntity project; - - @Column(nullable = false) - private String title; - - @Column(nullable = false) - private String url; - - private String description; - - public LinkEntity() { - // Empty constructor - } - - public LinkEntity(UUID id, ProjectEntity project, String title, String url, String description) { - this.id = id; - this.project = project; - this.title = title; - this.url = url; - this.description = description; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public ProjectEntity getProject() { - return project; - } - - public void setProject(ProjectEntity project) { - this.project = project; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - LinkEntity that = (LinkEntity) o; - return Objects.equals(id, that.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/LinkMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/LinkMapper.java deleted file mode 100644 index 76a337d..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/LinkMapper.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.link; - -import com.devaulty.backend.domain.model.Link; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -@Mapper(componentModel = "spring") -public interface LinkMapper { - - @Mapping(target = "project", ignore = true) - LinkEntity toEntity(Link link); - - @Mapping(target = "projectId", source = "project.id") - Link toDomain(LinkEntity linkEntity); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/LinkPersistenceAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/LinkPersistenceAdapter.java deleted file mode 100644 index 9f0dd62..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/LinkPersistenceAdapter.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.link; - -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.domain.model.Link; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Component -public class LinkPersistenceAdapter implements LinkRepositoryPort { - - private final SpringDataLinkRepository linkRepository; - private final SpringDataProjectRepository projectRepository; - private final LinkMapper linkMapper; - - public LinkPersistenceAdapter(SpringDataLinkRepository linkRepository, SpringDataProjectRepository projectRepository, LinkMapper linkMapper) { - this.linkRepository = linkRepository; - this.projectRepository = projectRepository; - this.linkMapper = linkMapper; - } - - @Override - public Link save(Link link) { - LinkEntity linkEntity = linkMapper.toEntity(link); - linkEntity.setProject(projectRepository.getReferenceById(link.getProjectId())); - LinkEntity savedLinkEntity = linkRepository.save(linkEntity); - return linkMapper.toDomain(savedLinkEntity); - } - - @Override - public Optional findById(UUID id) { - Optional entity = linkRepository.findById(id); - return entity.map(linkMapper::toDomain); - } - - @Override - public Page findAllByProject(UUID projectId, int page, int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page entities = linkRepository.findAllByProject_Id(projectId, pageable); - return entities.map(linkMapper::toDomain); - } - - @Override - public void deleteById(UUID id) { - linkRepository.deleteById(id); - } - - @Override - public boolean existsByIdAndProjectId(UUID id, UUID projectId) { - return linkRepository.existsByIdAndProject_Id(id, projectId); - } - - @Override - public String getSupportedType() { - return "link"; - } - - @Override - public List findExistingIdsByProject(List ids, UUID projectId) { - if (ids == null || ids.isEmpty()) { - return List.of(); - } - return linkRepository.findExistingIdsByProjectId(ids, projectId); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/SpringDataLinkRepository.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/SpringDataLinkRepository.java deleted file mode 100644 index 020ce47..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/link/SpringDataLinkRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.link; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.UUID; - -public interface SpringDataLinkRepository extends JpaRepository { - Page findAllByProject_Id(UUID projectId, Pageable pageable); - - boolean existsByIdAndProject_Id(UUID id, UUID projectId); - - @Query("SELECT l.id FROM LinkEntity l WHERE l.id IN :ids AND l.project.id = :projectId") - List findExistingIdsByProjectId( - @Param("ids") List ids, - @Param("projectId") UUID projectId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/NoteEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/NoteEntity.java deleted file mode 100644 index 5af2eaf..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/NoteEntity.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.note; - -import com.devaulty.backend.adapter.out.persistence.base.BaseJpaEntity; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import jakarta.persistence.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.util.Objects; -import java.util.UUID; - -@Table(name = "notes") -@Entity -public class NoteEntity extends BaseJpaEntity { - - @Id - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "project_id", nullable = false) - private ProjectEntity project; - - @Column(nullable = false) - private String title; - - private String content; - - @Column(nullable = false) - @JdbcTypeCode(SqlTypes.INTEGER) - private boolean archived; - - public NoteEntity() { - // Empty constructor - } - - public NoteEntity(UUID id, ProjectEntity project, String title, String content, boolean archived) { - this.id = id; - this.project = project; - this.title = title; - this.content = content; - this.archived = archived; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public ProjectEntity getProject() { - return project; - } - - public void setProject(ProjectEntity project) { - this.project = project; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public boolean isArchived() { - return archived; - } - - public void setArchived(boolean archived) { - this.archived = archived; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - NoteEntity that = (NoteEntity) o; - return Objects.equals(id, that.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/NoteMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/NoteMapper.java deleted file mode 100644 index 02cfd61..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/NoteMapper.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.note; - -import com.devaulty.backend.domain.model.Note; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -@Mapper(componentModel = "spring") -public interface NoteMapper { - - @Mapping(target = "project", ignore = true) - NoteEntity toEntity(Note note); - - @Mapping(target = "projectId", source = "project.id") - Note toDomain(NoteEntity noteEntity); - -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/NotePersistenceAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/NotePersistenceAdapter.java deleted file mode 100644 index 129f688..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/NotePersistenceAdapter.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.note; - -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Component -public class NotePersistenceAdapter implements NoteRepositoryPort { - - private final SpringDataNoteRepository noteRepository; - private final SpringDataProjectRepository projectRepository; - private final NoteMapper noteMapper; - - public NotePersistenceAdapter(SpringDataNoteRepository noteRepository, SpringDataProjectRepository projectRepository, NoteMapper noteMapper) { - this.noteRepository = noteRepository; - this.projectRepository = projectRepository; - this.noteMapper = noteMapper; - } - - @Override - public Note save(Note note) { - NoteEntity entity = noteMapper.toEntity(note); - entity.setProject(projectRepository.getReferenceById(note.getProjectId())); - NoteEntity savedEntity = noteRepository.save(entity); - return noteMapper.toDomain(savedEntity); - } - - @Override - public Optional findById(UUID id) { - Optional entity = noteRepository.findById(id); - return entity.map(noteMapper::toDomain); - } - - @Override - public Page findAllByProject(UUID projectId, int page, int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page entities = noteRepository.findAllByProject_Id(projectId, pageable); - return entities.map(noteMapper::toDomain); - } - - - @Override - public void deleteById(UUID id) { - noteRepository.deleteById(id); - } - - @Override - public boolean existsByIdAndProjectId(UUID id, UUID projectId) { - return noteRepository.existsByIdAndProject_Id(id, projectId); - } - - @Override - public String getSupportedType() { - return "note"; - } - - @Override - public List findExistingIdsByProject(List ids, UUID projectId) { - if (ids == null || ids.isEmpty()) { - return List.of(); - } - return noteRepository.findExistingIdsByProjectId(ids, projectId); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/SpringDataNoteRepository.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/SpringDataNoteRepository.java deleted file mode 100644 index 1c22cdc..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/note/SpringDataNoteRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.note; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.UUID; - -public interface SpringDataNoteRepository extends JpaRepository { - Page findAllByProject_Id(UUID projectId, Pageable pageable); - - boolean existsByIdAndProject_Id(UUID id, UUID projectId); - - @Query("SELECT n.id FROM NoteEntity n WHERE n.id IN :ids AND n.project.id = :projectId") - List findExistingIdsByProjectId( - @Param("ids") List ids, - @Param("projectId") UUID projectId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/ProblemEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/ProblemEntity.java deleted file mode 100644 index 9823f9b..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/ProblemEntity.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.problem; - -import com.devaulty.backend.adapter.out.persistence.base.BaseJpaEntity; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; -import jakarta.persistence.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.util.Objects; -import java.util.UUID; - -@Table(name = "problems") -@Entity -public class ProblemEntity extends BaseJpaEntity { - - @Id - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "project_id", nullable = false) - private ProjectEntity project; - - @Column(nullable = false) - private String title; - - @Column(name = "error_description") - private String errorDescription; - - private String solution; - - @Enumerated(EnumType.STRING) - private ProblemStatus status = ProblemStatus.OPEN; - - @Enumerated(EnumType.STRING) - private ProblemSeverity severity = ProblemSeverity.MEDIUM; - - public ProblemEntity() { - // Empty constructor - } - - public ProblemEntity(UUID id, ProjectEntity project, String title, String errorDescription, String solution, ProblemStatus status, ProblemSeverity severity) { - this.id = id; - this.project = project; - this.title = title; - this.errorDescription = errorDescription; - this.solution = solution; - this.status = status; - this.severity = severity; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public ProjectEntity getProject() { - return project; - } - - public void setProject(ProjectEntity project) { - this.project = project; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getErrorDescription() { - return errorDescription; - } - - public void setErrorDescription(String errorDescription) { - this.errorDescription = errorDescription; - } - - public String getSolution() { - return solution; - } - - public void setSolution(String solution) { - this.solution = solution; - } - - public ProblemStatus getStatus() { - return status; - } - - public void setStatus(ProblemStatus status) { - this.status = status; - } - - public ProblemSeverity getSeverity() { - return severity; - } - - public void setSeverity(ProblemSeverity severity) { - this.severity = severity; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - ProblemEntity that = (ProblemEntity) o; - return Objects.equals(id, that.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/ProblemMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/ProblemMapper.java deleted file mode 100644 index 4a54049..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/ProblemMapper.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.problem; - -import com.devaulty.backend.domain.model.Problem; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -@Mapper(componentModel = "spring") -public interface ProblemMapper { - - @Mapping(target = "project", ignore = true) - ProblemEntity toEntity(Problem problem); - - @Mapping(target = "projectId", source = "project.id") - Problem toDomain(ProblemEntity problemEntity); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/ProblemPersistenceAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/ProblemPersistenceAdapter.java deleted file mode 100644 index 4f8a520..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/ProblemPersistenceAdapter.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.problem; - -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Component -public class ProblemPersistenceAdapter implements ProblemRepositoryPort { - - private final SpringDataProblemRepository problemRepository; - private final SpringDataProjectRepository projectRepository; - private final ProblemMapper problemMapper; - - public ProblemPersistenceAdapter(SpringDataProblemRepository problemRepository, SpringDataProjectRepository projectRepository, ProblemMapper problemMapper) { - this.problemRepository = problemRepository; - this.projectRepository = projectRepository; - this.problemMapper = problemMapper; - } - - @Override - public Problem save(Problem problem) { - ProblemEntity entity = problemMapper.toEntity(problem); - entity.setProject(projectRepository.getReferenceById(problem.getProjectId())); - ProblemEntity savedEntity = problemRepository.save(entity); - return problemMapper.toDomain(savedEntity); - } - - @Override - public Optional findById(UUID id) { - Optional entity = problemRepository.findById(id); - return entity.map(problemMapper::toDomain); - } - - @Override - public Page findAllByProject(UUID projectId, int page, int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page entities = problemRepository.findAllByProject_Id(projectId, pageable); - return entities.map(problemMapper::toDomain); - } - - @Override - public void deleteById(UUID id) { - problemRepository.deleteById(id); - } - - @Override - public boolean existsByIdAndProjectId(UUID id, UUID projectId) { - return problemRepository.existsByIdAndProject_Id(id, projectId); - } - - @Override - public String getSupportedType() { - return "problem"; - } - - @Override - public List findExistingIdsByProject(List ids, UUID projectId) { - if (ids == null || ids.isEmpty()) { - return List.of(); - } - return problemRepository.findExistingIdsByProjectId(ids, projectId); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/SpringDataProblemRepository.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/SpringDataProblemRepository.java deleted file mode 100644 index b3fe00d..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/problem/SpringDataProblemRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.problem; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.UUID; - -public interface SpringDataProblemRepository extends JpaRepository { - Page findAllByProject_Id(UUID projectId, Pageable pageable); - - boolean existsByIdAndProject_Id(UUID id, UUID projectId); - - @Query("SELECT p.id FROM ProblemEntity p WHERE p.id IN :ids AND p.project.id = :projectId") - List findExistingIdsByProjectId( - @Param("ids") List ids, - @Param("projectId") UUID projectId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/ProjectEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/ProjectEntity.java deleted file mode 100644 index 7406174..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/ProjectEntity.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.project; - -import com.devaulty.backend.adapter.out.persistence.base.BaseJpaEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.util.Objects; -import java.util.UUID; - -@Table(name = "projects") -@Entity -public class ProjectEntity extends BaseJpaEntity { - - @Id - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID id; - - @Column(nullable = false) - private String name; - - private String description; - - private String icon; - - private String color; - - @Column(nullable = false) - @JdbcTypeCode(SqlTypes.INTEGER) - private boolean archived; - - public ProjectEntity() { - // Empty constructor - } - - public ProjectEntity(UUID id, String name, String description, String icon, String color, boolean archived) { - this.id = id; - this.name = name; - this.description = description; - this.icon = icon; - this.color = color; - this.archived = archived; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getIcon() { - return icon; - } - - public void setIcon(String icon) { - this.icon = icon; - } - - public String getColor() { - return color; - } - - public void setColor(String color) { - this.color = color; - } - - public boolean isArchived() { - return archived; - } - - public void setArchived(boolean archived) { - this.archived = archived; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - ProjectEntity that = (ProjectEntity) o; - return Objects.equals(id, that.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/ProjectMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/ProjectMapper.java deleted file mode 100644 index e0a668b..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/ProjectMapper.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.project; - -import com.devaulty.backend.domain.model.Project; -import org.mapstruct.Mapper; - -@Mapper(componentModel = "spring") -public interface ProjectMapper { - ProjectEntity toEntity(Project project); - - Project toDomain(ProjectEntity projectEntity); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/ProjectPersistenceAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/ProjectPersistenceAdapter.java deleted file mode 100644 index a88e68c..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/ProjectPersistenceAdapter.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.project; - -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; - -import java.util.Optional; -import java.util.UUID; - -@Component -public class ProjectPersistenceAdapter implements ProjectRepositoryPort { - - private final SpringDataProjectRepository projectRepository; - private final ProjectMapper projectMapper; - - public ProjectPersistenceAdapter(SpringDataProjectRepository projectRepository, ProjectMapper projectMapper) { - this.projectRepository = projectRepository; - this.projectMapper = projectMapper; - } - - @Override - public Project save(Project project) { - ProjectEntity entity = projectRepository.save(projectMapper.toEntity(project)); - return projectMapper.toDomain(entity); - } - - @Override - public Page findAll(int page, int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page entityPage = projectRepository.findAll(pageable); - return entityPage.map(projectMapper::toDomain); - } - - @Override - public Optional findById(UUID id) { - Optional entity = projectRepository.findById(id); - return entity.map(projectMapper::toDomain); - } - - @Override - public void deleteById(UUID id) { - projectRepository.deleteById(id); - } - - @Override - public boolean existsById(UUID id) { - return projectRepository.existsById(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/SpringDataProjectRepository.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/SpringDataProjectRepository.java deleted file mode 100644 index 3051e18..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/project/SpringDataProjectRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.project; - -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.UUID; - -public interface SpringDataProjectRepository extends JpaRepository { - -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/AppSettingEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/AppSettingEntity.java deleted file mode 100644 index d7f30f4..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/AppSettingEntity.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.setting; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; - -import java.util.Objects; - -@Table(name = "app_settings") -@Entity -public class AppSettingEntity { - - @Id - @Column(name = "key", nullable = false) - private String key; - - @Column(name = "value", nullable = false) - private String value; - - public AppSettingEntity() { - // Empty constructor - } - - public AppSettingEntity(String key, String value) { - this.key = key; - this.value = value; - } - - public String getKey() { - return key; - } - - public void setKey(String key) { - this.key = key; - } - - public String getValue() { - return value; - } - - public void setValue(String value) { - this.value = value; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - AppSettingEntity that = (AppSettingEntity) o; - return Objects.equals(key, that.key); - } - - @Override - public int hashCode() { - return Objects.hashCode(key); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/AppSettingMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/AppSettingMapper.java deleted file mode 100644 index 71cdf1e..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/AppSettingMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.setting; - -import com.devaulty.backend.domain.model.AppSetting; -import org.mapstruct.Mapper; - -@Mapper(componentModel = "spring") -public interface AppSettingMapper { - - AppSettingEntity toEntity(AppSetting appSetting); - - AppSetting toDomain(AppSettingEntity appSettingEntity); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/AppSettingPersistenceAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/AppSettingPersistenceAdapter.java deleted file mode 100644 index 42c1c83..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/AppSettingPersistenceAdapter.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.setting; - -import com.devaulty.backend.application.port.out.persistence.AppSettingRepositoryPort; -import com.devaulty.backend.domain.model.AppSetting; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@Component -public class AppSettingPersistenceAdapter implements AppSettingRepositoryPort { - - private final SpringDataAppSettingRepository appSettingRepository; - private final AppSettingMapper appSettingMapper; - - public AppSettingPersistenceAdapter(SpringDataAppSettingRepository appSettingRepository, AppSettingMapper appSettingMapper) { - this.appSettingRepository = appSettingRepository; - this.appSettingMapper = appSettingMapper; - } - - @Override - public AppSetting save(AppSetting appSetting) { - AppSettingEntity appSettingEntity = appSettingMapper.toEntity(appSetting); - return appSettingMapper.toDomain(appSettingRepository.save(appSettingEntity)); - } - - @Override - public Optional findByKey(String key) { - Optional entity = appSettingRepository.findById(key); - return entity.map(appSettingMapper::toDomain); - } - - @Override - public boolean existsByKey(String key) { - return appSettingRepository.existsById(key); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/SpringDataAppSettingRepository.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/SpringDataAppSettingRepository.java deleted file mode 100644 index 71390a1..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/setting/SpringDataAppSettingRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.setting; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface SpringDataAppSettingRepository extends JpaRepository { -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SnippetEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SnippetEntity.java deleted file mode 100644 index d598337..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SnippetEntity.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.snippet; - -import com.devaulty.backend.adapter.out.persistence.base.BaseJpaEntity; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; -import jakarta.persistence.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.util.Objects; -import java.util.UUID; - -@Table(name = "snippets") -@Entity -public class SnippetEntity extends BaseJpaEntity { - @Id - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "project_id", nullable = false) - private ProjectEntity project; - - @Column(nullable = false) - private String title; - - private String description; - - @Column(nullable = false) - private String content; - - @Enumerated(EnumType.STRING) - private SnippetLanguage language; - - @Column(name = "snippet_type", nullable = false) - @Enumerated(EnumType.STRING) - private SnippetType snippetType = SnippetType.COMMAND; - - public SnippetEntity() { - // Empty constructor - } - - public SnippetEntity(UUID id, ProjectEntity project, String title, String description, String content, SnippetLanguage language, SnippetType snippetType) { - this.id = id; - this.project = project; - this.title = title; - this.description = description; - this.content = content; - this.language = language; - this.snippetType = snippetType; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public ProjectEntity getProject() { - return project; - } - - public void setProject(ProjectEntity project) { - this.project = project; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public SnippetLanguage getLanguage() { - return language; - } - - public void setLanguage(SnippetLanguage language) { - this.language = language; - } - - public SnippetType getSnippetType() { - return snippetType; - } - - public void setSnippetType(SnippetType snippetType) { - this.snippetType = snippetType; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - SnippetEntity that = (SnippetEntity) o; - return Objects.equals(id, that.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SnippetMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SnippetMapper.java deleted file mode 100644 index 8275f86..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SnippetMapper.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.snippet; - -import com.devaulty.backend.domain.model.Snippet; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -@Mapper(componentModel = "spring") -public interface SnippetMapper { - - @Mapping(target = "project", ignore = true) - SnippetEntity toEntity(Snippet snippet); - - @Mapping(target = "projectId", source = "project.id") - Snippet toDomain(SnippetEntity snippetEntity); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SnippetPersistenceAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SnippetPersistenceAdapter.java deleted file mode 100644 index 9bbba13..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SnippetPersistenceAdapter.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.snippet; - -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import com.devaulty.backend.domain.model.Snippet; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Component -public class SnippetPersistenceAdapter implements SnippetRepositoryPort { - - private final SpringDataSnippetRepository snippetRepository; - private final SpringDataProjectRepository projectRepository; - private final SnippetMapper snippetMapper; - - public SnippetPersistenceAdapter(SpringDataSnippetRepository snippetRepository, SpringDataProjectRepository projectRepository, SnippetMapper snippetMapper) { - this.snippetRepository = snippetRepository; - this.projectRepository = projectRepository; - this.snippetMapper = snippetMapper; - } - - @Override - public Snippet save(Snippet snippet) { - SnippetEntity entity = snippetMapper.toEntity(snippet); - entity.setProject(projectRepository.getReferenceById(snippet.getProjectId())); - SnippetEntity savedEntity = snippetRepository.save(entity); - return snippetMapper.toDomain(savedEntity); - } - - @Override - public Optional findById(UUID id) { - Optional entity = snippetRepository.findById(id); - return entity.map(snippetMapper::toDomain); - } - - @Override - public Page findAllByProject(UUID projectId, int page, int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page snippets = snippetRepository.findAllByProject_Id(projectId, pageable); - return snippets.map(snippetMapper::toDomain); - } - - @Override - public void deleteById(UUID id) { - snippetRepository.deleteById(id); - } - - @Override - public boolean existsByIdAndProjectId(UUID id, UUID projectId) { - return snippetRepository.existsByIdAndProject_Id(id, projectId); - } - - @Override - public String getSupportedType() { - return "snippet"; - } - - @Override - public List findExistingIdsByProject(List ids, UUID projectId) { - if (ids == null || ids.isEmpty()) { - return List.of(); - } - return snippetRepository.findExistingIdsByProjectId(ids, projectId); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SpringDataSnippetRepository.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SpringDataSnippetRepository.java deleted file mode 100644 index 457226b..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/snippet/SpringDataSnippetRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.snippet; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.UUID; - -public interface SpringDataSnippetRepository extends JpaRepository { - Page findAllByProject_Id(UUID projectId, Pageable pageable); - - boolean existsByIdAndProject_Id(UUID id, UUID projectId); - - @Query("SELECT s.id FROM SnippetEntity s WHERE s.id IN :ids AND s.project.id = :projectId") - List findExistingIdsByProjectId( - @Param("ids") List ids, - @Param("projectId") UUID projectId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/SpringDataTagRepository.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/SpringDataTagRepository.java deleted file mode 100644 index cc2cd00..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/SpringDataTagRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.tag; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.UUID; - -public interface SpringDataTagRepository extends JpaRepository { - - @Query(""" - SELECT t FROM TagEntity t - WHERE t.project.id = :projectId - AND (:term IS NULL OR :term = '' OR LOWER(t.name) LIKE LOWER(CONCAT('%', :term, '%'))) - ORDER BY t.name ASC - """) - List searchByNameAndProjectId(@Param("term") String term, @Param("projectId") UUID projectId); - - List findAllByProject_Id(UUID projectId); - - boolean existsByNameAndProject_Id(String name, UUID projectId); - - boolean existsByIdAndProject_Id(UUID id, UUID projectId); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/TagEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/TagEntity.java deleted file mode 100644 index 974ff8c..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/TagEntity.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.tag; - -import com.devaulty.backend.adapter.out.persistence.base.BaseJpaEntity; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import jakarta.persistence.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.util.Objects; -import java.util.UUID; - -@Entity -@Table( - name = "tags", - uniqueConstraints = { - @UniqueConstraint( - name = "uk_tags_project_name", - columnNames = {"project_id", "name"} - ) - } -) -public class TagEntity extends BaseJpaEntity { - - @Id - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "project_id", nullable = false) - private ProjectEntity project; - - @Column(nullable = false) - private String name; - - private String color; - - public TagEntity() { - // Empty constructor - } - - public TagEntity(UUID id, ProjectEntity project, String name, String color) { - this.id = id; - this.project = project; - this.name = name; - this.color = color; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public ProjectEntity getProject() { - return project; - } - - public void setProject(ProjectEntity project) { - this.project = project; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getColor() { - return color; - } - - public void setColor(String color) { - this.color = color; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - TagEntity tagEntity = (TagEntity) o; - return Objects.equals(id, tagEntity.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/TagMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/TagMapper.java deleted file mode 100644 index aabfc78..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/TagMapper.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.tag; - -import com.devaulty.backend.domain.model.Tag; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -@Mapper(componentModel = "spring") -public interface TagMapper { - - @Mapping(target = "project", ignore = true) - TagEntity toEntity(Tag tag); - - @Mapping(target = "projectId", source = "project.id") - Tag toDomain(TagEntity tagEntity); -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/TagPersistenceAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/TagPersistenceAdapter.java deleted file mode 100644 index 734ee4d..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/TagPersistenceAdapter.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.tag; - -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.application.exception.ResourceAlreadyExistsException; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Component -public class TagPersistenceAdapter implements TagRepositoryPort { - - private final SpringDataTagRepository tagRepository; - private final SpringDataProjectRepository projectRepository; - private final TagMapper tagMapper; - - public TagPersistenceAdapter(SpringDataTagRepository tagRepository, SpringDataProjectRepository projectRepository, TagMapper tagMapper) { - this.tagRepository = tagRepository; - this.projectRepository = projectRepository; - this.tagMapper = tagMapper; - } - - @Override - public Tag save(Tag tag) { - try { - TagEntity entity = tagMapper.toEntity(tag); - entity.setProject(projectRepository.getReferenceById(tag.getProjectId())); - TagEntity savedEntity = tagRepository.save(entity); - return tagMapper.toDomain(savedEntity); - } catch (DataIntegrityViolationException e) { - throw new ResourceAlreadyExistsException("Tag", tag.getName()); - } - } - - @Override - public Optional findById(UUID id) { - Optional entity = tagRepository.findById(id); - return entity.map(tagMapper::toDomain); - } - - @Override - public List searchByNameAndProjectId(UUID projectId, String name) { - List listResult = tagRepository.searchByNameAndProjectId(name, projectId); - return listResult.stream().map(tagMapper::toDomain).toList(); - } - - @Override - public List findAllByProject(UUID projectId) { - List entities = tagRepository.findAllByProject_Id(projectId); - return entities.stream().map(tagMapper::toDomain).toList(); - } - - @Override - public void delete(UUID id) { - tagRepository.deleteById(id); - } - - @Override - public boolean existsByNameAndProjectId(UUID projectId, String name) { - return tagRepository.existsByNameAndProject_Id(name, projectId); - } - - @Override - public boolean existsByIdAndProjectId(UUID id, UUID projectId) { - return tagRepository.existsByIdAndProject_Id(id, projectId); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/ItemTagEntity.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/ItemTagEntity.java deleted file mode 100644 index 295c0d3..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/ItemTagEntity.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.tag.item; - -import com.devaulty.backend.adapter.out.persistence.tag.TagEntity; -import jakarta.persistence.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.util.Objects; -import java.util.UUID; - -@Entity -@Table(name = "item_tags") -@IdClass(ItemTagId.class) -public class ItemTagEntity { - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "tag_id") - @JdbcTypeCode(SqlTypes.VARCHAR) - private TagEntity tag; - - @Id - @Column(name = "item_type") - private String itemType; - - @Id - @Column(name = "item_id") - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID itemId; - - public ItemTagEntity() { - // Empty constructor - } - - public ItemTagEntity(TagEntity tag, String itemType, UUID itemId) { - this.tag = tag; - this.itemType = itemType; - this.itemId = itemId; - } - - public TagEntity getTag() { - return tag; - } - - public void setTag(TagEntity tag) { - this.tag = tag; - } - - public String getItemType() { - return itemType; - } - - public void setItemType(String itemType) { - this.itemType = itemType; - } - - public UUID getItemId() { - return itemId; - } - - public void setItemId(UUID itemId) { - this.itemId = itemId; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - ItemTagEntity that = (ItemTagEntity) o; - return Objects.equals(tag, that.tag) - && Objects.equals(itemType, that.itemType) - && Objects.equals(itemId, that.itemId); - } - - @Override - public int hashCode() { - return Objects.hash(tag, itemType, itemId); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/ItemTagId.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/ItemTagId.java deleted file mode 100644 index b8525f7..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/ItemTagId.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.tag.item; - -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.io.Serializable; -import java.util.Objects; -import java.util.UUID; - -public class ItemTagId implements Serializable { - - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID tag; - - private String itemType; - - @JdbcTypeCode(SqlTypes.VARCHAR) - private UUID itemId; - - public ItemTagId() { - // Empty constructor - } - - public ItemTagId(UUID tag, String itemType, UUID itemId) { - this.tag = tag; - this.itemType = itemType; - this.itemId = itemId; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ItemTagId that = (ItemTagId) o; - return Objects.equals(tag, that.tag) - && Objects.equals(itemType, that.itemType) - && Objects.equals(itemId, that.itemId); - } - - @Override - public int hashCode() { - return Objects.hash(tag, itemType, itemId); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/ItemTagPersistenceAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/ItemTagPersistenceAdapter.java deleted file mode 100644 index 43fad94..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/ItemTagPersistenceAdapter.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.tag.item; - -import com.devaulty.backend.adapter.out.persistence.tag.SpringDataTagRepository; -import com.devaulty.backend.adapter.out.persistence.tag.TagEntity; -import com.devaulty.backend.adapter.out.persistence.tag.TagMapper; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; - -@Component -public class ItemTagPersistenceAdapter implements ItemTagRepositoryPort { - - private final SpringDataItemTagRepository itemTagRepository; - private final SpringDataTagRepository tagRepository; - private final TagMapper tagMapper; - - public ItemTagPersistenceAdapter(SpringDataItemTagRepository itemTagRepository, SpringDataTagRepository tagRepository, TagMapper tagMapper) { - this.itemTagRepository = itemTagRepository; - this.tagRepository = tagRepository; - this.tagMapper = tagMapper; - } - - @Override - public void associateTagToItem(UUID tagId, String itemType, UUID itemId) { - TagEntity tagReference = tagRepository.getReferenceById(tagId); - itemTagRepository.save(new ItemTagEntity(tagReference, itemType, itemId)); - } - - @Override - public void disassembleTagFromItem(UUID projectId, UUID tagId, String itemType, UUID itemId) { - itemTagRepository.deleteByTagIdAndItemTypeAndItemIdAndProjectId(tagId, itemType, itemId, projectId); - } - - @Override - public void removeAllTagsFromItem(String itemType, UUID itemId) { - itemTagRepository.deleteByItemTypeAndItemId(itemType, itemId); - } - - @Override - public List findTagsForItem(String itemType, UUID projectId, UUID itemId) { - List entities = itemTagRepository.findByItemTypeAndItemIdAndProjectId(itemType, itemId, projectId); - return entities.stream() - .map(itemTagEntity -> tagMapper.toDomain(itemTagEntity.getTag())) - .toList(); - } - - @Override - public Map> findTagsForItems(String itemType, UUID projectId, List itemIds) { - if (itemIds == null || itemIds.isEmpty()) { - return Map.of(); - } - return itemTagRepository.findByItemTypeAndItemIdInAndProjectId(itemType, itemIds, projectId) - .stream() - .collect(Collectors.groupingBy( - ItemTagEntity::getItemId, - Collectors.mapping(e -> tagMapper.toDomain(e.getTag()), Collectors.toList()) - )); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/SpringDataItemTagRepository.java b/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/SpringDataItemTagRepository.java deleted file mode 100644 index 7e81928..0000000 --- a/backend/src/main/java/com/devaulty/backend/adapter/out/persistence/tag/item/SpringDataItemTagRepository.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.devaulty.backend.adapter.out.persistence.tag.item; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.UUID; - -public interface SpringDataItemTagRepository extends JpaRepository { - - @Transactional - void deleteByItemTypeAndItemId(String itemType, UUID itemId); - - @Query("SELECT it FROM ItemTagEntity it WHERE it.itemType = :itemType AND it.itemId = :itemId AND it.tag.project.id = :projectId") - List findByItemTypeAndItemIdAndProjectId( - @Param("itemType") String itemType, - @Param("itemId") UUID itemId, - @Param("projectId") UUID projectId - ); - - @Query("SELECT it FROM ItemTagEntity it WHERE it.itemType = :itemType AND it.itemId IN :itemIds AND it.tag.project.id = :projectId") - List findByItemTypeAndItemIdInAndProjectId( - @Param("itemType") String itemType, - @Param("itemIds") List itemIds, - @Param("projectId") UUID projectId - ); - - @Modifying - @Transactional - @Query("DELETE FROM ItemTagEntity it WHERE it.tag.id = :tagId AND it.itemType = :itemType AND it.itemId = :itemId AND it.tag.project.id = :projectId") - void deleteByTagIdAndItemTypeAndItemIdAndProjectId( - @Param("tagId") UUID tagId, - @Param("itemType") String itemType, - @Param("itemId") UUID itemId, - @Param("projectId") UUID projectId - ); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/BusinessRuleException.java b/backend/src/main/java/com/devaulty/backend/application/exception/BusinessRuleException.java deleted file mode 100644 index dc76c70..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/BusinessRuleException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class BusinessRuleException extends DevaultyException{ - public BusinessRuleException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/CryptoException.java b/backend/src/main/java/com/devaulty/backend/application/exception/CryptoException.java deleted file mode 100644 index 4e1aa22..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/CryptoException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class CryptoException extends DevaultyException{ - public CryptoException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/DevaultyException.java b/backend/src/main/java/com/devaulty/backend/application/exception/DevaultyException.java deleted file mode 100644 index b368a37..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/DevaultyException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.devaulty.backend.application.exception; - -public abstract class DevaultyException extends RuntimeException{ - protected DevaultyException(String message) { - super(message); - } - - protected DevaultyException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/InvalidMasterPasswordException.java b/backend/src/main/java/com/devaulty/backend/application/exception/InvalidMasterPasswordException.java deleted file mode 100644 index 51bb65b..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/InvalidMasterPasswordException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class InvalidMasterPasswordException extends DevaultyException{ - public InvalidMasterPasswordException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/JsonProcessingException.java b/backend/src/main/java/com/devaulty/backend/application/exception/JsonProcessingException.java deleted file mode 100644 index 55ef669..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/JsonProcessingException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class JsonProcessingException extends DevaultyException{ - public JsonProcessingException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/MasterPasswordAlreadyConfiguredException.java b/backend/src/main/java/com/devaulty/backend/application/exception/MasterPasswordAlreadyConfiguredException.java deleted file mode 100644 index 6f99a7d..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/MasterPasswordAlreadyConfiguredException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class MasterPasswordAlreadyConfiguredException extends DevaultyException{ - public MasterPasswordAlreadyConfiguredException() { - super("Master password already configured"); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/MasterPasswordNotConfiguredException.java b/backend/src/main/java/com/devaulty/backend/application/exception/MasterPasswordNotConfiguredException.java deleted file mode 100644 index 4e087bf..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/MasterPasswordNotConfiguredException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class MasterPasswordNotConfiguredException extends DevaultyException{ - public MasterPasswordNotConfiguredException() { - super("Master password not configured"); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/ResourceAlreadyExistsException.java b/backend/src/main/java/com/devaulty/backend/application/exception/ResourceAlreadyExistsException.java deleted file mode 100644 index 563693e..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/ResourceAlreadyExistsException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class ResourceAlreadyExistsException extends DevaultyException{ - public ResourceAlreadyExistsException(String resourceName, Object identifier) { - super(String.format("%s already exists with identifier %s", resourceName, identifier)); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/ResourceNotFoundException.java b/backend/src/main/java/com/devaulty/backend/application/exception/ResourceNotFoundException.java deleted file mode 100644 index ddd49aa..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/ResourceNotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class ResourceNotFoundException extends DevaultyException { - public ResourceNotFoundException(String resourceName, Object identifier) { - super(String.format("%s not found with identifier %s", resourceName, identifier)); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/UpdateNotAvailableException.java b/backend/src/main/java/com/devaulty/backend/application/exception/UpdateNotAvailableException.java deleted file mode 100644 index f16466c..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/UpdateNotAvailableException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class UpdateNotAvailableException extends DevaultyException{ - public UpdateNotAvailableException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/VaultLockedException.java b/backend/src/main/java/com/devaulty/backend/application/exception/VaultLockedException.java deleted file mode 100644 index dac5488..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/exception/VaultLockedException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.exception; - -public class VaultLockedException extends DevaultyException{ - public VaultLockedException() { - super("Vault is locked, please unlock it first"); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/credential/CreateCredentialImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/credential/CreateCredentialImpl.java deleted file mode 100644 index 52f9963..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/credential/CreateCredentialImpl.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.credential.CreateCredentialCommand; -import com.devaulty.backend.application.port.in.credential.CreateCredentialUseCase; -import com.devaulty.backend.application.port.in.credential.DecryptedCredential; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.CryptoPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.application.port.out.security.dto.CryptoResultDto; -import com.devaulty.backend.domain.model.Credential; -import org.springframework.transaction.annotation.Transactional; - -import javax.crypto.SecretKey; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.UUID; - -public class CreateCredentialImpl implements CreateCredentialUseCase { - - private final CredentialRepositoryPort credentialRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final CryptoPort cryptoPort; - private final MasterKeySessionPort masterKeySessionPort; - private final CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - - public CreateCredentialImpl(CredentialRepositoryPort credentialRepositoryPort, ProjectRepositoryPort projectRepositoryPort, CryptoPort cryptoPort, MasterKeySessionPort masterKeySessionPort, CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase) { - this.credentialRepositoryPort = credentialRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.cryptoPort = cryptoPort; - this.masterKeySessionPort = masterKeySessionPort; - this.checkMasterPasswordSetupUseCase = checkMasterPasswordSetupUseCase; - } - - /** - * Creates a new credential, encrypting its payload with the active master key. - * - *

⚠ MEMORY OWNERSHIP: the returned {@link DecryptedCredential#decryptedPayload()} - * is NOT zeroed here — ownership passes to the caller. Whoever consumes it MUST - * wipe it after use: - *

{@code Arrays.fill(decryptedCredential.decryptedPayload(), (byte) 0);}
- * See {@code docs/security/memory-hygiene.md} for the full rule. - * - * @param command plaintext payload ({@code char[]}); this method zeroes it — - * caller does not need to. - * @return credential with decrypted payload; caller owns and must zero - * {@code decryptedPayload} after use. - */ - - @Override - @Transactional - public DecryptedCredential execute(CreateCredentialCommand command) { - java.nio.ByteBuffer buffer = null; - byte[] payloadBytes = null; - byte[] decryptedBytes = null; - - try { - // 1. Basic validations - if (!projectRepositoryPort.existsById(command.projectId())) throw new ResourceNotFoundException("Project", command.projectId()); - if (checkMasterPasswordSetupUseCase.isSetupRequired()) throw new MasterPasswordNotConfiguredException(); - - // 2. Gets the key of the active on RAM - SecretKey key = masterKeySessionPort.getKey(); - if (key == null) throw new VaultLockedException(); - - // Generate UUID and compute AAD before encryption - UUID credentialId = UUID.randomUUID(); - byte[] aad = computeAad(command.projectId(), credentialId); - - // 3. Converts the payload into a byte array - buffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(command.payload())); - payloadBytes = new byte[buffer.remaining()]; - buffer.get(payloadBytes); - - // 4. Encrypts the payload using the key, Bouncy Castle, and AAD - CryptoResultDto cryptoResult = cryptoPort.encrypt(payloadBytes, key, aad); - - // 5. Persists the encrypted payload into the database - Credential credential = new Credential(); - credential.setId(credentialId); - credential.setProjectId(command.projectId()); - credential.setTitle(command.title()); - credential.setSecretType(command.secretType()); - credential.setPayloadEncrypted(cryptoResult.cipherText()); - credential.setEncryptionIv(cryptoResult.iv()); - credential.setEncryptionAuthTag(cryptoResult.authTag()); - credential.setNotes(command.notes()); - credential.setRelatedUrl(command.relatedUrl()); - credential.setCreatedAt(LocalDateTime.now()); - - Credential savedCredential = credentialRepositoryPort.save(credential); - - // 6. Decrypts the payload - decryptedBytes = cryptoPort.decrypt( - savedCredential.getPayloadEncrypted(), - savedCredential.getEncryptionIv(), - savedCredential.getEncryptionAuthTag(), - key, - aad) - ; - - // 7. Returns the decrypted credentials - return new DecryptedCredential( - savedCredential.getId(), - savedCredential.getProjectId(), - savedCredential.getTitle(), - savedCredential.getSecretType(), - decryptedBytes, - savedCredential.getNotes(), - savedCredential.getRelatedUrl(), - savedCredential.getCreatedAt(), - savedCredential.getUpdatedAt() - ); - - } finally { - // 8. Clears the payload from memory - // decryptedBytes is NOT cleared here — its ownership was transferred - // to the returned DecryptedCredential, and it will be cleared by - // whoever consumes it (CredentialWebMapper.jsonToMap). - if (buffer != null && buffer.hasArray()) { - Arrays.fill(buffer.array(), (byte) 0); - } - if(payloadBytes != null) Arrays.fill(payloadBytes, (byte) 0); - Arrays.fill(command.payload(), '\0'); - } - } - - private byte[] computeAad(UUID projectId, UUID credentialId) { - ByteBuffer buffer = ByteBuffer.allocate(32); - buffer.putLong(projectId.getMostSignificantBits()); - buffer.putLong(projectId.getLeastSignificantBits()); - buffer.putLong(credentialId.getMostSignificantBits()); - buffer.putLong(credentialId.getLeastSignificantBits()); - return buffer.array(); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/credential/DeleteCredentialImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/credential/DeleteCredentialImpl.java deleted file mode 100644 index 182390a..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/credential/DeleteCredentialImpl.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.credential.DeleteCredentialUseCase; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class DeleteCredentialImpl implements DeleteCredentialUseCase { - - private final CredentialRepositoryPort credentialRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final MasterKeySessionPort masterKeySessionPort; - private final CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - private final ItemTagRepositoryPort itemTagRepositoryPort; - - public DeleteCredentialImpl(CredentialRepositoryPort credentialRepositoryPort, ProjectRepositoryPort projectRepositoryPort, MasterKeySessionPort masterKeySessionPort, CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase, ItemTagRepositoryPort itemTagRepositoryPort) { - this.credentialRepositoryPort = credentialRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.masterKeySessionPort = masterKeySessionPort; - this.checkMasterPasswordSetupUseCase = checkMasterPasswordSetupUseCase; - this.itemTagRepositoryPort = itemTagRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID projectId, UUID id) { - - if (checkMasterPasswordSetupUseCase.isSetupRequired()) throw new MasterPasswordNotConfiguredException(); - if (masterKeySessionPort.getKey() == null) throw new VaultLockedException(); - if (!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - if(!credentialRepositoryPort.existsByIdAndProjectId(id, projectId)) throw new ResourceNotFoundException("Credential", id); - - itemTagRepositoryPort.removeAllTagsFromItem("credential", id); - credentialRepositoryPort.deleteById(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/credential/GetAllCredentialsByProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/credential/GetAllCredentialsByProjectImpl.java deleted file mode 100644 index 640a995..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/credential/GetAllCredentialsByProjectImpl.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.credential.CredentialSummary; -import com.devaulty.backend.application.port.in.credential.GetAllCredentialsByProjectUseCase; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.springframework.data.domain.Page; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetAllCredentialsByProjectImpl implements GetAllCredentialsByProjectUseCase { - - private final CredentialRepositoryPort credentialRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final MasterKeySessionPort masterKeySessionPort; - private final CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - - public GetAllCredentialsByProjectImpl(CredentialRepositoryPort credentialRepositoryPort, ProjectRepositoryPort projectRepositoryPort, MasterKeySessionPort masterKeySessionPort, CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase) { - this.credentialRepositoryPort = credentialRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.masterKeySessionPort = masterKeySessionPort; - this.checkMasterPasswordSetupUseCase = checkMasterPasswordSetupUseCase; - } - - @Override - @Transactional(readOnly = true) - public Page execute(UUID projectId, int page, int size) { - - // Basic validations - if (checkMasterPasswordSetupUseCase.isSetupRequired()) throw new MasterPasswordNotConfiguredException(); - if (masterKeySessionPort.getKey() == null) throw new VaultLockedException(); - if (!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - return credentialRepositoryPort.findAllByProject(projectId, page, size); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/credential/GetCredentialByIdImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/credential/GetCredentialByIdImpl.java deleted file mode 100644 index d094682..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/credential/GetCredentialByIdImpl.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.credential.DecryptedCredential; -import com.devaulty.backend.application.port.in.credential.GetCredentialByIdUseCase; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.CryptoPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.domain.model.Credential; -import org.springframework.transaction.annotation.Transactional; - -import javax.crypto.SecretKey; -import java.nio.ByteBuffer; -import java.util.UUID; - -public class GetCredentialByIdImpl implements GetCredentialByIdUseCase { - - private final CredentialRepositoryPort credentialRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final CryptoPort cryptoPort; - private final MasterKeySessionPort masterKeySessionPort; - private final CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - - public GetCredentialByIdImpl(CredentialRepositoryPort credentialRepositoryPort, ProjectRepositoryPort projectRepositoryPort, CryptoPort cryptoPort, MasterKeySessionPort masterKeySessionPort, CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase) { - this.credentialRepositoryPort = credentialRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.cryptoPort = cryptoPort; - this.masterKeySessionPort = masterKeySessionPort; - this.checkMasterPasswordSetupUseCase = checkMasterPasswordSetupUseCase; - } - - /** - * Retrieves a single credential by its ID within a specific project and decrypts its payload. - * - *

⚠ MEMORY OWNERSHIP: the returned {@link DecryptedCredential#decryptedPayload()} - * is NOT zeroed here — ownership passes to the caller. Whoever consumes it MUST - * wipe it after use: - *

{@code Arrays.fill(decryptedCredential.decryptedBytesPayload(), (byte) 0);}
- * See {@code docs/security/memory-hygiene.md} for the full rule. - * - * @param projectId the ID of the project the credential belongs to. - * @param id the ID of the credential to retrieve. - * @return the decrypted credential details; the caller takes ownership of the sensitive - * decrypted byte array and must wipe it after processing. - */ - - @Override - @Transactional(readOnly = true) - public DecryptedCredential execute(UUID projectId, UUID id) { - - SecretKey key = masterKeySessionPort.getKey(); - - if (checkMasterPasswordSetupUseCase.isSetupRequired()) throw new MasterPasswordNotConfiguredException(); - if (key == null) throw new VaultLockedException(); - if (!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - Credential credential = credentialRepositoryPort.findById(id) - .filter(c -> projectId.equals(c.getProjectId())) - .orElseThrow(() -> new ResourceNotFoundException("Credential", id)); - - byte[] aad = computeAad(projectId, id); - byte [] decryptedPayloadBytes = cryptoPort.decrypt( - credential.getPayloadEncrypted(), - credential.getEncryptionIv(), - credential.getEncryptionAuthTag(), - key, - aad - ); - - return new DecryptedCredential( - credential.getId(), - credential.getProjectId(), - credential.getTitle(), - credential.getSecretType(), - decryptedPayloadBytes, - credential.getNotes(), - credential.getRelatedUrl(), - credential.getCreatedAt(), - credential.getUpdatedAt() - ); - } - - private byte[] computeAad(UUID projectId, UUID credentialId) { - ByteBuffer buffer = ByteBuffer.allocate(32); - buffer.putLong(projectId.getMostSignificantBits()); - buffer.putLong(projectId.getLeastSignificantBits()); - buffer.putLong(credentialId.getMostSignificantBits()); - buffer.putLong(credentialId.getLeastSignificantBits()); - return buffer.array(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/credential/UpdateCredentialImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/credential/UpdateCredentialImpl.java deleted file mode 100644 index 3731692..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/credential/UpdateCredentialImpl.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.credential.DecryptedCredential; -import com.devaulty.backend.application.port.in.credential.UpdateCredentialCommand; -import com.devaulty.backend.application.port.in.credential.UpdateCredentialUseCase; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.CryptoPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.application.port.out.security.dto.CryptoResultDto; -import com.devaulty.backend.domain.model.Credential; -import org.springframework.transaction.annotation.Transactional; - -import javax.crypto.SecretKey; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.UUID; - -public class UpdateCredentialImpl implements UpdateCredentialUseCase { - - private final CredentialRepositoryPort credentialRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final CryptoPort cryptoPort; - private final MasterKeySessionPort masterKeySessionPort; - private final CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - - public UpdateCredentialImpl(CredentialRepositoryPort credentialRepositoryPort, ProjectRepositoryPort projectRepositoryPort, CryptoPort cryptoPort, MasterKeySessionPort masterKeySessionPort, CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase) { - this.credentialRepositoryPort = credentialRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.cryptoPort = cryptoPort; - this.masterKeySessionPort = masterKeySessionPort; - this.checkMasterPasswordSetupUseCase = checkMasterPasswordSetupUseCase; - } - - /** - * Updates an existing credential, re-encrypting the new payload if provided. - * - *

⚠ MEMORY OWNERSHIP: the returned {@link DecryptedCredential#decryptedPayload()} - * is NOT zeroed here — ownership passes to the caller. Whoever consumes it MUST - * wipe it after use: - *

{@code Arrays.fill(decryptedCredential.decryptedPayload(), (byte) 0);}
- * See {@code docs/security/memory-hygiene.md} for the full rule. - * - * @param command update details containing the target IDs, metadata, and optional new plaintext - * payload ({@code char[]}); this method zeroes the input payload if present — - * caller does not need to. - * @return the updated credential with decrypted payload; caller owns and must zero - * {@code decryptedPayload} after use. - */ - - @Override - @Transactional - public DecryptedCredential execute(UpdateCredentialCommand command) { - java.nio.ByteBuffer buffer = null; - byte[] payloadBytes = null; - byte[] decryptedBytes = null; - - try { - - SecretKey key = masterKeySessionPort.getKey(); - - if (checkMasterPasswordSetupUseCase.isSetupRequired()) throw new MasterPasswordNotConfiguredException(); - if (key == null) throw new VaultLockedException(); - if (!projectRepositoryPort.existsById(command.projectId())) - throw new ResourceNotFoundException("Project", command.projectId()); - - Credential credential = credentialRepositoryPort.findById(command.id()) - .filter(c -> command.projectId().equals(c.getProjectId())) - .orElseThrow(() -> new ResourceNotFoundException("Credential", command.id())); - - if (command.title() != null) credential.setTitle(command.title()); - if (command.secretType() != null) credential.setSecretType(command.secretType()); - if (command.notes() != null) credential.setNotes(command.notes()); - if (command.relatedUrl() != null) credential.setRelatedUrl(command.relatedUrl()); - byte[] aad = computeAad(command.projectId(), command.id()); - if (command.payload() != null) { - - buffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(command.payload())); - payloadBytes = new byte[buffer.remaining()]; - buffer.get(payloadBytes); - - CryptoResultDto cryptoResult = cryptoPort.encrypt(payloadBytes, key, aad); - - credential.setPayloadEncrypted(cryptoResult.cipherText()); - credential.setEncryptionIv(cryptoResult.iv()); - credential.setEncryptionAuthTag(cryptoResult.authTag()); - } - - credential.setUpdatedAt(LocalDateTime.now()); - credentialRepositoryPort.save(credential); - - decryptedBytes = cryptoPort.decrypt( - credential.getPayloadEncrypted(), - credential.getEncryptionIv(), - credential.getEncryptionAuthTag(), - key, - aad - ); - - return new DecryptedCredential( - credential.getId(), - credential.getProjectId(), - credential.getTitle(), - credential.getSecretType(), - decryptedBytes, - credential.getNotes(), - credential.getRelatedUrl(), - credential.getCreatedAt(), - credential.getUpdatedAt() - ); - - } finally { - // decryptedBytes is NOT cleared here — its ownership was transferred - // to the returned DecryptedCredential, and it will be cleared by - // whoever consumes it (CredentialWebMapper.jsonToMap). - if (buffer != null && buffer.hasArray()) { - Arrays.fill(buffer.array(), (byte) 0); - } - if(payloadBytes != null) Arrays.fill(payloadBytes, (byte) 0); - if(command.payload() != null) Arrays.fill(command.payload(), '\0'); - } - } - - private byte[] computeAad(UUID projectId, UUID credentialId) { - ByteBuffer buffer = ByteBuffer.allocate(32); - buffer.putLong(projectId.getMostSignificantBits()); - buffer.putLong(projectId.getLeastSignificantBits()); - buffer.putLong(credentialId.getMostSignificantBits()); - buffer.putLong(credentialId.getLeastSignificantBits()); - return buffer.array(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/link/CreateLinkImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/link/CreateLinkImpl.java deleted file mode 100644 index 5be9580..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/link/CreateLinkImpl.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.link.CreateLinkCommand; -import com.devaulty.backend.application.port.in.link.CreateLinkUseCase; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Link; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class CreateLinkImpl implements CreateLinkUseCase { - - private final LinkRepositoryPort linkRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public CreateLinkImpl(LinkRepositoryPort linkRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.linkRepositoryPort = linkRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Link execute(CreateLinkCommand command) { - - if(!projectRepositoryPort.existsById(command.projectId())) throw new ResourceNotFoundException("Project", command.projectId()); - - Link link = new Link(); - link.setId(UUID.randomUUID()); - link.setProjectId(command.projectId()); - link.setTitle(command.title()); - link.setUrl(command.url()); - link.setDescription(command.description()); - link.setCreatedAt(LocalDateTime.now()); - - return linkRepositoryPort.save(link); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/link/DeleteLinkImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/link/DeleteLinkImpl.java deleted file mode 100644 index 1fbcc90..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/link/DeleteLinkImpl.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.link.DeleteLinkUseCase; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class DeleteLinkImpl implements DeleteLinkUseCase { - - private final LinkRepositoryPort linkRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final ItemTagRepositoryPort itemTagRepositoryPort; - - public DeleteLinkImpl(LinkRepositoryPort linkRepositoryPort, ProjectRepositoryPort projectRepositoryPort, ItemTagRepositoryPort itemTagRepositoryPort) { - this.linkRepositoryPort = linkRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.itemTagRepositoryPort = itemTagRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID projectId, UUID id) { - - if (!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - if (!linkRepositoryPort.existsByIdAndProjectId(id, projectId)) throw new ResourceNotFoundException("Link", id); - - itemTagRepositoryPort.removeAllTagsFromItem("link", id); - linkRepositoryPort.deleteById(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/link/GetAllLinksByProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/link/GetAllLinksByProjectImpl.java deleted file mode 100644 index e7204ec..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/link/GetAllLinksByProjectImpl.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.link.GetAllLinksByProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Link; -import org.springframework.data.domain.Page; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetAllLinksByProjectImpl implements GetAllLinksByProjectUseCase { - - private final LinkRepositoryPort linkRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetAllLinksByProjectImpl(LinkRepositoryPort linkRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.linkRepositoryPort = linkRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Page execute(UUID projectId, int page, int size) { - - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - return linkRepositoryPort.findAllByProject(projectId, page, size); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/link/GetLinkByIdImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/link/GetLinkByIdImpl.java deleted file mode 100644 index d82325b..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/link/GetLinkByIdImpl.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.link.GetLinkByIdUseCase; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Link; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetLinkByIdImpl implements GetLinkByIdUseCase { - - private final LinkRepositoryPort linkRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetLinkByIdImpl(LinkRepositoryPort linkRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.linkRepositoryPort = linkRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Link execute(UUID projectId ,UUID id) { - - if (!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - Link link = linkRepositoryPort.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Link", id)); - - if (!link.getProjectId().equals(projectId)) throw new ResourceNotFoundException("Link", id); - - return link; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/link/UpdateLinkImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/link/UpdateLinkImpl.java deleted file mode 100644 index 24b0faf..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/link/UpdateLinkImpl.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.link.UpdateLinkCommand; -import com.devaulty.backend.application.port.in.link.UpdateLinkUseCase; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Link; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -public class UpdateLinkImpl implements UpdateLinkUseCase { - - private final LinkRepositoryPort linkRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public UpdateLinkImpl(LinkRepositoryPort linkRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.linkRepositoryPort = linkRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Link execute(UpdateLinkCommand command) { - - if(!projectRepositoryPort.existsById(command.projectId())) throw new ResourceNotFoundException("Project", command.projectId()); - - Link link = linkRepositoryPort.findById(command.id()) - .orElseThrow(() -> new ResourceNotFoundException("Link", command.id())); - - if(!link.getProjectId().equals(command.projectId())) throw new ResourceNotFoundException("Link", command.id()); - - if(command.title() != null) link.setTitle(command.title()); - if(command.url() != null) link.setUrl(command.url()); - if(command.description() != null) link.setDescription(command.description()); - link.setUpdatedAt(LocalDateTime.now()); - - return linkRepositoryPort.save(link); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/note/ArchiveNoteImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/note/ArchiveNoteImpl.java deleted file mode 100644 index 309de15..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/note/ArchiveNoteImpl.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.BusinessRuleException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.note.ArchiveNoteUseCase; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class ArchiveNoteImpl implements ArchiveNoteUseCase { - - private final NoteRepositoryPort noteRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public ArchiveNoteImpl(NoteRepositoryPort noteRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.noteRepositoryPort = noteRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID projectId, UUID id) { - - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - Note note = noteRepositoryPort.findById(id) - .filter(n -> projectId.equals(n.getProjectId())) - .orElseThrow(() -> new ResourceNotFoundException("Note", id)); - - if(note.isArchived()){ - throw new BusinessRuleException("Note already archived"); - } - - note.setArchived(true); - note.setUpdatedAt(LocalDateTime.now()); - noteRepositoryPort.save(note); - - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/note/CreateNoteImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/note/CreateNoteImpl.java deleted file mode 100644 index 14435eb..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/note/CreateNoteImpl.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.note.CreateNoteCommand; -import com.devaulty.backend.application.port.in.note.CreateNoteUseCase; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class CreateNoteImpl implements CreateNoteUseCase { - - private final NoteRepositoryPort noteRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public CreateNoteImpl(NoteRepositoryPort noteRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.noteRepositoryPort = noteRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Note execute(CreateNoteCommand command) { - - if(!projectRepositoryPort.existsById(command.projectId())) throw new ResourceNotFoundException("Project", command.projectId()); - - Note note = new Note(); - note.setId(UUID.randomUUID()); - note.setProjectId(command.projectId()); - note.setTitle(command.title()); - note.setContent(command.content()); - note.setArchived(false); - note.setCreatedAt(LocalDateTime.now()); - - return noteRepositoryPort.save(note); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/note/DeleteNoteImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/note/DeleteNoteImpl.java deleted file mode 100644 index fb74c4c..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/note/DeleteNoteImpl.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.note.DeleteNoteUseCase; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class DeleteNoteImpl implements DeleteNoteUseCase { - - private final NoteRepositoryPort noteRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final ItemTagRepositoryPort itemTagRepositoryPort; - - public DeleteNoteImpl(NoteRepositoryPort noteRepositoryPort, ProjectRepositoryPort projectRepositoryPort, ItemTagRepositoryPort itemTagRepositoryPort) { - this.noteRepositoryPort = noteRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.itemTagRepositoryPort = itemTagRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID projectId, UUID id) { - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - if (!noteRepositoryPort.existsByIdAndProjectId(id, projectId)) throw new ResourceNotFoundException("Note", id); - - itemTagRepositoryPort.removeAllTagsFromItem("note", id); - noteRepositoryPort.deleteById(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/note/GetAllNotesByProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/note/GetAllNotesByProjectImpl.java deleted file mode 100644 index b4effe5..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/note/GetAllNotesByProjectImpl.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.note.GetAllNotesByProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.springframework.data.domain.Page; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetAllNotesByProjectImpl implements GetAllNotesByProjectUseCase { - - private final NoteRepositoryPort noteRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetAllNotesByProjectImpl(NoteRepositoryPort noteRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.noteRepositoryPort = noteRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Page execute(UUID projectId, int page, int size) { - - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - return noteRepositoryPort.findAllByProject(projectId, page, size); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/note/GetNoteByIdImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/note/GetNoteByIdImpl.java deleted file mode 100644 index bcc822c..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/note/GetNoteByIdImpl.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.note.GetNoteByIdUseCase; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetNoteByIdImpl implements GetNoteByIdUseCase { - - private final NoteRepositoryPort noteRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetNoteByIdImpl(NoteRepositoryPort noteRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.noteRepositoryPort = noteRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Note execute(UUID projectId, UUID id) { - - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - Note note = noteRepositoryPort.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Note", id)); - - if(!note.getProjectId().equals(projectId)) throw new ResourceNotFoundException("Note", id); - - return note; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/note/UnarchiveNoteImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/note/UnarchiveNoteImpl.java deleted file mode 100644 index 9bb1e06..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/note/UnarchiveNoteImpl.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.BusinessRuleException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.note.UnarchiveNoteUseCase; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class UnarchiveNoteImpl implements UnarchiveNoteUseCase { - - private final NoteRepositoryPort noteRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public UnarchiveNoteImpl(NoteRepositoryPort noteRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.noteRepositoryPort = noteRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID projectId, UUID id) { - - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - Note note = noteRepositoryPort.findById(id) - .filter(n -> projectId.equals(n.getProjectId())) - .orElseThrow(() -> new ResourceNotFoundException("Note", id)); - - if(!note.isArchived()){ - throw new BusinessRuleException("Note not archived"); - } - - note.setArchived(false); - note.setUpdatedAt(LocalDateTime.now()); - - noteRepositoryPort.save(note); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/note/UpdateNoteImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/note/UpdateNoteImpl.java deleted file mode 100644 index d810eef..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/note/UpdateNoteImpl.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.note.UpdateNoteCommand; -import com.devaulty.backend.application.port.in.note.UpdateNoteUseCase; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -public class UpdateNoteImpl implements UpdateNoteUseCase { - - private final NoteRepositoryPort noteRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public UpdateNoteImpl(NoteRepositoryPort noteRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.noteRepositoryPort = noteRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Note execute(UpdateNoteCommand command) { - - if(!projectRepositoryPort.existsById(command.projectId())) throw new ResourceNotFoundException("Project", command.projectId()); - - Note note = noteRepositoryPort.findById(command.id()) - .filter(n -> command.projectId().equals(n.getProjectId())) - .orElseThrow(() -> new ResourceNotFoundException("Note", command.id())); - - if (command.title() != null ) note.setTitle(command.title()); - if (command.content() != null ) note.setContent(command.content()); - note.setUpdatedAt(LocalDateTime.now()); - - return noteRepositoryPort.save(note); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/problem/CreateProblemImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/problem/CreateProblemImpl.java deleted file mode 100644 index 624475a..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/problem/CreateProblemImpl.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.problem.CreateProblemCommand; -import com.devaulty.backend.application.port.in.problem.CreateProblemUseCase; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class CreateProblemImpl implements CreateProblemUseCase { - - private final ProblemRepositoryPort problemRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public CreateProblemImpl(ProblemRepositoryPort problemRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.problemRepositoryPort = problemRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Problem execute(CreateProblemCommand command) { - - if (!projectRepositoryPort.existsById(command.projectId())) throw new ResourceNotFoundException("Project", command.projectId()); - - Problem problem = new Problem(); - problem.setId(UUID.randomUUID()); - problem.setProjectId(command.projectId()); - problem.setTitle(command.title()); - problem.setErrorDescription(command.errorDescription()); - problem.setSolution(command.solution()); - problem.setStatus(command.status()); - problem.setSeverity(command.severity()); - problem.setCreatedAt(LocalDateTime.now()); - - return problemRepositoryPort.save(problem); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/problem/DeleteProblemImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/problem/DeleteProblemImpl.java deleted file mode 100644 index 161f47f..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/problem/DeleteProblemImpl.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.problem.DeleteProblemUseCase; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class DeleteProblemImpl implements DeleteProblemUseCase { - - private final ProblemRepositoryPort problemRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final ItemTagRepositoryPort itemTagRepositoryPort; - - public DeleteProblemImpl(ProblemRepositoryPort problemRepositoryPort, ProjectRepositoryPort projectRepositoryPort, ItemTagRepositoryPort itemTagRepositoryPort) { - this.problemRepositoryPort = problemRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.itemTagRepositoryPort = itemTagRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID projectId, UUID id) { - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - if (!problemRepositoryPort.existsByIdAndProjectId(id, projectId)) throw new ResourceNotFoundException("Problem", id); - - itemTagRepositoryPort.removeAllTagsFromItem("problem", id); - problemRepositoryPort.deleteById(id); - - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/problem/GetAllProblemsByProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/problem/GetAllProblemsByProjectImpl.java deleted file mode 100644 index b397f99..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/problem/GetAllProblemsByProjectImpl.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.problem.GetAllProblemsByProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import org.springframework.data.domain.Page; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetAllProblemsByProjectImpl implements GetAllProblemsByProjectUseCase { - - private final ProblemRepositoryPort problemRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetAllProblemsByProjectImpl(ProblemRepositoryPort problemRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.problemRepositoryPort = problemRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Page execute(UUID projectId, int page, int size) { - - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - return problemRepositoryPort.findAllByProject(projectId, page, size); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/problem/GetProblemByIdImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/problem/GetProblemByIdImpl.java deleted file mode 100644 index 23ed6b0..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/problem/GetProblemByIdImpl.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.problem.GetProblemByIdUseCase; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetProblemByIdImpl implements GetProblemByIdUseCase { - - private final ProblemRepositoryPort problemRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetProblemByIdImpl(ProblemRepositoryPort problemRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.problemRepositoryPort = problemRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Problem execute(UUID projectId, UUID id) { - - if (!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - Problem problem = problemRepositoryPort.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Problem", id)); - - if(!problem.getProjectId().equals(projectId)) throw new ResourceNotFoundException("Problem", id); - - return problem; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/problem/UpdateProblemImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/problem/UpdateProblemImpl.java deleted file mode 100644 index 3f51316..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/problem/UpdateProblemImpl.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.problem.UpdateProblemCommand; -import com.devaulty.backend.application.port.in.problem.UpdateProblemUseCase; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -public class UpdateProblemImpl implements UpdateProblemUseCase { - - private final ProblemRepositoryPort problemRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public UpdateProblemImpl(ProblemRepositoryPort problemRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.problemRepositoryPort = problemRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Problem execute(UpdateProblemCommand command) { - - if(!projectRepositoryPort.existsById(command.projectId())) throw new ResourceNotFoundException("Project", command.projectId()); - - Problem problem = problemRepositoryPort.findById(command.id()) - .orElseThrow(() -> new ResourceNotFoundException("Problem", command.id())); - - if(!problem.getProjectId().equals(command.projectId())) throw new ResourceNotFoundException("Problem", command.id()); - - if (command.title() != null ) problem.setTitle(command.title()); - if (command.errorDescription() != null ) problem.setErrorDescription(command.errorDescription()); - if (command.solution() != null ) problem.setSolution(command.solution()); - if (command.severity() != null ) problem.setSeverity(command.severity()); - problem.setUpdatedAt(LocalDateTime.now()); - - return problemRepositoryPort.save(problem); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/problem/UpdateProblemStatusImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/problem/UpdateProblemStatusImpl.java deleted file mode 100644 index 460f5b5..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/problem/UpdateProblemStatusImpl.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.problem.UpdateProblemStatusCommand; -import com.devaulty.backend.application.port.in.problem.UpdateProblemStatusUseCase; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -public class UpdateProblemStatusImpl implements UpdateProblemStatusUseCase { - - private final ProblemRepositoryPort problemRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public UpdateProblemStatusImpl(ProblemRepositoryPort problemRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.problemRepositoryPort = problemRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Problem execute(UpdateProblemStatusCommand command) { - - if(!projectRepositoryPort.existsById(command.projectId())) throw new ResourceNotFoundException("Project", command.projectId()); - - Problem problem = problemRepositoryPort.findById(command.id()) - .orElseThrow(() -> new ResourceNotFoundException("Problem", command.id())); - - if(!problem.getProjectId().equals(command.projectId())) throw new ResourceNotFoundException("Problem", command.id()); - - problem.setStatus(command.status()); - problem.setUpdatedAt(LocalDateTime.now()); - - return problemRepositoryPort.save(problem); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/project/ArchiveProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/project/ArchiveProjectImpl.java deleted file mode 100644 index d7355ab..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/project/ArchiveProjectImpl.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.BusinessRuleException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.project.ArchiveProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class ArchiveProjectImpl implements ArchiveProjectUseCase { - - private final ProjectRepositoryPort projectRepositoryPort; - - public ArchiveProjectImpl(ProjectRepositoryPort projectRepositoryPort) { - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID id) { - Project project = projectRepositoryPort.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Project", id)); - - if (!project.isArchived()){ - project.setArchived(true); - project.setUpdatedAt(LocalDateTime.now()); - projectRepositoryPort.save(project); - } else { - throw new BusinessRuleException("Project already archived"); - } - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/project/CreateProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/project/CreateProjectImpl.java deleted file mode 100644 index a9e4ab6..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/project/CreateProjectImpl.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.port.in.project.CreateProjectCommand; -import com.devaulty.backend.application.port.in.project.CreateProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class CreateProjectImpl implements CreateProjectUseCase { - - private final ProjectRepositoryPort projectRepository; - - public CreateProjectImpl(ProjectRepositoryPort projectRepository) { - this.projectRepository = projectRepository; - } - - @Override - @Transactional - public Project execute(CreateProjectCommand command) { - Project project = new Project(); - project.setId(UUID.randomUUID()); - project.setName(command.name()); - project.setDescription(command.description()); - project.setIcon(command.icon()); - project.setColor(command.color()); - project.setArchived(false); - project.setCreatedAt(LocalDateTime.now()); - return projectRepository.save(project); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/project/DeleteProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/project/DeleteProjectImpl.java deleted file mode 100644 index 3279570..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/project/DeleteProjectImpl.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.project.DeleteProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class DeleteProjectImpl implements DeleteProjectUseCase { - - private final ProjectRepositoryPort projectRepositoryPort; - - public DeleteProjectImpl(ProjectRepositoryPort projectRepositoryPort) { - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID id) { - - if (!projectRepositoryPort.existsById(id)){ - throw new ResourceNotFoundException("Project", id); - } - - projectRepositoryPort.deleteById(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/project/GetAllProjectsImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/project/GetAllProjectsImpl.java deleted file mode 100644 index c246d62..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/project/GetAllProjectsImpl.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.port.in.project.GetAllProjectsUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.springframework.data.domain.Page; -import org.springframework.transaction.annotation.Transactional; - -public class GetAllProjectsImpl implements GetAllProjectsUseCase { - - private final ProjectRepositoryPort projectRepositoryPort; - - public GetAllProjectsImpl(ProjectRepositoryPort projectRepositoryPort) { - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Page execute(int page, int size) { - return projectRepositoryPort.findAll(page, size); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/project/GetProjectByIdImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/project/GetProjectByIdImpl.java deleted file mode 100644 index ecf1f1f..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/project/GetProjectByIdImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.project.GetProjectByIdUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetProjectByIdImpl implements GetProjectByIdUseCase { - - private final ProjectRepositoryPort projectRepository; - - public GetProjectByIdImpl(ProjectRepositoryPort projectRepository) { - this.projectRepository = projectRepository; - } - - @Override - @Transactional(readOnly = true) - public Project execute(UUID id) { - return projectRepository.findById(id). - orElseThrow(() -> new ResourceNotFoundException("Project", id)); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/project/UnarchiveProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/project/UnarchiveProjectImpl.java deleted file mode 100644 index acbc050..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/project/UnarchiveProjectImpl.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.BusinessRuleException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.project.UnarchiveProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class UnarchiveProjectImpl implements UnarchiveProjectUseCase { - - private final ProjectRepositoryPort projectRepositoryPort; - - public UnarchiveProjectImpl(ProjectRepositoryPort projectRepositoryPort) { - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID id) { - Project project = projectRepositoryPort.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Project", id)); - - if(project.isArchived()){ - project.setArchived(false); - project.setUpdatedAt(LocalDateTime.now()); - projectRepositoryPort.save(project); - } else { - throw new BusinessRuleException("Project already unarchived"); - } - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/project/UpdateProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/project/UpdateProjectImpl.java deleted file mode 100644 index c88ea60..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/project/UpdateProjectImpl.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.project.CreateProjectCommand; -import com.devaulty.backend.application.port.in.project.UpdateProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class UpdateProjectImpl implements UpdateProjectUseCase { - - private final ProjectRepositoryPort projectRepositoryPort; - - public UpdateProjectImpl(ProjectRepositoryPort projectRepositoryPort) { - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Project execute(UUID id, CreateProjectCommand command) { - - Project project = projectRepositoryPort.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Project", id)); - - if(command.name() != null) project.setName(command.name()); - if(command.description() != null) project.setDescription(command.description()); - if(command.icon() != null) project.setIcon(command.icon()); - if(command.color() != null) project.setColor(command.color()); - project.setUpdatedAt(LocalDateTime.now()); - - return projectRepositoryPort.save(project); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImpl.java deleted file mode 100644 index e0706d7..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImpl.java +++ /dev/null @@ -1,225 +0,0 @@ -package com.devaulty.backend.application.impl.release; - -import com.devaulty.backend.application.port.in.release.AppUpdateInfo; -import com.devaulty.backend.application.port.in.release.CheckForUpdatesUseCase; -import com.devaulty.backend.application.port.out.external.release.ReleasePort; -import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; -import com.devaulty.backend.application.port.out.external.release.dto.ReleaseAssetInfo; -import com.devaulty.backend.infrastructure.properties.DevaultyProperties; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.util.List; -import java.util.Optional; - -public class CheckForUpdatesImpl implements CheckForUpdatesUseCase { - - private final ReleasePort releasePort; - private final DevaultyProperties devaultyProperties; - - private static final String OS_RELEASE_FILE = "/etc/os-release"; - private static final Logger logger = LoggerFactory.getLogger(CheckForUpdatesImpl.class); - - public CheckForUpdatesImpl(ReleasePort releasePort, DevaultyProperties devaultyProperties) { - this.releasePort = releasePort; - this.devaultyProperties = devaultyProperties; - } - - @Override - public AppUpdateInfo execute() { - - LatestReleaseInfo latestRelease = releasePort.getLatestRelease(); - - if (latestRelease == null) { - return new AppUpdateInfo( - false, - devaultyProperties != null ? devaultyProperties.getVersion() : null, - null, - null, - null, - null, - 0L, - null - ); - } - - String cleanLatestVersion = latestRelease.tagName().replaceAll("(?i)^v", "").trim(); - String currentVersion = devaultyProperties != null ? devaultyProperties.getVersion() : null; - - boolean updateAvailable = isNewerVersion(currentVersion, cleanLatestVersion); - - ReleaseAssetInfo targetAsset = findAssetForCurrentOs(latestRelease.assets()); - String downloadUrl = targetAsset != null ? targetAsset.downloadUrl() : null; - long downloadSize = targetAsset != null ? targetAsset.sizeInBytes() : 0L; - - return new AppUpdateInfo( - updateAvailable, - currentVersion, - latestRelease.tagName(), - latestRelease.name(), - latestRelease.body(), - downloadUrl, - downloadSize, - latestRelease.publishedAt() - ); - } - - private boolean isNewerVersion(String currentVersion, String latestVersion) { - if (currentVersion == null || latestVersion == null) { - return false; - } - - int hyphenCurrent = currentVersion.indexOf('-'); - int hyphenLatest = latestVersion.indexOf('-'); - - String currentCore = hyphenCurrent >= 0 ? currentVersion.substring(0, hyphenCurrent) : currentVersion; - String latestCore = hyphenLatest >= 0 ? latestVersion.substring(0, hyphenLatest) : latestVersion; - - String currentPre = hyphenCurrent >= 0 ? currentVersion.substring(hyphenCurrent + 1) : null; - String latestPre = hyphenLatest >= 0 ? latestVersion.substring(hyphenLatest + 1) : null; - - // 1. Compare numeric core (major.minor.patch) - int coreComparison = compareCore(currentCore, latestCore); - if (coreComparison != 0) { - return coreComparison < 0; - } - - // 2. Same core: stable (no pre-release) > any pre-release (SemVer spec) - if (currentPre == null && latestPre != null) { - return false; // current is stable, latest is pre-release → not newer - } - if (currentPre != null && latestPre == null) { - return true; // current is pre-release, latest is stable → newer - } - if (currentPre == null) { - return false; // both stable, same version - } - - // 3. Both have pre-release identifiers: compare dot-separated identifiers - return comparePreRelease(currentPre, latestPre) < 0; - } - - private int compareCore(String a, String b) { - String[] aParts = a.split("\\."); - String[] bParts = b.split("\\."); - int length = Math.max(aParts.length, bParts.length); - for (int i = 0; i < length; i++) { - int va = i < aParts.length ? parseVersionPart(aParts[i]) : 0; - int vb = i < bParts.length ? parseVersionPart(bParts[i]) : 0; - if (va != vb) return Integer.compare(va, vb); - } - return 0; - } - - /** - * Compares two pre-release strings per SemVer 2.0 spec: - * - Numeric identifiers are compared numerically. - * - Alphanumeric identifiers are compared lexically (ASCII). - * - A numeric identifier always has lower precedence than alphanumeric. - * Returns negative if a < b, positive if a > b, 0 if equal. - */ - private int comparePreRelease(String a, String b) { - String[] aIds = a.split("\\."); - String[] bIds = b.split("\\."); - int length = Math.max(aIds.length, bIds.length); - for (int i = 0; i < length; i++) { - if (i >= aIds.length) return -1; // a has fewer identifiers → a is smaller - if (i >= bIds.length) return 1; - - String ai = aIds[i]; - String bi = bIds[i]; - - boolean aNum = ai.chars().allMatch(Character::isDigit); - boolean bNum = bi.chars().allMatch(Character::isDigit); - - int cmp; - if (aNum && bNum) { - cmp = Integer.compare(Integer.parseInt(ai), Integer.parseInt(bi)); - } else if (aNum) { - cmp = -1; // numeric < alphanumeric - } else if (bNum) { - cmp = 1; - } else { - cmp = ai.compareTo(bi); - } - if (cmp != 0) return cmp; - } - return 0; - } - - - private int parseVersionPart(String part) { - try { - return Integer.parseInt(part); - } catch (NumberFormatException e) { - return 0; - } - } - - private ReleaseAssetInfo findAssetForCurrentOs(List assets) { - if (assets == null) { - return null; - } - - String os = System.getProperty("os.name").toLowerCase(); - - String extension; - if (os.contains("win")) { - extension = ".msi"; - } else if (os.contains("mac")) { - extension = ".dmg"; - } else { - extension = detectLinuxExtension(); - if (extension == null) { - return null; - } - } - - return assets.stream() - .filter(asset -> asset.fileName().endsWith(extension)) - .findFirst() - .orElse(null); - } - - private String detectLinuxExtension() { - - Optional osRelease = readOsRelease(); - if (osRelease.isEmpty()) { - return null; - } - - String content = osRelease.get().toLowerCase(); - - boolean isRpmBased = content.contains("rhel") - || content.contains("fedora") - || content.contains("suse"); - - boolean isDebBased = content.contains("debian") - || content.contains("ubuntu"); - - if (isRpmBased) { - return ".rpm"; - } - if (isDebBased) { - return ".deb"; - } - - logger.warn("Could not detect Linux distribution from /etc/os-release"); - return null; - } - - private Optional readOsRelease() { - File file = new File(OS_RELEASE_FILE); - if (!file.exists()) { - return Optional.empty(); - } - try { - return Optional.of(Files.readString(file.toPath())); - } catch (IOException e) { - return Optional.empty(); - } - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/DownloadUpdateImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/DownloadUpdateImpl.java deleted file mode 100644 index 1dfbd92..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/release/DownloadUpdateImpl.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.devaulty.backend.application.impl.release; - -import com.devaulty.backend.application.exception.UpdateNotAvailableException; -import com.devaulty.backend.application.port.in.release.*; -import com.devaulty.backend.application.port.in.release.enums.UpdateStatus; -import com.devaulty.backend.application.port.out.external.release.ReleasePort; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.function.Consumer; - -public class DownloadUpdateImpl implements DownloadUpdateUseCase { - - private static final long PROGRESS_EMIT_THRESHOLD_BYTES = 512 * 1024; // 512KB - private static final int COPY_BUFFER_SIZE = 8 * 1024; // 8KB for read - private final ReleasePort releasePort; - private final CheckForUpdatesUseCase checkForUpdatesUseCase; - private final InstallUpdateUseCase installUpdateUseCase; - - public DownloadUpdateImpl(ReleasePort releasePort, CheckForUpdatesUseCase checkForUpdatesUseCase, InstallUpdateUseCase installUpdateUseCase) { - this.releasePort = releasePort; - this.checkForUpdatesUseCase = checkForUpdatesUseCase; - this.installUpdateUseCase = installUpdateUseCase; - } - - @Override - public void execute(Consumer onProgress) { - - AppUpdateInfo updateInfo = checkForUpdatesUseCase.execute(); - - if (!updateInfo.updateAvailable() || updateInfo.downloadUrl() == null) { - throw new UpdateNotAvailableException( - "There is no update available for this application" - ); - } - - String downloadUrl = updateInfo.downloadUrl(); - long totalBytes = updateInfo.downloadSizeInBytes(); - - File tempDir = ReleaseTempFolder.resolve(); - String rawFileName = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1); - String fileName = sanitizeFileName(rawFileName); - - Path targetPath = new File(tempDir, fileName).toPath(); - verifyWithinTempDir(targetPath, tempDir); - - runDownloadProcess(downloadUrl, totalBytes, targetPath, onProgress); - } - - private String sanitizeFileName(String rawFileName) { - String sanitized = rawFileName - .replace("/", "") - .replace("\\", "") - .replace("\0", "") - .trim(); - - if (sanitized.isEmpty()) { - throw new IllegalArgumentException("Derived installer file name is empty after sanitization."); - } - - return sanitized; - } - - private void verifyWithinTempDir(Path targetPath, File tempDir) { - try { - Path canonicalTarget = targetPath.toFile().getCanonicalFile().toPath(); - Path canonicalTempDir = tempDir.getCanonicalFile().toPath(); - - if (!canonicalTarget.startsWith(canonicalTempDir)) { - throw new IllegalStateException("Installer target path resolved outside of the expected temp directory."); - } - } catch (IOException e) { - throw new IllegalStateException("Failed to resolve installer target path.", e); - } - } - - private void runDownloadProcess(String downloadUrl, long totalBytes, Path targetPath, Consumer onProgress) { - boolean knownSize = totalBytes > 0; - long downloadedBytes = 0; - long bytesSinceLastEmit = 0; - - onProgress.accept(new UpdateProgressInfo(UpdateStatus.DOWNLOADING, 0, 0L, totalBytes, null)); - - try { - try (InputStream in = releasePort.downloadAsset(downloadUrl); - OutputStream out = Files.newOutputStream( - targetPath, - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.TRUNCATE_EXISTING)) { - - byte[] buffer = new byte[COPY_BUFFER_SIZE]; - int read; - - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - - downloadedBytes += read; - bytesSinceLastEmit += read; - - // Só emite progresso de tempos em tempos, não a cada 8KB lido - if (bytesSinceLastEmit >= PROGRESS_EMIT_THRESHOLD_BYTES) { - int percentage = knownSize ? (int) ((downloadedBytes * 100) / totalBytes) : 0; - onProgress.accept(new UpdateProgressInfo( - UpdateStatus.DOWNLOADING, percentage, downloadedBytes, totalBytes, null - )); - bytesSinceLastEmit = 0; - } - } - } - - if (knownSize && downloadedBytes != totalBytes) { - throw new IOException("Download incomplete: received " + downloadedBytes + " bytes, expected " + totalBytes + " bytes."); - } - - // Garante um evento final de 100% mesmo que o último lote tenha sido pequeno - onProgress.accept(new UpdateProgressInfo( - UpdateStatus.DOWNLOADING, knownSize ? 100 : 0, downloadedBytes, totalBytes, null - )); - - onProgress.accept(new UpdateProgressInfo( - UpdateStatus.INSTALLING, 100, totalBytes, totalBytes, null - )); - - try { - installUpdateUseCase.execute(targetPath); - onProgress.accept(new UpdateProgressInfo( - UpdateStatus.COMPLETED, 100, totalBytes, totalBytes, null - )); - } catch (Exception installError) { - onProgress.accept(new UpdateProgressInfo( - UpdateStatus.FAILED, 100, totalBytes, totalBytes, - "Download Succeed, but the application failed to Install : " + installError.getMessage() - )); - } - - } catch (IOException ex) { - deleteQuietly(targetPath); - onProgress.accept(new UpdateProgressInfo( - UpdateStatus.FAILED, 0, downloadedBytes, totalBytes, ex.getMessage() - )); - } - } - - private void deleteQuietly(Path path) { - try { - Files.deleteIfExists(path); - } catch (Exception ignored) { - // Best-effort cleanup - } - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImpl.java deleted file mode 100644 index afaacb1..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImpl.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.devaulty.backend.application.impl.release; - -import com.devaulty.backend.application.port.in.release.GetCurrentVersionUseCase; -import com.devaulty.backend.infrastructure.properties.DevaultyProperties; - -public class GetCurrentVersionImpl implements GetCurrentVersionUseCase { - - private final DevaultyProperties devaultyProperties; - - public GetCurrentVersionImpl(DevaultyProperties devaultyProperties) { - this.devaultyProperties = devaultyProperties; - } - - @Override - public String execute() { - return devaultyProperties.getVersion(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/InstallUpdateImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/InstallUpdateImpl.java deleted file mode 100644 index b2f0215..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/release/InstallUpdateImpl.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.devaulty.backend.application.impl.release; - -import com.devaulty.backend.application.exception.UpdateNotAvailableException; -import com.devaulty.backend.application.port.in.release.InstallUpdateUseCase; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.SpringApplication; -import org.springframework.context.ConfigurableApplicationContext; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -/** - * Launches the platform-specific installer for a previously downloaded update - * and schedules the current application instance to shut down so the - * installer can replace the running files. - * - *

Known limitation: on macOS the update flow only mounts the {@code .dmg} - * image; it does not copy the app into {@code /Applications} or relaunch it - * automatically. A full macOS auto-update typically requires a dedicated - * mechanism (e.g. Sparkle) and is left as a follow-up.

- */ -public class InstallUpdateImpl implements InstallUpdateUseCase { - - private static final Logger logger = LoggerFactory.getLogger(InstallUpdateImpl.class); - - private static final long SHUTDOWN_DELAY_SECONDS = 5; - private static final String WINDOWS_INSTALL_DIR_ENV = "DEVAULTY_INSTALL_DIR"; - private static final String WINDOWS_DEFAULT_INSTALL_DIR = "C:\\Program Files\\devaulty"; - - private final ConfigurableApplicationContext applicationContext; - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - - public InstallUpdateImpl(ConfigurableApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } - - @Override - public void execute(Path installerFile) { - if (installerFile == null || !Files.exists(installerFile)) { - throw new UpdateNotAvailableException("No downloaded installer file found for: " + installerFile); - } - - File verifiedInstaller = verifyWithinTempDir(installerFile.toFile(), ReleaseTempFolder.resolve()); - - logger.info("Starting installer for file: {}", verifiedInstaller.getAbsolutePath()); - launchInstallerProcess(verifiedInstaller); - scheduleApplicationShutdown(); - } - - private void launchInstallerProcess(File installerFile) { - String os = System.getProperty("os.name").toLowerCase(); - String filePath = installerFile.getAbsolutePath(); - long currentPid = ProcessHandle.current().pid(); - - try { - if (os.contains("win")) { - launchWindowsInstaller(filePath, currentPid); - } else if (os.contains("mac")) { - launchMacInstaller(filePath, currentPid); - } else { - launchLinuxInstaller(filePath, currentPid); - } - } catch (IOException e) { - logger.error("Failed to launch native installer process", e); - throw new IllegalStateException("Failed to launch application installer process.", e); - } - } - - private void launchLinuxInstaller(String filePath, long currentPid) throws IOException { - String installProgram = filePath.endsWith(".rpm") ? "rpm" : "dpkg"; - String installFlag = filePath.endsWith(".rpm") ? "-Uvh" : "-i"; - - // Wait for the current process to exit, install the package with pkexec, - // then relaunch the app fully detached (nohup + setsid) so it survives - // after the installer script exits. - // Uses ";" instead of "&&" so the app always relaunches, even if the - // installation fails — the previous version is still installed and - // should remain usable. - String script = - "PID=\"$1\"; FILE=\"$2\"; PROGRAM=\"$3\"; FLAG=\"$4\"; " + - "while kill -0 \"$PID\" 2>/dev/null; do sleep 0.2; done; " + - "pkexec \"$PROGRAM\" \"$FLAG\" \"$FILE\"; " + - "nohup setsid devaulty >/dev/null 2>&1 &"; - - List command = List.of( - "bash", "-c", script, "bash", - String.valueOf(currentPid), filePath, installProgram, installFlag - ); - - startDetached(command); - } - - private void launchMacInstaller(String filePath, long currentPid) throws IOException { - // Read the note about limitations at the top of the class. - // After mounting the DMG the app is always relaunched so the user is - // not left without a running instance. The relaunch starts the - // currently-installed version; the user still needs to manually copy - // the new .app from the mounted image into /Applications. - String script = - "PID=\"$1\"; FILE=\"$2\"; " + - "while kill -0 \"$PID\" 2>/dev/null; do sleep 0.2; done; " + - "open \"$FILE\"; " + - "sleep 2; open -a Devaulty"; - - List command = List.of( - "bash", "-c", script, "bash", - String.valueOf(currentPid), filePath - ); - - startDetached(command); - } - - private void launchWindowsInstaller(String filePath, long currentPid) throws IOException { - String installDir = System.getenv(WINDOWS_INSTALL_DIR_ENV); - if (installDir == null || installDir.isBlank()) { - installDir = WINDOWS_DEFAULT_INSTALL_DIR; - logger.warn("{} not set, falling back to default install path: {}", - WINDOWS_INSTALL_DIR_ENV, installDir); - } - String executablePath = installDir + "\\devaulty.exe"; - - String script = - "param($ProcId, $File, $ExePath) " + - "Wait-Process -Id $ProcId -ErrorAction SilentlyContinue; " + - "Start-Process msiexec.exe -ArgumentList '/i', $File, '/qb' -Wait; " + - "Start-Process $ExePath"; - - List command = List.of( - "powershell", "-NoProfile", "-WindowStyle", "Hidden", - "-Command", script, - String.valueOf(currentPid), filePath, executablePath - ); - - startDetached(command); - } - - protected void startDetached(List command) throws IOException { - ProcessBuilder builder = new ProcessBuilder(command); - builder.redirectOutput(ProcessBuilder.Redirect.DISCARD); - builder.redirectError(ProcessBuilder.Redirect.DISCARD); - builder.start(); - } - - private File verifyWithinTempDir(File installerFile, File tempDir) { - try { - Path canonicalInstaller = installerFile.getCanonicalFile().toPath(); - Path canonicalTempDir = tempDir.getCanonicalFile().toPath(); - - if (!canonicalInstaller.startsWith(canonicalTempDir)) { - throw new IllegalStateException("Installer file resolved outside of the expected temp directory."); - } - return canonicalInstaller.toFile(); - } catch (IOException e) { - throw new IllegalStateException("Failed to resolve installer file path.", e); - } - } - - // Temp folder resolution delegated to ReleaseTempFolder to ensure both - // DownloadUpdateImpl and InstallUpdateImpl always use the same directory. - - protected void scheduleApplicationShutdown() { - scheduler.schedule(() -> { - logger.info("Shutting down current application instance for update replacement..."); - int exitCode = SpringApplication.exit(applicationContext, () -> 0); - scheduler.shutdown(); - System.exit(exitCode); - }, SHUTDOWN_DELAY_SECONDS, TimeUnit.SECONDS); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/ReleaseTempFolder.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/ReleaseTempFolder.java deleted file mode 100644 index 83f0f9b..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/release/ReleaseTempFolder.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.devaulty.backend.application.impl.release; - -import java.io.File; - -/** - * Shared utility for resolving the OS-specific temporary directory used by - * the Devaulty update pipeline. Both {@link DownloadUpdateImpl} and - * {@link InstallUpdateImpl} must resolve the same directory so that the - * installer file written by the download step can be located by the install - * step without path mismatches. - * Can be overridden via {@code -Ddevaulty.temp.dir} (e.g. in tests for isolation). - */ -public final class ReleaseTempFolder { - - private ReleaseTempFolder() {} - - public static File resolve() { - String customDir = System.getProperty("devaulty.temp.dir"); - if (customDir != null && !customDir.isBlank()) { - File tempDir = new File(customDir); - if (!tempDir.exists()) { - tempDir.mkdirs(); - } - if (!tempDir.isDirectory()) { - throw new IllegalStateException( - "Configured devaulty.temp.dir is not a valid directory: " + customDir); - } - return tempDir; - } - - String userHome = System.getProperty("user.home"); - String os = System.getProperty("os.name").toLowerCase(); - - File tempDir; - if (os.contains("win")) { - String localAppData = System.getenv("LOCALAPPDATA"); - File base = localAppData != null ? new File(localAppData) : new File(userHome, "AppData/Local"); - tempDir = new File(base, "devaulty/temp"); - } else if (os.contains("mac")) { - tempDir = new File(userHome, "Library/Caches/devaulty/temp"); - } else { - tempDir = new File(userHome, ".config/devaulty/temp"); - } - - if (!tempDir.exists()) { - tempDir.mkdirs(); - } - - return tempDir; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/security/CheckMasterPasswordSetupImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/security/CheckMasterPasswordSetupImpl.java deleted file mode 100644 index a608c0c..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/security/CheckMasterPasswordSetupImpl.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.AppSettingRepositoryPort; -import org.springframework.transaction.annotation.Transactional; - -public class CheckMasterPasswordSetupImpl implements CheckMasterPasswordSetupUseCase { - - private final AppSettingRepositoryPort appSettingRepositoryPort; - - private static final String MASTER_PASSWORD_HASH_KEY = "master_password_hash"; - - public CheckMasterPasswordSetupImpl(AppSettingRepositoryPort appSettingRepositoryPort) { - this.appSettingRepositoryPort = appSettingRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public boolean isSetupRequired() { - return !appSettingRepositoryPort.existsByKey(MASTER_PASSWORD_HASH_KEY); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/security/GetSessionStatusImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/security/GetSessionStatusImpl.java deleted file mode 100644 index bc24f54..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/security/GetSessionStatusImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.port.in.security.GetSessionStatusUseCase; -import com.devaulty.backend.application.port.in.security.SessionStatus; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; - -import java.time.Duration; - -public class GetSessionStatusImpl implements GetSessionStatusUseCase { - - private static final Duration SESSION_TIMEOUT = Duration.ofMinutes(15); - - private final MasterKeySessionPort sessionHolder; - - public GetSessionStatusImpl(MasterKeySessionPort sessionHolder) { - this.sessionHolder = sessionHolder; - } - - @Override - public SessionStatus execute() { - boolean active = sessionHolder.hasKey(); - long secondsLeft = sessionHolder.getSecondsRemaining(SESSION_TIMEOUT); - return new SessionStatus(active, secondsLeft); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/security/LockVaultImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/security/LockVaultImpl.java deleted file mode 100644 index 946f80f..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/security/LockVaultImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.application.port.in.security.LockVaultUseCase; - -public class LockVaultImpl implements LockVaultUseCase { - - private final MasterKeySessionPort sessionHolder; - - public LockVaultImpl(MasterKeySessionPort sessionHolder) { - this.sessionHolder = sessionHolder; - } - - @Override - public void execute() { - // Purges key bytes completely from RAM - sessionHolder.clear(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/security/SetupMasterPasswordImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/security/SetupMasterPasswordImpl.java deleted file mode 100644 index e1b3dde..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/security/SetupMasterPasswordImpl.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.application.exception.MasterPasswordAlreadyConfiguredException; -import com.devaulty.backend.application.port.in.security.SetupMasterPasswordUseCase; -import com.devaulty.backend.application.port.out.persistence.AppSettingRepositoryPort; -import com.devaulty.backend.application.port.out.security.KeyDerivationPort; -import com.devaulty.backend.domain.model.AppSetting; -import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import javax.crypto.SecretKey; -import java.nio.CharBuffer; -import java.security.SecureRandom; -import java.util.Arrays; -import java.util.Base64; - -public class SetupMasterPasswordImpl implements SetupMasterPasswordUseCase { - - private final AppSettingRepositoryPort appSettingRepositoryPort; - private final MasterKeySessionPort sessionHolder; - private final Argon2PasswordEncoder argon2PasswordEncoder; - private final KeyDerivationPort keyDerivationPort; - - private static final String MASTER_PASSWORD_HASH_KEY = "master_password_hash"; - private static final String MASTER_PASSWORD_SALT_KEY = "master_password_salt"; - - public SetupMasterPasswordImpl(AppSettingRepositoryPort appSettingRepositoryPort, MasterKeySessionPort sessionHolder, KeyDerivationPort keyDerivationPort) { - this.appSettingRepositoryPort = appSettingRepositoryPort; - this.sessionHolder = sessionHolder; - this.keyDerivationPort = keyDerivationPort; - // Instantiates the default Spring Security encoder for the login verification hash - this.argon2PasswordEncoder = new Argon2PasswordEncoder(16, 32, 2, 65536, 3); - } - - @Override - @Transactional - public void execute(char[] password) { - try { - // Prevents overwriting if already configured - if(appSettingRepositoryPort.existsByKey(MASTER_PASSWORD_HASH_KEY)) throw new MasterPasswordAlreadyConfiguredException(); - - // 1. Generates a strong, random 16-byte global application salt - byte[] saltBytes = new byte[16]; - new SecureRandom().nextBytes(saltBytes); - String saltBase64 = Base64.getEncoder().encodeToString(saltBytes); - - // 2. Generates the quick-match authentication hash for future validation - String hash = argon2PasswordEncoder.encode(CharBuffer.wrap(password)); - - // 3. Persists both elements inside the app_settings table via SQLite - appSettingRepositoryPort.save(new AppSetting(MASTER_PASSWORD_SALT_KEY, saltBase64)); - appSettingRepositoryPort.save(new AppSetting(MASTER_PASSWORD_HASH_KEY, hash)); - - // 4. Derives the AES-GCM key once and populates the memory session holder immediately - SecretKey secretKey = keyDerivationPort.deriveKey(password, saltBytes); - Arrays.fill(saltBytes, (byte) 0); - - // 5. Publishes the key only after the transaction commits successfully if active, otherwise publishes immediately - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - sessionHolder.setKey(secretKey); - } - }); - } else { - sessionHolder.setKey(secretKey); - } - - } finally { - // CRITICAL PROTECTION: Overwrites the input char array immediately after the use case completes - Arrays.fill(password, '\0'); - } - } - -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/security/UnlockVaultImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/security/UnlockVaultImpl.java deleted file mode 100644 index 0afd0eb..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/security/UnlockVaultImpl.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.exception.InvalidMasterPasswordException; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.port.in.security.UnlockVaultUseCase; -import com.devaulty.backend.application.port.out.persistence.AppSettingRepositoryPort; -import com.devaulty.backend.application.port.out.security.KeyDerivationPort; -import com.devaulty.backend.domain.model.AppSetting; -import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; -import org.springframework.transaction.annotation.Transactional; - -import javax.crypto.SecretKey; -import java.nio.CharBuffer; -import java.util.Arrays; -import java.util.Base64; - -public class UnlockVaultImpl implements UnlockVaultUseCase { - - private final AppSettingRepositoryPort appSettingRepositoryPort; - private final MasterKeySessionPort sessionHolder; - private final Argon2PasswordEncoder argon2PasswordEncoder; - private final KeyDerivationPort keyDerivationPort; - - private static final String MASTER_PASSWORD_HASH_KEY = "master_password_hash"; - private static final String MASTER_PASSWORD_SALT_KEY = "master_password_salt"; - - public UnlockVaultImpl(AppSettingRepositoryPort appSettingRepositoryPort, MasterKeySessionPort sessionHolder, KeyDerivationPort keyDerivationPort) { - this.appSettingRepositoryPort = appSettingRepositoryPort; - this.sessionHolder = sessionHolder; - this.keyDerivationPort = keyDerivationPort; - this.argon2PasswordEncoder = new Argon2PasswordEncoder(16, 32, 2, 65536, 3); - } - - @Override - @Transactional(readOnly = true) - public boolean execute(char[] password) { - try { - // 1. Get hash from DB - String hash = appSettingRepositoryPort.findByKey(MASTER_PASSWORD_HASH_KEY) - .map(AppSetting::getValue) - .orElseThrow(MasterPasswordNotConfiguredException::new); - - // 2. Validates the password against the hash - if (argon2PasswordEncoder.matches(CharBuffer.wrap(password), hash)) { - - // 3. Gets the salt from DB for key derivation - String saltBase64 = appSettingRepositoryPort.findByKey(MASTER_PASSWORD_SALT_KEY) - .map(AppSetting::getValue) - .orElseThrow(MasterPasswordNotConfiguredException::new); - - byte[] saltBytes = Base64.getDecoder().decode(saltBase64); - - // 4. Derives the AES-GCM key once and populates the memory session holder immediately - SecretKey secretKey = keyDerivationPort.deriveKey(password, saltBytes); - sessionHolder.setKey(secretKey); - return true; - } - - throw new InvalidMasterPasswordException("Invalid MasterPassword!"); - - } finally { - // CRITICAL PROTECTION: Overwrites the input char array immediately after the use case completes - Arrays.fill(password, '\0'); - } - - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/CreateSnippetImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/snippet/CreateSnippetImpl.java deleted file mode 100644 index aff3a1d..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/CreateSnippetImpl.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.snippet.CreateSnippetCommand; -import com.devaulty.backend.application.port.in.snippet.CreateSnippetUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import com.devaulty.backend.domain.model.Snippet; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class CreateSnippetImpl implements CreateSnippetUseCase { - - private final SnippetRepositoryPort snippetRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public CreateSnippetImpl(SnippetRepositoryPort snippetRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.snippetRepositoryPort = snippetRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Snippet execute(CreateSnippetCommand command) { - - if(!projectRepositoryPort.existsById(command.projectId())){ - throw new ResourceNotFoundException("Project", command.projectId()); - } - - Snippet snippet = new Snippet(); - snippet.setId(UUID.randomUUID()); - snippet.setProjectId(command.projectId()); - snippet.setTitle(command.title()); - snippet.setDescription(command.description()); - snippet.setContent(command.content()); - snippet.setLanguage(command.language()); - snippet.setSnippetType(command.snippetType()); - snippet.setCreatedAt(LocalDateTime.now()); - - return snippetRepositoryPort.save(snippet); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/DeleteSnippetImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/snippet/DeleteSnippetImpl.java deleted file mode 100644 index 7f53fad..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/DeleteSnippetImpl.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.snippet.DeleteSnippetUseCase; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class DeleteSnippetImpl implements DeleteSnippetUseCase { - - private final SnippetRepositoryPort snippetRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final ItemTagRepositoryPort itemTagRepositoryPort; - - public DeleteSnippetImpl(SnippetRepositoryPort snippetRepositoryPort, ProjectRepositoryPort projectRepositoryPort, ItemTagRepositoryPort itemTagRepositoryPort) { - this.snippetRepositoryPort = snippetRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.itemTagRepositoryPort = itemTagRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID projectId, UUID id) { - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - if (!snippetRepositoryPort.existsByIdAndProjectId(id, projectId)) throw new ResourceNotFoundException("Snippet", id); - - itemTagRepositoryPort.removeAllTagsFromItem("snippet", id); - snippetRepositoryPort.deleteById(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/GetAllSnippetsByProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/snippet/GetAllSnippetsByProjectImpl.java deleted file mode 100644 index 1980e9b..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/GetAllSnippetsByProjectImpl.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.snippet.GetAllSnippetsByProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import com.devaulty.backend.domain.model.Snippet; -import org.springframework.data.domain.Page; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetAllSnippetsByProjectImpl implements GetAllSnippetsByProjectUseCase { - - private final SnippetRepositoryPort snippetRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetAllSnippetsByProjectImpl(SnippetRepositoryPort snippetRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.snippetRepositoryPort = snippetRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Page execute(UUID projectId, int page, int size) { - - if(!projectRepositoryPort.existsById(projectId)){ - throw new ResourceNotFoundException("Project", projectId); - } - - return snippetRepositoryPort.findAllByProject(projectId, page, size); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/GetSnippetByIdImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/snippet/GetSnippetByIdImpl.java deleted file mode 100644 index 175278a..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/GetSnippetByIdImpl.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.snippet.GetSnippetByIdUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import com.devaulty.backend.domain.model.Snippet; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetSnippetByIdImpl implements GetSnippetByIdUseCase { - - private final SnippetRepositoryPort snippetRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetSnippetByIdImpl(SnippetRepositoryPort snippetRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.snippetRepositoryPort = snippetRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Snippet execute(UUID projectId, UUID id) { - - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - Snippet snippet = snippetRepositoryPort.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Snippet", id)); - - if(!snippet.getProjectId().equals(projectId)) throw new ResourceNotFoundException("Snippet", id); - - return snippet; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/UpdateSnippetImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/snippet/UpdateSnippetImpl.java deleted file mode 100644 index 29f9585..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/snippet/UpdateSnippetImpl.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.snippet.UpdateSnippetCommand; -import com.devaulty.backend.application.port.in.snippet.UpdateSnippetUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import com.devaulty.backend.domain.model.Snippet; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -public class UpdateSnippetImpl implements UpdateSnippetUseCase { - - private final SnippetRepositoryPort snippetRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public UpdateSnippetImpl(SnippetRepositoryPort snippetRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.snippetRepositoryPort = snippetRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Snippet execute(UpdateSnippetCommand command) { - - if(!projectRepositoryPort.existsById(command.projectId())) throw new ResourceNotFoundException("Project", command.projectId()); - - Snippet snippet = snippetRepositoryPort.findById(command.id()) - .orElseThrow(() -> new ResourceNotFoundException("Snippet", command.id())); - - if(!snippet.getProjectId().equals(command.projectId())) throw new ResourceNotFoundException("Snippet", command.id()); - - if (command.title() != null ) snippet.setTitle(command.title()); - if (command.description() != null ) snippet.setDescription(command.description()); - if (command.content() != null ) snippet.setContent(command.content()); - if (command.language() != null ) snippet.setLanguage(command.language()); - if (command.snippetType() != null ) snippet.setSnippetType(command.snippetType()); - snippet.setUpdatedAt(LocalDateTime.now()); - - return snippetRepositoryPort.save(snippet); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/CreateTagImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/CreateTagImpl.java deleted file mode 100644 index 3c35b5d..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/CreateTagImpl.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceAlreadyExistsException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.CreateTagCommand; -import com.devaulty.backend.application.port.in.tag.CreateTagUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class CreateTagImpl implements CreateTagUseCase { - - private final TagRepositoryPort tagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public CreateTagImpl(TagRepositoryPort tagRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.tagRepositoryPort = tagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Tag execute(CreateTagCommand command) { - - if (!projectRepositoryPort.existsById(command.projectId())) { - throw new ResourceNotFoundException("Project", command.projectId()); - } - - String sanitizedName = command.name().trim(); - - if (tagRepositoryPort.existsByNameAndProjectId(command.projectId(), sanitizedName)) { - throw new ResourceAlreadyExistsException("Tag", sanitizedName); - } - - Tag tag = new Tag(); - tag.setId(UUID.randomUUID()); - tag.setProjectId(command.projectId()); - tag.setName(sanitizedName); - tag.setColor(command.color()); - tag.setCreatedAt(LocalDateTime.now()); - - return tagRepositoryPort.save(tag); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/DeleteTagImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/DeleteTagImpl.java deleted file mode 100644 index a000dbe..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/DeleteTagImpl.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.DeleteTagUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class DeleteTagImpl implements DeleteTagUseCase { - - private final TagRepositoryPort tagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public DeleteTagImpl(TagRepositoryPort tagRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.tagRepositoryPort = tagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public void execute(UUID projectId, UUID id) { - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - if(!tagRepositoryPort.existsByIdAndProjectId(id, projectId)) throw new ResourceNotFoundException("Tag", id); - - tagRepositoryPort.delete(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/GetAllTagsByProjectImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/GetAllTagsByProjectImpl.java deleted file mode 100644 index 2de5ef9..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/GetAllTagsByProjectImpl.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.GetAllTagsByProjectUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.UUID; - -public class GetAllTagsByProjectImpl implements GetAllTagsByProjectUseCase { - - private final TagRepositoryPort tagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetAllTagsByProjectImpl(TagRepositoryPort tagRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.tagRepositoryPort = tagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public List execute(UUID projectId) { - - if (!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - return tagRepositoryPort.findAllByProject(projectId); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/GetTagByIdImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/GetTagByIdImpl.java deleted file mode 100644 index 46aa0f4..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/GetTagByIdImpl.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.GetTagByIdUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -public class GetTagByIdImpl implements GetTagByIdUseCase { - - private final TagRepositoryPort tagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public GetTagByIdImpl(TagRepositoryPort tagRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.tagRepositoryPort = tagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public Tag execute(UUID projectId, UUID id) { - - if (!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - return tagRepositoryPort.findById(id) - .filter(t -> projectId.equals(t.getProjectId())) - .orElseThrow(() -> new ResourceNotFoundException("Tag", id)); - - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/SearchTagByNameImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/SearchTagByNameImpl.java deleted file mode 100644 index 46e495a..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/SearchTagByNameImpl.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.SearchTagByNameUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.UUID; - -public class SearchTagByNameImpl implements SearchTagByNameUseCase { - - private final TagRepositoryPort tagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public SearchTagByNameImpl(TagRepositoryPort tagRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.tagRepositoryPort = tagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional(readOnly = true) - public List execute(UUID projectId, String name) { - - if(!projectRepositoryPort.existsById(projectId)) throw new ResourceNotFoundException("Project", projectId); - - return tagRepositoryPort.searchByNameAndProjectId(projectId, name); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/UpdateTagImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/UpdateTagImpl.java deleted file mode 100644 index b342682..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/UpdateTagImpl.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceAlreadyExistsException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.UpdateTagCommand; -import com.devaulty.backend.application.port.in.tag.UpdateTagUseCase; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -public class UpdateTagImpl implements UpdateTagUseCase { - - private final TagRepositoryPort tagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - - public UpdateTagImpl(TagRepositoryPort tagRepositoryPort, ProjectRepositoryPort projectRepositoryPort) { - this.tagRepositoryPort = tagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - } - - @Override - @Transactional - public Tag execute(UpdateTagCommand command) { - - if (!projectRepositoryPort.existsById(command.projectId())) - throw new ResourceNotFoundException("Project", command.projectId()); - - Tag tag = tagRepositoryPort.findById(command.id()) - .filter(t -> command.projectId().equals(t.getProjectId())) - .orElseThrow(() -> new ResourceNotFoundException("Tag", command.id())); - - - if (command.name() != null) { - - String sanitizedName = command.name().trim(); - if (tagRepositoryPort.existsByNameAndProjectId(command.projectId(), sanitizedName) && !tag.getName().equals(sanitizedName)) { - throw new ResourceAlreadyExistsException("Tag", sanitizedName); - } - tag.setName(sanitizedName); - - } - if (command.color() != null) tag.setColor(command.color()); - tag.setUpdatedAt(LocalDateTime.now()); - - return tagRepositoryPort.save(tag); - - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/AssociateTagToItemImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/AssociateTagToItemImpl.java deleted file mode 100644 index f2f5db1..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/AssociateTagToItemImpl.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.devaulty.backend.application.impl.tag.item; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.item.AssociateTagToItemUseCase; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectScopedRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.UUID; - -public class AssociateTagToItemImpl implements AssociateTagToItemUseCase { - - private final ItemTagRepositoryPort itemTagRepositoryPort; - private final TagRepositoryPort tagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final List projectScopedRepositories; - - public AssociateTagToItemImpl(ItemTagRepositoryPort itemTagRepositoryPort, - TagRepositoryPort tagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - List projectScopedRepositories) { - this.itemTagRepositoryPort = itemTagRepositoryPort; - this.tagRepositoryPort = tagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.projectScopedRepositories = projectScopedRepositories; - } - - @Override - @Transactional - public void execute(UUID projectId, String itemType, UUID itemId, UUID tagId) { - - if (!projectRepositoryPort.existsById(projectId)) { - throw new ResourceNotFoundException("Project", projectId); - } - if (!tagRepositoryPort.existsByIdAndProjectId(tagId, projectId)) { - throw new ResourceNotFoundException("Tag", tagId); - } - - String canonicalType = validateItemOwnership(itemType, projectId, itemId); - - itemTagRepositoryPort.associateTagToItem(tagId, canonicalType, itemId); - } - - private String validateItemOwnership(String itemType, UUID projectId, UUID itemId) { - ProjectScopedRepositoryPort repo = projectScopedRepositories.stream() - .filter(r -> r.getSupportedType().equalsIgnoreCase(itemType)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unsupported item type: " + itemType)); - - boolean exists = repo.existsByIdAndProjectId(itemId, projectId); - - if (!exists) { - String resourceName = itemType.substring(0, 1).toUpperCase() + itemType.substring(1).toLowerCase(); - throw new ResourceNotFoundException(resourceName, itemId); - } - - return repo.getSupportedType(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemImpl.java deleted file mode 100644 index ed64637..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemImpl.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.devaulty.backend.application.impl.tag.item; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectScopedRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.UUID; - -public class GetTagsForItemImpl implements GetTagsForItemUseCase { - - private final ItemTagRepositoryPort itemTagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final List projectScopedRepositories; - - public GetTagsForItemImpl(ItemTagRepositoryPort itemTagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - List projectScopedRepositories) { - this.itemTagRepositoryPort = itemTagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.projectScopedRepositories = projectScopedRepositories; - } - - @Override - @Transactional(readOnly = true) - public List execute(String itemType, UUID projectId, UUID itemId) { - - if (!projectRepositoryPort.existsById(projectId)) { - throw new ResourceNotFoundException("Project", projectId); - } - - String canonicalType = validateItemOwnership(itemType, projectId, itemId); - - return itemTagRepositoryPort.findTagsForItem(canonicalType, projectId, itemId); - } - - private String validateItemOwnership(String itemType, UUID projectId, UUID itemId) { - ProjectScopedRepositoryPort repo = projectScopedRepositories.stream() - .filter(r -> r.getSupportedType().equalsIgnoreCase(itemType)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unsupported item type: " + itemType)); - - boolean exists = repo.existsByIdAndProjectId(itemId, projectId); - - if (!exists) { - String resourceName = itemType.substring(0, 1).toUpperCase() + itemType.substring(1).toLowerCase(); - throw new ResourceNotFoundException(resourceName, itemId); - } - - return repo.getSupportedType(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemsImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemsImpl.java deleted file mode 100644 index fd5362f..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemsImpl.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.devaulty.backend.application.impl.tag.item; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemsUseCase; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectScopedRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Map; -import java.util.UUID; - -public class GetTagsForItemsImpl implements GetTagsForItemsUseCase { - - private final ItemTagRepositoryPort itemTagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final List projectScopedRepositories; - - public GetTagsForItemsImpl(ItemTagRepositoryPort itemTagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - List projectScopedRepositories) { - this.itemTagRepositoryPort = itemTagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.projectScopedRepositories = projectScopedRepositories; - } - - @Override - @Transactional(readOnly = true) - public Map> execute(String itemType, UUID projectId, List itemIds) { - - if (!projectRepositoryPort.existsById(projectId)) { - throw new ResourceNotFoundException("Project", projectId); - } - - String canonicalType = validateItemsOwnership(itemType, projectId, itemIds); - - return itemTagRepositoryPort.findTagsForItems(canonicalType, projectId, itemIds); - } - - private String validateItemsOwnership(String itemType, UUID projectId, List itemIds) { - ProjectScopedRepositoryPort repo = projectScopedRepositories.stream() - .filter(r -> r.getSupportedType().equalsIgnoreCase(itemType)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unsupported item type: " + itemType)); - - List existingIds = repo.findExistingIdsByProject(itemIds, projectId); - - long distinctInputCount = itemIds.stream().distinct().count(); - - if (existingIds.size() != distinctInputCount) { - UUID missingId = itemIds.stream() - .filter(id -> !existingIds.contains(id)) - .findFirst() - .orElse(null); - - String resourceName = itemType.substring(0, 1).toUpperCase() + itemType.substring(1).toLowerCase(); - - if (missingId == null) { - throw new ResourceNotFoundException(resourceName, UUID.randomUUID()); - } - - throw new ResourceNotFoundException(resourceName, missingId); - } - - return repo.getSupportedType(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/RemoveTagFromItemImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/RemoveTagFromItemImpl.java deleted file mode 100644 index 88cfedf..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/impl/tag/item/RemoveTagFromItemImpl.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.devaulty.backend.application.impl.tag.item; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.item.RemoveTagFromItemUseCase; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectScopedRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.UUID; - -public class RemoveTagFromItemImpl implements RemoveTagFromItemUseCase { - - private final ItemTagRepositoryPort itemTagRepositoryPort; - private final ProjectRepositoryPort projectRepositoryPort; - private final TagRepositoryPort tagRepositoryPort; - private final List projectScopedRepositories; - - public RemoveTagFromItemImpl(ItemTagRepositoryPort itemTagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - TagRepositoryPort tagRepositoryPort, - List projectScopedRepositories) { - this.itemTagRepositoryPort = itemTagRepositoryPort; - this.projectRepositoryPort = projectRepositoryPort; - this.tagRepositoryPort = tagRepositoryPort; - this.projectScopedRepositories = projectScopedRepositories; - } - - @Override - @Transactional - public void execute(UUID projectId, String itemType, UUID itemId, UUID tagId) { - if (!projectRepositoryPort.existsById(projectId)) { - throw new ResourceNotFoundException("Project", projectId); - } - - if (!tagRepositoryPort.existsByIdAndProjectId(tagId, projectId)) { - throw new ResourceNotFoundException("Tag", tagId); - } - - String canonicalType = validateItemOwnership(itemType, projectId, itemId); - - itemTagRepositoryPort.disassembleTagFromItem(projectId, tagId, canonicalType, itemId); - } - - private String validateItemOwnership(String itemType, UUID projectId, UUID itemId) { - ProjectScopedRepositoryPort repo = projectScopedRepositories.stream() - .filter(r -> r.getSupportedType().equalsIgnoreCase(itemType)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unsupported item type: " + itemType)); - - boolean exists = repo.existsByIdAndProjectId(itemId, projectId); - - if (!exists) { - String resourceName = itemType.substring(0, 1).toUpperCase() + itemType.substring(1).toLowerCase(); - throw new ResourceNotFoundException(resourceName, itemId); - } - - return repo.getSupportedType(); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/CreateCredentialCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/credential/CreateCredentialCommand.java deleted file mode 100644 index 8058de2..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/CreateCredentialCommand.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.application.port.in.credential; - -import com.devaulty.backend.domain.model.enums.CredentialSecretType; - -import java.util.UUID; - -public record CreateCredentialCommand( - UUID projectId, - String title, - CredentialSecretType secretType, - char[] payload, - String notes, - String relatedUrl -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/CreateCredentialUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/credential/CreateCredentialUseCase.java deleted file mode 100644 index 7503261..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/CreateCredentialUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.devaulty.backend.application.port.in.credential; - -public interface CreateCredentialUseCase { - DecryptedCredential execute(CreateCredentialCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/CredentialSummary.java b/backend/src/main/java/com/devaulty/backend/application/port/in/credential/CredentialSummary.java deleted file mode 100644 index ece770e..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/CredentialSummary.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.devaulty.backend.application.port.in.credential; - -import com.devaulty.backend.domain.model.enums.CredentialSecretType; - -import java.time.LocalDateTime; -import java.util.UUID; - -public record CredentialSummary( - UUID id, - UUID projectId, - String title, - CredentialSecretType secretType, - String relatedUrl, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/DecryptedCredential.java b/backend/src/main/java/com/devaulty/backend/application/port/in/credential/DecryptedCredential.java deleted file mode 100644 index 7915b09..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/DecryptedCredential.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.devaulty.backend.application.port.in.credential; - -import com.devaulty.backend.domain.model.enums.CredentialSecretType; - -import java.time.LocalDateTime; -import java.util.UUID; - -public record DecryptedCredential( - UUID id, - UUID projectId, - String title, - CredentialSecretType secretType, - byte[] decryptedPayload, - String notes, - String relatedUrl, - LocalDateTime createdAt, - LocalDateTime updatedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/DeleteCredentialUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/credential/DeleteCredentialUseCase.java deleted file mode 100644 index 08a37f8..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/DeleteCredentialUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.credential; - -import java.util.UUID; - -public interface DeleteCredentialUseCase { - void execute(UUID projectId, UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/GetAllCredentialsByProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/credential/GetAllCredentialsByProjectUseCase.java deleted file mode 100644 index 4195d87..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/GetAllCredentialsByProjectUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.in.credential; - -import org.springframework.data.domain.Page; - -import java.util.UUID; - -public interface GetAllCredentialsByProjectUseCase { - Page execute(UUID projectId, int page, int size); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/GetCredentialByIdUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/credential/GetCredentialByIdUseCase.java deleted file mode 100644 index cd66c4c..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/GetCredentialByIdUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.credential; - -import java.util.UUID; - -public interface GetCredentialByIdUseCase { - DecryptedCredential execute(UUID projectId, UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/UpdateCredentialCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/credential/UpdateCredentialCommand.java deleted file mode 100644 index 0fafc3a..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/UpdateCredentialCommand.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.devaulty.backend.application.port.in.credential; - -import com.devaulty.backend.domain.model.enums.CredentialSecretType; - -import java.util.UUID; - -public record UpdateCredentialCommand( - UUID id, - UUID projectId, - String title, - CredentialSecretType secretType, - char[] payload, - String notes, - String relatedUrl -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/UpdateCredentialUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/credential/UpdateCredentialUseCase.java deleted file mode 100644 index 0efae63..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/credential/UpdateCredentialUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.devaulty.backend.application.port.in.credential; - -public interface UpdateCredentialUseCase { - DecryptedCredential execute(UpdateCredentialCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/link/CreateLinkCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/link/CreateLinkCommand.java deleted file mode 100644 index d2b11fd..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/link/CreateLinkCommand.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.devaulty.backend.application.port.in.link; - -import java.util.UUID; - -public record CreateLinkCommand( - UUID projectId, - String title, - String url, - String description -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/link/CreateLinkUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/link/CreateLinkUseCase.java deleted file mode 100644 index 0bb9326..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/link/CreateLinkUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.link; - -import com.devaulty.backend.domain.model.Link; - -public interface CreateLinkUseCase { - Link execute(CreateLinkCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/link/DeleteLinkUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/link/DeleteLinkUseCase.java deleted file mode 100644 index 2daa48e..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/link/DeleteLinkUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.link; - -import java.util.UUID; - -public interface DeleteLinkUseCase { - void execute(UUID projectId, UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/link/GetAllLinksByProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/link/GetAllLinksByProjectUseCase.java deleted file mode 100644 index 0abf9ba..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/link/GetAllLinksByProjectUseCase.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.in.link; - -import com.devaulty.backend.domain.model.Link; -import org.springframework.data.domain.Page; - -import java.util.UUID; - -public interface GetAllLinksByProjectUseCase { - Page execute(UUID projectId, int page, int size); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/link/GetLinkByIdUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/link/GetLinkByIdUseCase.java deleted file mode 100644 index 63a79a6..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/link/GetLinkByIdUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.in.link; - -import com.devaulty.backend.domain.model.Link; - -import java.util.UUID; - -public interface GetLinkByIdUseCase { - Link execute(UUID projectId ,UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/link/UpdateLinkCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/link/UpdateLinkCommand.java deleted file mode 100644 index c15ca9b..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/link/UpdateLinkCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.devaulty.backend.application.port.in.link; - -import java.util.UUID; - -public record UpdateLinkCommand( - UUID id, - UUID projectId, - String title, - String url, - String description -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/link/UpdateLinkUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/link/UpdateLinkUseCase.java deleted file mode 100644 index b225df9..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/link/UpdateLinkUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.link; - -import com.devaulty.backend.domain.model.Link; - -public interface UpdateLinkUseCase { - Link execute(UpdateLinkCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/note/ArchiveNoteUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/note/ArchiveNoteUseCase.java deleted file mode 100644 index 54f91d1..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/note/ArchiveNoteUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.note; - -import java.util.UUID; - -public interface ArchiveNoteUseCase { - void execute(UUID projectId ,UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/note/CreateNoteCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/note/CreateNoteCommand.java deleted file mode 100644 index d965a76..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/note/CreateNoteCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.in.note; - -import java.util.UUID; - -public record CreateNoteCommand( - UUID projectId, - String title, - String content -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/note/CreateNoteUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/note/CreateNoteUseCase.java deleted file mode 100644 index b195e1a..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/note/CreateNoteUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.note; - -import com.devaulty.backend.domain.model.Note; - -public interface CreateNoteUseCase { - Note execute(CreateNoteCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/note/DeleteNoteUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/note/DeleteNoteUseCase.java deleted file mode 100644 index 336c565..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/note/DeleteNoteUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.note; - -import java.util.UUID; - -public interface DeleteNoteUseCase { - void execute(UUID projectId, UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/note/GetAllNotesByProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/note/GetAllNotesByProjectUseCase.java deleted file mode 100644 index fe2dcbb..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/note/GetAllNotesByProjectUseCase.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.in.note; - -import com.devaulty.backend.domain.model.Note; -import org.springframework.data.domain.Page; - -import java.util.UUID; - -public interface GetAllNotesByProjectUseCase { - Page execute(UUID projectId, int page, int size); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/note/GetNoteByIdUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/note/GetNoteByIdUseCase.java deleted file mode 100644 index 762cd8f..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/note/GetNoteByIdUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.in.note; - -import com.devaulty.backend.domain.model.Note; - -import java.util.UUID; - -public interface GetNoteByIdUseCase { - Note execute(UUID projectId ,UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/note/UnarchiveNoteUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/note/UnarchiveNoteUseCase.java deleted file mode 100644 index 7a86822..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/note/UnarchiveNoteUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.note; - -import java.util.UUID; - -public interface UnarchiveNoteUseCase { - void execute(UUID projectId ,UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/note/UpdateNoteCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/note/UpdateNoteCommand.java deleted file mode 100644 index 8227235..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/note/UpdateNoteCommand.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.devaulty.backend.application.port.in.note; - -import java.util.UUID; - -public record UpdateNoteCommand( - UUID id, - UUID projectId, - String title, - String content -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/note/UpdateNoteUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/note/UpdateNoteUseCase.java deleted file mode 100644 index d77c821..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/note/UpdateNoteUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.note; - -import com.devaulty.backend.domain.model.Note; - -public interface UpdateNoteUseCase { - Note execute(UpdateNoteCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/CreateProblemCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/problem/CreateProblemCommand.java deleted file mode 100644 index f2739b2..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/CreateProblemCommand.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.devaulty.backend.application.port.in.problem; - -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; - -import java.util.UUID; - -public record CreateProblemCommand( - UUID projectId, - String title, - String errorDescription, - String solution, - ProblemStatus status, - ProblemSeverity severity -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/CreateProblemUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/problem/CreateProblemUseCase.java deleted file mode 100644 index e4291ff..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/CreateProblemUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.problem; - -import com.devaulty.backend.domain.model.Problem; - -public interface CreateProblemUseCase { - Problem execute(CreateProblemCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/DeleteProblemUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/problem/DeleteProblemUseCase.java deleted file mode 100644 index c096fe8..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/DeleteProblemUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.problem; - -import java.util.UUID; - -public interface DeleteProblemUseCase { - void execute(UUID projectId, UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/GetAllProblemsByProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/problem/GetAllProblemsByProjectUseCase.java deleted file mode 100644 index 4763b28..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/GetAllProblemsByProjectUseCase.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.in.problem; - -import com.devaulty.backend.domain.model.Problem; -import org.springframework.data.domain.Page; - -import java.util.UUID; - -public interface GetAllProblemsByProjectUseCase { - Page execute(UUID projectId, int page, int size); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/GetProblemByIdUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/problem/GetProblemByIdUseCase.java deleted file mode 100644 index ad0301e..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/GetProblemByIdUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.in.problem; - -import com.devaulty.backend.domain.model.Problem; - -import java.util.UUID; - -public interface GetProblemByIdUseCase { - Problem execute(UUID projectId,UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemCommand.java deleted file mode 100644 index d84dbe4..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemCommand.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.devaulty.backend.application.port.in.problem; - -import com.devaulty.backend.domain.model.enums.ProblemSeverity; - -import java.util.UUID; - -public record UpdateProblemCommand( - UUID id, - UUID projectId, - String title, - String errorDescription, - String solution, - // Problem Status has a dedicated update logic - ProblemSeverity severity -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemStatusCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemStatusCommand.java deleted file mode 100644 index c0ded68..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemStatusCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.devaulty.backend.application.port.in.problem; - -import com.devaulty.backend.domain.model.enums.ProblemStatus; - -import java.util.UUID; - -public record UpdateProblemStatusCommand( - UUID id, - UUID projectId, - ProblemStatus status -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemStatusUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemStatusUseCase.java deleted file mode 100644 index 468dfac..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemStatusUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.problem; - -import com.devaulty.backend.domain.model.Problem; - -public interface UpdateProblemStatusUseCase { - Problem execute(UpdateProblemStatusCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemUseCase.java deleted file mode 100644 index ce416e6..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/problem/UpdateProblemUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.problem; - -import com.devaulty.backend.domain.model.Problem; - -public interface UpdateProblemUseCase { - Problem execute(UpdateProblemCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/project/ArchiveProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/project/ArchiveProjectUseCase.java deleted file mode 100644 index c511ecf..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/project/ArchiveProjectUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.project; - -import java.util.UUID; - -public interface ArchiveProjectUseCase { - void execute(UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/project/CreateProjectCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/project/CreateProjectCommand.java deleted file mode 100644 index 36d0879..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/project/CreateProjectCommand.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.in.project; - -public record CreateProjectCommand( - String name, - String description, - String icon, - String color -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/project/CreateProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/project/CreateProjectUseCase.java deleted file mode 100644 index d2e9b7d..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/project/CreateProjectUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.project; - -import com.devaulty.backend.domain.model.Project; - -public interface CreateProjectUseCase { - Project execute(CreateProjectCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/project/DeleteProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/project/DeleteProjectUseCase.java deleted file mode 100644 index cebd819..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/project/DeleteProjectUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.project; - -import java.util.UUID; - -public interface DeleteProjectUseCase { - void execute(UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/project/GetAllProjectsUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/project/GetAllProjectsUseCase.java deleted file mode 100644 index 79cb9b4..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/project/GetAllProjectsUseCase.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.devaulty.backend.application.port.in.project; - -import com.devaulty.backend.domain.model.Project; -import org.springframework.data.domain.Page; - -public interface GetAllProjectsUseCase { - Page execute(int page, int size); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/project/GetProjectByIdUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/project/GetProjectByIdUseCase.java deleted file mode 100644 index bed1c73..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/project/GetProjectByIdUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.in.project; - -import com.devaulty.backend.domain.model.Project; - -import java.util.UUID; - -public interface GetProjectByIdUseCase { - Project execute(UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/project/UnarchiveProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/project/UnarchiveProjectUseCase.java deleted file mode 100644 index d3135c4..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/project/UnarchiveProjectUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.project; - -import java.util.UUID; - -public interface UnarchiveProjectUseCase { - void execute(UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/project/UpdateProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/project/UpdateProjectUseCase.java deleted file mode 100644 index 6f00af7..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/project/UpdateProjectUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.in.project; - -import com.devaulty.backend.domain.model.Project; - -import java.util.UUID; - -public interface UpdateProjectUseCase { - Project execute(UUID id, CreateProjectCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/AppUpdateInfo.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/AppUpdateInfo.java deleted file mode 100644 index 46cda57..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/release/AppUpdateInfo.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.application.port.in.release; - -import java.time.Instant; - -public record AppUpdateInfo( - boolean updateAvailable, - String currentVersion, - String latestVersion, - String releaseTitle, - String releaseNotes, - String downloadUrl, - Long downloadSizeInBytes, - Instant publishedAt -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/CheckForUpdatesUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/CheckForUpdatesUseCase.java deleted file mode 100644 index 74be8b9..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/release/CheckForUpdatesUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.devaulty.backend.application.port.in.release; - -public interface CheckForUpdatesUseCase { - AppUpdateInfo execute(); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/DownloadUpdateUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/DownloadUpdateUseCase.java deleted file mode 100644 index c28af26..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/release/DownloadUpdateUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.release; - -import java.util.function.Consumer; - -public interface DownloadUpdateUseCase { - void execute(Consumer onProgress); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/GetCurrentVersionUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/GetCurrentVersionUseCase.java deleted file mode 100644 index 0801012..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/release/GetCurrentVersionUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.devaulty.backend.application.port.in.release; - -public interface GetCurrentVersionUseCase { - String execute(); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/InstallUpdateUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/InstallUpdateUseCase.java deleted file mode 100644 index 32be398..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/release/InstallUpdateUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.release; - -import java.nio.file.Path; - -public interface InstallUpdateUseCase { - void execute(Path installerFile); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/UpdateProgressInfo.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/UpdateProgressInfo.java deleted file mode 100644 index 7a3f357..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/release/UpdateProgressInfo.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.devaulty.backend.application.port.in.release; - -import com.devaulty.backend.application.port.in.release.enums.UpdateStatus; - -public record UpdateProgressInfo( - UpdateStatus status, - int percentage, // 0 to 100 - long downloadedBytes, // e.g: 45.200.000 - long totalBytes, // e.g: 154.000.000 - String errorMessage -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/enums/UpdateStatus.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/enums/UpdateStatus.java deleted file mode 100644 index d42476e..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/release/enums/UpdateStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.devaulty.backend.application.port.in.release.enums; - -public enum UpdateStatus { - DOWNLOADING, - COMPLETED, - FAILED, - INSTALLING -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/security/CheckMasterPasswordSetupUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/security/CheckMasterPasswordSetupUseCase.java deleted file mode 100644 index 0e9ac26..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/security/CheckMasterPasswordSetupUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.devaulty.backend.application.port.in.security; - -public interface CheckMasterPasswordSetupUseCase { - boolean isSetupRequired(); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/security/GetSessionStatusUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/security/GetSessionStatusUseCase.java deleted file mode 100644 index cc8cdd3..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/security/GetSessionStatusUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.devaulty.backend.application.port.in.security; - -public interface GetSessionStatusUseCase { - SessionStatus execute(); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/security/LockVaultUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/security/LockVaultUseCase.java deleted file mode 100644 index 8674843..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/security/LockVaultUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.devaulty.backend.application.port.in.security; - -public interface LockVaultUseCase { - void execute(); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/security/SessionStatus.java b/backend/src/main/java/com/devaulty/backend/application/port/in/security/SessionStatus.java deleted file mode 100644 index 893e8f6..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/security/SessionStatus.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.security; - -public record SessionStatus( - boolean active, - long secondsLeft -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/security/SetupMasterPasswordUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/security/SetupMasterPasswordUseCase.java deleted file mode 100644 index 0cd6abd..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/security/SetupMasterPasswordUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.devaulty.backend.application.port.in.security; - -public interface SetupMasterPasswordUseCase { - void execute(char[] password); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/security/UnlockVaultUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/security/UnlockVaultUseCase.java deleted file mode 100644 index d1eae24..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/security/UnlockVaultUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.devaulty.backend.application.port.in.security; - -public interface UnlockVaultUseCase { - boolean execute(char[] password); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/CreateSnippetCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/CreateSnippetCommand.java deleted file mode 100644 index 6616ab8..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/CreateSnippetCommand.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.devaulty.backend.application.port.in.snippet; - -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; - -import java.util.UUID; - -public record CreateSnippetCommand( - UUID projectId, - String title, - String description, - String content, - SnippetLanguage language, - SnippetType snippetType -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/CreateSnippetUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/CreateSnippetUseCase.java deleted file mode 100644 index d2d6dd2..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/CreateSnippetUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.snippet; - -import com.devaulty.backend.domain.model.Snippet; - -public interface CreateSnippetUseCase { - Snippet execute(CreateSnippetCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/DeleteSnippetUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/DeleteSnippetUseCase.java deleted file mode 100644 index 34cd99d..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/DeleteSnippetUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.snippet; - -import java.util.UUID; - -public interface DeleteSnippetUseCase { - void execute(UUID projectId, UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/GetAllSnippetsByProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/GetAllSnippetsByProjectUseCase.java deleted file mode 100644 index 54f960c..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/GetAllSnippetsByProjectUseCase.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.in.snippet; - -import com.devaulty.backend.domain.model.Snippet; -import org.springframework.data.domain.Page; - -import java.util.UUID; - -public interface GetAllSnippetsByProjectUseCase { - Page execute(UUID projectId, int page, int size); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/GetSnippetByIdUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/GetSnippetByIdUseCase.java deleted file mode 100644 index a80a3fe..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/GetSnippetByIdUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.in.snippet; - -import com.devaulty.backend.domain.model.Snippet; - -import java.util.UUID; - -public interface GetSnippetByIdUseCase { - Snippet execute(UUID projectId, UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/UpdateSnippetCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/UpdateSnippetCommand.java deleted file mode 100644 index 03cd0ef..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/UpdateSnippetCommand.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.devaulty.backend.application.port.in.snippet; - -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; - -import java.util.UUID; - -public record UpdateSnippetCommand( - UUID id, - UUID projectId, - String title, - String description, - String content, - SnippetLanguage language, - SnippetType snippetType -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/UpdateSnippetUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/UpdateSnippetUseCase.java deleted file mode 100644 index b453fc6..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/snippet/UpdateSnippetUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.snippet; - -import com.devaulty.backend.domain.model.Snippet; - -public interface UpdateSnippetUseCase { - Snippet execute(UpdateSnippetCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/CreateTagCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/CreateTagCommand.java deleted file mode 100644 index 2f01346..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/CreateTagCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.in.tag; - -import java.util.UUID; - -public record CreateTagCommand( - UUID projectId, - String name, - String color -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/CreateTagUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/CreateTagUseCase.java deleted file mode 100644 index 55f8630..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/CreateTagUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.tag; - -import com.devaulty.backend.domain.model.Tag; - -public interface CreateTagUseCase { - Tag execute(CreateTagCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/DeleteTagUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/DeleteTagUseCase.java deleted file mode 100644 index 86af9f1..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/DeleteTagUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.tag; - -import java.util.UUID; - -public interface DeleteTagUseCase { - void execute(UUID projectId, UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/GetAllTagsByProjectUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/GetAllTagsByProjectUseCase.java deleted file mode 100644 index 0502441..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/GetAllTagsByProjectUseCase.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.in.tag; - -import com.devaulty.backend.domain.model.Tag; - -import java.util.List; -import java.util.UUID; - -public interface GetAllTagsByProjectUseCase { - List execute(UUID projectId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/GetTagByIdUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/GetTagByIdUseCase.java deleted file mode 100644 index 029fbf5..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/GetTagByIdUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.in.tag; - -import com.devaulty.backend.domain.model.Tag; - -import java.util.UUID; - -public interface GetTagByIdUseCase { - Tag execute(UUID projectId ,UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/SearchTagByNameUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/SearchTagByNameUseCase.java deleted file mode 100644 index facaf03..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/SearchTagByNameUseCase.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.in.tag; - -import com.devaulty.backend.domain.model.Tag; - -import java.util.List; -import java.util.UUID; - -public interface SearchTagByNameUseCase { - List execute(UUID projectId, String name); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/UpdateTagCommand.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/UpdateTagCommand.java deleted file mode 100644 index 18a44a7..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/UpdateTagCommand.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.devaulty.backend.application.port.in.tag; - -import java.util.UUID; - -public record UpdateTagCommand( - UUID id, - UUID projectId, - String name, - String color -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/UpdateTagUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/UpdateTagUseCase.java deleted file mode 100644 index 40b18d9..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/UpdateTagUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.tag; - -import com.devaulty.backend.domain.model.Tag; - -public interface UpdateTagUseCase { - Tag execute(UpdateTagCommand command); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/AssociateTagToItemUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/AssociateTagToItemUseCase.java deleted file mode 100644 index 1e8bcd3..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/AssociateTagToItemUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.tag.item; - -import java.util.UUID; - -public interface AssociateTagToItemUseCase { - void execute(UUID projectId, String itemType ,UUID itemId, UUID tagId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/GetTagsForItemUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/GetTagsForItemUseCase.java deleted file mode 100644 index 77a9b0e..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/GetTagsForItemUseCase.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.in.tag.item; - -import com.devaulty.backend.domain.model.Tag; - -import java.util.List; -import java.util.UUID; - -public interface GetTagsForItemUseCase { - List execute(String itemType, UUID projectId, UUID itemId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/GetTagsForItemsUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/GetTagsForItemsUseCase.java deleted file mode 100644 index a6bb82f..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/GetTagsForItemsUseCase.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.devaulty.backend.application.port.in.tag.item; - -import com.devaulty.backend.domain.model.Tag; - -import java.util.List; -import java.util.Map; -import java.util.UUID; - -public interface GetTagsForItemsUseCase { - Map> execute(String itemType, UUID projectId, List itemIds); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/RemoveTagFromItemUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/RemoveTagFromItemUseCase.java deleted file mode 100644 index e50083e..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/in/tag/item/RemoveTagFromItemUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.in.tag.item; - -import java.util.UUID; - -public interface RemoveTagFromItemUseCase { - void execute(UUID projectId, String itemType ,UUID itemId, UUID tagId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/ReleasePort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/ReleasePort.java deleted file mode 100644 index 0617e40..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/ReleasePort.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.devaulty.backend.application.port.out.external.release; - -import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; - -import java.io.InputStream; - -public interface ReleasePort { - LatestReleaseInfo getLatestRelease(); - - InputStream downloadAsset(String downloadUrl); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/LatestReleaseInfo.java b/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/LatestReleaseInfo.java deleted file mode 100644 index 791c6c7..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/LatestReleaseInfo.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.application.port.out.external.release.dto; - -import java.time.Instant; -import java.util.List; - -public record LatestReleaseInfo( - String tagName, - String name, - String body, - String htmlUrl, - Instant publishedAt, - boolean isPreRelease, - List assets -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/ReleaseAssetInfo.java b/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/ReleaseAssetInfo.java deleted file mode 100644 index f695de0..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/ReleaseAssetInfo.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devaulty.backend.application.port.out.external.release.dto; - -public record ReleaseAssetInfo( - String fileName, - String downloadUrl, - long sizeInBytes, - String contentType -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/AppSettingRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/AppSettingRepositoryPort.java deleted file mode 100644 index dffae3d..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/AppSettingRepositoryPort.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import com.devaulty.backend.domain.model.AppSetting; - -import java.util.Optional; - -public interface AppSettingRepositoryPort { - - AppSetting save(AppSetting appSetting); - - Optional findByKey(String key); - - boolean existsByKey(String key); - -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/CredentialRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/CredentialRepositoryPort.java deleted file mode 100644 index 2c06f3a..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/CredentialRepositoryPort.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import com.devaulty.backend.application.port.in.credential.CredentialSummary; -import com.devaulty.backend.domain.model.Credential; -import org.springframework.data.domain.Page; - -import java.util.Optional; -import java.util.UUID; - -public interface CredentialRepositoryPort extends ProjectScopedRepositoryPort { - - Credential save(Credential credential); - - Optional findById(UUID id); - - Page findAllByProject(UUID projectId, int page, int size); - - void deleteById(UUID id); - - boolean existsByIdAndProjectId(UUID id, UUID projectId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ItemTagRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ItemTagRepositoryPort.java deleted file mode 100644 index 40373df..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ItemTagRepositoryPort.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import com.devaulty.backend.domain.model.Tag; - -import java.util.List; -import java.util.Map; -import java.util.UUID; - -public interface ItemTagRepositoryPort { - - void associateTagToItem(UUID tagId, String itemType, UUID itemId); - - void disassembleTagFromItem(UUID projectId, UUID tagId, String itemType, UUID itemId); - - void removeAllTagsFromItem(String itemType, UUID itemId); - - List findTagsForItem(String itemType, UUID projectId, UUID itemId); - - Map> findTagsForItems(String itemType, UUID projectId, List itemIds); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/LinkRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/LinkRepositoryPort.java deleted file mode 100644 index db959ce..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/LinkRepositoryPort.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import com.devaulty.backend.domain.model.Link; -import org.springframework.data.domain.Page; - -import java.util.Optional; -import java.util.UUID; - -public interface LinkRepositoryPort extends ProjectScopedRepositoryPort { - Link save(Link link); - - Optional findById(UUID id); - - Page findAllByProject(UUID projectId, int page, int size); - - void deleteById(UUID id); - - boolean existsByIdAndProjectId(UUID id, UUID projectId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/NoteRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/NoteRepositoryPort.java deleted file mode 100644 index 88638b4..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/NoteRepositoryPort.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import com.devaulty.backend.domain.model.Note; -import org.springframework.data.domain.Page; - -import java.util.Optional; -import java.util.UUID; - -public interface NoteRepositoryPort extends ProjectScopedRepositoryPort { - - Note save(Note note); - - Optional findById(UUID id); - - Page findAllByProject(UUID projectId, int page, int size); - - void deleteById(UUID id); - - boolean existsByIdAndProjectId(UUID id, UUID projectId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ProblemRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ProblemRepositoryPort.java deleted file mode 100644 index 69595a7..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ProblemRepositoryPort.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import com.devaulty.backend.domain.model.Problem; -import org.springframework.data.domain.Page; - -import java.util.Optional; -import java.util.UUID; - -public interface ProblemRepositoryPort extends ProjectScopedRepositoryPort { - Problem save(Problem problem); - - Optional findById(UUID id); - - Page findAllByProject(UUID projectId, int page, int size); - - void deleteById(UUID id); - - boolean existsByIdAndProjectId(UUID id, UUID projectId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ProjectRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ProjectRepositoryPort.java deleted file mode 100644 index c53b75b..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ProjectRepositoryPort.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import com.devaulty.backend.domain.model.Project; -import org.springframework.data.domain.Page; - -import java.util.Optional; -import java.util.UUID; - -public interface ProjectRepositoryPort { - - Project save(Project project); - - Page findAll(int page, int size); - - Optional findById(UUID id); - - void deleteById(UUID id); - - boolean existsById(UUID id); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ProjectScopedRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ProjectScopedRepositoryPort.java deleted file mode 100644 index 5ca2f83..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/ProjectScopedRepositoryPort.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import java.util.List; -import java.util.UUID; - -public interface ProjectScopedRepositoryPort { - String getSupportedType(); - boolean existsByIdAndProjectId(UUID id, UUID projectId); - List findExistingIdsByProject(List ids, UUID projectId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/SnippetRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/SnippetRepositoryPort.java deleted file mode 100644 index 1f4ebd0..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/SnippetRepositoryPort.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import com.devaulty.backend.domain.model.Snippet; -import org.springframework.data.domain.Page; - -import java.util.Optional; -import java.util.UUID; - -public interface SnippetRepositoryPort extends ProjectScopedRepositoryPort { - Snippet save(Snippet snippet); - - Optional findById(UUID id); - - Page findAllByProject(UUID projectId, int page, int size); - - void deleteById(UUID id); - - boolean existsByIdAndProjectId(UUID id, UUID projectId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/TagRepositoryPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/TagRepositoryPort.java deleted file mode 100644 index 86cf515..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/persistence/TagRepositoryPort.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.devaulty.backend.application.port.out.persistence; - -import com.devaulty.backend.domain.model.Tag; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface TagRepositoryPort { - Tag save(Tag tag); - - Optional findById(UUID id); - - List searchByNameAndProjectId(UUID projectId ,String name); - - List findAllByProject(UUID projectId); - - void delete(UUID id); - - boolean existsByNameAndProjectId(UUID projectId ,String name); - - boolean existsByIdAndProjectId(UUID id, UUID projectId); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/security/CryptoPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/security/CryptoPort.java deleted file mode 100644 index b5bb007..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/security/CryptoPort.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.devaulty.backend.application.port.out.security; - -import com.devaulty.backend.application.port.out.security.dto.CryptoResultDto; - -import javax.crypto.SecretKey; - -public interface CryptoPort { - - CryptoResultDto encrypt(byte[] plainData, SecretKey secretKey, byte[] aad); - - byte[] decrypt(byte[] cipherText, byte[] iv, byte[] authTag, SecretKey secretKey, byte[] aad); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/security/KeyDerivationPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/security/KeyDerivationPort.java deleted file mode 100644 index 46fd7e8..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/security/KeyDerivationPort.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.application.port.out.security; - -import javax.crypto.SecretKey; - -public interface KeyDerivationPort { - SecretKey deriveKey(char[] password, byte[] salt); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/security/MasterKeySessionPort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/security/MasterKeySessionPort.java deleted file mode 100644 index d2c7ec5..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/security/MasterKeySessionPort.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.devaulty.backend.application.port.out.security; - -import javax.crypto.SecretKey; -import java.time.Duration; - -public interface MasterKeySessionPort { - void setKey(SecretKey key); - SecretKey getKey(); - boolean hasKey(); - void clear(); - void touch(); - Long getSecondsRemaining(Duration timeout); - SecretKey getOrClearIfExpired(Duration timeout); -} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/security/dto/CryptoResultDto.java b/backend/src/main/java/com/devaulty/backend/application/port/out/security/dto/CryptoResultDto.java deleted file mode 100644 index 232232c..0000000 --- a/backend/src/main/java/com/devaulty/backend/application/port/out/security/dto/CryptoResultDto.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.devaulty.backend.application.port.out.security.dto; - -public record CryptoResultDto( - byte[] cipherText, - byte[] iv, - byte[] authTag -) { -} diff --git a/backend/src/main/java/com/devaulty/backend/desktop/DevaultyDesktop.java b/backend/src/main/java/com/devaulty/backend/desktop/DevaultyDesktop.java deleted file mode 100644 index f06491f..0000000 --- a/backend/src/main/java/com/devaulty/backend/desktop/DevaultyDesktop.java +++ /dev/null @@ -1,209 +0,0 @@ -package com.devaulty.backend.desktop; - -import com.devaulty.backend.BackendApplication; -import com.devaulty.backend.desktop.listener.ServerPortListener; -import com.devaulty.backend.infrastructure.security.AppTokenContext; -import javafx.application.Application; -import javafx.application.Platform; -import javafx.beans.value.ChangeListener; -import javafx.concurrent.Worker; -import javafx.geometry.Rectangle2D; -import javafx.scene.Scene; -import javafx.scene.control.Alert; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.layout.StackPane; -import javafx.scene.paint.Color; -import javafx.scene.web.WebView; -import javafx.stage.Screen; -import javafx.stage.Stage; -import javafx.stage.StageStyle; -import org.springframework.boot.builder.SpringApplicationBuilder; -import org.springframework.context.ConfigurableApplicationContext; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.util.concurrent.TimeUnit; - -public class DevaultyDesktop extends Application { - - private static final double SPLASH_WIDTH = 550; - private static final double SPLASH_HEIGHT = 400; - - private ConfigurableApplicationContext springContext; - private Stage splashStage; - private WebView webView; - private ChangeListener loadWorkerListener; - - @Override - public void init() { - File devaultyDir = new File(System.getProperty("user.home"), ".config/devaulty"); - if (!devaultyDir.exists()) { - devaultyDir.mkdirs(); - } - - Thread springThread = new Thread(() -> { - try { - this.springContext = new SpringApplicationBuilder(BackendApplication.class) - .properties("server.port=0") - .run(); - } catch (Exception e) { - Platform.runLater(() -> showErrorAndExit("Failed to start Devaulty Local Server", e)); - } - }, "devaulty-spring-boot"); - - springThread.setDaemon(true); - springThread.start(); - } - - @Override - public void start(Stage primaryStage) { - createAndShowSplashScreen(); - - webView = new WebView(); - webView.setContextMenuEnabled(false); - - Rectangle2D bounds = Screen.getPrimary().getVisualBounds(); - configurePrimaryStage(primaryStage, webView, bounds); - - ServerPortListener.serverPortFuture - .orTimeout(60, TimeUnit.SECONDS) - .thenAccept(port -> Platform.runLater(() -> loadApplication(webView, primaryStage, port))) - .exceptionally(ex -> { - Platform.runLater(() -> showErrorAndExit("Failed to obtain server port", ex)); - return null; - }); - } - - private void configurePrimaryStage(Stage primaryStage, WebView webView, Rectangle2D bounds) { - Scene mainScene = new Scene(webView, bounds.getWidth(), bounds.getHeight()); - primaryStage.setTitle("Devaulty"); - primaryStage.setScene(mainScene); - - URL iconUrl = getClass().getResource("/static/icon/devaulty-icon.png"); - if (iconUrl != null) { - primaryStage.getIcons().add(new Image(iconUrl.toExternalForm())); - } - - primaryStage.setMinWidth(1000); - primaryStage.setMinHeight(650); - primaryStage.setX(bounds.getMinX()); - primaryStage.setY(bounds.getMinY()); - primaryStage.setWidth(bounds.getWidth()); - primaryStage.setHeight(bounds.getHeight()); - } - - private void loadApplication(WebView webView, Stage primaryStage, Integer port) { - String appUrl = "http://localhost:" + port; - webView.getEngine().load(appUrl); - - // Keep a reference to the listener so it can be explicitly removed - // once the page finishes loading — prevents it from lingering on the - // stateProperty for the entire lifetime of the WebView. - loadWorkerListener = (obs, oldState, newState) -> - handleWorkerStateChange(webView, primaryStage, newState); - webView.getEngine().getLoadWorker().stateProperty().addListener(loadWorkerListener); - } - - private void handleWorkerStateChange(WebView webView, Stage primaryStage, Worker.State newState) { - if (newState == Worker.State.RUNNING) { - injectInternalToken(webView); - } else if (newState == Worker.State.SUCCEEDED) { - injectInternalToken(webView); - showMainStageAndCloseSplash(primaryStage); - detachLoadWorkerListener(webView); - } else if (newState == Worker.State.FAILED || newState == Worker.State.CANCELLED) { - detachLoadWorkerListener(webView); - showErrorAndExit("Failed to load Devaulty Interface", - webView.getEngine().getLoadWorker().getException()); - } - } - - private void detachLoadWorkerListener(WebView webView) { - if (loadWorkerListener != null) { - webView.getEngine().getLoadWorker().stateProperty().removeListener(loadWorkerListener); - loadWorkerListener = null; - } - } - - private void injectInternalToken(WebView webView) { - try { - String script = String.format("window.DEVAULTY_INTERNAL_TOKEN = '%s';", AppTokenContext.PROCESS_TOKEN); - webView.getEngine().executeScript(script); - } catch (Exception ignored) { - // Safe to ignore if JavaScript execution context is not yet available or already disposed - } - } - - private void showMainStageAndCloseSplash(Stage primaryStage) { - primaryStage.show(); - if (splashStage != null) { - splashStage.close(); - splashStage.setScene(null); // drops the reference chain to StackPane/ImageView/Image - splashStage = null; - } - } - - @Override - public void stop() { - detachLoadWorkerListener(webView); - - if (webView != null) { - webView.getEngine().load(null); // stops any in-flight load and releases the current page - webView = null; - } - - if (this.springContext != null) { - this.springContext.close(); - } - } - - private void createAndShowSplashScreen() { - splashStage = new Stage(); - splashStage.initStyle(StageStyle.TRANSPARENT); - - URL iconUrl = getClass().getResource("/static/icon/devaulty-icon.png"); - if (iconUrl != null) { - splashStage.getIcons().add(new Image(iconUrl.toExternalForm())); - } - - StackPane root = new StackPane(); - root.setStyle("-fx-background-color: transparent;"); - - try (InputStream logoStream = getClass().getResourceAsStream("/static/devaulty-splash-screen-logo.png")) { - if (logoStream != null) { - ImageView logoView = new ImageView(new Image(logoStream)); - logoView.setFitWidth(550); - logoView.setPreserveRatio(true); - logoView.setSmooth(true); - root.getChildren().add(logoView); - } - } catch (IOException e) { - // Splash logo is decorative; failing to load it should not block startup - } - - Scene splashScene = new Scene(root, SPLASH_WIDTH, SPLASH_HEIGHT); - splashScene.setFill(Color.TRANSPARENT); - - splashStage.setTitle("Devaulty"); - splashStage.setScene(splashScene); - - Rectangle2D bounds = Screen.getPrimary().getVisualBounds(); - splashStage.setX(bounds.getMinX() + (bounds.getWidth() - SPLASH_WIDTH) / 2); - splashStage.setY(bounds.getMinY() + (bounds.getHeight() - SPLASH_HEIGHT) / 2); - - splashStage.show(); - } - - private void showErrorAndExit(String message, Throwable cause) { - Alert alert = new Alert(Alert.AlertType.ERROR); - alert.setTitle("Initialization Error"); - alert.setHeaderText(message); - alert.setContentText(cause.getLocalizedMessage()); - alert.showAndWait(); - Platform.exit(); - System.exit(1); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/desktop/DevaultyMainLauncher.java b/backend/src/main/java/com/devaulty/backend/desktop/DevaultyMainLauncher.java deleted file mode 100644 index 9d313f6..0000000 --- a/backend/src/main/java/com/devaulty/backend/desktop/DevaultyMainLauncher.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devaulty.backend.desktop; - -import javafx.application.Application; - -public class DevaultyMainLauncher { - public static void main(String[] args) { - // Launch the JavaFX application. - Application.launch(DevaultyDesktop.class, args); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/desktop/listener/ServerPortListener.java b/backend/src/main/java/com/devaulty/backend/desktop/listener/ServerPortListener.java deleted file mode 100644 index ac66d4c..0000000 --- a/backend/src/main/java/com/devaulty/backend/desktop/listener/ServerPortListener.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.devaulty.backend.desktop.listener; - -import org.springframework.boot.web.server.context.WebServerInitializedEvent; -import org.springframework.context.ApplicationListener; -import org.springframework.stereotype.Component; - -import java.util.concurrent.CompletableFuture; - -@Component -public class ServerPortListener implements ApplicationListener { - - public static final CompletableFuture serverPortFuture = new CompletableFuture<>(); - - @Override - public void onApplicationEvent(WebServerInitializedEvent event) { - int port = event.getWebServer().getPort(); - serverPortFuture.complete(port); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/AppSetting.java b/backend/src/main/java/com/devaulty/backend/domain/model/AppSetting.java deleted file mode 100644 index 8b0b012..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/AppSetting.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.devaulty.backend.domain.model; - -import java.util.Objects; - -public class AppSetting { - - private String key; - private String value; - - public AppSetting() { - // Empty constructor - } - - public AppSetting(String key, String value) { - this.key = key; - this.value = value; - } - - public String getKey() { - return key; - } - - public void setKey(String key) { - this.key = key; - } - - public String getValue() { - return value; - } - - public void setValue(String value) { - this.value = value; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - AppSetting that = (AppSetting) o; - return Objects.equals(key, that.key); - } - - @Override - public int hashCode() { - return Objects.hashCode(key); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/Credential.java b/backend/src/main/java/com/devaulty/backend/domain/model/Credential.java deleted file mode 100644 index 3265560..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/Credential.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.devaulty.backend.domain.model; - -import com.devaulty.backend.domain.model.base.BaseEntity; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; - -import java.util.Objects; -import java.util.UUID; - -public class Credential extends BaseEntity { - - private UUID id; - private UUID projectId; - private String title; - private CredentialSecretType secretType; - private byte[] payloadEncrypted; - private byte[] encryptionIv; - private byte[] encryptionAuthTag; - private String notes; - private String relatedUrl; - - public Credential() { - // Empty constructor - } - - public Credential(UUID id, UUID projectId, String title, CredentialSecretType secretType, byte[] payloadEncrypted, byte[] encryptionIv, byte[] encryptionAuthTag, String notes, String relatedUrl) { - this.id = id; - this.projectId = projectId; - this.title = title; - this.secretType = secretType; - this.payloadEncrypted = payloadEncrypted; - this.encryptionIv = encryptionIv; - this.encryptionAuthTag = encryptionAuthTag; - this.notes = notes; - this.relatedUrl = relatedUrl; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public UUID getProjectId() { - return projectId; - } - - public void setProjectId(UUID projectId) { - this.projectId = projectId; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public CredentialSecretType getSecretType() { - return secretType; - } - - public void setSecretType(CredentialSecretType secretType) { - this.secretType = secretType; - } - - public byte[] getPayloadEncrypted() { - return payloadEncrypted; - } - - public void setPayloadEncrypted(byte[] payloadEncrypted) { - this.payloadEncrypted = payloadEncrypted; - } - - public byte[] getEncryptionIv() { - return encryptionIv; - } - - public void setEncryptionIv(byte[] encryptionIv) { - this.encryptionIv = encryptionIv; - } - - public byte[] getEncryptionAuthTag() { - return encryptionAuthTag; - } - - public void setEncryptionAuthTag(byte[] encryptionAuthTag) { - this.encryptionAuthTag = encryptionAuthTag; - } - - public String getNotes() { - return notes; - } - - public void setNotes(String notes) { - this.notes = notes; - } - - public String getRelatedUrl() { - return relatedUrl; - } - - public void setRelatedUrl(String relatedUrl) { - this.relatedUrl = relatedUrl; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - Credential that = (Credential) o; - return Objects.equals(id, that.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/Link.java b/backend/src/main/java/com/devaulty/backend/domain/model/Link.java deleted file mode 100644 index b0a6c88..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/Link.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.devaulty.backend.domain.model; - -import com.devaulty.backend.domain.model.base.BaseEntity; - -import java.util.Objects; -import java.util.UUID; - -public class Link extends BaseEntity { - - private UUID id; - private UUID projectId; - private String title; - private String url; - private String description; - - public Link() { - // Empty constructor - } - - public Link(UUID id, UUID projectId, String title, String url, String description) { - this.id = id; - this.projectId = projectId; - this.title = title; - this.url = url; - this.description = description; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public UUID getProjectId() { - return projectId; - } - - public void setProjectId(UUID projectId) { - this.projectId = projectId; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - Link link = (Link) o; - return Objects.equals(id, link.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/Note.java b/backend/src/main/java/com/devaulty/backend/domain/model/Note.java deleted file mode 100644 index ab09d75..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/Note.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.devaulty.backend.domain.model; - -import com.devaulty.backend.domain.model.base.BaseEntity; - -import java.util.Objects; -import java.util.UUID; - -public class Note extends BaseEntity { - - private UUID id; - private UUID projectId; - private String title; - private String content; - private boolean archived; - - public Note() { - // Empty constructor - } - - public Note(UUID id, UUID projectId, String title, String content, boolean archived) { - this.id = id; - this.projectId = projectId; - this.title = title; - this.content = content; - this.archived = archived; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public UUID getProjectId() { - return projectId; - } - - public void setProjectId(UUID projectId) { - this.projectId = projectId; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public boolean isArchived() { - return archived; - } - - public void setArchived(boolean archived) { - this.archived = archived; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - Note note = (Note) o; - return Objects.equals(id, note.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/Problem.java b/backend/src/main/java/com/devaulty/backend/domain/model/Problem.java deleted file mode 100644 index 1aa8e8c..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/Problem.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.devaulty.backend.domain.model; - -import com.devaulty.backend.domain.model.base.BaseEntity; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; - -import java.util.Objects; -import java.util.UUID; - -public class Problem extends BaseEntity { - - private UUID id; - private UUID projectId; - private String title; - private String errorDescription; - private String solution; - private ProblemStatus status = ProblemStatus.OPEN; - private ProblemSeverity severity = ProblemSeverity.MEDIUM; // Medium by default - - public Problem() { - // Empty constructor - } - - public Problem(UUID id, UUID projectId, String title, String errorDescription, String solution, ProblemStatus status, ProblemSeverity severity) { - this.id = id; - this.projectId = projectId; - this.title = title; - this.errorDescription = errorDescription; - this.solution = solution; - this.status = status; - this.severity = severity; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public UUID getProjectId() { - return projectId; - } - - public void setProjectId(UUID projectId) { - this.projectId = projectId; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getErrorDescription() { - return errorDescription; - } - - public void setErrorDescription(String errorDescription) { - this.errorDescription = errorDescription; - } - - public String getSolution() { - return solution; - } - - public void setSolution(String solution) { - this.solution = solution; - } - - public ProblemStatus getStatus() { - return status; - } - - public void setStatus(ProblemStatus status) { - this.status = status; - } - - public ProblemSeverity getSeverity() { - return severity; - } - - public void setSeverity(ProblemSeverity severity) { - this.severity = severity; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - Problem problem = (Problem) o; - return Objects.equals(id, problem.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/Project.java b/backend/src/main/java/com/devaulty/backend/domain/model/Project.java deleted file mode 100644 index d666b9d..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/Project.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.devaulty.backend.domain.model; - -import com.devaulty.backend.domain.model.base.BaseEntity; - -import java.util.Objects; -import java.util.UUID; - -public class Project extends BaseEntity { - - private UUID id; - private String name; - private String description; - private String icon; - private String color; - private boolean archived; - - public Project() { - // Empty constructor - } - - public Project(UUID id, String name, String description, String icon, String color, boolean archived) { - this.id = id; - this.name = name; - this.description = description; - this.icon = icon; - this.color = color; - this.archived = archived; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getIcon() { - return icon; - } - - public void setIcon(String icon) { - this.icon = icon; - } - - public String getColor() { - return color; - } - - public void setColor(String color) { - this.color = color; - } - - public boolean isArchived() { - return archived; - } - - public void setArchived(boolean archived) { - this.archived = archived; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - Project project = (Project) o; - return Objects.equals(id, project.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/Snippet.java b/backend/src/main/java/com/devaulty/backend/domain/model/Snippet.java deleted file mode 100644 index 45f3de5..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/Snippet.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.devaulty.backend.domain.model; - -import com.devaulty.backend.domain.model.base.BaseEntity; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; - -import java.util.Objects; -import java.util.UUID; - -public class Snippet extends BaseEntity { - - private UUID id; - private UUID projectId; - private String title; - private String description; - private String content; - private SnippetLanguage language; - private SnippetType snippetType = SnippetType.COMMAND; - - public Snippet() { - // Empty constructor - } - - public Snippet(UUID id, UUID projectId, String title, String description, String content, SnippetLanguage language, SnippetType snippetType) { - this.id = id; - this.projectId = projectId; - this.title = title; - this.description = description; - this.content = content; - this.language = language; - this.snippetType = snippetType; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public UUID getProjectId() { - return projectId; - } - - public void setProjectId(UUID projectId) { - this.projectId = projectId; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public SnippetLanguage getLanguage() { - return language; - } - - public void setLanguage(SnippetLanguage language) { - this.language = language; - } - - public SnippetType getSnippetType() { - return snippetType; - } - - public void setSnippetType(SnippetType snippetType) { - this.snippetType = snippetType; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - Snippet snippet = (Snippet) o; - return Objects.equals(id, snippet.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/Tag.java b/backend/src/main/java/com/devaulty/backend/domain/model/Tag.java deleted file mode 100644 index 0ac1f6e..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/Tag.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.devaulty.backend.domain.model; - -import com.devaulty.backend.domain.model.base.BaseEntity; - -import java.util.Objects; -import java.util.UUID; - -public class Tag extends BaseEntity { - - private UUID id; - private UUID projectId; - private String name; - private String color; - - public Tag() { - // Empty constructor - } - - public Tag(UUID id, UUID projectId, String name, String color) { - this.id = id; - this.projectId = projectId; - this.name = name; - this.color = color; - } - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public UUID getProjectId() { - return projectId; - } - - public void setProjectId(UUID projectId) { - this.projectId = projectId; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getColor() { - return color; - } - - public void setColor(String color) { - this.color = color; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - Tag tag = (Tag) o; - return Objects.equals(id, tag.id); - } - - @Override - public int hashCode() { - return Objects.hashCode(id); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/base/BaseEntity.java b/backend/src/main/java/com/devaulty/backend/domain/model/base/BaseEntity.java deleted file mode 100644 index 8e5f728..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/base/BaseEntity.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.devaulty.backend.domain.model.base; - -import java.time.LocalDateTime; - -public abstract class BaseEntity { - - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(LocalDateTime createdAt) { - this.createdAt = createdAt; - } - - public LocalDateTime getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(LocalDateTime updatedAt) { - this.updatedAt = updatedAt; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/enums/CredentialSecretType.java b/backend/src/main/java/com/devaulty/backend/domain/model/enums/CredentialSecretType.java deleted file mode 100644 index 98e8380..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/enums/CredentialSecretType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.devaulty.backend.domain.model.enums; - -public enum CredentialSecretType { - LOGIN, - API_KEY, - RAW_TEXT -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/enums/ProblemSeverity.java b/backend/src/main/java/com/devaulty/backend/domain/model/enums/ProblemSeverity.java deleted file mode 100644 index 2d8cfbe..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/enums/ProblemSeverity.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.devaulty.backend.domain.model.enums; - -public enum ProblemSeverity { - LOW, - MEDIUM, - HIGH, - CRITICAL -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/enums/ProblemStatus.java b/backend/src/main/java/com/devaulty/backend/domain/model/enums/ProblemStatus.java deleted file mode 100644 index 7f5d0e3..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/enums/ProblemStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.devaulty.backend.domain.model.enums; - -public enum ProblemStatus { - OPEN, - WORKING_ON, - RESOLVED, - WONT_FIX -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/enums/SnippetLanguage.java b/backend/src/main/java/com/devaulty/backend/domain/model/enums/SnippetLanguage.java deleted file mode 100644 index c2acbb1..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/enums/SnippetLanguage.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.devaulty.backend.domain.model.enums; - -public enum SnippetLanguage { - - // Shells / terminal - BASH, - FISH, - ZSH, - SH, - POWERSHELL, - BATCH, - - // Code Languages - JAVA, - KOTLIN, - JAVASCRIPT, - TYPESCRIPT, - PYTHON, - GO, - RUST, - C, - CPP, - CSHARP, - PHP, - RUBY, - SWIFT, - DART, - SCALA, - LUA, - PERL, - R, - ELIXIR, - HASKELL, - CLOJURE, - GROOVY, - - // Web / frontend - HTML, - CSS, - SCSS, - LESS, - JSX, - TSX, - VUE, - SVELTE, - - // Data / config / markup - JSON, - YAML, - XML, - TOML, - INI, - ENV, - CSV, - MARKDOWN, - PROPERTIES, - - // Infra / DevOps - DOCKERFILE, - DOCKER_COMPOSE, - NGINX, - APACHE, - TERRAFORM, - ANSIBLE, - KUBERNETES_YAML, - HELM, - MAKEFILE, - CMAKE, - GRADLE, - MAVEN_POM, - - // Databases - SQL, - PLSQL, - GRAPHQL, - MONGODB, - - // CI/CD - GITHUB_ACTIONS, - GITLAB_CI, - JENKINSFILE, - - // Others / Generics - REGEX, - DIFF, - LOG, - PLAIN_TEXT -} diff --git a/backend/src/main/java/com/devaulty/backend/domain/model/enums/SnippetType.java b/backend/src/main/java/com/devaulty/backend/domain/model/enums/SnippetType.java deleted file mode 100644 index d168d36..0000000 --- a/backend/src/main/java/com/devaulty/backend/domain/model/enums/SnippetType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.devaulty.backend.domain.model.enums; - -public enum SnippetType { - COMMAND, - CODE -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/CredentialBeanConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/CredentialBeanConfig.java deleted file mode 100644 index 5544a0d..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/CredentialBeanConfig.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.credential.*; -import com.devaulty.backend.application.port.in.credential.*; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.CryptoPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class CredentialBeanConfig { - - @Bean - public CreateCredentialUseCase createCredentialUseCase( - CredentialRepositoryPort credentialRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - CryptoPort cryptoPort, - MasterKeySessionPort masterKeySessionPort, - CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase - ){ - return new CreateCredentialImpl( - credentialRepositoryPort, - projectRepositoryPort, - cryptoPort, - masterKeySessionPort, - checkMasterPasswordSetupUseCase - ); - } - - @Bean - public GetAllCredentialsByProjectUseCase getCredentialsByProjectUseCase( - CredentialRepositoryPort credentialRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - MasterKeySessionPort masterKeySessionPort, - CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase - ){ - return new GetAllCredentialsByProjectImpl( - credentialRepositoryPort, - projectRepositoryPort, - masterKeySessionPort, - checkMasterPasswordSetupUseCase - ); - } - - @Bean - public GetCredentialByIdUseCase getCredentialByIdUseCase( - CredentialRepositoryPort credentialRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - CryptoPort cryptoPort, - MasterKeySessionPort masterKeySessionPort, - CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase - ){ - return new GetCredentialByIdImpl( - credentialRepositoryPort, - projectRepositoryPort, - cryptoPort, - masterKeySessionPort, - checkMasterPasswordSetupUseCase - ); - } - - @Bean - public UpdateCredentialUseCase updateCredentialUseCase( - CredentialRepositoryPort credentialRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - CryptoPort cryptoPort, - MasterKeySessionPort masterKeySessionPort, - CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase - ){ - return new UpdateCredentialImpl( - credentialRepositoryPort, - projectRepositoryPort, - cryptoPort, - masterKeySessionPort, - checkMasterPasswordSetupUseCase - ); - } - - @Bean - public DeleteCredentialUseCase deleteCredentialUseCase( - CredentialRepositoryPort credentialRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - MasterKeySessionPort masterKeySessionPort, - CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase, - ItemTagRepositoryPort itemTagRepositoryPort - ){ - return new DeleteCredentialImpl( - credentialRepositoryPort, - projectRepositoryPort, - masterKeySessionPort, - checkMasterPasswordSetupUseCase, - itemTagRepositoryPort - ); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ItemTagBeanConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ItemTagBeanConfig.java deleted file mode 100644 index 804b11c..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ItemTagBeanConfig.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.tag.item.AssociateTagToItemImpl; -import com.devaulty.backend.application.impl.tag.item.GetTagsForItemImpl; -import com.devaulty.backend.application.impl.tag.item.GetTagsForItemsImpl; -import com.devaulty.backend.application.impl.tag.item.RemoveTagFromItemImpl; -import com.devaulty.backend.application.port.in.tag.item.AssociateTagToItemUseCase; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase; -import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemsUseCase; -import com.devaulty.backend.application.port.in.tag.item.RemoveTagFromItemUseCase; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectScopedRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.util.List; - -@Configuration -public class ItemTagBeanConfig { - - @Bean - public GetTagsForItemUseCase getTagsForItemUseCase( - ItemTagRepositoryPort itemTagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - List projectScopedRepositories - ){ - return new GetTagsForItemImpl( - itemTagRepositoryPort, - projectRepositoryPort, - projectScopedRepositories - ); - } - - @Bean - public GetTagsForItemsUseCase getTagsForItemsUseCase( - ItemTagRepositoryPort itemTagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - List projectScopedRepositories - ){ - return new GetTagsForItemsImpl( - itemTagRepositoryPort, - projectRepositoryPort, - projectScopedRepositories - ); - } - - @Bean - public AssociateTagToItemUseCase associateTagToItemUseCase( - ItemTagRepositoryPort itemTagRepositoryPort, - TagRepositoryPort tagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - List projectScopedRepositories - ){ - return new AssociateTagToItemImpl( - itemTagRepositoryPort, - tagRepositoryPort, - projectRepositoryPort, - projectScopedRepositories - ); - } - - @Bean - public RemoveTagFromItemUseCase removeTagFromItemUseCase( - ItemTagRepositoryPort itemTagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - TagRepositoryPort tagRepositoryPort, - List projectScopedRepositories - ){ - return new RemoveTagFromItemImpl( - itemTagRepositoryPort, - projectRepositoryPort, - tagRepositoryPort, - projectScopedRepositories - ); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/LinkBeanConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/LinkBeanConfig.java deleted file mode 100644 index d7a3ecb..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/LinkBeanConfig.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.link.*; -import com.devaulty.backend.application.port.in.link.*; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class LinkBeanConfig { - - @Bean - public CreateLinkUseCase createLinkUseCase ( - LinkRepositoryPort linkRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new CreateLinkImpl( - linkRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetAllLinksByProjectUseCase getAllLinksByProjectUseCase( - LinkRepositoryPort linkRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new GetAllLinksByProjectImpl( - linkRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetLinkByIdUseCase getLinkByIdUseCase( - LinkRepositoryPort linkRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new GetLinkByIdImpl( - linkRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public DeleteLinkUseCase deleteLinkUseCase( - LinkRepositoryPort linkRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - ItemTagRepositoryPort itemTagRepositoryPort - ){ - return new DeleteLinkImpl( - linkRepositoryPort, - projectRepositoryPort, - itemTagRepositoryPort - ); - } - - @Bean - public UpdateLinkUseCase updateLinkUseCase( - LinkRepositoryPort linkRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new UpdateLinkImpl( - linkRepositoryPort, - projectRepositoryPort - ); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/NoteBeanConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/NoteBeanConfig.java deleted file mode 100644 index 79bd827..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/NoteBeanConfig.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.note.*; -import com.devaulty.backend.application.port.in.note.*; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class NoteBeanConfig { - - @Bean - public CreateNoteUseCase createNoteUseCase( - NoteRepositoryPort noteRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new CreateNoteImpl( - noteRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetAllNotesByProjectUseCase getAllNotesByProjectUseCase( - NoteRepositoryPort noteRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new GetAllNotesByProjectImpl( - noteRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetNoteByIdUseCase getNoteByIdUseCase( - NoteRepositoryPort noteRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new GetNoteByIdImpl( - noteRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public DeleteNoteUseCase deleteNoteUseCase( - NoteRepositoryPort noteRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - ItemTagRepositoryPort itemTagRepositoryPort - ){ - return new DeleteNoteImpl( - noteRepositoryPort, - projectRepositoryPort, - itemTagRepositoryPort - ); - } - - @Bean - public ArchiveNoteUseCase archiveNoteUseCase( - NoteRepositoryPort noteRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new ArchiveNoteImpl( - noteRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public UnarchiveNoteUseCase unarchiveNoteUseCase( - NoteRepositoryPort noteRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new UnarchiveNoteImpl( - noteRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public UpdateNoteUseCase updateNoteUseCase( - NoteRepositoryPort noteRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new UpdateNoteImpl( - noteRepositoryPort, - projectRepositoryPort - ); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/OpenApiConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/OpenApiConfig.java deleted file mode 100644 index 7c94dbb..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/OpenApiConfig.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.info.License; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class OpenApiConfig { - - @Bean - public OpenAPI customOpenAPI() { - String securitySchemeName = "DevaultyInternalToken"; - - return new OpenAPI() - .info(new Info() - .title("Devaulty API") - .version("v1") - .description("Restful API for Devaulty") - .license(new License().name("Apache 2.0").url("http://springdoc.org"))) - .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) - .components(new Components() - .addSecuritySchemes(securitySchemeName, new SecurityScheme() - .name("X-Devaulty-Internal-Token") - .type(SecurityScheme.Type.APIKEY) - .in(SecurityScheme.In.HEADER) - .description("Devaulty Internal Token"))); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ProblemBeanConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ProblemBeanConfig.java deleted file mode 100644 index 38d249d..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ProblemBeanConfig.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.problem.*; -import com.devaulty.backend.application.port.in.problem.*; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class ProblemBeanConfig { - - @Bean - public CreateProblemUseCase createProblemUseCase( - ProblemRepositoryPort problemRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new CreateProblemImpl( - problemRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetAllProblemsByProjectUseCase getAllProblemsByProjectUseCase( - ProblemRepositoryPort problemRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new GetAllProblemsByProjectImpl( - problemRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetProblemByIdUseCase getProblemByIdUseCase( - ProblemRepositoryPort problemRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new GetProblemByIdImpl( - problemRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public DeleteProblemUseCase deleteProblemUseCase( - ProblemRepositoryPort problemRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - ItemTagRepositoryPort itemTagRepositoryPort - ){ - return new DeleteProblemImpl( - problemRepositoryPort, - projectRepositoryPort, - itemTagRepositoryPort - ); - } - - @Bean - public UpdateProblemUseCase updateProblemUseCase( - ProblemRepositoryPort problemRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new UpdateProblemImpl( - problemRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public UpdateProblemStatusUseCase updateProblemStatusUseCase( - ProblemRepositoryPort problemRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new UpdateProblemStatusImpl( - problemRepositoryPort, - projectRepositoryPort - ); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ProjectBeanConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ProjectBeanConfig.java deleted file mode 100644 index e88a94f..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ProjectBeanConfig.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.project.*; -import com.devaulty.backend.application.port.in.project.*; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class ProjectBeanConfig { - - @Bean - public CreateProjectUseCase createProjectUseCase(ProjectRepositoryPort projectRepository) { - return new CreateProjectImpl(projectRepository); - } - - @Bean - public GetAllProjectsUseCase getAllProjectsUseCase(ProjectRepositoryPort projectRepository) { - return new GetAllProjectsImpl(projectRepository); - } - - @Bean - public GetProjectByIdUseCase getProjectByIdUseCase(ProjectRepositoryPort projectRepository) { - return new GetProjectByIdImpl(projectRepository); - } - - @Bean - public ArchiveProjectUseCase archiveProjectUseCase(ProjectRepositoryPort projectRepository) { - return new ArchiveProjectImpl(projectRepository); - } - - @Bean - public UnarchiveProjectUseCase unarchiveProjectUseCase(ProjectRepositoryPort projectRepository) { - return new UnarchiveProjectImpl(projectRepository); - } - - @Bean - public DeleteProjectUseCase deleteProjectUseCase(ProjectRepositoryPort projectRepository) { - return new DeleteProjectImpl(projectRepository); - } - - @Bean - public UpdateProjectUseCase updateProjectUseCase(ProjectRepositoryPort projectRepository) { - return new UpdateProjectImpl(projectRepository); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ReleaseConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ReleaseConfig.java deleted file mode 100644 index 1d58c93..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ReleaseConfig.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.release.CheckForUpdatesImpl; -import com.devaulty.backend.application.impl.release.DownloadUpdateImpl; -import com.devaulty.backend.application.impl.release.GetCurrentVersionImpl; -import com.devaulty.backend.application.impl.release.InstallUpdateImpl; -import com.devaulty.backend.application.port.in.release.CheckForUpdatesUseCase; -import com.devaulty.backend.application.port.in.release.DownloadUpdateUseCase; -import com.devaulty.backend.application.port.in.release.GetCurrentVersionUseCase; -import com.devaulty.backend.application.port.in.release.InstallUpdateUseCase; -import com.devaulty.backend.application.port.out.external.release.ReleasePort; -import com.devaulty.backend.infrastructure.properties.DevaultyProperties; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class ReleaseConfig { - - @Bean - public CheckForUpdatesUseCase checkForUpdatesUseCase( - ReleasePort releasePort, - DevaultyProperties devaultyProperties - ){ - return new CheckForUpdatesImpl(releasePort, - devaultyProperties - ); - } - - @Bean - public DownloadUpdateUseCase downloadUpdateUseCase( - ReleasePort releasePort, - CheckForUpdatesUseCase checkForUpdatesUseCase, - InstallUpdateUseCase installUpdateUseCase - ){ - return new DownloadUpdateImpl( - releasePort, - checkForUpdatesUseCase, - installUpdateUseCase - ); - } - - @Bean - public InstallUpdateUseCase installUpdateUseCase( - ConfigurableApplicationContext configurableApplicationContext - ){ - return new InstallUpdateImpl( - configurableApplicationContext - ); - } - - @Bean - public GetCurrentVersionUseCase getActualVersionUseCase( - DevaultyProperties devaultyProperties - ){ - return new GetCurrentVersionImpl( - devaultyProperties - ); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/SecurityBeanConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/SecurityBeanConfig.java deleted file mode 100644 index 74c2007..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/SecurityBeanConfig.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.security.*; -import com.devaulty.backend.application.port.in.security.*; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.application.port.out.persistence.AppSettingRepositoryPort; -import com.devaulty.backend.application.port.out.security.KeyDerivationPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class SecurityBeanConfig { - - @Bean - public CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase(AppSettingRepositoryPort appSettingRepositoryPort){ - return new CheckMasterPasswordSetupImpl(appSettingRepositoryPort); - } - - @Bean - public LockVaultUseCase lockVaultUseCase(MasterKeySessionPort masterKeySessionPort){ - return new LockVaultImpl(masterKeySessionPort); - } - - @Bean - public SetupMasterPasswordUseCase setupMasterPasswordUseCase( - AppSettingRepositoryPort appSettingRepositoryPort, - MasterKeySessionPort masterKeySessionPort, - KeyDerivationPort keyDerivationPort - ){ - return new SetupMasterPasswordImpl( - appSettingRepositoryPort, - masterKeySessionPort, - keyDerivationPort - ); - } - - @Bean - public UnlockVaultUseCase unlockVaultUseCase( - AppSettingRepositoryPort appSettingRepositoryPort, - MasterKeySessionPort sessionHolder, - KeyDerivationPort keyDerivationPort - ){ - return new UnlockVaultImpl( - appSettingRepositoryPort, - sessionHolder, - keyDerivationPort - ); - } - - @Bean - public GetSessionStatusUseCase getSessionStatusUseCase( - MasterKeySessionPort sessionHolder - ){ - return new GetSessionStatusImpl( - sessionHolder - ); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/SnippetBeanConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/SnippetBeanConfig.java deleted file mode 100644 index 74d4217..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/SnippetBeanConfig.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.snippet.*; -import com.devaulty.backend.application.port.in.snippet.*; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class SnippetBeanConfig { - - @Bean - public CreateSnippetUseCase createSnippetUseCase( - SnippetRepositoryPort snippetRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ) { - return new CreateSnippetImpl( - snippetRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetAllSnippetsByProjectUseCase getAllSnippetsByProjectUseCase( - SnippetRepositoryPort snippetRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new GetAllSnippetsByProjectImpl( - snippetRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetSnippetByIdUseCase getSnippetByIdUseCase( - SnippetRepositoryPort snippetRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new GetSnippetByIdImpl( - snippetRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public UpdateSnippetUseCase updateSnippetUseCase( - SnippetRepositoryPort snippetRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new UpdateSnippetImpl( - snippetRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public DeleteSnippetUseCase deleteSnippetUseCase( - SnippetRepositoryPort snippetRepositoryPort, - ProjectRepositoryPort projectRepositoryPort, - ItemTagRepositoryPort itemTagRepositoryPort - ){ - return new DeleteSnippetImpl( - snippetRepositoryPort, - projectRepositoryPort, - itemTagRepositoryPort - ); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/TagBeanConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/TagBeanConfig.java deleted file mode 100644 index 4a5e770..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/TagBeanConfig.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.devaulty.backend.infrastructure.configuration; - -import com.devaulty.backend.application.impl.tag.*; -import com.devaulty.backend.application.port.in.tag.*; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class TagBeanConfig { - - @Bean - public CreateTagUseCase createTagUseCase( - TagRepositoryPort tagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort) { - return new CreateTagImpl( - tagRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetTagByIdUseCase getTagByIdUseCase( - TagRepositoryPort tagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ) { - return new GetTagByIdImpl( - tagRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public SearchTagByNameUseCase searchTagByNameUseCase( - TagRepositoryPort tagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ) { - return new SearchTagByNameImpl( - tagRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public GetAllTagsByProjectUseCase getAllTagsByProjectUseCase( - TagRepositoryPort tagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new GetAllTagsByProjectImpl( - tagRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public UpdateTagUseCase updateTagUseCase( - TagRepositoryPort tagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new UpdateTagImpl( - tagRepositoryPort, - projectRepositoryPort - ); - } - - @Bean - public DeleteTagUseCase deleteTagUseCase( - TagRepositoryPort tagRepositoryPort, - ProjectRepositoryPort projectRepositoryPort - ){ - return new DeleteTagImpl( - tagRepositoryPort, - projectRepositoryPort - ); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/properties/DevaultyProperties.java b/backend/src/main/java/com/devaulty/backend/infrastructure/properties/DevaultyProperties.java deleted file mode 100644 index b32d47e..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/properties/DevaultyProperties.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.devaulty.backend.infrastructure.properties; - -import jakarta.validation.constraints.NotBlank; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; -import org.springframework.validation.annotation.Validated; - -@Component -@Validated -@ConfigurationProperties(prefix = "app") -public class DevaultyProperties { - - @NotBlank(message = "app.version cannot be blank") - private String version; - - public String getVersion() { - return version; - } - - public void setVersion(String version) { - this.version = version; - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/security/AppTokenContext.java b/backend/src/main/java/com/devaulty/backend/infrastructure/security/AppTokenContext.java deleted file mode 100644 index eff11cc..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/security/AppTokenContext.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.devaulty.backend.infrastructure.security; - -import java.util.UUID; - -public class AppTokenContext { - - public static final String HEADER_NAME = "X-Devaulty-Internal-Token"; - // Unique token generated in memory - public static final String PROCESS_TOKEN = UUID.randomUUID().toString(); - - private AppTokenContext() { - /* This utility class should not be instantiated */ - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/security/InternalAppTokenFilter.java b/backend/src/main/java/com/devaulty/backend/infrastructure/security/InternalAppTokenFilter.java deleted file mode 100644 index db322a9..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/security/InternalAppTokenFilter.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.devaulty.backend.infrastructure.security; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Component -@Order(1) -public class InternalAppTokenFilter extends OncePerRequestFilter { - - @Value("${devaulty.dev.token:#{null}}") - private String devToken; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - String path = request.getRequestURI(); - - // 1. Allows static resources from React, Swagger and OPTIONS (Pre-flight CORS on dev) - if (!path.startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) { - filterChain.doFilter(request, response); - return; - } - - // 2. For API requests, requires the request token - String requestToken = request.getHeader(AppTokenContext.HEADER_NAME); - - // 3. Safety check - // In Dev: accepts the devToken - // In Prod: accepts the processToken - boolean isDevTokenValid = devToken != null && !devToken.isBlank() && devToken.equals(requestToken); - boolean isProcessTokenValid = AppTokenContext.PROCESS_TOKEN.equals(requestToken); - - if (requestToken == null || (!isDevTokenValid && !isProcessTokenValid)) { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json"); - response.getWriter().write("{\"error\": \"Forbidden: Unauthorized local process request.\"}"); - return; - } - - filterChain.doFilter(request, response); - } -} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/security/WebConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/security/WebConfig.java deleted file mode 100644 index 751b619..0000000 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/security/WebConfig.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.devaulty.backend.infrastructure.security; - -import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.Resource; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import org.springframework.web.servlet.resource.PathResourceResolver; - -import java.io.IOException; - -@Configuration -public class WebConfig implements WebMvcConfigurer { - - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*") - .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") - .allowedHeaders("*") - .allowCredentials(true); - } - - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/**") - .addResourceLocations("classpath:/static/") - .resourceChain(true) - .addResolver(new PathResourceResolver() { - @Override - protected Resource getResource(String resourcePath, Resource location) throws IOException { - Resource requestedResource = location.createRelative(resourcePath); - - // 1. If static file exists, serves it - if (requestedResource.exists() && requestedResource.isReadable()) { - return requestedResource; - } - - // 2. Excludes /api, /v3, /swagger-ui endpoints from SPA fallback so non-existent API routes return 404 - if (resourcePath.startsWith("api/") || resourcePath.startsWith("v3/") || resourcePath.startsWith("swagger-ui/")) { - return null; - } - - // 3. Fallback to SPA index.html for frontend routes (/dashboard, /settings, etc.) - return new ClassPathResource("/static/index.html"); - } - }); - } -} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml deleted file mode 100644 index a1bbf65..0000000 --- a/backend/src/main/resources/application-dev.yml +++ /dev/null @@ -1,14 +0,0 @@ -spring: - datasource: - url: jdbc:sqlite:./data/devaulty-dev.db?foreign_keys=on - jpa: - show-sql: true - properties: - hibernate: - format_sql: true - devtools: - restart: - additional-exclude: static/**,jpackage/**,db/** -devaulty: - dev: - token: "dev-secret-token" \ No newline at end of file diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml deleted file mode 100644 index 2cf2ce4..0000000 --- a/backend/src/main/resources/application-prod.yml +++ /dev/null @@ -1,16 +0,0 @@ -spring: - datasource: - url: jdbc:sqlite:${user.home}/.config/devaulty/devaulty.db?foreign_keys=on - jpa: - show-sql: false - -springdoc: - api-docs: - enabled: false - swagger-ui: - enabled: false - -logging: - level: - com.devaulty: INFO - org.springframework: ERROR diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml deleted file mode 100644 index 0d9d5a4..0000000 --- a/backend/src/main/resources/application.yaml +++ /dev/null @@ -1,33 +0,0 @@ -app: - version: "0.1.6-alpha" - -spring: - application: - name: backend - profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} - - datasource: - driver-class-name: org.sqlite.JDBC - hikari: - maximum-pool-size: 2 - minimum-idle: 1 - idle-timeout: 30000 - max-lifetime: 60000 - jpa: - database-platform: org.hibernate.community.dialect.SQLiteDialect - open-in-view: false - hibernate: - ddl-auto: validate - properties: - hibernate: - default_batch_fetch_size: 20 - liquibase: - change-log: classpath:/db/changelog/db.changelog-master.yaml - -server: - jetty: - threads: - max: 10 - min: 2 - idle-timeout: 30s diff --git a/backend/src/main/resources/db/changelog/changesets/001-create-app-settings-table.yaml b/backend/src/main/resources/db/changelog/changesets/001-create-app-settings-table.yaml deleted file mode 100644 index 78dd82d..0000000 --- a/backend/src/main/resources/db/changelog/changesets/001-create-app-settings-table.yaml +++ /dev/null @@ -1,30 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 001-create-app-settings-table - author: Matheus Cunha - changes: - - createTable: - tableName: app_settings - columns: - - - column: - name: key - type: TEXT - constraints: - primaryKey: true - nullable: false - - - column: - name: value - type: TEXT - constraints: - nullable: false - - - insert: - tableName: app_settings - columns: - - column: { name: "key", value: "crypto_version" } - - column: { name: "value", value: "1" } - rollback: - - dropTable: - tableName: app_settings diff --git a/backend/src/main/resources/db/changelog/changesets/002-create-projects-table.yaml b/backend/src/main/resources/db/changelog/changesets/002-create-projects-table.yaml deleted file mode 100644 index 034f7d9..0000000 --- a/backend/src/main/resources/db/changelog/changesets/002-create-projects-table.yaml +++ /dev/null @@ -1,60 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 002-create-projects-table - author: Matheus-Cunha - changes: - - createTable: - tableName: projects - columns: - - column: - name: id - type: TEXT - constraints: - primaryKey: true - nullable: false - - - column: - name: name - type: TEXT - constraints: - nullable: false - - - column: - name: description - type: TEXT - - - column: - name: icon - type: TEXT - - - column: - name: color - type: TEXT - - - column: - name: archived - type: INTEGER - defaultValueNumeric: 0 - constraints: - nullable: false - - - column: - name: created_at - type: TEXT - constraints: - nullable: false - - - column: - name: updated_at - type: TEXT - - - createIndex: - indexName: idx_projects_archived - tableName: projects - columns: - - column: - name: archived - rollback: - - dropTable: - tableName: projects - diff --git a/backend/src/main/resources/db/changelog/changesets/003-create-snippets-table.yaml b/backend/src/main/resources/db/changelog/changesets/003-create-snippets-table.yaml deleted file mode 100644 index c406b6f..0000000 --- a/backend/src/main/resources/db/changelog/changesets/003-create-snippets-table.yaml +++ /dev/null @@ -1,70 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 003-create-snippets-table - author: Matheus Cunha - changes: - - createTable: - tableName: snippets - columns: - - column: - name: id - type: TEXT - constraints: - primaryKey: true - nullable: false - - - column: - name: project_id - type: TEXT - constraints: - nullable: false - foreignKeyName: fk_snippets_project - references: projects(id) - deleteCascade: true - - - column: - name: title - type: TEXT - constraints: - nullable: false - - - column: - name: description - type: TEXT - - - column: - name: content - type: TEXT - constraints: - nullable: false - - - column: - name: language - type: TEXT - - - column: - name: snippet_type - type: TEXT - constraints: - nullable: false - - - column: - name: created_at - type: TEXT - constraints: - nullable: false - - - column: - name: updated_at - type: TEXT - - - createIndex: - indexName: idx_snippets_project - tableName: snippets - columns: - - column: - name: project_id - - rollback: - - dropTable: - tableName: snippets \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/changesets/004-create-links-table.yaml b/backend/src/main/resources/db/changelog/changesets/004-create-links-table.yaml deleted file mode 100644 index a450331..0000000 --- a/backend/src/main/resources/db/changelog/changesets/004-create-links-table.yaml +++ /dev/null @@ -1,60 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 004-create-links-table - author: Matheus Cunha - changes: - - createTable: - tableName: links - columns: - - column: - name: id - type: TEXT - constraints: - primaryKey: true - nullable: false - - - column: - name: project_id - type: TEXT - constraints: - nullable: false - foreignKeyName: fk_links_project - references: projects(id) - deleteCascade: true - - - column: - name: title - type: TEXT - constraints: - nullable: false - - - column: - name: url - type: TEXT - constraints: - nullable: false - - - column: - name: description - type: TEXT - - - column: - name: created_at - type: TEXT - constraints: - nullable: false - - - column: - name: updated_at - type: TEXT - - - createIndex: - indexName: idx_links_project - tableName: links - columns: - - column: - name: project_id - rollback: - - dropTable: - tableName: links - diff --git a/backend/src/main/resources/db/changelog/changesets/005-create-problems-table.yaml b/backend/src/main/resources/db/changelog/changesets/005-create-problems-table.yaml deleted file mode 100644 index 826e197..0000000 --- a/backend/src/main/resources/db/changelog/changesets/005-create-problems-table.yaml +++ /dev/null @@ -1,70 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 005-create-problems-table - author: Matheus Cunha - changes: - - createTable: - tableName: problems - columns: - - column: - name: id - type: TEXT - constraints: - primaryKey: true - nullable: false - - - column: - name: project_id - type: TEXT - constraints: - nullable: false - foreignKeyName: fk_problems_project - references: projects(id) - deleteCascade: true - - - column: - name: title - type: TEXT - constraints: - nullable: false - - - column: - name: error_description - type: TEXT - - - column: - name: solution - type: TEXT - - - column: - name: status - type: TEXT - constraints: - nullable: false - - - column: - name: severity - type: TEXT - constraints: - nullable: false - - - column: - name: created_at - type: TEXT - constraints: - nullable: false - - - column: - name: updated_at - type: TEXT - - - createIndex: - indexName: idx_problems_project - tableName: problems - columns: - - column: - name: project_id - - rollback: - - dropTable: - tableName: problems \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/changesets/006-create-notes-table.yaml b/backend/src/main/resources/db/changelog/changesets/006-create-notes-table.yaml deleted file mode 100644 index 5cee7a7..0000000 --- a/backend/src/main/resources/db/changelog/changesets/006-create-notes-table.yaml +++ /dev/null @@ -1,61 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 006-create-notes-table - author: Matheus Cunha - changes: - - createTable: - tableName: notes - columns: - - column: - name: id - type: TEXT - constraints: - primaryKey: true - nullable: false - - - column: - name: project_id - type: TEXT - constraints: - nullable: false - foreignKeyName: fk_notes_project - references: projects(id) - deleteCascade: true - - - column: - name: title - type: TEXT - constraints: - nullable: false - - - column: - name: content - type: TEXT - - - column: - name: archived - type: INTEGER - defaultValueNumeric: 0 - constraints: - nullable: false - - - column: - name: created_at - type: TEXT - constraints: - nullable: false - - - column: - name: updated_at - type: TEXT - - - createIndex: - indexName: idx_notes_project - tableName: notes - columns: - - column: - name: project_id - - rollback: - - dropTable: - tableName: notes \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/changesets/007-create-credentials-table.yaml b/backend/src/main/resources/db/changelog/changesets/007-create-credentials-table.yaml deleted file mode 100644 index fcf5dc5..0000000 --- a/backend/src/main/resources/db/changelog/changesets/007-create-credentials-table.yaml +++ /dev/null @@ -1,81 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 007-create-credentials-table - author: Matheus Cunha - changes: - - createTable: - tableName: credentials - columns: - - column: - name: id - type: TEXT - constraints: - primaryKey: true - nullable: false - - - column: - name: project_id - type: TEXT - constraints: - foreignKeyName: fk_credentials_project - references: projects(id) - nullable: false - deleteCascade: true - - - column: - name: title - type: TEXT - constraints: - nullable: false - - - column: - name: secret_type - type: TEXT - constraints: - nullable: false - - - column: - name: payload_encrypted - type: BLOB - constraints: - nullable: false - - - column: - name: encryption_iv - type: BLOB - constraints: - nullable: false - - - column: - name: encryption_auth_tag - type: BLOB - constraints: - nullable: false - - - column: - name: notes - type: TEXT - - - column: - name: related_url - type: TEXT - - - column: - name: created_at - type: TEXT - constraints: - nullable: false - - - column: - name: updated_at - type: TEXT - - - createIndex: - indexName: idx_credentials_project - tableName: credentials - columns: - - column: - name: project_id - rollback: - - dropTable: - tableName: credentials diff --git a/backend/src/main/resources/db/changelog/changesets/008-create-tags-table.yaml b/backend/src/main/resources/db/changelog/changesets/008-create-tags-table.yaml deleted file mode 100644 index fe9b869..0000000 --- a/backend/src/main/resources/db/changelog/changesets/008-create-tags-table.yaml +++ /dev/null @@ -1,57 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 008-create-tags-table - author: Matheus Cunha - changes: - - createTable: - tableName: tags - columns: - - column: - name: id - type: TEXT - constraints: - primaryKey: true - nullable: false - - - column: - name: project_id - type: TEXT - constraints: - foreignKeyName: fk_tags_project - references: projects(id) - nullable: false - deleteCascade: true - - - column: - name: name - type: TEXT - constraints: - nullable: false - - - column: - name: color - type: TEXT - - - column: - name: created_at - type: TEXT - constraints: - nullable: false - - - column: - name: updated_at - type: TEXT - - - createIndex: - indexName: uk_tags_project_name - tableName: tags - unique: true - columns: - - column: - name: project_id - - column: - name: name - - rollback: - - dropTable: - tableName: tags \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/changesets/009-create-item-tags-table.yaml b/backend/src/main/resources/db/changelog/changesets/009-create-item-tags-table.yaml deleted file mode 100644 index fa8a477..0000000 --- a/backend/src/main/resources/db/changelog/changesets/009-create-item-tags-table.yaml +++ /dev/null @@ -1,44 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 009-create-item-tags-table - author: Matheus Cunha - changes: - - createTable: - tableName: item_tags - columns: - - column: - name: tag_id - type: TEXT - constraints: - primaryKey: true - nullable: false - foreignKeyName: fk_item_tags_tag - references: tags(id) - deleteCascade: true - - - column: - name: item_type - type: TEXT - constraints: - primaryKey: true - nullable: false - - - column: - name: item_id - type: TEXT - constraints: - primaryKey: true - nullable: false - - - createIndex: - indexName: idx_item_tags_item - tableName: item_tags - columns: - - column: - name: item_type - - column: - name: item_id - - rollback: - - dropTable: - tableName: item_tags \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.yaml b/backend/src/main/resources/db/changelog/db.changelog-master.yaml deleted file mode 100644 index 8d3fa6d..0000000 --- a/backend/src/main/resources/db/changelog/db.changelog-master.yaml +++ /dev/null @@ -1,27 +0,0 @@ -databaseChangeLog: - - include: - file: db/changelog/changesets/001-create-app-settings-table.yaml - - - include: - file: db/changelog/changesets/002-create-projects-table.yaml - - - include: - file: db/changelog/changesets/003-create-snippets-table.yaml - - - include: - file: db/changelog/changesets/004-create-links-table.yaml - - - include: - file: db/changelog/changesets/005-create-problems-table.yaml - - - include: - file: db/changelog/changesets/006-create-notes-table.yaml - - - include: - file: db/changelog/changesets/007-create-credentials-table.yaml - - - include: - file: db/changelog/changesets/008-create-tags-table.yaml - - - include: - file: db/changelog/changesets/009-create-item-tags-table.yaml diff --git a/backend/src/main/resources/jpackage/linux/devaulty.desktop b/backend/src/main/resources/jpackage/linux/devaulty.desktop deleted file mode 100644 index 266d82c..0000000 --- a/backend/src/main/resources/jpackage/linux/devaulty.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[Desktop Entry] -Name=Devaulty -Comment=Devaulty - Secure Local Vault -Exec=/opt/devaulty/bin/devaulty -Icon=/opt/devaulty/lib/devaulty.png -Terminal=false -Type=Application -Categories=Utility; -StartupWMClass=com.devaulty.backend.desktop.DevaultyDesktop diff --git a/backend/src/main/resources/jpackage/macos/Info.plist b/backend/src/main/resources/jpackage/macos/Info.plist deleted file mode 100644 index eb37aad..0000000 --- a/backend/src/main/resources/jpackage/macos/Info.plist +++ /dev/null @@ -1,18 +0,0 @@ - - - - - CFBundleExecutable - devaulty - CFBundleIconFile - devaulty-icon.icns - CFBundleIdentifier - com.devaulty.desktop - CFBundleName - Devaulty - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.1.0 - - diff --git a/backend/src/main/resources/static/icon/devaulty-icon.icns b/backend/src/main/resources/static/icon/devaulty-icon.icns deleted file mode 100644 index 7b68f0c..0000000 Binary files a/backend/src/main/resources/static/icon/devaulty-icon.icns and /dev/null differ diff --git a/backend/src/main/resources/static/icon/devaulty-icon.ico b/backend/src/main/resources/static/icon/devaulty-icon.ico deleted file mode 100644 index 35e35d8..0000000 Binary files a/backend/src/main/resources/static/icon/devaulty-icon.ico and /dev/null differ diff --git a/backend/src/test/java/com/devaulty/backend/BackendApplicationTests.java b/backend/src/test/java/com/devaulty/backend/BackendApplicationTests.java deleted file mode 100644 index eaa1059..0000000 --- a/backend/src/test/java/com/devaulty/backend/BackendApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.devaulty.backend; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles("test") -class BackendApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/scheduler/VaultAutoLockSchedulerTest.java b/backend/src/test/java/com/devaulty/backend/adapter/in/scheduler/VaultAutoLockSchedulerTest.java deleted file mode 100644 index b123665..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/scheduler/VaultAutoLockSchedulerTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.devaulty.backend.adapter.in.scheduler; - -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.Duration; - -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class VaultAutoLockSchedulerTest { - - @Mock - private MasterKeySessionPort masterKeySessionPort; - - @InjectMocks - private VaultAutoLockScheduler scheduler; - - @Test - void purgeExpiredSession_shouldDoNothing_whenSessionHasNoKey() { - // Arrange - when(masterKeySessionPort.hasKey()).thenReturn(false); - - // Act - scheduler.purgeExpiredSession(); - - // Assert - verify(masterKeySessionPort, times(1)).hasKey(); - verify(masterKeySessionPort, never()).getOrClearIfExpired(any()); - } - - @Test - void purgeExpiredSession_shouldDoNothing_whenSessionIsValid() { - // Arrange - when(masterKeySessionPort.hasKey()).thenReturn(true); - when(masterKeySessionPort.getOrClearIfExpired(any(Duration.class))).thenReturn(mock(javax.crypto.SecretKey.class)); - - // Act - scheduler.purgeExpiredSession(); - - // Assert - verify(masterKeySessionPort, times(1)).hasKey(); - verify(masterKeySessionPort, times(1)).getOrClearIfExpired(Duration.ofMinutes(15)); - } - - @Test - void purgeExpiredSession_shouldClearSession_whenSessionIsExpired() { - // Arrange - when(masterKeySessionPort.hasKey()).thenReturn(true); - when(masterKeySessionPort.getOrClearIfExpired(any(Duration.class))).thenReturn(null); - - // Act - scheduler.purgeExpiredSession(); - - // Assert - verify(masterKeySessionPort, times(1)).hasKey(); - verify(masterKeySessionPort, times(1)).getOrClearIfExpired(Duration.ofMinutes(15)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/credential/CredentialControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/credential/CredentialControllerIT.java deleted file mode 100644 index d65ba08..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/credential/CredentialControllerIT.java +++ /dev/null @@ -1,507 +0,0 @@ -package com.devaulty.backend.adapter.in.web.credential; - -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.adapter.out.persistence.credential.CredentialEntity; -import com.devaulty.backend.adapter.out.persistence.credential.SpringDataCredentialRepository; -import com.devaulty.backend.adapter.out.persistence.setting.AppSettingEntity; -import com.devaulty.backend.adapter.out.persistence.setting.SpringDataAppSettingRepository; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.application.port.out.security.CryptoPort; -import com.devaulty.backend.application.port.out.security.dto.CryptoResultDto; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; - -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.UUID; - -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -class CredentialControllerIT extends BaseIntegrationTest { - - @Autowired - private SpringDataCredentialRepository credentialRepository; - - @Autowired - private SpringDataProjectRepository projectRepository; - - @Autowired - private SpringDataAppSettingRepository appSettingRepository; - - @Autowired - private MasterKeySessionPort masterKeySessionPort; - - @Autowired - private CryptoPort cryptoPort; - - private ProjectEntity savedProject; - private SecretKey mockSecretKey; - - @BeforeEach - void setUpData() { - credentialRepository.deleteAll(); - projectRepository.deleteAll(); - appSettingRepository.deleteAll(); - - // 1. Setup global configuration so setup is not required - appSettingRepository.save(new AppSettingEntity("master_password_hash", "mockHash")); - appSettingRepository.save(new AppSettingEntity("master_password_salt", "mockSalt")); - - // 2. Unlock vault with dummy AES-256 key (32 bytes of zeros) - mockSecretKey = new SecretKeySpec(new byte[32], "AES"); - masterKeySessionPort.setKey(mockSecretKey); - - // 3. Create a project - ProjectEntity project = new ProjectEntity( - UUID.randomUUID(), - "Integration Project", - "Description", - "folder", - "#FFF", - false - ); - project.setCreatedAt(LocalDateTime.now()); - savedProject = projectRepository.save(project); - } - - @Test - void createCredential_shouldReturnCreated_whenLoginRequestIsValid() throws Exception { - String json = """ - { - "title": "My Login DB", - "secretType": "LOGIN", - "username": "dbuser", - "password": "dbpassword", - "notes": "some notes", - "relatedUrl": "http://db-server" - } - """; - - mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").isNotEmpty()) - .andExpect(jsonPath("$.projectId").value(savedProject.getId().toString())) - .andExpect(jsonPath("$.title").value("My Login DB")) - .andExpect(jsonPath("$.secretType").value("LOGIN")) - .andExpect(jsonPath("$.decryptedPayload.username").value("dbuser")) - .andExpect(jsonPath("$.decryptedPayload.password").value("dbpassword")) - .andExpect(jsonPath("$.notes").value("some notes")) - .andExpect(jsonPath("$.relatedUrl").value("http://db-server")) - .andExpect(jsonPath("$.createdAt").isNotEmpty()) - .andExpect(jsonPath("$.tags").isEmpty()); - - assertEquals(1, credentialRepository.count()); - - // Reload the entity to assert at-rest protection and context-binding AAD decryption - CredentialEntity reloaded = credentialRepository.findAll().getFirst(); - assertNotNull(reloaded.getPayloadEncrypted()); - assertFalse(new String(reloaded.getPayloadEncrypted()).contains("dbuser")); - assertFalse(new String(reloaded.getPayloadEncrypted()).contains("dbpassword")); - - byte[] decrypted = cryptoPort.decrypt( - reloaded.getPayloadEncrypted(), - reloaded.getEncryptionIv(), - reloaded.getEncryptionAuthTag(), - mockSecretKey, - computeAad(savedProject.getId(), reloaded.getId()) - ); - - String decryptedStr = new String(decrypted); - assertTrue(decryptedStr.contains("dbuser")); - assertTrue(decryptedStr.contains("dbpassword")); - } - - @Test - void createCredential_shouldReturnCreated_whenApiKeyRequestIsValid() throws Exception { - String json = """ - { - "title": "My Api Key", - "secretType": "API_KEY", - "apiKey": "key_12345" - } - """; - - mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.secretType").value("API_KEY")) - .andExpect(jsonPath("$.decryptedPayload.apiKey").value("key_12345")); - } - - @Test - void createCredential_shouldReturnCreated_whenRawTextRequestIsValid() throws Exception { - String json = """ - { - "title": "My Raw text", - "secretType": "RAW_TEXT", - "rawTextContent": "private certificate text" - } - """; - - mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.secretType").value("RAW_TEXT")) - .andExpect(jsonPath("$.decryptedPayload.rawText").value("private certificate text")); - } - - @Test - void createCredential_shouldReturnBadRequest_whenSecretFieldsAreMissingForLogin() throws Exception { - String json = """ - { - "title": "Invalid Login", - "secretType": "LOGIN", - "username": "admin" - } - """; - - mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors[0].field").value("validSecretPayload")) - .andExpect(jsonPath("$.errors[0].message").value("Required secret fields are missing for the selected secret type")); - } - - @Test - void createCredential_shouldReturnBadRequest_whenTitleIsTooShort() throws Exception { - String json = """ - { - "title": "A", - "secretType": "API_KEY", - "apiKey": "key" - } - """; - - mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors[0].field").value("title")) - .andExpect(jsonPath("$.errors[0].message").value("Title must be between 2 and 255 characters")); - } - - @Test - void createCredential_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - String json = """ - { - "title": "Title", - "secretType": "API_KEY", - "apiKey": "key" - } - """; - - mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", nonExistentProjectId) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)); - } - - @Test - void createCredential_shouldReturnLocked_whenVaultIsLocked() throws Exception { - masterKeySessionPort.clear(); // Lock vault - - String json = """ - { - "title": "Title", - "secretType": "API_KEY", - "apiKey": "key" - } - """; - - mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isLocked()) - .andExpect(jsonPath("$.message").value("Vault is locked, please unlock it first")); - } - - @Test - void createCredential_shouldReturnForbidden_whenSetupIsRequired() throws Exception { - appSettingRepository.deleteAll(); // Force setup required - - String json = """ - { - "title": "Title", - "secretType": "API_KEY", - "apiKey": "key" - } - """; - - mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.message").value("Master password not configured")); - } - - @Test - void getAllCredentials_shouldReturnPagedSummaries() throws Exception { - CredentialEntity c1 = new CredentialEntity( - UUID.randomUUID(), - savedProject, - "Cred 1", - CredentialSecretType.LOGIN, - new byte[]{1, 2}, - new byte[]{3}, - new byte[]{4}, - "notes", - "http://url1" - ); - c1.setCreatedAt(LocalDateTime.now().minusHours(1)); - - CredentialEntity c2 = new CredentialEntity( - UUID.randomUUID(), - savedProject, - "Cred 2", - CredentialSecretType.API_KEY, - new byte[]{5, 6}, - new byte[]{7}, - new byte[]{8}, - "notes 2", - "http://url2" - ); - c2.setCreatedAt(LocalDateTime.now()); - - credentialRepository.save(c1); - credentialRepository.save(c2); - - mockMvc.perform(get("/api/v1/projects/{projectId}/credentials", savedProject.getId()) - .param("page", "0") - .param("size", "5")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content", hasSize(2))) - .andExpect(jsonPath("$.content[0].title").value("Cred 2")) - .andExpect(jsonPath("$.content[0].tags").isEmpty()) - .andExpect(jsonPath("$.content[1].title").value("Cred 1")) - .andExpect(jsonPath("$.content[1].tags").isEmpty()) - .andExpect(jsonPath("$.page.totalElements").value(2)); - } - - @Test - void getCredentialById_shouldReturnDecryptedCredential() throws Exception { - UUID credentialId = UUID.randomUUID(); - byte[] payload = "{\"username\":\"admin\",\"password\":\"secret\"}".getBytes(); - CryptoResultDto cryptoResult = cryptoPort.encrypt(payload, mockSecretKey, computeAad(savedProject.getId(), credentialId)); - - CredentialEntity entity = new CredentialEntity( - credentialId, - savedProject, - "Get Credential", - CredentialSecretType.LOGIN, - cryptoResult.cipherText(), - cryptoResult.iv(), - cryptoResult.authTag(), - "notes", - "http://url" - ); - entity.setCreatedAt(LocalDateTime.now()); - credentialRepository.save(entity); - - mockMvc.perform(get("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(credentialId.toString())) - .andExpect(jsonPath("$.title").value("Get Credential")) - .andExpect(jsonPath("$.decryptedPayload.username").value("admin")) - .andExpect(jsonPath("$.decryptedPayload.password").value("secret")) - .andExpect(jsonPath("$.tags").isEmpty()); - } - - @Test - void getCredentialById_shouldReturnNotFound_whenCredentialDoesNotExist() throws Exception { - UUID nonExistentId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), nonExistentId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Credential not found with identifier " + nonExistentId)); - } - - @Test - void getCredentialById_shouldReturnNotFound_whenCredentialDoesNotBelongToProject() throws Exception { - // Create another project - ProjectEntity otherProject = new ProjectEntity(UUID.randomUUID(), "Other", "Desc", "folder", "#000", false); - otherProject.setCreatedAt(LocalDateTime.now()); - projectRepository.save(otherProject); - - UUID credentialId = UUID.randomUUID(); - byte[] payload = "{\"apiKey\":\"secret_key\"}".getBytes(); - CryptoResultDto cryptoResult = cryptoPort.encrypt(payload, mockSecretKey, computeAad(otherProject.getId(), credentialId)); - - CredentialEntity entity = new CredentialEntity( - credentialId, - otherProject, - "Other project's credential", - CredentialSecretType.API_KEY, - cryptoResult.cipherText(), - cryptoResult.iv(), - cryptoResult.authTag(), - null, - null - ); - entity.setCreatedAt(LocalDateTime.now()); - credentialRepository.save(entity); - - // Fetch using savedProject's ID (not otherProject's ID) - mockMvc.perform(get("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Credential not found with identifier " + credentialId)); - } - - @Test - void updateCredential_shouldUpdateTitleAndMetadataOnly_whenNoPayloadProvided() throws Exception { - UUID credentialId = UUID.randomUUID(); - byte[] payload = "{\"apiKey\":\"orig_key\"}".getBytes(); - CryptoResultDto cryptoResult = cryptoPort.encrypt(payload, mockSecretKey, computeAad(savedProject.getId(), credentialId)); - - CredentialEntity entity = new CredentialEntity( - credentialId, - savedProject, - "Old Title", - CredentialSecretType.API_KEY, - cryptoResult.cipherText(), - cryptoResult.iv(), - cryptoResult.authTag(), - "Old notes", - "http://old" - ); - entity.setCreatedAt(LocalDateTime.now()); - credentialRepository.save(entity); - - String json = """ - { - "title": "New Title", - "notes": "New notes", - "relatedUrl": "http://new" - } - """; - - mockMvc.perform(patch("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("New Title")) - .andExpect(jsonPath("$.decryptedPayload.apiKey").value("orig_key")) - .andExpect(jsonPath("$.notes").value("New notes")) - .andExpect(jsonPath("$.relatedUrl").value("http://new")) - .andExpect(jsonPath("$.updatedAt").isNotEmpty()); - - // Reload to assert metadata updated but ciphertext preserved unchanged! - CredentialEntity reloaded = credentialRepository.findById(credentialId).orElseThrow(); - assertEquals("New Title", reloaded.getTitle()); - assertEquals("New notes", reloaded.getNotes()); - assertEquals("http://new", reloaded.getRelatedUrl()); - assertArrayEquals(cryptoResult.cipherText(), reloaded.getPayloadEncrypted()); - assertArrayEquals(cryptoResult.iv(), reloaded.getEncryptionIv()); - assertArrayEquals(cryptoResult.authTag(), reloaded.getEncryptionAuthTag()); - } - - @Test - void updateCredential_shouldUpdatePayloadAndReencrypt_whenNewPayloadProvided() throws Exception { - UUID credentialId = UUID.randomUUID(); - byte[] payload = "{\"apiKey\":\"orig_key\"}".getBytes(); - CryptoResultDto cryptoResult = cryptoPort.encrypt(payload, mockSecretKey, computeAad(savedProject.getId(), credentialId)); - - CredentialEntity entity = new CredentialEntity( - credentialId, - savedProject, - "Title", - CredentialSecretType.API_KEY, - cryptoResult.cipherText(), - cryptoResult.iv(), - cryptoResult.authTag(), - null, - null - ); - entity.setCreatedAt(LocalDateTime.now()); - credentialRepository.save(entity); - - String json = """ - { - "secretType": "RAW_TEXT", - "rawTextContent": "new raw text content" - } - """; - - mockMvc.perform(patch("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.secretType").value("RAW_TEXT")) - .andExpect(jsonPath("$.decryptedPayload.rawText").value("new raw text content")); - - // Reload to assert ciphertext, IV, and tag changed, and decrypts successfully to new payload using AAD - CredentialEntity reloaded = credentialRepository.findById(credentialId).orElseThrow(); - assertEquals(CredentialSecretType.RAW_TEXT, reloaded.getSecretType()); - assertFalse(Arrays.equals(cryptoResult.cipherText(), reloaded.getPayloadEncrypted())); - assertFalse(Arrays.equals(cryptoResult.iv(), reloaded.getEncryptionIv())); - assertFalse(Arrays.equals(cryptoResult.authTag(), reloaded.getEncryptionAuthTag())); - - byte[] decrypted = cryptoPort.decrypt( - reloaded.getPayloadEncrypted(), - reloaded.getEncryptionIv(), - reloaded.getEncryptionAuthTag(), - mockSecretKey, - computeAad(savedProject.getId(), credentialId) - ); - assertEquals("{\"rawText\":\"new raw text content\"}", new String(decrypted)); - } - - @Test - void deleteCredential_shouldRemoveCredentialAndReturnNoContent() throws Exception { - UUID credentialId = UUID.randomUUID(); - CredentialEntity entity = new CredentialEntity( - credentialId, - savedProject, - "To delete", - CredentialSecretType.API_KEY, - new byte[]{1}, - new byte[]{2}, - new byte[]{3}, - null, - null - ); - entity.setCreatedAt(LocalDateTime.now()); - credentialRepository.save(entity); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId)) - .andExpect(status().isNoContent()); - - assertFalse(credentialRepository.existsById(credentialId)); - } - - @Test - void deleteCredential_shouldReturnNotFound_whenCredentialDoesNotExist() throws Exception { - UUID nonExistentId = UUID.randomUUID(); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), nonExistentId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Credential not found with identifier " + nonExistentId)); - } - - private byte[] computeAad(UUID projectId, UUID credentialId) { - java.nio.ByteBuffer buffer = java.nio.ByteBuffer.allocate(32); - buffer.putLong(projectId.getMostSignificantBits()); - buffer.putLong(projectId.getLeastSignificantBits()); - buffer.putLong(credentialId.getMostSignificantBits()); - buffer.putLong(credentialId.getLeastSignificantBits()); - return buffer.array(); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/link/LinkControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/link/LinkControllerIT.java deleted file mode 100644 index a109364..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/link/LinkControllerIT.java +++ /dev/null @@ -1,317 +0,0 @@ -package com.devaulty.backend.adapter.in.web.link; - -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import com.devaulty.backend.adapter.in.web.link.dto.CreateLinkRequest; -import com.devaulty.backend.adapter.in.web.link.dto.UpdateLinkRequest; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.adapter.out.persistence.link.LinkEntity; -import com.devaulty.backend.adapter.out.persistence.link.SpringDataLinkRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; - -import java.time.LocalDateTime; -import java.util.UUID; - -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -class LinkControllerIT extends BaseIntegrationTest { - - @Autowired - private SpringDataLinkRepository linkRepository; - - @Autowired - private SpringDataProjectRepository projectRepository; - - private ProjectEntity savedProject; - - @BeforeEach - void setUpData() { - linkRepository.deleteAll(); - projectRepository.deleteAll(); - - ProjectEntity project = new ProjectEntity( - UUID.randomUUID(), - "Integration Project", - "Description", - "folder", - "#FFF", - false - ); - project.setCreatedAt(LocalDateTime.now()); - savedProject = projectRepository.save(project); - } - - @Test - void createLink_shouldReturnCreated() throws Exception { - CreateLinkRequest request = new CreateLinkRequest( - "My Link", - "https://example.com", - "Link description" - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/links", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(header().exists("Location")) - .andExpect(jsonPath("$.id").isNotEmpty()) - .andExpect(jsonPath("$.title").value("My Link")) - .andExpect(jsonPath("$.url").value("https://example.com")) - .andExpect(jsonPath("$.description").value("Link description")) - .andExpect(jsonPath("$.createdAt").isNotEmpty()) - .andExpect(jsonPath("$.tags").isEmpty()); - - assertEquals(1, linkRepository.count()); - } - - @Test - void createLink_shouldReturnBadRequest_whenValidationFails() throws Exception { - // Title blank (min 2), URL blank - CreateLinkRequest request = new CreateLinkRequest( - " ", - " ", - "Link description" - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/links", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors", hasSize(2))); - } - - @Test - void createLink_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - CreateLinkRequest request = new CreateLinkRequest( - "Link Title", - "https://google.com", - "description" - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/links", nonExistentProjectId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)) - .andExpect(jsonPath("$.message").value("Project not found with identifier " + nonExistentProjectId)); - } - - @Test - void getAllLinks_shouldReturnPagedLinks() throws Exception { - LinkEntity l1 = new LinkEntity(UUID.randomUUID(), savedProject, "Link 1", "https://url1.com", "Desc 1"); - l1.setCreatedAt(LocalDateTime.now().minusHours(1)); - LinkEntity l2 = new LinkEntity(UUID.randomUUID(), savedProject, "Link 2", "https://url2.com", "Desc 2"); - l2.setCreatedAt(LocalDateTime.now()); - linkRepository.save(l1); - linkRepository.save(l2); - - mockMvc.perform(get("/api/v1/projects/{projectId}/links", savedProject.getId()) - .param("page", "0") - .param("size", "5")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content", hasSize(2))) - .andExpect(jsonPath("$.content[0].title").value("Link 2")) - .andExpect(jsonPath("$.content[0].tags").isEmpty()) - .andExpect(jsonPath("$.content[1].title").value("Link 1")) - .andExpect(jsonPath("$.content[1].tags").isEmpty()) - .andExpect(jsonPath("$.page.totalElements").value(2)); - } - - @Test - void getAllLinks_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/links", nonExistentProjectId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)); - } - - @Test - void getLinkById_shouldReturnLink() throws Exception { - UUID id = UUID.randomUUID(); - LinkEntity link = new LinkEntity(id, savedProject, "Get Link", "https://url.com", "Desc"); - link.setCreatedAt(LocalDateTime.now()); - linkRepository.save(link); - - mockMvc.perform(get("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), id)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(id.toString())) - .andExpect(jsonPath("$.title").value("Get Link")) - .andExpect(jsonPath("$.tags").isEmpty()); - } - - @Test - void getLinkById_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/links/{linkId}", nonExistentProjectId, linkId)) - .andExpect(status().isNotFound()); - } - - @Test - void getLinkById_shouldReturnNotFound_whenLinkDoesNotExist() throws Exception { - UUID nonExistentLinkId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), nonExistentLinkId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Link not found with identifier " + nonExistentLinkId)); - } - - @Test - void getLinkById_shouldReturnNotFound_whenLinkDoesNotBelongToProject() throws Exception { - // Create another project - ProjectEntity otherProject = new ProjectEntity(UUID.randomUUID(), "Other", "Desc", "folder", "#000", false); - otherProject.setCreatedAt(LocalDateTime.now()); - projectRepository.save(otherProject); - - // Create link for other project - UUID linkId = UUID.randomUUID(); - LinkEntity link = new LinkEntity(linkId, otherProject, "Other Link", "https://other.com", "Desc"); - link.setCreatedAt(LocalDateTime.now()); - linkRepository.save(link); - - // Request other project's link under savedProject's path - mockMvc.perform(get("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), linkId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Link not found with identifier " + linkId)); - } - - @Test - void updateLink_shouldUpdateAndReturnLink() throws Exception { - UUID id = UUID.randomUUID(); - LinkEntity link = new LinkEntity(id, savedProject, "Old Title", "https://old.com", "Old Desc"); - link.setCreatedAt(LocalDateTime.now()); - linkRepository.save(link); - - UpdateLinkRequest request = new UpdateLinkRequest( - "New Title", - "https://new.com", - "New Desc" - ); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("New Title")) - .andExpect(jsonPath("$.url").value("https://new.com")) - .andExpect(jsonPath("$.description").value("New Desc")) - .andExpect(jsonPath("$.updatedAt").isNotEmpty()); - - LinkEntity updated = linkRepository.findById(id).orElseThrow(); - assertEquals("New Title", updated.getTitle()); - } - - @Test - void updateLink_shouldReturnBadRequest_whenValidationFails() throws Exception { - UUID id = UUID.randomUUID(); - LinkEntity link = new LinkEntity(id, savedProject, "Old Title", "https://old.com", "Old Desc"); - link.setCreatedAt(LocalDateTime.now()); - linkRepository.save(link); - - // Blank/spaces title - UpdateLinkRequest request = new UpdateLinkRequest(" ", null, null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")); - } - - @Test - void updateLink_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - UpdateLinkRequest request = new UpdateLinkRequest("New Title", null, null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/links/{linkId}", nonExistentProjectId, linkId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()); - } - - @Test - void updateLink_shouldReturnNotFound_whenLinkDoesNotExist() throws Exception { - UUID nonExistentLinkId = UUID.randomUUID(); - UpdateLinkRequest request = new UpdateLinkRequest("New Title", null, null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), nonExistentLinkId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()); - } - - @Test - void updateLink_shouldReturnNotFound_whenLinkDoesNotBelongToProject() throws Exception { - ProjectEntity otherProject = new ProjectEntity(UUID.randomUUID(), "Other", "Desc", "folder", "#000", false); - otherProject.setCreatedAt(LocalDateTime.now()); - projectRepository.save(otherProject); - - UUID linkId = UUID.randomUUID(); - LinkEntity link = new LinkEntity(linkId, otherProject, "Other Link", "https://other.com", "Desc"); - link.setCreatedAt(LocalDateTime.now()); - linkRepository.save(link); - - UpdateLinkRequest request = new UpdateLinkRequest("New Title", null, null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), linkId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()); - } - - @Test - void deleteLink_shouldDeleteAndReturnNoContent() throws Exception { - UUID id = UUID.randomUUID(); - LinkEntity link = new LinkEntity(id, savedProject, "To Delete", "https://delete.com", "Desc"); - link.setCreatedAt(LocalDateTime.now()); - linkRepository.save(link); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), id)) - .andExpect(status().isNoContent()); - - assertFalse(linkRepository.existsById(id)); - } - - @Test - void deleteLink_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/links/{linkId}", nonExistentProjectId, linkId)) - .andExpect(status().isNotFound()); - } - - @Test - void deleteLink_shouldReturnNotFound_whenLinkDoesNotExist() throws Exception { - UUID nonExistentLinkId = UUID.randomUUID(); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), nonExistentLinkId)) - .andExpect(status().isNotFound()); - } - - @Test - void deleteLink_shouldReturnNotFound_whenLinkDoesNotBelongToProject() throws Exception { - ProjectEntity otherProject = new ProjectEntity(UUID.randomUUID(), "Other", "Desc", "folder", "`#000`", false); - otherProject.setCreatedAt(LocalDateTime.now()); - projectRepository.save(otherProject); - - UUID linkId = UUID.randomUUID(); - LinkEntity link = new LinkEntity(linkId, otherProject, "Other Link", "https://other.com", "Desc"); - link.setCreatedAt(LocalDateTime.now()); - linkRepository.save(link); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/links/{linkId}", savedProject.getId(), linkId)) - .andExpect(status().isNotFound()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/note/NoteControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/note/NoteControllerIT.java deleted file mode 100644 index dddeb44..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/note/NoteControllerIT.java +++ /dev/null @@ -1,306 +0,0 @@ -package com.devaulty.backend.adapter.in.web.note; - -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import com.devaulty.backend.adapter.in.web.note.dto.*; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.adapter.out.persistence.note.NoteEntity; -import com.devaulty.backend.adapter.out.persistence.note.SpringDataNoteRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; - -import java.time.LocalDateTime; -import java.util.UUID; - -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -class NoteControllerIT extends BaseIntegrationTest { - - @Autowired - private SpringDataNoteRepository noteRepository; - - @Autowired - private SpringDataProjectRepository projectRepository; - - private ProjectEntity savedProject; - - @BeforeEach - void setUpData() { - noteRepository.deleteAll(); - projectRepository.deleteAll(); - - ProjectEntity project = new ProjectEntity( - UUID.randomUUID(), - "Integration Project", - "Description", - "folder", - "#FFF", - false - ); - project.setCreatedAt(LocalDateTime.now()); - savedProject = projectRepository.save(project); - } - - @Test - void createNote_shouldReturnCreated() throws Exception { - CreateNoteRequest request = new CreateNoteRequest( - "My Note", - "Note content here" - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/notes", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(header().exists("Location")) - .andExpect(jsonPath("$.id").isNotEmpty()) - .andExpect(jsonPath("$.title").value("My Note")) - .andExpect(jsonPath("$.content").value("Note content here")) - .andExpect(jsonPath("$.archived").value(false)) - .andExpect(jsonPath("$.createdAt").isNotEmpty()) - .andExpect(jsonPath("$.tags").isEmpty()); - - assertEquals(1, noteRepository.count()); - } - - @Test - void createNote_shouldReturnBadRequest_whenValidationFails() throws Exception { - // Title blank (min 2) - CreateNoteRequest request = new CreateNoteRequest( - " ", - "Content" - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/notes", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors", hasSize(1))); - } - - @Test - void createNote_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - CreateNoteRequest request = new CreateNoteRequest( - "Note Title", - "content" - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/notes", nonExistentProjectId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)) - .andExpect(jsonPath("$.message").value("Project not found with identifier " + nonExistentProjectId)); - } - - @Test - void getAllNotes_shouldReturnPagedNotes() throws Exception { - NoteEntity n1 = new NoteEntity(UUID.randomUUID(), savedProject, "Note 1", "Content 1", false); - n1.setCreatedAt(LocalDateTime.now().minusHours(1)); - NoteEntity n2 = new NoteEntity(UUID.randomUUID(), savedProject, "Note 2", "Content 2", false); - n2.setCreatedAt(LocalDateTime.now()); - noteRepository.save(n1); - noteRepository.save(n2); - - mockMvc.perform(get("/api/v1/projects/{projectId}/notes", savedProject.getId()) - .param("page", "0") - .param("size", "5")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content", hasSize(2))) - .andExpect(jsonPath("$.content[0].title").value("Note 2")) - .andExpect(jsonPath("$.content[0].tags").isEmpty()) - .andExpect(jsonPath("$.content[1].title").value("Note 1")) - .andExpect(jsonPath("$.content[1].tags").isEmpty()) - .andExpect(jsonPath("$.page.totalElements").value(2)); - } - - @Test - void getAllNotes_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/notes", nonExistentProjectId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)); - } - - @Test - void getNoteById_shouldReturnNote() throws Exception { - UUID id = UUID.randomUUID(); - NoteEntity note = new NoteEntity(id, savedProject, "Get Note", "Content", false); - note.setCreatedAt(LocalDateTime.now()); - noteRepository.save(note); - - mockMvc.perform(get("/api/v1/projects/{projectId}/notes/{noteID}", savedProject.getId(), id)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(id.toString())) - .andExpect(jsonPath("$.title").value("Get Note")) - .andExpect(jsonPath("$.tags").isEmpty()); - } - - @Test - void getNoteById_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/notes/{noteID}", nonExistentProjectId, noteId)) - .andExpect(status().isNotFound()); - } - - @Test - void getNoteById_shouldReturnNotFound_whenNoteDoesNotExist() throws Exception { - UUID nonExistentNoteId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/notes/{noteID}", savedProject.getId(), nonExistentNoteId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Note not found with identifier " + nonExistentNoteId)); - } - - @Test - void getNoteById_shouldReturnNotFound_whenNoteDoesNotBelongToProject() throws Exception { - // Create another project - ProjectEntity otherProject = new ProjectEntity(UUID.randomUUID(), "Other", "Desc", "folder", "#000", false); - otherProject.setCreatedAt(LocalDateTime.now()); - projectRepository.save(otherProject); - - // Create note for other project - UUID noteId = UUID.randomUUID(); - NoteEntity note = new NoteEntity(noteId, otherProject, "Other Note", "Content", false); - note.setCreatedAt(LocalDateTime.now()); - noteRepository.save(note); - - // Request other project's note under savedProject's path - mockMvc.perform(get("/api/v1/projects/{projectId}/notes/{noteID}", savedProject.getId(), noteId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Note not found with identifier " + noteId)); - } - - @Test - void updateNote_shouldUpdateAndReturnNote() throws Exception { - UUID id = UUID.randomUUID(); - NoteEntity note = new NoteEntity(id, savedProject, "Old Title", "Old Content", false); - note.setCreatedAt(LocalDateTime.now()); - noteRepository.save(note); - - UpdateNoteRequest request = new UpdateNoteRequest( - "New Title", - "New Content" - ); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/notes/{noteId}", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("New Title")) - .andExpect(jsonPath("$.content").value("New Content")) - .andExpect(jsonPath("$.updatedAt").isNotEmpty()); - - NoteEntity updated = noteRepository.findById(id).orElseThrow(); - assertEquals("New Title", updated.getTitle()); - } - - @Test - void updateNote_shouldReturnBadRequest_whenValidationFails() throws Exception { - UUID id = UUID.randomUUID(); - NoteEntity note = new NoteEntity(id, savedProject, "Old Title", "Old Content", false); - note.setCreatedAt(LocalDateTime.now()); - noteRepository.save(note); - - // Blank title - UpdateNoteRequest request = new UpdateNoteRequest(" ", null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/notes/{noteId}", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")); - } - - @Test - void archiveNote_shouldArchiveAndReturnNoContent() throws Exception { - UUID id = UUID.randomUUID(); - NoteEntity note = new NoteEntity(id, savedProject, "To Archive", "Content", false); - note.setCreatedAt(LocalDateTime.now()); - noteRepository.save(note); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/notes/{noteId}/archive", savedProject.getId(), id)) - .andExpect(status().isNoContent()); - - NoteEntity archived = noteRepository.findById(id).orElseThrow(); - assertTrue(archived.isArchived()); - } - - @Test - void archiveNote_shouldReturnBadRequest_whenAlreadyArchived() throws Exception { - UUID id = UUID.randomUUID(); - NoteEntity note = new NoteEntity(id, savedProject, "Archived", "Content", true); - note.setCreatedAt(LocalDateTime.now()); - noteRepository.save(note); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/notes/{noteId}/archive", savedProject.getId(), id)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Note already archived")); - } - - @Test - void unarchiveNote_shouldUnarchiveAndReturnNoContent() throws Exception { - UUID id = UUID.randomUUID(); - NoteEntity note = new NoteEntity(id, savedProject, "Archived", "Content", true); - note.setCreatedAt(LocalDateTime.now()); - noteRepository.save(note); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/notes/{noteId}/unarchive", savedProject.getId(), id)) - .andExpect(status().isNoContent()); - - NoteEntity unarchived = noteRepository.findById(id).orElseThrow(); - assertFalse(unarchived.isArchived()); - } - - @Test - void unarchiveNote_shouldReturnBadRequest_whenNotArchived() throws Exception { - UUID id = UUID.randomUUID(); - NoteEntity note = new NoteEntity(id, savedProject, "Active", "Content", false); - note.setCreatedAt(LocalDateTime.now()); - noteRepository.save(note); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/notes/{noteId}/unarchive", savedProject.getId(), id)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Note not archived")); - } - - @Test - void deleteNote_shouldDeleteAndReturnNoContent() throws Exception { - UUID id = UUID.randomUUID(); - NoteEntity note = new NoteEntity(id, savedProject, "To Delete", "Content", false); - note.setCreatedAt(LocalDateTime.now()); - noteRepository.save(note); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/notes/{noteId}", savedProject.getId(), id)) - .andExpect(status().isNoContent()); - - assertFalse(noteRepository.existsById(id)); - } - - @Test - void deleteNote_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/notes/{noteId}", nonExistentProjectId, noteId)) - .andExpect(status().isNotFound()); - } - - @Test - void deleteNote_shouldReturnNotFound_whenNoteDoesNotExist() throws Exception { - UUID nonExistentNoteId = UUID.randomUUID(); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/notes/{noteId}", savedProject.getId(), nonExistentNoteId)) - .andExpect(status().isNotFound()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/problem/ProblemControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/problem/ProblemControllerIT.java deleted file mode 100644 index 90784f6..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/problem/ProblemControllerIT.java +++ /dev/null @@ -1,306 +0,0 @@ -package com.devaulty.backend.adapter.in.web.problem; - -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import com.devaulty.backend.adapter.in.web.problem.dto.*; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.adapter.out.persistence.problem.ProblemEntity; -import com.devaulty.backend.adapter.out.persistence.problem.SpringDataProblemRepository; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; - -import java.time.LocalDateTime; -import java.util.UUID; - -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -class ProblemControllerIT extends BaseIntegrationTest { - - @Autowired - private SpringDataProblemRepository problemRepository; - - @Autowired - private SpringDataProjectRepository projectRepository; - - private ProjectEntity savedProject; - - @BeforeEach - void setUpData() { - problemRepository.deleteAll(); - projectRepository.deleteAll(); - - ProjectEntity project = new ProjectEntity( - UUID.randomUUID(), - "Integration Project", - "Description", - "folder", - "#FFF", - false - ); - project.setCreatedAt(LocalDateTime.now()); - savedProject = projectRepository.save(project); - } - - @Test - void createProblem_shouldReturnCreated() throws Exception { - CreateProblemRequest request = new CreateProblemRequest( - "My Problem", - "Exception stacktrace", - "Fix configuration", - ProblemStatus.OPEN, - ProblemSeverity.HIGH - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/problems", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(header().exists("Location")) - .andExpect(jsonPath("$.id").isNotEmpty()) - .andExpect(jsonPath("$.title").value("My Problem")) - .andExpect(jsonPath("$.errorDescription").value("Exception stacktrace")) - .andExpect(jsonPath("$.solution").value("Fix configuration")) - .andExpect(jsonPath("$.status").value("OPEN")) - .andExpect(jsonPath("$.severity").value("HIGH")) - .andExpect(jsonPath("$.createdAt").isNotEmpty()) - .andExpect(jsonPath("$.tags").isEmpty()); - - assertEquals(1, problemRepository.count()); - } - - @Test - void createProblem_shouldReturnBadRequest_whenValidationFails() throws Exception { - // Title blank (min 2), Status null, Severity null - CreateProblemRequest request = new CreateProblemRequest( - " ", - "Error description", - "Solution", - null, - null - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/problems", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors", hasSize(3))); - } - - @Test - void createProblem_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - CreateProblemRequest request = new CreateProblemRequest( - "Problem Title", - "description", - "solution", - ProblemStatus.OPEN, - ProblemSeverity.MEDIUM - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/problems", nonExistentProjectId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)) - .andExpect(jsonPath("$.message").value("Project not found with identifier " + nonExistentProjectId)); - } - - @Test - void getAllProblems_shouldReturnPagedProblems() throws Exception { - ProblemEntity p1 = new ProblemEntity(UUID.randomUUID(), savedProject, "Problem 1", "Error 1", "Sol 1", ProblemStatus.OPEN, ProblemSeverity.MEDIUM); - p1.setCreatedAt(LocalDateTime.now().minusHours(1)); - ProblemEntity p2 = new ProblemEntity(UUID.randomUUID(), savedProject, "Problem 2", "Error 2", "Sol 2", ProblemStatus.RESOLVED, ProblemSeverity.HIGH); - p2.setCreatedAt(LocalDateTime.now()); - problemRepository.save(p1); - problemRepository.save(p2); - - mockMvc.perform(get("/api/v1/projects/{projectId}/problems", savedProject.getId()) - .param("page", "0") - .param("size", "5")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content", hasSize(2))) - .andExpect(jsonPath("$.content[0].title").value("Problem 2")) - .andExpect(jsonPath("$.content[0].tags").isEmpty()) - .andExpect(jsonPath("$.content[1].title").value("Problem 1")) - .andExpect(jsonPath("$.content[1].tags").isEmpty()) - .andExpect(jsonPath("$.page.totalElements").value(2)); - } - - @Test - void getAllProblems_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/problems", nonExistentProjectId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)); - } - - @Test - void getProblemById_shouldReturnProblem() throws Exception { - UUID id = UUID.randomUUID(); - ProblemEntity problem = new ProblemEntity(id, savedProject, "Get Problem", "Error", "Sol", ProblemStatus.OPEN, ProblemSeverity.LOW); - problem.setCreatedAt(LocalDateTime.now()); - problemRepository.save(problem); - - mockMvc.perform(get("/api/v1/projects/{projectId}/problems/{problemId}", savedProject.getId(), id)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(id.toString())) - .andExpect(jsonPath("$.title").value("Get Problem")) - .andExpect(jsonPath("$.tags").isEmpty()); - } - - @Test - void getProblemById_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/problems/{problemId}", nonExistentProjectId, problemId)) - .andExpect(status().isNotFound()); - } - - @Test - void getProblemById_shouldReturnNotFound_whenProblemDoesNotExist() throws Exception { - UUID nonExistentProblemId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/problems/{problemId}", savedProject.getId(), nonExistentProblemId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Problem not found with identifier " + nonExistentProblemId)); - } - - @Test - void getProblemById_shouldReturnNotFound_whenProblemDoesNotBelongToProject() throws Exception { - // Create another project - ProjectEntity otherProject = new ProjectEntity(UUID.randomUUID(), "Other", "Desc", "folder", "#000", false); - otherProject.setCreatedAt(LocalDateTime.now()); - projectRepository.save(otherProject); - - // Create problem for other project - UUID problemId = UUID.randomUUID(); - ProblemEntity problem = new ProblemEntity(problemId, otherProject, "Other Problem", "Error", "Sol", ProblemStatus.OPEN, ProblemSeverity.MEDIUM); - problem.setCreatedAt(LocalDateTime.now()); - problemRepository.save(problem); - - // Request other project's problem under savedProject's path - mockMvc.perform(get("/api/v1/projects/{projectId}/problems/{problemId}", savedProject.getId(), problemId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Problem not found with identifier " + problemId)); - } - - @Test - void updateProblem_shouldUpdateAndReturnProblem() throws Exception { - UUID id = UUID.randomUUID(); - ProblemEntity problem = new ProblemEntity(id, savedProject, "Old Title", "Old Error", "Old Sol", ProblemStatus.OPEN, ProblemSeverity.LOW); - problem.setCreatedAt(LocalDateTime.now()); - problemRepository.save(problem); - - UpdateProblemRequest request = new UpdateProblemRequest( - "New Title", - "New Error", - "New Sol", - ProblemSeverity.HIGH - ); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/problems/{problemId}", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("New Title")) - .andExpect(jsonPath("$.errorDescription").value("New Error")) - .andExpect(jsonPath("$.solution").value("New Sol")) - .andExpect(jsonPath("$.severity").value("HIGH")) - .andExpect(jsonPath("$.updatedAt").isNotEmpty()); - - ProblemEntity updated = problemRepository.findById(id).orElseThrow(); - assertEquals("New Title", updated.getTitle()); - } - - @Test - void updateProblem_shouldReturnBadRequest_whenValidationFails() throws Exception { - UUID id = UUID.randomUUID(); - ProblemEntity problem = new ProblemEntity(id, savedProject, "Old Title", "Old Error", "Old Sol", ProblemStatus.OPEN, ProblemSeverity.LOW); - problem.setCreatedAt(LocalDateTime.now()); - problemRepository.save(problem); - - // Blank title - UpdateProblemRequest request = new UpdateProblemRequest(" ", null, null, null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/problems/{problemId}", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")); - } - - @Test - void updateProblemStatus_shouldUpdateAndReturnProblem() throws Exception { - UUID id = UUID.randomUUID(); - ProblemEntity problem = new ProblemEntity(id, savedProject, "Title", "Error", "Sol", ProblemStatus.OPEN, ProblemSeverity.LOW); - problem.setCreatedAt(LocalDateTime.now()); - problemRepository.save(problem); - - UpdateProblemStatusRequest request = new UpdateProblemStatusRequest(ProblemStatus.RESOLVED); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/problems/{problemId}/status", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("RESOLVED")) - .andExpect(jsonPath("$.updatedAt").isNotEmpty()); - - ProblemEntity updated = problemRepository.findById(id).orElseThrow(); - assertEquals(ProblemStatus.RESOLVED, updated.getStatus()); - } - - @Test - void updateProblemStatus_shouldReturnBadRequest_whenStatusIsNull() throws Exception { - UUID id = UUID.randomUUID(); - ProblemEntity problem = new ProblemEntity(id, savedProject, "Title", "Error", "Sol", ProblemStatus.OPEN, ProblemSeverity.LOW); - problem.setCreatedAt(LocalDateTime.now()); - problemRepository.save(problem); - - UpdateProblemStatusRequest request = new UpdateProblemStatusRequest(null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/problems/{problemId}/status", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - void deleteProblem_shouldDeleteAndReturnNoContent() throws Exception { - UUID id = UUID.randomUUID(); - ProblemEntity problem = new ProblemEntity(id, savedProject, "To Delete", "Error", "Sol", ProblemStatus.OPEN, ProblemSeverity.LOW); - problem.setCreatedAt(LocalDateTime.now()); - problemRepository.save(problem); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/problems/{problemId}", savedProject.getId(), id)) - .andExpect(status().isNoContent()); - - assertFalse(problemRepository.existsById(id)); - } - - @Test - void deleteProblem_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/problems/{problemId}", nonExistentProjectId, problemId)) - .andExpect(status().isNotFound()); - } - - @Test - void deleteProblem_shouldReturnNotFound_whenProblemDoesNotExist() throws Exception { - UUID nonExistentProblemId = UUID.randomUUID(); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/problems/{problemId}", savedProject.getId(), nonExistentProblemId)) - .andExpect(status().isNotFound()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/project/ProjectControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/project/ProjectControllerIT.java deleted file mode 100644 index 8d5a6b4..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/project/ProjectControllerIT.java +++ /dev/null @@ -1,229 +0,0 @@ -package com.devaulty.backend.adapter.in.web.project; - -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import com.devaulty.backend.adapter.in.web.project.dto.CreateProjectRequest; -import com.devaulty.backend.adapter.in.web.project.dto.UpdateProjectRequest; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; - -import java.time.LocalDateTime; -import java.util.UUID; - -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -class ProjectControllerIT extends BaseIntegrationTest { - - @Autowired - private SpringDataProjectRepository projectRepository; - - @BeforeEach - void cleanDatabase() { - projectRepository.deleteAll(); - } - - @Test - void createProject_shouldReturnCreated() throws Exception { - CreateProjectRequest request = new CreateProjectRequest( - "My Project", - "Project description", - "folder", - "#FF5733" - ); - - mockMvc.perform(post("/api/v1/projects") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(header().exists("Location")) - .andExpect(jsonPath("$.id").isNotEmpty()) - .andExpect(jsonPath("$.name").value("My Project")) - .andExpect(jsonPath("$.description").value("Project description")) - .andExpect(jsonPath("$.icon").value("folder")) - .andExpect(jsonPath("$.color").value("#FF5733")) - .andExpect(jsonPath("$.archived").value(false)) - .andExpect(jsonPath("$.createdAt").isNotEmpty()); - - assertEquals(1, projectRepository.count()); - } - - @Test - void createProject_shouldReturnBadRequest_whenValidationFails() throws Exception { - // Name too short (min 3) - CreateProjectRequest request = new CreateProjectRequest( - "A", - "Project description", - "folder", - "#invalidColor" - ); - - mockMvc.perform(post("/api/v1/projects") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors", hasSize(2))); - } - - @Test - void getAllProjects_shouldReturnPagedProjects() throws Exception { - ProjectEntity p1 = new ProjectEntity(UUID.randomUUID(), "Project 1", "Desc 1", "icon1", "#000", false); - p1.setCreatedAt(LocalDateTime.now().minusHours(1)); // Older - ProjectEntity p2 = new ProjectEntity(UUID.randomUUID(), "Project 2", "Desc 2", "icon2", "#fff", false); - p2.setCreatedAt(LocalDateTime.now()); // Newer - projectRepository.save(p1); - projectRepository.save(p2); - - mockMvc.perform(get("/api/v1/projects") - .param("page", "0") - .param("size", "5")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content", hasSize(2))) - .andExpect(jsonPath("$.content[0].name").value("Project 2")) // Sorted DESC by createdAt - .andExpect(jsonPath("$.content[1].name").value("Project 1")) - .andExpect(jsonPath("$.page.totalElements").value(2)); - } - - @Test - void getProjectById_shouldReturnProject() throws Exception { - UUID id = UUID.randomUUID(); - ProjectEntity project = new ProjectEntity(id, "Unique Project", "Description", "star", "#123", false); - project.setCreatedAt(LocalDateTime.now()); - projectRepository.save(project); - - mockMvc.perform(get("/api/v1/projects/{id}", id)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(id.toString())) - .andExpect(jsonPath("$.name").value("Unique Project")); - } - - @Test - void getProjectById_shouldReturnNotFound_whenDoesNotExist() throws Exception { - UUID randomId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{id}", randomId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)) - .andExpect(jsonPath("$.message").value("Project not found with identifier " + randomId)); - } - - @Test - void updateProject_shouldUpdateAndReturnProject() throws Exception { - UUID id = UUID.randomUUID(); - ProjectEntity project = new ProjectEntity(id, "Old Name", "Old Desc", "old-icon", "#000", false); - project.setCreatedAt(LocalDateTime.now()); - projectRepository.save(project); - - UpdateProjectRequest request = new UpdateProjectRequest( - "New Name", - "New Desc", - "new-icon", - "#FFF" - ); - - mockMvc.perform(patch("/api/v1/projects/{id}", id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("New Name")) - .andExpect(jsonPath("$.description").value("New Desc")) - .andExpect(jsonPath("$.icon").value("new-icon")) - .andExpect(jsonPath("$.color").value("#FFF")) - .andExpect(jsonPath("$.updatedAt").isNotEmpty()); - - ProjectEntity updated = projectRepository.findById(id).orElseThrow(); - assertEquals("New Name", updated.getName()); - } - - @Test - void updateProject_shouldReturnNotFound_whenDoesNotExist() throws Exception { - UUID randomId = UUID.randomUUID(); - UpdateProjectRequest request = new UpdateProjectRequest("Name", "Desc", "icon", "#fff"); - - mockMvc.perform(patch("/api/v1/projects/{id}", randomId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()); - } - - @Test - void archiveProject_shouldArchiveAndReturnNoContent() throws Exception { - UUID id = UUID.randomUUID(); - ProjectEntity project = new ProjectEntity(id, "To Archive", "Desc", "icon", "#000", false); - project.setCreatedAt(LocalDateTime.now()); - projectRepository.save(project); - - mockMvc.perform(patch("/api/v1/projects/{id}/archive", id)) - .andExpect(status().isNoContent()); - - ProjectEntity archived = projectRepository.findById(id).orElseThrow(); - assertTrue(archived.isArchived()); - assertNotNull(archived.getUpdatedAt()); - } - - @Test - void archiveProject_shouldReturnBadRequest_whenAlreadyArchived() throws Exception { - UUID id = UUID.randomUUID(); - ProjectEntity project = new ProjectEntity(id, "Archived", "Desc", "icon", "#000", true); - project.setCreatedAt(LocalDateTime.now()); - projectRepository.save(project); - - mockMvc.perform(patch("/api/v1/projects/{id}/archive", id)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Project already archived")); - } - - @Test - void unarchiveProject_shouldUnarchiveAndReturnNoContent() throws Exception { - UUID id = UUID.randomUUID(); - ProjectEntity project = new ProjectEntity(id, "To Unarchive", "Desc", "icon", "#000", true); - project.setCreatedAt(LocalDateTime.now()); - projectRepository.save(project); - - mockMvc.perform(patch("/api/v1/projects/{id}/unarchive", id)) - .andExpect(status().isNoContent()); - - ProjectEntity unarchived = projectRepository.findById(id).orElseThrow(); - assertFalse(unarchived.isArchived()); - assertNotNull(unarchived.getUpdatedAt()); - } - - @Test - void unarchiveProject_shouldReturnBadRequest_whenAlreadyActive() throws Exception { - UUID id = UUID.randomUUID(); - ProjectEntity project = new ProjectEntity(id, "Active", "Desc", "icon", "#000", false); - project.setCreatedAt(LocalDateTime.now()); - projectRepository.save(project); - - mockMvc.perform(patch("/api/v1/projects/{id}/unarchive", id)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Project already unarchived")); - } - - @Test - void deleteProject_shouldDeleteAndReturnNoContent() throws Exception { - UUID id = UUID.randomUUID(); - ProjectEntity project = new ProjectEntity(id, "To Delete", "Desc", "icon", "#000", false); - project.setCreatedAt(LocalDateTime.now()); - projectRepository.save(project); - - mockMvc.perform(delete("/api/v1/projects/{id}", id)) - .andExpect(status().isNoContent()); - - assertFalse(projectRepository.existsById(id)); - } - - @Test - void deleteProject_shouldReturnNotFound_whenDoesNotExist() throws Exception { - UUID randomId = UUID.randomUUID(); - - mockMvc.perform(delete("/api/v1/projects/{id}", randomId)) - .andExpect(status().isNotFound()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/release/ReleaseControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/release/ReleaseControllerIT.java deleted file mode 100644 index 04201dc..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/release/ReleaseControllerIT.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.devaulty.backend.adapter.in.web.release; - -import com.devaulty.backend.application.port.in.release.InstallUpdateUseCase; -import com.devaulty.backend.application.port.out.external.release.ReleasePort; -import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; -import com.devaulty.backend.application.port.out.external.release.dto.ReleaseAssetInfo; -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import com.devaulty.backend.infrastructure.security.AppTokenContext; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.springframework.http.MediaType; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MvcResult; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.time.Instant; -import java.util.List; - -import static org.hamcrest.Matchers.*; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -class ReleaseControllerIT extends BaseIntegrationTest { - - @TempDir - Path tempFolder; - - @MockitoBean - private ReleasePort releasePort; - - @MockitoBean - private InstallUpdateUseCase installUpdateUseCase; - - @BeforeEach - void setUp() { - System.setProperty("devaulty.temp.dir", tempFolder.toString()); - } - - @AfterEach - void tearDown() { - System.clearProperty("devaulty.temp.dir"); - } - - @Test - @DisplayName("GET /api/v1/releases/current-app-version should return 200 OK with actual version payload") - void getAppInfo_shouldReturn200OK_withActualVersion() throws Exception { - mockMvc.perform(get("/api/v1/releases/current-app-version") - .header(AppTokenContext.HEADER_NAME, AppTokenContext.PROCESS_TOKEN) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.actualVersion", not(emptyOrNullString()))); - } - - @Test - @DisplayName("GET /api/v1/releases/check should return 200 OK with update response payload") - void checkUpdates_shouldReturn200OK_withUpdateInfo() throws Exception { - // Arrange - String currentOs = System.getProperty("os.name").toLowerCase(); - String extension = currentOs.contains("win") ? ".msi" : currentOs.contains("mac") ? ".dmg" : ".deb"; - - ReleaseAssetInfo asset = new ReleaseAssetInfo( - "devaulty_0.2.0" + extension, - "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0" + extension, - 50000000L, - "application/octet-stream" - ); - LatestReleaseInfo latestRelease = new LatestReleaseInfo( - "v0.2.0", - "Release v0.2.0", - "Changelog content", - "https://github.com/MathCunha16/Devaulty/releases/tag/v0.2.0", - Instant.now(), - false, - List.of(asset) - ); - - when(releasePort.getLatestRelease()).thenReturn(latestRelease); - - // Act & Assert - mockMvc.perform(get("/api/v1/releases/check") - .header(AppTokenContext.HEADER_NAME, AppTokenContext.PROCESS_TOKEN) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.updateAvailable").value(true)) - .andExpect(jsonPath("$.currentVersion").isNotEmpty()) - .andExpect(jsonPath("$.latestVersion").value("v0.2.0")) - .andExpect(jsonPath("$.releaseTitle").value("Release v0.2.0")) - .andExpect(jsonPath("$.releaseNotes").value("Changelog content")) - .andExpect(jsonPath("$.downloadUrl").value(containsString(extension))) - .andExpect(jsonPath("$.downloadSizeInBytes").value(50000000L)); - } - - @Test - @DisplayName("POST /api/v1/releases/download-and-install should return 200 OK with SSE stream") - void downloadUpdate_shouldReturn200OK_withSSEStream() throws Exception { - // Arrange - String currentOs = System.getProperty("os.name").toLowerCase(); - String extension = currentOs.contains("win") ? ".msi" : currentOs.contains("mac") ? ".dmg" : ".deb"; - String downloadUrl = "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0" + extension; - - ReleaseAssetInfo asset = new ReleaseAssetInfo("devaulty_0.2.0" + extension, downloadUrl, 11L, "application/octet-stream"); - LatestReleaseInfo latestRelease = new LatestReleaseInfo( - "v0.2.0", - "Release v0.2.0", - "Changelog content", - "https://github.com/MathCunha16/Devaulty/releases/tag/v0.2.0", - Instant.now(), - false, - List.of(asset) - ); - - when(releasePort.getLatestRelease()).thenReturn(latestRelease); - - InputStream fakeAssetStream = new ByteArrayInputStream("hello world".getBytes(StandardCharsets.UTF_8)); - when(releasePort.downloadAsset(anyString())).thenReturn(fakeAssetStream); - - MvcResult mvcResult = mockMvc.perform(post("/api/v1/releases/download-and-install") - .header(AppTokenContext.HEADER_NAME, AppTokenContext.PROCESS_TOKEN) - .accept(MediaType.TEXT_EVENT_STREAM)) - .andExpect(request().asyncStarted()) - .andReturn(); - mvcResult.getAsyncResult(); - - mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk()) - .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)); - } -} \ No newline at end of file diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/security/SecurityControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/security/SecurityControllerIT.java deleted file mode 100644 index 2f20acb..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/security/SecurityControllerIT.java +++ /dev/null @@ -1,239 +0,0 @@ -package com.devaulty.backend.adapter.in.web.security; - -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import com.devaulty.backend.adapter.out.persistence.setting.AppSettingEntity; -import com.devaulty.backend.adapter.out.persistence.setting.SpringDataAppSettingRepository; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; - -import javax.crypto.spec.SecretKeySpec; - -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -class SecurityControllerIT extends BaseIntegrationTest { - - @Autowired - private SpringDataAppSettingRepository appSettingRepository; - - @Autowired - private MasterKeySessionPort masterKeySessionPort; - - @BeforeEach - void setUpData() { - appSettingRepository.deleteAll(); - masterKeySessionPort.clear(); - } - - @Test - void setupMasterPassword_shouldReturnNoContent_whenFirstTime() throws Exception { - String json = """ - { - "masterPassword": "mySuperSecretPassword123" - } - """; - - mockMvc.perform(post("/api/v1/security/master-password") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isNoContent()); - - assertTrue(appSettingRepository.existsById("master_password_hash")); - assertTrue(appSettingRepository.existsById("master_password_salt")); - - AppSettingEntity hashEntity = appSettingRepository.findById("master_password_hash").orElseThrow(); - AppSettingEntity saltEntity = appSettingRepository.findById("master_password_salt").orElseThrow(); - - assertFalse(hashEntity.getValue().isEmpty()); - assertFalse(saltEntity.getValue().isEmpty()); - assertNotEquals("mySuperSecretPassword123", hashEntity.getValue()); - - assertTrue(masterKeySessionPort.hasKey()); - } - - @Test - void setupMasterPassword_shouldReturnBadRequest_whenPasswordTooShort() throws Exception { - String json = """ - { - "masterPassword": "short" - } - """; - - mockMvc.perform(post("/api/v1/security/master-password") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors[0].field").value("masterPassword")) - .andExpect(jsonPath("$.errors[0].message").value("Password must be between 8 and 255 characters")); - - assertFalse(appSettingRepository.existsById("master_password_hash")); - assertFalse(masterKeySessionPort.hasKey()); - } - - @Test - void setupMasterPassword_shouldReturnConflict_whenAlreadyConfigured() throws Exception { - appSettingRepository.save(new AppSettingEntity("master_password_hash", "mockHash")); - appSettingRepository.save(new AppSettingEntity("master_password_salt", "mockSalt")); - - String json = """ - { - "masterPassword": "mySuperSecretPassword123" - } - """; - - mockMvc.perform(post("/api/v1/security/master-password") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.message").value("Master password already configured")); - } - - @Test - void checkMasterPasswordSetup_shouldReturnTrue_whenNotConfigured() throws Exception { - mockMvc.perform(get("/api/v1/security/master-password/required-status")) - .andExpect(status().isOk()) - .andExpect(content().string("true")); - } - - @Test - void checkMasterPasswordSetup_shouldReturnFalse_whenAlreadyConfigured() throws Exception { - appSettingRepository.save(new AppSettingEntity("master_password_hash", "mockHash")); - appSettingRepository.save(new AppSettingEntity("master_password_salt", "mockSalt")); - - mockMvc.perform(get("/api/v1/security/master-password/required-status")) - .andExpect(status().isOk()) - .andExpect(content().string("false")); - } - - @Test - void getSessionStatus_shouldReturnInactiveAndZeroSeconds_whenLocked() throws Exception { - mockMvc.perform(get("/api/v1/security/vault/status")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.active").value(false)) - .andExpect(jsonPath("$.secondsLeft").value(0)); - } - - @Test - void getSessionStatus_shouldReturnActiveAndRemainingSeconds_whenUnlocked() throws Exception { - masterKeySessionPort.setKey(new SecretKeySpec(new byte[32], "AES")); - - mockMvc.perform(get("/api/v1/security/vault/status")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.active").value(true)) - .andExpect(jsonPath("$.secondsLeft").value(greaterThan(0))); - } - - @Test - void unlockVault_shouldReturnTrue_whenPasswordIsCorrect() throws Exception { - // 1. Setup a master password first - String setupJson = """ - { - "masterPassword": "mySuperSecretPassword123" - } - """; - - mockMvc.perform(post("/api/v1/security/master-password") - .contentType(MediaType.APPLICATION_JSON) - .content(setupJson)) - .andExpect(status().isNoContent()); - - // Lock vault for testing unlock explicitly - masterKeySessionPort.clear(); - assertFalse(masterKeySessionPort.hasKey()); - - // 2. Unlock vault - String unlockJson = """ - { - "masterPassword": "mySuperSecretPassword123" - } - """; - - mockMvc.perform(post("/api/v1/security/vault/unlock") - .contentType(MediaType.APPLICATION_JSON) - .content(unlockJson)) - .andExpect(status().isOk()) - .andExpect(content().string("true")); - - assertTrue(masterKeySessionPort.hasKey()); - } - - @Test - void unlockVault_shouldReturnUnauthorized_whenPasswordIsWrong() throws Exception { - // 1. Setup a master password - String setupJson = """ - { - "masterPassword": "mySuperSecretPassword123" - } - """; - - mockMvc.perform(post("/api/v1/security/master-password") - .contentType(MediaType.APPLICATION_JSON) - .content(setupJson)) - .andExpect(status().isNoContent()); - - masterKeySessionPort.clear(); - - // 2. Try to unlock with wrong password - String unlockJson = """ - { - "masterPassword": "wrongPassword123" - } - """; - - mockMvc.perform(post("/api/v1/security/vault/unlock") - .contentType(MediaType.APPLICATION_JSON) - .content(unlockJson)) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("Invalid MasterPassword!")); - - assertFalse(masterKeySessionPort.hasKey()); - } - - @Test - void unlockVault_shouldReturnForbidden_whenNotConfigured() throws Exception { - String json = """ - { - "masterPassword": "mySuperSecretPassword123" - } - """; - - mockMvc.perform(post("/api/v1/security/vault/unlock") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.message").value("Master password not configured")); - } - - @Test - void unlockVault_shouldReturnBadRequest_whenPasswordTooShort() throws Exception { - String json = """ - { - "masterPassword": "short" - } - """; - - mockMvc.perform(post("/api/v1/security/vault/unlock") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors[0].field").value("masterPassword")); - } - - @Test - void lockVault_shouldClearSessionAndReturnNoContent() throws Exception { - masterKeySessionPort.setKey(new SecretKeySpec(new byte[32], "AES")); - assertTrue(masterKeySessionPort.hasKey()); - - mockMvc.perform(post("/api/v1/security/vault/lock")) - .andExpect(status().isNoContent()); - - assertFalse(masterKeySessionPort.hasKey()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/snippet/SnippetControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/snippet/SnippetControllerIT.java deleted file mode 100644 index b3c6463..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/snippet/SnippetControllerIT.java +++ /dev/null @@ -1,304 +0,0 @@ -package com.devaulty.backend.adapter.in.web.snippet; - -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import com.devaulty.backend.adapter.in.web.snippet.dto.CreateSnippetRequest; -import com.devaulty.backend.adapter.in.web.snippet.dto.UpdateSnippetRequest; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.adapter.out.persistence.snippet.SnippetEntity; -import com.devaulty.backend.adapter.out.persistence.snippet.SpringDataSnippetRepository; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; - -import java.time.LocalDateTime; -import java.util.UUID; - -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -class SnippetControllerIT extends BaseIntegrationTest { - - @Autowired - private SpringDataSnippetRepository snippetRepository; - - @Autowired - private SpringDataProjectRepository projectRepository; - - private ProjectEntity savedProject; - - @BeforeEach - void setUpData() { - snippetRepository.deleteAll(); - projectRepository.deleteAll(); - - ProjectEntity project = new ProjectEntity( - UUID.randomUUID(), - "Integration Project", - "Description", - "folder", - "#FFF", - false - ); - project.setCreatedAt(LocalDateTime.now()); - savedProject = projectRepository.save(project); - } - - @Test - void createSnippet_shouldReturnCreated() throws Exception { - CreateSnippetRequest request = new CreateSnippetRequest( - "My Snippet", - "Snippet description", - "echo 'hello world'", - SnippetLanguage.BASH, - SnippetType.COMMAND - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/snippets", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(header().exists("Location")) - .andExpect(jsonPath("$.id").isNotEmpty()) - .andExpect(jsonPath("$.title").value("My Snippet")) - .andExpect(jsonPath("$.description").value("Snippet description")) - .andExpect(jsonPath("$.content").value("echo 'hello world'")) - .andExpect(jsonPath("$.language").value("BASH")) - .andExpect(jsonPath("$.snippetType").value("COMMAND")) - .andExpect(jsonPath("$.createdAt").isNotEmpty()) - .andExpect(jsonPath("$.tags").isEmpty()); - - assertEquals(1, snippetRepository.count()); - } - - @Test - void createSnippet_shouldReturnBadRequest_whenValidationFails() throws Exception { - // Title too short (min 2), content blank - CreateSnippetRequest request = new CreateSnippetRequest( - "A", - "Snippet description", - " ", - SnippetLanguage.BASH, - SnippetType.COMMAND - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/snippets", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors", hasSize(2))); - } - - @Test - void createSnippet_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - CreateSnippetRequest request = new CreateSnippetRequest( - "Snippet Title", - "Snippet description", - "echo 'hello'", - SnippetLanguage.BASH, - SnippetType.COMMAND - ); - - mockMvc.perform(post("/api/v1/projects/{projectId}/snippets", nonExistentProjectId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)) - .andExpect(jsonPath("$.message").value("Project not found with identifier " + nonExistentProjectId)); - } - - @Test - void getAllSnippets_shouldReturnPagedSnippets() throws Exception { - SnippetEntity s1 = new SnippetEntity(UUID.randomUUID(), savedProject, "Snippet 1", "Desc 1", "content 1", SnippetLanguage.JAVA, SnippetType.CODE); - s1.setCreatedAt(LocalDateTime.now().minusHours(1)); - SnippetEntity s2 = new SnippetEntity(UUID.randomUUID(), savedProject, "Snippet 2", "Desc 2", "content 2", SnippetLanguage.PYTHON, SnippetType.CODE); - s2.setCreatedAt(LocalDateTime.now()); - snippetRepository.save(s1); - snippetRepository.save(s2); - - mockMvc.perform(get("/api/v1/projects/{projectId}/snippets", savedProject.getId()) - .param("page", "0") - .param("size", "5")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content", hasSize(2))) - .andExpect(jsonPath("$.content[0].title").value("Snippet 2")) - .andExpect(jsonPath("$.content[1].title").value("Snippet 1")) - .andExpect(jsonPath("$.page.totalElements").value(2)); - } - - @Test - void getAllSnippets_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/snippets", nonExistentProjectId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)); - } - - @Test - void getSnippetById_shouldReturnSnippet() throws Exception { - UUID id = UUID.randomUUID(); - SnippetEntity snippet = new SnippetEntity(id, savedProject, "Get Snippet", "Desc", "print(1)", SnippetLanguage.PYTHON, SnippetType.CODE); - snippet.setCreatedAt(LocalDateTime.now()); - snippetRepository.save(snippet); - - mockMvc.perform(get("/api/v1/projects/{projectId}/snippets/{snippetId}", savedProject.getId(), id)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(id.toString())) - .andExpect(jsonPath("$.title").value("Get Snippet")); - } - - @Test - void getSnippetById_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/snippets/{snippetId}", nonExistentProjectId, snippetId)) - .andExpect(status().isNotFound()); - } - - @Test - void getSnippetById_shouldReturnNotFound_whenSnippetDoesNotExist() throws Exception { - UUID nonExistentSnippetId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/snippets/{snippetId}", savedProject.getId(), nonExistentSnippetId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Snippet not found with identifier " + nonExistentSnippetId)); - } - - @Test - void getSnippetById_shouldReturnNotFound_whenSnippetDoesNotBelongToProject() throws Exception { - // Create another project - ProjectEntity otherProject = new ProjectEntity(UUID.randomUUID(), "Other", "Desc", "folder", "#000", false); - otherProject.setCreatedAt(LocalDateTime.now()); - projectRepository.save(otherProject); - - // Create snippet for other project - UUID snippetId = UUID.randomUUID(); - SnippetEntity snippet = new SnippetEntity(snippetId, otherProject, "Other Snippet", "Desc", "ls", SnippetLanguage.BASH, SnippetType.COMMAND); - snippet.setCreatedAt(LocalDateTime.now()); - snippetRepository.save(snippet); - - // Perform request requesting other project's snippet under savedProject's path - mockMvc.perform(get("/api/v1/projects/{projectId}/snippets/{snippetId}", savedProject.getId(), snippetId)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.message").value("Snippet not found with identifier " + snippetId)); - } - - @Test - void updateSnippet_shouldUpdateAndReturnSnippet() throws Exception { - UUID id = UUID.randomUUID(); - SnippetEntity snippet = new SnippetEntity(id, savedProject, "Old Title", "Old Desc", "old content", SnippetLanguage.JAVA, SnippetType.CODE); - snippet.setCreatedAt(LocalDateTime.now()); - snippetRepository.save(snippet); - - UpdateSnippetRequest request = new UpdateSnippetRequest( - "New Title", - "New Desc", - "new content", - SnippetLanguage.KOTLIN, - SnippetType.CODE - ); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/snippets/{snippetId}", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("New Title")) - .andExpect(jsonPath("$.description").value("New Desc")) - .andExpect(jsonPath("$.content").value("new content")) - .andExpect(jsonPath("$.language").value("KOTLIN")) - .andExpect(jsonPath("$.updatedAt").isNotEmpty()); - - SnippetEntity updated = snippetRepository.findById(id).orElseThrow(); - assertEquals("New Title", updated.getTitle()); - } - - @Test - void updateSnippet_shouldReturnBadRequest_whenValidationFails() throws Exception { - UUID id = UUID.randomUUID(); - SnippetEntity snippet = new SnippetEntity(id, savedProject, "Old Title", "Old Desc", "old content", SnippetLanguage.JAVA, SnippetType.CODE); - snippet.setCreatedAt(LocalDateTime.now()); - snippetRepository.save(snippet); - - // Title too short - UpdateSnippetRequest request = new UpdateSnippetRequest("A", null, null, null, null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/snippets/{snippetId}", savedProject.getId(), id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")); - } - - @Test - void updateSnippet_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - UpdateSnippetRequest request = new UpdateSnippetRequest("New Title", null, null, null, null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/snippets/{snippetId}", nonExistentProjectId, snippetId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()); - } - - @Test - void updateSnippet_shouldReturnNotFound_whenSnippetDoesNotExist() throws Exception { - UUID nonExistentSnippetId = UUID.randomUUID(); - UpdateSnippetRequest request = new UpdateSnippetRequest("New Title", null, null, null, null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/snippets/{snippetId}", savedProject.getId(), nonExistentSnippetId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()); - } - - @Test - void updateSnippet_shouldReturnNotFound_whenSnippetDoesNotBelongToProject() throws Exception { - ProjectEntity otherProject = new ProjectEntity(UUID.randomUUID(), "Other", "Desc", "folder", "#000", false); - otherProject.setCreatedAt(LocalDateTime.now()); - projectRepository.save(otherProject); - - UUID snippetId = UUID.randomUUID(); - SnippetEntity snippet = new SnippetEntity(snippetId, otherProject, "Other Snippet", "Desc", "ls", SnippetLanguage.BASH, SnippetType.COMMAND); - snippet.setCreatedAt(LocalDateTime.now()); - snippetRepository.save(snippet); - - UpdateSnippetRequest request = new UpdateSnippetRequest("New Title", null, null, null, null); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/snippets/{snippetId}", savedProject.getId(), snippetId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()); - } - - @Test - void deleteSnippet_shouldDeleteAndReturnNoContent() throws Exception { - UUID id = UUID.randomUUID(); - SnippetEntity snippet = new SnippetEntity(id, savedProject, "To Delete", "Desc", "rm", SnippetLanguage.BASH, SnippetType.COMMAND); - snippet.setCreatedAt(LocalDateTime.now()); - snippetRepository.save(snippet); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/snippets/{snippetId}", savedProject.getId(), id)) - .andExpect(status().isNoContent()); - - assertFalse(snippetRepository.existsById(id)); - } - - @Test - void deleteSnippet_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/snippets/{snippetId}", nonExistentProjectId, snippetId)) - .andExpect(status().isNotFound()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/tag/TagControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/tag/TagControllerIT.java deleted file mode 100644 index af8afbf..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/tag/TagControllerIT.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag; - -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import com.devaulty.backend.adapter.in.web.tag.dto.CreateTagRequest; -import com.devaulty.backend.adapter.in.web.tag.dto.UpdateTagRequest; -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.adapter.out.persistence.tag.TagEntity; -import com.devaulty.backend.adapter.out.persistence.tag.SpringDataTagRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; - -import java.time.LocalDateTime; -import java.util.UUID; - -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -class TagControllerIT extends BaseIntegrationTest { - - @Autowired - private SpringDataTagRepository tagRepository; - - @Autowired - private SpringDataProjectRepository projectRepository; - - private ProjectEntity savedProject; - - @BeforeEach - void setUpData() { - tagRepository.deleteAll(); - projectRepository.deleteAll(); - - ProjectEntity project = new ProjectEntity( - UUID.randomUUID(), - "Tag Integration Project", - "Description", - "folder", - "#FFF", - false - ); - project.setCreatedAt(LocalDateTime.now()); - savedProject = projectRepository.save(project); - } - - private TagEntity createAndSaveTag(String name, String color) { - TagEntity tag = new TagEntity(UUID.randomUUID(), savedProject, name, color); - tag.setCreatedAt(LocalDateTime.now()); - tag.setUpdatedAt(LocalDateTime.now()); - return tagRepository.save(tag); - } - - @Test - void createTag_shouldReturnCreated() throws Exception { - CreateTagRequest request = new CreateTagRequest("java", "#3A3A3A"); - - mockMvc.perform(post("/api/v1/projects/{projectId}/tags", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(header().exists("Location")) - .andExpect(jsonPath("$.id").isNotEmpty()) - .andExpect(jsonPath("$.name").value("java")) - .andExpect(jsonPath("$.color").value("#3A3A3A")); - - assertEquals(1, tagRepository.count()); - } - - @Test - void createTag_shouldReturnBadRequest_whenValidationFails() throws Exception { - // Name blank, invalid hex color - CreateTagRequest request = new CreateTagRequest(" ", "not-a-color"); - - mockMvc.perform(post("/api/v1/projects/{projectId}/tags", savedProject.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Validation error")) - .andExpect(jsonPath("$.errors", hasSize(2))); - } - - @Test - void getAllTags_shouldReturnList() throws Exception { - createAndSaveTag("docker", "#111"); - createAndSaveTag("spring", "#222"); - - mockMvc.perform(get("/api/v1/projects/{projectId}/tags", savedProject.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(2))) - .andExpect(jsonPath("$[*].name", containsInAnyOrder("docker", "spring"))); - } - - @Test - void getTagById_shouldReturnTag() throws Exception { - TagEntity tag = createAndSaveTag("kubernetes", "#444"); - - mockMvc.perform(get("/api/v1/projects/{projectId}/tags/{tagId}", savedProject.getId(), tag.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(tag.getId().toString())) - .andExpect(jsonPath("$.name").value("kubernetes")) - .andExpect(jsonPath("$.color").value("#444")); - } - - @Test - void getTagById_shouldReturnNotFound_whenTagDoesNotExist() throws Exception { - UUID nonExistentId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/projects/{projectId}/tags/{tagId}", savedProject.getId(), nonExistentId)) - .andExpect(status().isNotFound()); - } - - @Test - void searchByName_shouldReturnMatchingTags() throws Exception { - createAndSaveTag("react-js", "#111"); - createAndSaveTag("vue-js", "#222"); - createAndSaveTag("angular", "#333"); - - mockMvc.perform(get("/api/v1/projects/{projectId}/tags/search", savedProject.getId()) - .param("name", "js")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(2))) - .andExpect(jsonPath("$[*].name", containsInAnyOrder("react-js", "vue-js"))); - } - - @Test - void updateTag_shouldUpdateFields() throws Exception { - TagEntity tag = createAndSaveTag("old-name", "#111"); - - UpdateTagRequest request = new UpdateTagRequest("new-name", "#555"); - - mockMvc.perform(patch("/api/v1/projects/{projectId}/tags/{tagId}", savedProject.getId(), tag.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("new-name")) - .andExpect(jsonPath("$.color").value("#555")); - - TagEntity updated = tagRepository.findById(tag.getId()).orElseThrow(); - assertEquals("new-name", updated.getName()); - assertEquals("#555", updated.getColor()); - } - - @Test - void deleteTag_shouldRemoveTag() throws Exception { - TagEntity tag = createAndSaveTag("temp", "#999"); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/tags/{tagId}", savedProject.getId(), tag.getId())) - .andExpect(status().isNoContent()); - - assertFalse(tagRepository.existsById(tag.getId())); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/tag/item/ItemTagControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/tag/item/ItemTagControllerIT.java deleted file mode 100644 index 9215e99..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/tag/item/ItemTagControllerIT.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.devaulty.backend.adapter.in.web.tag.item; - -import com.devaulty.backend.adapter.out.persistence.project.ProjectEntity; -import com.devaulty.backend.adapter.out.persistence.project.SpringDataProjectRepository; -import com.devaulty.backend.adapter.out.persistence.snippet.SnippetEntity; -import com.devaulty.backend.adapter.out.persistence.snippet.SpringDataSnippetRepository; -import com.devaulty.backend.adapter.out.persistence.tag.SpringDataTagRepository; -import com.devaulty.backend.adapter.out.persistence.tag.TagEntity; -import com.devaulty.backend.adapter.out.persistence.tag.item.ItemTagEntity; -import com.devaulty.backend.adapter.out.persistence.tag.item.ItemTagId; -import com.devaulty.backend.adapter.out.persistence.tag.item.SpringDataItemTagRepository; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; -import com.devaulty.backend.infrastructure.BaseIntegrationTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -import java.time.LocalDateTime; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class ItemTagControllerIT extends BaseIntegrationTest { - - @Autowired - private SpringDataItemTagRepository itemTagRepository; - - @Autowired - private SpringDataTagRepository tagRepository; - - @Autowired - private SpringDataSnippetRepository snippetRepository; - - @Autowired - private SpringDataProjectRepository projectRepository; - - private ProjectEntity savedProject; - private TagEntity savedTag; - private SnippetEntity savedSnippet; - - @BeforeEach - void setUpData() { - itemTagRepository.deleteAll(); - tagRepository.deleteAll(); - snippetRepository.deleteAll(); - projectRepository.deleteAll(); - - ProjectEntity project = new ProjectEntity( - UUID.randomUUID(), - "ItemTag Integration Project", - "Description", - "folder", - "#FFF", - false - ); - project.setCreatedAt(LocalDateTime.now()); - savedProject = projectRepository.save(project); - - TagEntity tag = new TagEntity(UUID.randomUUID(), savedProject, "docker", "#123"); - tag.setCreatedAt(LocalDateTime.now()); - tag.setUpdatedAt(LocalDateTime.now()); - savedTag = tagRepository.save(tag); - - SnippetEntity snippet = new SnippetEntity( - UUID.randomUUID(), - savedProject, - "My Docker Compose", - "Compose desc", - "version: '3'", - SnippetLanguage.YAML, - SnippetType.CODE - ); - snippet.setCreatedAt(LocalDateTime.now()); - savedSnippet = snippetRepository.save(snippet); - } - - @Test - void associateTag_shouldCreateAssociation() throws Exception { - mockMvc.perform(put("/api/v1/projects/{projectId}/items/{itemType}/{itemId}/tags/{tagId}", - savedProject.getId(), "snippet", savedSnippet.getId(), savedTag.getId())) - .andExpect(status().isNoContent()); - - ItemTagId associationId = new ItemTagId(savedTag.getId(), "snippet", savedSnippet.getId()); - assertTrue(itemTagRepository.existsById(associationId)); - } - - @Test - void associateTag_shouldReturnNotFound_whenTagDoesNotExist() throws Exception { - UUID nonExistentTagId = UUID.randomUUID(); - - mockMvc.perform(put("/api/v1/projects/{projectId}/items/{itemType}/{itemId}/tags/{tagId}", - savedProject.getId(), "snippet", savedSnippet.getId(), nonExistentTagId)) - .andExpect(status().isNotFound()); - } - - @Test - void associateTag_shouldReturnNotFound_whenProjectDoesNotExist() throws Exception { - UUID nonExistentProjectId = UUID.randomUUID(); - - mockMvc.perform(put("/api/v1/projects/{projectId}/items/{itemType}/{itemId}/tags/{tagId}", - nonExistentProjectId, "snippet", savedSnippet.getId(), savedTag.getId())) - .andExpect(status().isNotFound()); - } - - @Test - void disassociateTag_shouldRemoveAssociation() throws Exception { - ItemTagEntity association = new ItemTagEntity(savedTag, "snippet", savedSnippet.getId()); - itemTagRepository.save(association); - - mockMvc.perform(delete("/api/v1/projects/{projectId}/items/{itemType}/{itemId}/tags/{tagId}", - savedProject.getId(), "snippet", savedSnippet.getId(), savedTag.getId())) - .andExpect(status().isNoContent()); - - ItemTagId associationId = new ItemTagId(savedTag.getId(), "snippet", savedSnippet.getId()); - assertFalse(itemTagRepository.existsById(associationId)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/out/crypto/Argon2KeyDeriverAdapterTest.java b/backend/src/test/java/com/devaulty/backend/adapter/out/crypto/Argon2KeyDeriverAdapterTest.java deleted file mode 100644 index ef0300f..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/out/crypto/Argon2KeyDeriverAdapterTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.devaulty.backend.adapter.out.crypto; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import javax.crypto.SecretKey; -import java.security.SecureRandom; - -import static org.junit.jupiter.api.Assertions.*; - -class Argon2KeyDeriverAdapterTest { - - private Argon2KeyDeriverAdapter keyDeriver; - - @BeforeEach - void setUp() { - keyDeriver = new Argon2KeyDeriverAdapter(); - } - - @Test - void shouldDeriveValidKeyFromPasswordAndSalt() { - // Arrange - char[] password = "mySecretMasterPassword".toCharArray(); - byte[] salt = new byte[16]; - new SecureRandom().nextBytes(salt); - - // Act - SecretKey key = keyDeriver.deriveKey(password, salt); - - // Assert - assertNotNull(key); - assertEquals("AES", key.getAlgorithm()); - assertNotNull(key.getEncoded()); - assertEquals(32, key.getEncoded().length); // 256 bits - - // Check original password input was NOT mutated by the keyDeriver call itself - // (Wiping standard in Devaulty happens in the use cases finally block, but the keyDeriver method - // doesn't touch the original password reference, it copies/encodes it to bytes and clears the bytes copy). - assertEquals('m', password[0]); - } - - @Test - void shouldDeriveSameKey_forSameInputs() { - // Arrange - char[] password = "mySecretMasterPassword".toCharArray(); - byte[] salt = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; - - // Act - SecretKey key1 = keyDeriver.deriveKey(password, salt); - SecretKey key2 = keyDeriver.deriveKey(password, salt); - - // Assert - assertArrayEquals(key1.getEncoded(), key2.getEncoded()); - } - - @Test - void shouldDeriveDifferentKey_whenPasswordsAreDifferent() { - // Arrange - char[] password1 = "mySecretMasterPassword".toCharArray(); - char[] password2 = "differentMasterPassword".toCharArray(); - byte[] salt = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; - - // Act - SecretKey key1 = keyDeriver.deriveKey(password1, salt); - SecretKey key2 = keyDeriver.deriveKey(password2, salt); - - // Assert - assertFalse(java.util.Arrays.equals(key1.getEncoded(), key2.getEncoded())); - } - - @Test - void shouldDeriveDifferentKey_whenSaltsAreDifferent() { - // Arrange - char[] password = "mySecretMasterPassword".toCharArray(); - byte[] salt1 = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; - byte[] salt2 = new byte[]{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 10, 11, 12, 13, 14, 15}; - - // Act - SecretKey key1 = keyDeriver.deriveKey(password, salt1); - SecretKey key2 = keyDeriver.deriveKey(password, salt2); - - // Assert - assertFalse(java.util.Arrays.equals(key1.getEncoded(), key2.getEncoded())); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/out/crypto/BouncyCastleCryptoAdapterTest.java b/backend/src/test/java/com/devaulty/backend/adapter/out/crypto/BouncyCastleCryptoAdapterTest.java deleted file mode 100644 index c7adcb2..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/out/crypto/BouncyCastleCryptoAdapterTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.devaulty.backend.adapter.out.crypto; - -import com.devaulty.backend.application.exception.CryptoException; -import com.devaulty.backend.application.exception.InvalidMasterPasswordException; -import com.devaulty.backend.application.port.out.security.dto.CryptoResultDto; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; - -import static org.junit.jupiter.api.Assertions.*; - -class BouncyCastleCryptoAdapterTest { - - private BouncyCastleCryptoAdapter cryptoAdapter; - private SecretKey secretKey; - private byte[] aad; - - @BeforeEach - void setUp() { - cryptoAdapter = new BouncyCastleCryptoAdapter(); - // Generate a 256-bit AES key (32 bytes) - secretKey = new SecretKeySpec(new byte[32], "AES"); - aad = new byte[]{1, 2, 3, 4}; - } - - @Test - void shouldEncryptAndDecryptSuccessfully() { - // Arrange - byte[] originalData = "My sensitive secret data here!".getBytes(StandardCharsets.UTF_8); - - // Act - Encrypt - CryptoResultDto encryptResult = cryptoAdapter.encrypt(originalData, secretKey, aad); - - // Assert Encryption metadata - assertNotNull(encryptResult); - assertNotNull(encryptResult.cipherText()); - assertNotNull(encryptResult.iv()); - assertEquals(12, encryptResult.iv().length); // GCM standard IV is 12 bytes - assertNotNull(encryptResult.authTag()); - assertEquals(16, encryptResult.authTag().length); // GCM standard Auth Tag is 16 bytes - - // Act - Decrypt - byte[] decryptedData = cryptoAdapter.decrypt( - encryptResult.cipherText(), - encryptResult.iv(), - encryptResult.authTag(), - secretKey, - aad - ); - - // Assert decrypted payload match - assertArrayEquals(originalData, decryptedData); - assertEquals("My sensitive secret data here!", new String(decryptedData, StandardCharsets.UTF_8)); - } - - @Test - void shouldThrowInvalidMasterPasswordException_whenAuthTagIsCorrupted() { - // Arrange - byte[] originalData = "Sensitive information".getBytes(StandardCharsets.UTF_8); - CryptoResultDto encryptResult = cryptoAdapter.encrypt(originalData, secretKey, aad); - - // Corrupt the auth tag - byte[] corruptedAuthTag = encryptResult.authTag().clone(); - corruptedAuthTag[0] ^= 1; // Flip one bit - - // Act & Assert - byte[] cipherText = encryptResult.cipherText(); - byte[] iv = encryptResult.iv(); - assertThrows(InvalidMasterPasswordException.class, () -> { - cryptoAdapter.decrypt( - cipherText, - iv, - corruptedAuthTag, - secretKey, - aad - ); - }); - } - - @Test - void shouldThrowInvalidMasterPasswordException_whenCipherTextIsCorrupted() { - // Arrange - byte[] originalData = "Sensitive information".getBytes(StandardCharsets.UTF_8); - CryptoResultDto encryptResult = cryptoAdapter.encrypt(originalData, secretKey, aad); - - // Corrupt the ciphertext - byte[] corruptedCipherText = encryptResult.cipherText().clone(); - corruptedCipherText[0] ^= 1; - - // Act & Assert - byte[] iv = encryptResult.iv(); - byte[] authTag = encryptResult.authTag(); - assertThrows(InvalidMasterPasswordException.class, () -> { - cryptoAdapter.decrypt( - corruptedCipherText, - iv, - authTag, - secretKey, - aad - ); - }); - } - - @Test - void shouldThrowInvalidMasterPasswordException_whenIvLengthIsInvalid() { - // Arrange - byte[] originalData = "Sensitive information".getBytes(StandardCharsets.UTF_8); - CryptoResultDto encryptResult = cryptoAdapter.encrypt(originalData, secretKey, aad); - - byte[] invalidIv = new byte[5]; // Standard GCM IV should be 12 bytes typically - - // Act & Assert - byte[] cipherText = encryptResult.cipherText(); - byte[] authTag = encryptResult.authTag(); - assertThrows(InvalidMasterPasswordException.class, () -> { - cryptoAdapter.decrypt( - cipherText, - invalidIv, - authTag, - secretKey, - aad - ); - }); - } - - @Test - void shouldThrowInvalidMasterPasswordException_whenAadIsDifferent() { - // Arrange - byte[] originalData = "Sensitive information".getBytes(StandardCharsets.UTF_8); - CryptoResultDto encryptResult = cryptoAdapter.encrypt(originalData, secretKey, aad); - - byte[] differentAad = new byte[]{9, 9, 9, 9}; - - // Act & Assert - byte[] cipherText = encryptResult.cipherText(); - byte[] iv = encryptResult.iv(); - byte[] authTag = encryptResult.authTag(); - assertThrows(InvalidMasterPasswordException.class, () -> { - cryptoAdapter.decrypt( - cipherText, - iv, - authTag, - secretKey, - differentAad - ); - }); - } - - @Test - void shouldThrowCryptoException_whenKeyIsNullOnEncryption() { - byte[] originalData = "data".getBytes(); - - assertThrows(CryptoException.class, () -> { - cryptoAdapter.encrypt(originalData, null, aad); - }); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/out/crypto/MasterKeySessionHolderTest.java b/backend/src/test/java/com/devaulty/backend/adapter/out/crypto/MasterKeySessionHolderTest.java deleted file mode 100644 index 7709d7d..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/out/crypto/MasterKeySessionHolderTest.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.devaulty.backend.adapter.out.crypto; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; - -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; -import java.time.Duration; -import java.time.Instant; - -import static org.junit.jupiter.api.Assertions.*; - -class MasterKeySessionHolderTest { - - private MasterKeySessionHolder sessionHolder; - - @BeforeEach - void setUp() { - sessionHolder = new MasterKeySessionHolder(); - } - - @Test - void shouldInitializeWithNoKey() { - assertNull(sessionHolder.getKey()); - assertFalse(sessionHolder.hasKey()); - } - - @Test - void shouldSetAndGetKey() { - // Arrange - byte[] keyBytes = new byte[32]; - keyBytes[0] = 5; - SecretKey secretKey = new SecretKeySpec(keyBytes, "AES"); - - // Act - sessionHolder.setKey(secretKey); - - // Assert - assertTrue(sessionHolder.hasKey()); - assertSame(secretKey, sessionHolder.getKey()); - - Instant lastActivity = (Instant) ReflectionTestUtils.getField(sessionHolder, "lastActivityAt"); - assertNotNull(lastActivity); - } - - @Test - void shouldClearSessionSecurely() { - // Arrange - byte[] keyBytes = new byte[]{1, 2, 3, 4}; - SecretKey secretKey = new SecretKeySpec(keyBytes, "AES"); - sessionHolder.setKey(secretKey); - - byte[] rawKeyBytesInHolder = (byte[]) ReflectionTestUtils.getField(sessionHolder, "rawKeyBytes"); - assertNotNull(rawKeyBytesInHolder); - assertEquals(1, rawKeyBytesInHolder[0]); - - // Act - sessionHolder.clear(); - - // Assert - assertFalse(sessionHolder.hasKey()); - assertNull(sessionHolder.getKey()); - assertNull(ReflectionTestUtils.getField(sessionHolder, "rawKeyBytes")); - assertNull(ReflectionTestUtils.getField(sessionHolder, "lastActivityAt")); - - // Verify the original raw bytes inside the memory array were securely zeroed out! - for (byte b : rawKeyBytesInHolder) { - assertEquals(0, b); - } - } - - @Test - void getKey_shouldNotTouch_whenNoKeyIsPresent() { - assertNull(ReflectionTestUtils.getField(sessionHolder, "lastActivityAt")); - sessionHolder.getKey(); - assertNull(ReflectionTestUtils.getField(sessionHolder, "lastActivityAt")); - } - - @Test - void setKey_shouldClearPreviousKeyBytes_whenNewKeyIsSet() { - // Arrange - byte[] keyBytes1 = new byte[]{1, 2, 3, 4}; - SecretKey secretKey1 = new SecretKeySpec(keyBytes1, "AES"); - sessionHolder.setKey(secretKey1); - - byte[] rawKeyBytesInHolder1 = (byte[]) ReflectionTestUtils.getField(sessionHolder, "rawKeyBytes"); - assertNotNull(rawKeyBytesInHolder1); - assertEquals(1, rawKeyBytesInHolder1[0]); - - byte[] keyBytes2 = new byte[]{5, 6, 7, 8}; - SecretKey secretKey2 = new SecretKeySpec(keyBytes2, "AES"); - - // Act - sessionHolder.setKey(secretKey2); - - // Assert - // Verify the original raw bytes of the first key were securely zeroed out! - for (byte b : rawKeyBytesInHolder1) { - assertEquals(0, b); - } - } - - @Test - void getSecondsRemaining_shouldReturnZero_whenNoKeyIsPresent() { - Long remaining = sessionHolder.getSecondsRemaining(Duration.ofMinutes(15)); - assertEquals(0L, remaining); - } - - @Test - void getSecondsRemaining_shouldReturnZero_whenLastActivityIsNull() { - SecretKey secretKey = new SecretKeySpec(new byte[32], "AES"); - sessionHolder.setKey(secretKey); - ReflectionTestUtils.setField(sessionHolder, "lastActivityAt", null); - - Long remaining = sessionHolder.getSecondsRemaining(Duration.ofMinutes(15)); - assertEquals(0L, remaining); - } - - @Test - void getSecondsRemaining_shouldReturnCorrectValue_whenActive() { - SecretKey secretKey = new SecretKeySpec(new byte[32], "AES"); - sessionHolder.setKey(secretKey); - - // Set last activity to 5 minutes ago - Instant fiveMinutesAgo = Instant.now().minus(Duration.ofMinutes(5)); - ReflectionTestUtils.setField(sessionHolder, "lastActivityAt", fiveMinutesAgo); - - // 15 minutes timeout - 5 minutes elapsed = 10 minutes (600 seconds) remaining - Long remaining = sessionHolder.getSecondsRemaining(Duration.ofMinutes(15)); - - // Assert remaining is roughly 600 seconds (allowing 2 seconds of tolerance) - assertTrue(remaining >= 598 && remaining <= 600); - } - - @Test - void getSecondsRemaining_shouldReturnZero_whenTimeIsExpired() { - SecretKey secretKey = new SecretKeySpec(new byte[32], "AES"); - sessionHolder.setKey(secretKey); - - // Set last activity to 20 minutes ago - Instant twentyMinutesAgo = Instant.now().minus(Duration.ofMinutes(20)); - ReflectionTestUtils.setField(sessionHolder, "lastActivityAt", twentyMinutesAgo); - - Long remaining = sessionHolder.getSecondsRemaining(Duration.ofMinutes(15)); - assertEquals(0L, remaining); - } - - @Test - void shouldUpdateActivityTimeOnTouch() { - SecretKey secretKey = new SecretKeySpec(new byte[32], "AES"); - sessionHolder.setKey(secretKey); - - Instant initialActivity = (Instant) ReflectionTestUtils.getField(sessionHolder, "lastActivityAt"); - assertNotNull(initialActivity); - - // Set back to make touch noticeable - ReflectionTestUtils.setField(sessionHolder, "lastActivityAt", initialActivity.minus(Duration.ofMinutes(10))); - - // Act - sessionHolder.touch(); - - // Assert - Instant newActivity = (Instant) ReflectionTestUtils.getField(sessionHolder, "lastActivityAt"); - assertNotNull(newActivity); - assertTrue(newActivity.isAfter(initialActivity.minus(Duration.ofMinutes(10)))); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/out/external/github/GitHubClientTest.java b/backend/src/test/java/com/devaulty/backend/adapter/out/external/github/GitHubClientTest.java deleted file mode 100644 index 8e85637..0000000 --- a/backend/src/test/java/com/devaulty/backend/adapter/out/external/github/GitHubClientTest.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.devaulty.backend.adapter.out.external.github; - -import com.devaulty.backend.adapter.out.external.github.dto.GitHubAssetResponse; -import com.devaulty.backend.adapter.out.external.github.dto.GitHubReleaseResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.HttpServerErrorException; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.RestClientResponseException; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@SuppressWarnings({"unchecked", "rawtypes"}) -class GitHubClientTest { - - @Mock - private RestClient githubRestClient; - - @Mock - private RestClient downloadRestClient; - - @Mock - private RestClient.RequestHeadersUriSpec requestHeadersUriSpec; - - @Mock - private RestClient.RequestHeadersSpec requestHeadersSpec; - - @Mock - private RestClient.ResponseSpec responseSpec; - - @Mock - private RestClient.RequestHeadersUriSpec downloadRequestHeadersUriSpec; - - @Mock - private RestClient.RequestHeadersSpec downloadRequestHeadersSpec; - - private GitHubClient gitHubClient; - - @BeforeEach - void setUp() { - gitHubClient = new GitHubClient(githubRestClient, downloadRestClient); - } - - @Test - @DisplayName("getLatestRelease should return GitHubReleaseResponse when request succeeds") - void getLatestRelease_shouldReturnReleaseResponse_whenGitHubReturnsRelease() { - GitHubAssetResponse asset = new GitHubAssetResponse("devaulty_0.2.0.deb", "https://download.url", 50000000L, "application/octet-stream", "sha256:12345"); - GitHubReleaseResponse expectedResponse = new GitHubReleaseResponse( - "v0.2.0", - "Release v0.2.0", - "Changelog notes", - "https://github.com/tag/v0.2.0", - Instant.now(), - false, - List.of(asset) - ); - - when(githubRestClient.get()).thenReturn(requestHeadersUriSpec); - when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec); - when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); - when(responseSpec.body(GitHubReleaseResponse.class)).thenReturn(expectedResponse); - - GitHubReleaseResponse actualResponse = gitHubClient.getLatestRelease(); - - assertNotNull(actualResponse); - assertEquals("v0.2.0", actualResponse.tagName()); - assertEquals("Release v0.2.0", actualResponse.name()); - assertEquals("Changelog notes", actualResponse.body()); - assertEquals(1, actualResponse.assets().size()); - - verify(githubRestClient, times(1)).get(); - } - - @Test - @DisplayName("getLatestRelease should return null when 404 Not Found RestClientResponseException occurs") - void getLatestRelease_shouldReturnNull_when404NotFoundOccurs() { - when(githubRestClient.get()).thenReturn(requestHeadersUriSpec); - when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec); - when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); - when(responseSpec.body(GitHubReleaseResponse.class)) - .thenThrow(HttpClientErrorException.create( - org.springframework.http.HttpStatus.NOT_FOUND, - "Not Found", null, null, null - )); - - GitHubReleaseResponse actualResponse = gitHubClient.getLatestRelease(); - - assertNull(actualResponse); - verify(githubRestClient, times(1)).get(); - } - - @Test - @DisplayName("getLatestRelease should rethrow exception when 500 Server Error RestClientResponseException occurs") - void getLatestRelease_shouldThrowException_whenServerErrorOccurs() { - when(githubRestClient.get()).thenReturn(requestHeadersUriSpec); - when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec); - when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); - when(responseSpec.body(GitHubReleaseResponse.class)) - .thenThrow(HttpServerErrorException.create( - org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR, - "Internal Server Error", null, null, null - )); - - assertThrows(RestClientResponseException.class, () -> gitHubClient.getLatestRelease()); - verify(githubRestClient, times(1)).get(); - } - - @Test - @DisplayName("downloadAsset should return an InputStream when download URL is valid") - void downloadAsset_shouldReturnInputStream_whenUrlIsValid() { - String downloadUrl = "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0.deb"; - InputStream mockStream = new ByteArrayInputStream("binary content".getBytes(StandardCharsets.UTF_8)); - - when(downloadRestClient.get()).thenReturn(downloadRequestHeadersUriSpec); - when(downloadRequestHeadersUriSpec.uri(any(URI.class))).thenReturn(downloadRequestHeadersSpec); - when(downloadRequestHeadersSpec.exchange(any(), eq(false))).thenReturn(mockStream); - - InputStream result = gitHubClient.downloadAsset(downloadUrl); - - assertNotNull(result); - assertSame(mockStream, result); - verify(downloadRestClient, times(1)).get(); - verify(githubRestClient, never()).get(); - } -} \ No newline at end of file diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/credential/CreateCredentialImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/credential/CreateCredentialImplTest.java deleted file mode 100644 index ccfc702..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/credential/CreateCredentialImplTest.java +++ /dev/null @@ -1,198 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.credential.CreateCredentialCommand; -import com.devaulty.backend.application.port.in.credential.DecryptedCredential; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.CryptoPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.application.port.out.security.dto.CryptoResultDto; -import com.devaulty.backend.domain.model.Credential; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import javax.crypto.SecretKey; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CreateCredentialImplTest { - - @Mock - private CredentialRepositoryPort credentialRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private CryptoPort cryptoPort; - - @Mock - private MasterKeySessionPort masterKeySessionPort; - - @Mock - private CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - - @InjectMocks - private CreateCredentialImpl createCredentialUseCase; - - @Test - void shouldCreateCredentialSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - char[] passwordPayload = "mySecretPassword".toCharArray(); - CreateCredentialCommand command = new CreateCredentialCommand( - projectId, - "DB Pass", - CredentialSecretType.LOGIN, - passwordPayload, - "notes", - "http://db" - ); - - SecretKey mockSecretKey = mock(SecretKey.class); - byte[] mockEncryptedPayload = new byte[]{1, 2, 3}; - byte[] mockIv = new byte[]{4, 5}; - byte[] mockAuthTag = new byte[]{6, 7}; - byte[] mockDecryptedPayload = "mySecretPassword".getBytes(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(masterKeySessionPort.getKey()).thenReturn(mockSecretKey); - - when(cryptoPort.encrypt(any(byte[].class), eq(mockSecretKey), any(byte[].class))) - .thenReturn(new CryptoResultDto(mockEncryptedPayload, mockIv, mockAuthTag)); - - when(credentialRepository.save(any(Credential.class))).thenAnswer(invocation -> { - Credential c = invocation.getArgument(0); - c.setCreatedAt(java.time.LocalDateTime.now()); - return c; - }); - - when(cryptoPort.decrypt(eq(mockEncryptedPayload), eq(mockIv), eq(mockAuthTag), eq(mockSecretKey), any(byte[].class))) - .thenReturn(mockDecryptedPayload); - - // Act - DecryptedCredential result = createCredentialUseCase.execute(command); - - // Assert - assertNotNull(result); - assertNotNull(result.id()); - assertEquals(projectId, result.projectId()); - assertEquals("DB Pass", result.title()); - assertEquals(CredentialSecretType.LOGIN, result.secretType()); - assertArrayEquals(mockDecryptedPayload, result.decryptedPayload()); - assertEquals("notes", result.notes()); - assertEquals("http://db", result.relatedUrl()); - assertNotNull(result.createdAt()); - - // The method should zero out the command payload - for (char c : command.payload()) { - assertEquals('\0', c); - } - - verify(projectRepository, times(1)).existsById(projectId); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(masterKeySessionPort, times(1)).getKey(); - verify(cryptoPort, times(1)).encrypt(any(byte[].class), eq(mockSecretKey), any(byte[].class)); - verify(credentialRepository, times(1)).save(any(Credential.class)); - verify(cryptoPort, times(1)).decrypt(eq(mockEncryptedPayload), eq(mockIv), eq(mockAuthTag), eq(mockSecretKey), any(byte[].class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - char[] passwordPayload = "mySecretPassword".toCharArray(); - CreateCredentialCommand command = new CreateCredentialCommand( - projectId, - "DB Pass", - CredentialSecretType.LOGIN, - passwordPayload, - "notes", - "http://db" - ); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - createCredentialUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, never()).save(any()); - // Clean up: payload is zeroed here successfully because validations are now inside the try-finally block - assertEquals('\0', command.payload()[0]); - } - - @Test - void shouldThrowMasterPasswordNotConfiguredExceptionWhenSetupIsRequired() { - // Arrange - UUID projectId = UUID.randomUUID(); - char[] passwordPayload = "mySecretPassword".toCharArray(); - CreateCredentialCommand command = new CreateCredentialCommand( - projectId, - "DB Pass", - CredentialSecretType.LOGIN, - passwordPayload, - "notes", - "http://db" - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(true); - - // Act & Assert - assertThrows(MasterPasswordNotConfiguredException.class, () -> { - createCredentialUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(credentialRepository, never()).save(any()); - // Clean up: payload is zeroed here successfully because validations are now inside the try-finally block - assertEquals('\0', command.payload()[0]); - } - - @Test - void shouldThrowVaultLockedExceptionWhenMasterKeyIsMissing() { - // Arrange - UUID projectId = UUID.randomUUID(); - char[] passwordPayload = "mySecretPassword".toCharArray(); - CreateCredentialCommand command = new CreateCredentialCommand( - projectId, - "DB Pass", - CredentialSecretType.LOGIN, - passwordPayload, - "notes", - "http://db" - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(masterKeySessionPort.getKey()).thenReturn(null); - - // Act & Assert - assertThrows(VaultLockedException.class, () -> { - createCredentialUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(masterKeySessionPort, times(1)).getKey(); - verify(credentialRepository, never()).save(any()); - assertEquals('\0', command.payload()[0]); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/credential/DeleteCredentialImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/credential/DeleteCredentialImplTest.java deleted file mode 100644 index c79048a..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/credential/DeleteCredentialImplTest.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import javax.crypto.SecretKey; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DeleteCredentialImplTest { - - @Mock - private CredentialRepositoryPort credentialRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private MasterKeySessionPort masterKeySessionPort; - - @Mock - private CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - - @Mock - private ItemTagRepositoryPort itemTagRepository; - - @InjectMocks - private DeleteCredentialImpl deleteCredentialUseCase; - - @Test - void shouldDeleteCredentialSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.existsByIdAndProjectId(credentialId, projectId)).thenReturn(true); - - // Act - deleteCredentialUseCase.execute(projectId, credentialId); - - // Assert - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).existsByIdAndProjectId(credentialId, projectId); - verify(itemTagRepository, times(1)).removeAllTagsFromItem("credential", credentialId); - verify(credentialRepository, times(1)).deleteById(credentialId); - } - - @Test - void shouldThrowVaultLockedExceptionWhenKeyIsNull() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(masterKeySessionPort.getKey()).thenReturn(null); - - // Act & Assert - assertThrows(VaultLockedException.class, () -> { - deleteCredentialUseCase.execute(projectId, credentialId); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, never()).existsById(any()); - verify(credentialRepository, never()).existsByIdAndProjectId(any(), any()); - verify(credentialRepository, never()).deleteById(any()); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - } - - @Test - void shouldThrowMasterPasswordNotConfiguredExceptionWhenSetupIsRequired() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(true); - - // Act & Assert - assertThrows(MasterPasswordNotConfiguredException.class, () -> { - deleteCredentialUseCase.execute(projectId, credentialId); - }); - - verify(masterKeySessionPort, never()).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, never()).existsById(any()); - verify(credentialRepository, never()).existsByIdAndProjectId(any(), any()); - verify(credentialRepository, never()).deleteById(any()); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteCredentialUseCase.execute(projectId, credentialId); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, never()).existsByIdAndProjectId(any(), any()); - verify(credentialRepository, never()).deleteById(any()); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenCredentialDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.existsByIdAndProjectId(credentialId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteCredentialUseCase.execute(projectId, credentialId); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).existsByIdAndProjectId(credentialId, projectId); - verify(credentialRepository, never()).deleteById(any()); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenCredentialDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - // existsByIdAndProjectId returns false because it belongs to another project - when(credentialRepository.existsByIdAndProjectId(credentialId, projectId)).thenReturn(false); - - // Act & Assert - ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { - deleteCredentialUseCase.execute(projectId, credentialId); - }); - assertEquals("Credential not found with identifier " + credentialId, exception.getMessage()); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).existsByIdAndProjectId(credentialId, projectId); - verify(credentialRepository, never()).deleteById(any()); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/credential/GetAllCredentialsByProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/credential/GetAllCredentialsByProjectImplTest.java deleted file mode 100644 index c939a6f..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/credential/GetAllCredentialsByProjectImplTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.credential.CredentialSummary; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; - -import javax.crypto.SecretKey; -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetAllCredentialsByProjectImplTest { - - @Mock - private CredentialRepositoryPort credentialRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private MasterKeySessionPort masterKeySessionPort; - - @Mock - private CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - - @InjectMocks - private GetAllCredentialsByProjectImpl getAllCredentialsUseCase; - - @Test - void shouldReturnPageOfCredentials() { - // Arrange - UUID projectId = UUID.randomUUID(); - int page = 0; - int size = 10; - SecretKey mockKey = mock(SecretKey.class); - - CredentialSummary summary = new CredentialSummary( - UUID.randomUUID(), - projectId, - "Title", - CredentialSecretType.LOGIN, - "http://url", - java.time.LocalDateTime.now(), - null - ); - List list = Collections.singletonList(summary); - Page expectedPage = new PageImpl<>(list); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.findAllByProject(projectId, page, size)).thenReturn(expectedPage); - - // Act - Page result = getAllCredentialsUseCase.execute(projectId, page, size); - - // Assert - assertNotNull(result); - assertEquals(expectedPage, result); - assertEquals(1, result.getTotalElements()); - assertEquals(summary, result.getContent().getFirst()); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).findAllByProject(projectId, page, size); - } - - @Test - void shouldThrowVaultLockedExceptionWhenKeyIsNull() { - // Arrange - UUID projectId = UUID.randomUUID(); - - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(masterKeySessionPort.getKey()).thenReturn(null); - - // Act & Assert - assertThrows(VaultLockedException.class, () -> { - getAllCredentialsUseCase.execute(projectId, 0, 10); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, never()).existsById(any()); - verify(credentialRepository, never()).findAllByProject(any(), anyInt(), anyInt()); - } - - @Test - void shouldThrowMasterPasswordNotConfiguredExceptionWhenSetupIsRequired() { - // Arrange - UUID projectId = UUID.randomUUID(); - - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(true); - - // Act & Assert - assertThrows(MasterPasswordNotConfiguredException.class, () -> { - getAllCredentialsUseCase.execute(projectId, 0, 10); - }); - - verify(masterKeySessionPort, never()).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, never()).existsById(any()); - verify(credentialRepository, never()).findAllByProject(any(), anyInt(), anyInt()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getAllCredentialsUseCase.execute(projectId, 0, 10); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, never()).findAllByProject(any(), anyInt(), anyInt()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/credential/GetCredentialByIdImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/credential/GetCredentialByIdImplTest.java deleted file mode 100644 index a081f94..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/credential/GetCredentialByIdImplTest.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.credential.DecryptedCredential; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.CryptoPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.domain.model.Credential; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import javax.crypto.SecretKey; -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetCredentialByIdImplTest { - - @Mock - private CredentialRepositoryPort credentialRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private CryptoPort cryptoPort; - - @Mock - private MasterKeySessionPort masterKeySessionPort; - - @Mock - private CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - - @InjectMocks - private GetCredentialByIdImpl getCredentialByIdUseCase; - - @Test - void shouldReturnCredentialWhenFoundAndBelongsToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - byte[] encryptedPayload = new byte[]{1, 2, 3}; - byte[] iv = new byte[]{4}; - byte[] authTag = new byte[]{5}; - byte[] decryptedPayload = "decrypted".getBytes(); - - Credential credential = new Credential(); - credential.setId(credentialId); - credential.setProjectId(projectId); - credential.setTitle("Title"); - credential.setSecretType(CredentialSecretType.LOGIN); - credential.setPayloadEncrypted(encryptedPayload); - credential.setEncryptionIv(iv); - credential.setEncryptionAuthTag(authTag); - credential.setCreatedAt(java.time.LocalDateTime.now()); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.findById(credentialId)).thenReturn(Optional.of(credential)); - when(cryptoPort.decrypt(eq(encryptedPayload), eq(iv), eq(authTag), eq(mockKey), any(byte[].class))).thenReturn(decryptedPayload); - - // Act - DecryptedCredential result = getCredentialByIdUseCase.execute(projectId, credentialId); - - // Assert - assertNotNull(result); - assertEquals(credentialId, result.id()); - assertEquals(projectId, result.projectId()); - assertEquals("Title", result.title()); - assertEquals(CredentialSecretType.LOGIN, result.secretType()); - assertArrayEquals(decryptedPayload, result.decryptedPayload()); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).findById(credentialId); - verify(cryptoPort, times(1)).decrypt(eq(encryptedPayload), eq(iv), eq(authTag), eq(mockKey), any(byte[].class)); - } - - @Test - void shouldThrowVaultLockedExceptionWhenKeyIsNull() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(masterKeySessionPort.getKey()).thenReturn(null); - - // Act & Assert - assertThrows(VaultLockedException.class, () -> { - getCredentialByIdUseCase.execute(projectId, credentialId); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, never()).existsById(any()); - verify(credentialRepository, never()).findById(any()); - } - - @Test - void shouldThrowMasterPasswordNotConfiguredExceptionWhenSetupIsRequired() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(true); - - // Act & Assert - assertThrows(MasterPasswordNotConfiguredException.class, () -> { - getCredentialByIdUseCase.execute(projectId, credentialId); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, never()).existsById(any()); - verify(credentialRepository, never()).findById(any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getCredentialByIdUseCase.execute(projectId, credentialId); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, never()).findById(any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenCredentialDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.findById(credentialId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getCredentialByIdUseCase.execute(projectId, credentialId); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).findById(credentialId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenCredentialDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - Credential credential = new Credential(); - credential.setId(credentialId); - credential.setProjectId(UUID.randomUUID()); // Different project ID - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.findById(credentialId)).thenReturn(Optional.of(credential)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getCredentialByIdUseCase.execute(projectId, credentialId); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).findById(credentialId); - verify(cryptoPort, never()).decrypt(any(), any(), any(), any(), any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/credential/UpdateCredentialImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/credential/UpdateCredentialImplTest.java deleted file mode 100644 index 10dbd10..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/credential/UpdateCredentialImplTest.java +++ /dev/null @@ -1,298 +0,0 @@ -package com.devaulty.backend.application.impl.credential; - -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.exception.VaultLockedException; -import com.devaulty.backend.application.port.in.credential.DecryptedCredential; -import com.devaulty.backend.application.port.in.credential.UpdateCredentialCommand; -import com.devaulty.backend.application.port.in.security.CheckMasterPasswordSetupUseCase; -import com.devaulty.backend.application.port.out.persistence.CredentialRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.security.CryptoPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.application.port.out.security.dto.CryptoResultDto; -import com.devaulty.backend.domain.model.Credential; -import com.devaulty.backend.domain.model.enums.CredentialSecretType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import javax.crypto.SecretKey; -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UpdateCredentialImplTest { - - @Mock - private CredentialRepositoryPort credentialRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private CryptoPort cryptoPort; - - @Mock - private MasterKeySessionPort masterKeySessionPort; - - @Mock - private CheckMasterPasswordSetupUseCase checkMasterPasswordSetupUseCase; - - @InjectMocks - private UpdateCredentialImpl updateCredentialUseCase; - - @Test - void shouldUpdateCredentialSuccessfully_includingPayload() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - char[] newPayload = "newPayload".toCharArray(); - UpdateCredentialCommand command = new UpdateCredentialCommand( - credentialId, - projectId, - "New Title", - CredentialSecretType.API_KEY, - newPayload, - "New Notes", - "http://new" - ); - - byte[] oldEncryptedPayload = new byte[]{1, 2}; - byte[] oldIv = new byte[]{3}; - byte[] oldAuthTag = new byte[]{4}; - - Credential credential = new Credential(); - credential.setId(credentialId); - credential.setProjectId(projectId); - credential.setTitle("Old Title"); - credential.setSecretType(CredentialSecretType.LOGIN); - credential.setPayloadEncrypted(oldEncryptedPayload); - credential.setEncryptionIv(oldIv); - credential.setEncryptionAuthTag(oldAuthTag); - - byte[] newEncrypted = new byte[]{5, 6}; - byte[] newIv = new byte[]{7}; - byte[] newAuthTag = new byte[]{8}; - byte[] decryptedPayload = "newPayload".getBytes(); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.findById(credentialId)).thenReturn(Optional.of(credential)); - when(cryptoPort.encrypt(any(byte[].class), eq(mockKey), any(byte[].class))) - .thenReturn(new CryptoResultDto(newEncrypted, newIv, newAuthTag)); - when(credentialRepository.save(any(Credential.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(cryptoPort.decrypt(eq(newEncrypted), eq(newIv), eq(newAuthTag), eq(mockKey), any(byte[].class))).thenReturn(decryptedPayload); - - // Act - DecryptedCredential result = updateCredentialUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("New Title", result.title()); - assertEquals(CredentialSecretType.API_KEY, result.secretType()); - assertEquals("New Notes", result.notes()); - assertEquals("http://new", result.relatedUrl()); - assertArrayEquals(decryptedPayload, result.decryptedPayload()); - assertNotNull(result.updatedAt()); - - // Payload zeroed out in command - for (char c : command.payload()) { - assertEquals('\0', c); - } - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).findById(credentialId); - verify(cryptoPort, times(1)).encrypt(any(byte[].class), eq(mockKey), any(byte[].class)); - verify(credentialRepository, times(1)).save(credential); - verify(cryptoPort, times(1)).decrypt(eq(newEncrypted), eq(newIv), eq(newAuthTag), eq(mockKey), any(byte[].class)); - } - - @Test - void shouldUpdateCredentialSuccessfully_excludingPayload() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - - UpdateCredentialCommand command = new UpdateCredentialCommand( - credentialId, - projectId, - null, - null, - null, - null, - null - ); - - byte[] oldEncryptedPayload = new byte[]{1, 2}; - byte[] oldIv = new byte[]{3}; - byte[] oldAuthTag = new byte[]{4}; - byte[] decryptedPayload = "oldPayload".getBytes(); - - Credential credential = new Credential(); - credential.setId(credentialId); - credential.setProjectId(projectId); - credential.setTitle("Old Title"); - credential.setSecretType(CredentialSecretType.LOGIN); - credential.setPayloadEncrypted(oldEncryptedPayload); - credential.setEncryptionIv(oldIv); - credential.setEncryptionAuthTag(oldAuthTag); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.findById(credentialId)).thenReturn(Optional.of(credential)); - when(credentialRepository.save(any(Credential.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(cryptoPort.decrypt(eq(oldEncryptedPayload), eq(oldIv), eq(oldAuthTag), eq(mockKey), any(byte[].class))).thenReturn(decryptedPayload); - - // Act - DecryptedCredential result = updateCredentialUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("Old Title", result.title()); - assertEquals(CredentialSecretType.LOGIN, result.secretType()); - assertArrayEquals(decryptedPayload, result.decryptedPayload()); - assertNotNull(result.updatedAt()); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).findById(credentialId); - verify(cryptoPort, never()).encrypt(any(), any(), any()); - verify(credentialRepository, times(1)).save(credential); - verify(cryptoPort, times(1)).decrypt(eq(oldEncryptedPayload), eq(oldIv), eq(oldAuthTag), eq(mockKey), any(byte[].class)); - } - - @Test - void shouldThrowVaultLockedExceptionWhenKeyIsNull() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - UpdateCredentialCommand command = new UpdateCredentialCommand(credentialId, projectId, null, null, null, null, null); - - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(masterKeySessionPort.getKey()).thenReturn(null); - - // Act & Assert - assertThrows(VaultLockedException.class, () -> { - updateCredentialUseCase.execute(command); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, never()).existsById(any()); - verify(credentialRepository, never()).findById(any()); - } - - @Test - void shouldThrowMasterPasswordNotConfiguredExceptionWhenSetupIsRequired() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - UpdateCredentialCommand command = new UpdateCredentialCommand(credentialId, projectId, null, null, null, null, null); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(true); - - // Act & Assert - assertThrows(MasterPasswordNotConfiguredException.class, () -> { - updateCredentialUseCase.execute(command); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, never()).existsById(any()); - verify(credentialRepository, never()).findById(any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - UpdateCredentialCommand command = new UpdateCredentialCommand(credentialId, projectId, null, null, null, null, null); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateCredentialUseCase.execute(command); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, never()).findById(any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenCredentialDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - UpdateCredentialCommand command = new UpdateCredentialCommand(credentialId, projectId, null, null, null, null, null); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.findById(credentialId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateCredentialUseCase.execute(command); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).findById(credentialId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenCredentialDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID credentialId = UUID.randomUUID(); - SecretKey mockKey = mock(SecretKey.class); - UpdateCredentialCommand command = new UpdateCredentialCommand(credentialId, projectId, null, null, null, null, null); - - Credential credential = new Credential(); - credential.setId(credentialId); - credential.setProjectId(otherProjectId); - - when(masterKeySessionPort.getKey()).thenReturn(mockKey); - when(checkMasterPasswordSetupUseCase.isSetupRequired()).thenReturn(false); - when(projectRepository.existsById(projectId)).thenReturn(true); - when(credentialRepository.findById(credentialId)).thenReturn(Optional.of(credential)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateCredentialUseCase.execute(command); - }); - - verify(masterKeySessionPort, times(1)).getKey(); - verify(checkMasterPasswordSetupUseCase, times(1)).isSetupRequired(); - verify(projectRepository, times(1)).existsById(projectId); - verify(credentialRepository, times(1)).findById(credentialId); - verify(credentialRepository, never()).save(any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/link/CreateLinkImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/link/CreateLinkImplTest.java deleted file mode 100644 index ec705a8..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/link/CreateLinkImplTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.link.CreateLinkCommand; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Link; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CreateLinkImplTest { - - @Mock - private LinkRepositoryPort linkRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private CreateLinkImpl createLinkUseCase; - - @Test - void shouldCreateLinkSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateLinkCommand command = new CreateLinkCommand( - projectId, - "Test Title", - "https://example.com", - "Test Desc" - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.save(any(Link.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Link result = createLinkUseCase.execute(command); - - // Assert - assertNotNull(result); - assertNotNull(result.getId()); - assertEquals(projectId, result.getProjectId()); - assertEquals("Test Title", result.getTitle()); - assertEquals("https://example.com", result.getUrl()); - assertEquals("Test Desc", result.getDescription()); - assertNotNull(result.getCreatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).save(any(Link.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateLinkCommand command = new CreateLinkCommand( - projectId, - "Test Title", - "https://example.com", - "Test Desc" - ); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - createLinkUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, never()).save(any(Link.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/link/DeleteLinkImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/link/DeleteLinkImplTest.java deleted file mode 100644 index f15f3d9..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/link/DeleteLinkImplTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DeleteLinkImplTest { - - @Mock - private LinkRepositoryPort linkRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private ItemTagRepositoryPort itemTagRepository; - - @InjectMocks - private DeleteLinkImpl deleteLinkUseCase; - - @Test - void shouldDeleteLinkSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.existsByIdAndProjectId(linkId, projectId)).thenReturn(true); - - // Act - deleteLinkUseCase.execute(projectId, linkId); - - // Assert - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).existsByIdAndProjectId(linkId, projectId); - verify(itemTagRepository, times(1)).removeAllTagsFromItem("link", linkId); - verify(linkRepository, times(1)).deleteById(linkId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteLinkUseCase.execute(projectId, linkId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, never()).existsByIdAndProjectId(any(UUID.class), any(UUID.class)); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(linkRepository, never()).deleteById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenLinkDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.existsByIdAndProjectId(linkId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteLinkUseCase.execute(projectId, linkId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).existsByIdAndProjectId(linkId, projectId); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(linkRepository, never()).deleteById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenLinkDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - // existsByIdAndProjectId returns false because it belongs to another project - when(linkRepository.existsByIdAndProjectId(linkId, projectId)).thenReturn(false); - - // Act & Assert - ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { - deleteLinkUseCase.execute(projectId, linkId); - }); - assertEquals("Link not found with identifier " + linkId, exception.getMessage()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).existsByIdAndProjectId(linkId, projectId); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(linkRepository, never()).deleteById(any(UUID.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/link/GetAllLinksByProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/link/GetAllLinksByProjectImplTest.java deleted file mode 100644 index c6cb939..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/link/GetAllLinksByProjectImplTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Link; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetAllLinksByProjectImplTest { - - @Mock - private LinkRepositoryPort linkRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetAllLinksByProjectImpl getAllLinksUseCase; - - @Test - void shouldReturnPageOfLinks() { - // Arrange - UUID projectId = UUID.randomUUID(); - int page = 0; - int size = 10; - Link link = new Link(UUID.randomUUID(), projectId, "Title", "https://url.com", "Desc"); - List list = Collections.singletonList(link); - Page expectedPage = new PageImpl<>(list); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.findAllByProject(projectId, page, size)).thenReturn(expectedPage); - - // Act - Page result = getAllLinksUseCase.execute(projectId, page, size); - - // Assert - assertNotNull(result); - assertEquals(expectedPage, result); - assertEquals(1, result.getTotalElements()); - assertEquals(link, result.getContent().getFirst()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).findAllByProject(projectId, page, size); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getAllLinksUseCase.execute(projectId, 0, 10); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, never()).findAllByProject(any(UUID.class), anyInt(), anyInt()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/link/GetLinkByIdImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/link/GetLinkByIdImplTest.java deleted file mode 100644 index ce59f8e..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/link/GetLinkByIdImplTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Link; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetLinkByIdImplTest { - - @Mock - private LinkRepositoryPort linkRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetLinkByIdImpl getLinkByIdUseCase; - - @Test - void shouldReturnLinkWhenFoundAndBelongsToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - Link expectedLink = new Link(linkId, projectId, "Title", "https://url.com", "Desc"); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.findById(linkId)).thenReturn(Optional.of(expectedLink)); - - // Act - Link result = getLinkByIdUseCase.execute(projectId, linkId); - - // Assert - assertNotNull(result); - assertEquals(expectedLink, result); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).findById(linkId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getLinkByIdUseCase.execute(projectId, linkId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenLinkDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.findById(linkId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getLinkByIdUseCase.execute(projectId, linkId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).findById(linkId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenLinkDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - Link linkOfOtherProject = new Link(linkId, otherProjectId, "Title", "https://url.com", "Desc"); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.findById(linkId)).thenReturn(Optional.of(linkOfOtherProject)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getLinkByIdUseCase.execute(projectId, linkId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).findById(linkId); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/link/UpdateLinkImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/link/UpdateLinkImplTest.java deleted file mode 100644 index ac8ee27..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/link/UpdateLinkImplTest.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.devaulty.backend.application.impl.link; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.link.UpdateLinkCommand; -import com.devaulty.backend.application.port.out.persistence.LinkRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Link; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UpdateLinkImplTest { - - @Mock - private LinkRepositoryPort linkRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private UpdateLinkImpl updateLinkUseCase; - - @Test - void shouldUpdateFieldsWhenValidCommand() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - Link existingLink = new Link(linkId, projectId, "Old Title", "https://old.com", "Old Desc"); - UpdateLinkCommand command = new UpdateLinkCommand( - linkId, - projectId, - "New Title", - "https://new.com", - "New Desc" - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.findById(linkId)).thenReturn(Optional.of(existingLink)); - when(linkRepository.save(any(Link.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Link result = updateLinkUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("New Title", result.getTitle()); - assertEquals("https://new.com", result.getUrl()); - assertEquals("New Desc", result.getDescription()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).findById(linkId); - verify(linkRepository, times(1)).save(existingLink); - } - - @Test - void shouldNotUpdateFieldsWhenNull() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - Link existingLink = new Link(linkId, projectId, "Old Title", "https://old.com", "Old Desc"); - UpdateLinkCommand command = new UpdateLinkCommand( - linkId, - projectId, - null, - null, - null - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.findById(linkId)).thenReturn(Optional.of(existingLink)); - when(linkRepository.save(any(Link.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Link result = updateLinkUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("Old Title", result.getTitle()); - assertEquals("https://old.com", result.getUrl()); - assertEquals("Old Desc", result.getDescription()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).findById(linkId); - verify(linkRepository, times(1)).save(existingLink); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - UpdateLinkCommand command = new UpdateLinkCommand(linkId, projectId, "Title", null, null); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateLinkUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenLinkDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - UpdateLinkCommand command = new UpdateLinkCommand(linkId, projectId, "Title", null, null); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.findById(linkId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateLinkUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).findById(linkId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenLinkDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID linkId = UUID.randomUUID(); - Link linkOfOtherProject = new Link(linkId, otherProjectId, "Title", "https://url.com", "Desc"); - UpdateLinkCommand command = new UpdateLinkCommand(linkId, projectId, "Title", null, null); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(linkRepository.findById(linkId)).thenReturn(Optional.of(linkOfOtherProject)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateLinkUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(linkRepository, times(1)).findById(linkId); - verify(linkRepository, never()).save(any(Link.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/note/ArchiveNoteImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/note/ArchiveNoteImplTest.java deleted file mode 100644 index 4c5481e..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/note/ArchiveNoteImplTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.BusinessRuleException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class ArchiveNoteImplTest { - - @Mock - private NoteRepositoryPort noteRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private ArchiveNoteImpl archiveNoteUseCase; - - @Test - void shouldArchiveNoteSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note note = new Note(noteId, projectId, "Title", "Content", false); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(note)); - - // Act - archiveNoteUseCase.execute(projectId, noteId); - - // Assert - assertTrue(note.isArchived()); - assertNotNull(note.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - verify(noteRepository, times(1)).save(note); - } - - @Test - void shouldThrowBusinessRuleExceptionWhenAlreadyArchived() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note note = new Note(noteId, projectId, "Title", "Content", true); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(note)); - - // Act & Assert - assertThrows(BusinessRuleException.class, () -> { - archiveNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - verify(noteRepository, never()).save(any(Note.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - archiveNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - archiveNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note note = new Note(noteId, otherProjectId, "Title", "Content", false); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(note)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - archiveNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - verify(noteRepository, never()).save(any(Note.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/note/CreateNoteImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/note/CreateNoteImplTest.java deleted file mode 100644 index 860be1d..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/note/CreateNoteImplTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.note.CreateNoteCommand; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CreateNoteImplTest { - - @Mock - private NoteRepositoryPort noteRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private CreateNoteImpl createNoteUseCase; - - @Test - void shouldCreateNoteSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateNoteCommand command = new CreateNoteCommand( - projectId, - "Test Note", - "Test Content" - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.save(any(Note.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Note result = createNoteUseCase.execute(command); - - // Assert - assertNotNull(result); - assertNotNull(result.getId()); - assertEquals(projectId, result.getProjectId()); - assertEquals("Test Note", result.getTitle()); - assertEquals("Test Content", result.getContent()); - assertFalse(result.isArchived()); - assertNotNull(result.getCreatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).save(any(Note.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateNoteCommand command = new CreateNoteCommand( - projectId, - "Test Note", - "Test Content" - ); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - createNoteUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, never()).save(any(Note.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/note/DeleteNoteImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/note/DeleteNoteImplTest.java deleted file mode 100644 index eef1686..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/note/DeleteNoteImplTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DeleteNoteImplTest { - - @Mock - private NoteRepositoryPort noteRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private ItemTagRepositoryPort itemTagRepository; - - @InjectMocks - private DeleteNoteImpl deleteNoteUseCase; - - @Test - void shouldDeleteNoteSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.existsByIdAndProjectId(noteId, projectId)).thenReturn(true); - - // Act - deleteNoteUseCase.execute(projectId, noteId); - - // Assert - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).existsByIdAndProjectId(noteId, projectId); - verify(itemTagRepository, times(1)).removeAllTagsFromItem("note", noteId); - verify(noteRepository, times(1)).deleteById(noteId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, never()).existsByIdAndProjectId(any(UUID.class), any(UUID.class)); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(noteRepository, never()).deleteById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.existsByIdAndProjectId(noteId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).existsByIdAndProjectId(noteId, projectId); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(noteRepository, never()).deleteById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - // existsByIdAndProjectId returns false because it belongs to another project - when(noteRepository.existsByIdAndProjectId(noteId, projectId)).thenReturn(false); - - // Act & Assert - ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { - deleteNoteUseCase.execute(projectId, noteId); - }); - assertEquals("Note not found with identifier " + noteId, exception.getMessage()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).existsByIdAndProjectId(noteId, projectId); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(noteRepository, never()).deleteById(any(UUID.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/note/GetAllNotesByProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/note/GetAllNotesByProjectImplTest.java deleted file mode 100644 index c6220eb..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/note/GetAllNotesByProjectImplTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetAllNotesByProjectImplTest { - - @Mock - private NoteRepositoryPort noteRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetAllNotesByProjectImpl getAllNotesUseCase; - - @Test - void shouldReturnPageOfNotes() { - // Arrange - UUID projectId = UUID.randomUUID(); - int page = 0; - int size = 10; - Note note = new Note(UUID.randomUUID(), projectId, "Title", "Content", false); - List list = Collections.singletonList(note); - Page expectedPage = new PageImpl<>(list); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findAllByProject(projectId, page, size)).thenReturn(expectedPage); - - // Act - Page result = getAllNotesUseCase.execute(projectId, page, size); - - // Assert - assertNotNull(result); - assertEquals(expectedPage, result); - assertEquals(1, result.getTotalElements()); - assertEquals(note, result.getContent().getFirst()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findAllByProject(projectId, page, size); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getAllNotesUseCase.execute(projectId, 0, 10); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, never()).findAllByProject(any(UUID.class), anyInt(), anyInt()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/note/GetNoteByIdImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/note/GetNoteByIdImplTest.java deleted file mode 100644 index 2671676..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/note/GetNoteByIdImplTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetNoteByIdImplTest { - - @Mock - private NoteRepositoryPort noteRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetNoteByIdImpl getNoteByIdUseCase; - - @Test - void shouldReturnNoteWhenFoundAndBelongsToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note expectedNote = new Note(noteId, projectId, "Title", "Content", false); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(expectedNote)); - - // Act - Note result = getNoteByIdUseCase.execute(projectId, noteId); - - // Assert - assertNotNull(result); - assertEquals(expectedNote, result); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getNoteByIdUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getNoteByIdUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note noteOfOtherProject = new Note(noteId, otherProjectId, "Title", "Content", false); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(noteOfOtherProject)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getNoteByIdUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/note/UnarchiveNoteImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/note/UnarchiveNoteImplTest.java deleted file mode 100644 index 2aef561..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/note/UnarchiveNoteImplTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.BusinessRuleException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UnarchiveNoteImplTest { - - @Mock - private NoteRepositoryPort noteRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private UnarchiveNoteImpl unarchiveNoteUseCase; - - @Test - void shouldUnarchiveNoteSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note note = new Note(noteId, projectId, "Title", "Content", true); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(note)); - - // Act - unarchiveNoteUseCase.execute(projectId, noteId); - - // Assert - assertFalse(note.isArchived()); - assertNotNull(note.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - verify(noteRepository, times(1)).save(note); - } - - @Test - void shouldThrowBusinessRuleExceptionWhenAlreadyUnarchived() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note note = new Note(noteId, projectId, "Title", "Content", false); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(note)); - - // Act & Assert - assertThrows(BusinessRuleException.class, () -> { - unarchiveNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - verify(noteRepository, never()).save(any(Note.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - unarchiveNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - unarchiveNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note note = new Note(noteId, otherProjectId, "Title", "Content", true); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(note)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - unarchiveNoteUseCase.execute(projectId, noteId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - verify(noteRepository, never()).save(any(Note.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/note/UpdateNoteImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/note/UpdateNoteImplTest.java deleted file mode 100644 index 8fda099..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/note/UpdateNoteImplTest.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.devaulty.backend.application.impl.note; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.note.UpdateNoteCommand; -import com.devaulty.backend.application.port.out.persistence.NoteRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Note; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UpdateNoteImplTest { - - @Mock - private NoteRepositoryPort noteRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private UpdateNoteImpl updateNoteUseCase; - - @Test - void shouldUpdateFieldsWhenValidCommand() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note existingNote = new Note(noteId, projectId, "Old Title", "Old Content", false); - UpdateNoteCommand command = new UpdateNoteCommand( - noteId, - projectId, - "New Title", - "New Content" - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(existingNote)); - when(noteRepository.save(any(Note.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Note result = updateNoteUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("New Title", result.getTitle()); - assertEquals("New Content", result.getContent()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - verify(noteRepository, times(1)).save(existingNote); - } - - @Test - void shouldNotUpdateFieldsWhenNull() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note existingNote = new Note(noteId, projectId, "Old Title", "Old Content", false); - UpdateNoteCommand command = new UpdateNoteCommand( - noteId, - projectId, - null, - null - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(existingNote)); - when(noteRepository.save(any(Note.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Note result = updateNoteUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("Old Title", result.getTitle()); - assertEquals("Old Content", result.getContent()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - verify(noteRepository, times(1)).save(existingNote); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - UpdateNoteCommand command = new UpdateNoteCommand(noteId, projectId, "Title", null); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateNoteUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - UpdateNoteCommand command = new UpdateNoteCommand(noteId, projectId, "Title", null); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateNoteUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNoteDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID noteId = UUID.randomUUID(); - Note noteOfOtherProject = new Note(noteId, otherProjectId, "Title", "Content", false); - UpdateNoteCommand command = new UpdateNoteCommand(noteId, projectId, "Title", null); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(noteRepository.findById(noteId)).thenReturn(Optional.of(noteOfOtherProject)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateNoteUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(noteRepository, times(1)).findById(noteId); - verify(noteRepository, never()).save(any(Note.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/problem/CreateProblemImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/problem/CreateProblemImplTest.java deleted file mode 100644 index 9a171f0..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/problem/CreateProblemImplTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.problem.CreateProblemCommand; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CreateProblemImplTest { - - @Mock - private ProblemRepositoryPort problemRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private CreateProblemImpl createProblemUseCase; - - @Test - void shouldCreateProblemSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateProblemCommand command = new CreateProblemCommand( - projectId, - "Test Title", - "Error Description", - "Solution", - ProblemStatus.OPEN, - ProblemSeverity.HIGH - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.save(any(Problem.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Problem result = createProblemUseCase.execute(command); - - // Assert - assertNotNull(result); - assertNotNull(result.getId()); - assertEquals(projectId, result.getProjectId()); - assertEquals("Test Title", result.getTitle()); - assertEquals("Error Description", result.getErrorDescription()); - assertEquals("Solution", result.getSolution()); - assertEquals(ProblemStatus.OPEN, result.getStatus()); - assertEquals(ProblemSeverity.HIGH, result.getSeverity()); - assertNotNull(result.getCreatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).save(any(Problem.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateProblemCommand command = new CreateProblemCommand( - projectId, - "Test Title", - "Error Description", - "Solution", - ProblemStatus.OPEN, - ProblemSeverity.HIGH - ); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - createProblemUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, never()).save(any(Problem.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/problem/DeleteProblemImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/problem/DeleteProblemImplTest.java deleted file mode 100644 index 0ddd5ba..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/problem/DeleteProblemImplTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DeleteProblemImplTest { - - @Mock - private ProblemRepositoryPort problemRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private ItemTagRepositoryPort itemTagRepository; - - @InjectMocks - private DeleteProblemImpl deleteProblemUseCase; - - @Test - void shouldDeleteProblemSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.existsByIdAndProjectId(problemId, projectId)).thenReturn(true); - - // Act - deleteProblemUseCase.execute(projectId, problemId); - - // Assert - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).existsByIdAndProjectId(problemId, projectId); - verify(itemTagRepository, times(1)).removeAllTagsFromItem("problem", problemId); - verify(problemRepository, times(1)).deleteById(problemId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteProblemUseCase.execute(projectId, problemId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, never()).existsByIdAndProjectId(any(UUID.class), any(UUID.class)); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(problemRepository, never()).deleteById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProblemDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.existsByIdAndProjectId(problemId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteProblemUseCase.execute(projectId, problemId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).existsByIdAndProjectId(problemId, projectId); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(problemRepository, never()).deleteById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProblemDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - // existsByIdAndProjectId returns false because it belongs to another project - when(problemRepository.existsByIdAndProjectId(problemId, projectId)).thenReturn(false); - - // Act & Assert - ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { - deleteProblemUseCase.execute(projectId, problemId); - }); - assertEquals("Problem not found with identifier " + problemId, exception.getMessage()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).existsByIdAndProjectId(problemId, projectId); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(problemRepository, never()).deleteById(any(UUID.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/problem/GetAllProblemsByProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/problem/GetAllProblemsByProjectImplTest.java deleted file mode 100644 index 7b51912..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/problem/GetAllProblemsByProjectImplTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetAllProblemsByProjectImplTest { - - @Mock - private ProblemRepositoryPort problemRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetAllProblemsByProjectImpl getAllProblemsUseCase; - - @Test - void shouldReturnPageOfProblems() { - // Arrange - UUID projectId = UUID.randomUUID(); - int page = 0; - int size = 10; - Problem problem = new Problem(); - problem.setId(UUID.randomUUID()); - problem.setProjectId(projectId); - problem.setTitle("Title"); - problem.setStatus(ProblemStatus.OPEN); - problem.setSeverity(ProblemSeverity.MEDIUM); - List list = Collections.singletonList(problem); - Page expectedPage = new PageImpl<>(list); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findAllByProject(projectId, page, size)).thenReturn(expectedPage); - - // Act - Page result = getAllProblemsUseCase.execute(projectId, page, size); - - // Assert - assertNotNull(result); - assertEquals(expectedPage, result); - assertEquals(1, result.getTotalElements()); - assertEquals(problem, result.getContent().getFirst()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findAllByProject(projectId, page, size); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getAllProblemsUseCase.execute(projectId, 0, 10); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, never()).findAllByProject(any(UUID.class), anyInt(), anyInt()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/problem/GetProblemByIdImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/problem/GetProblemByIdImplTest.java deleted file mode 100644 index 7db6464..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/problem/GetProblemByIdImplTest.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetProblemByIdImplTest { - - @Mock - private ProblemRepositoryPort problemRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetProblemByIdImpl getProblemByIdUseCase; - - @Test - void shouldReturnProblemWhenFoundAndBelongsToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - Problem expectedProblem = new Problem(); - expectedProblem.setId(problemId); - expectedProblem.setProjectId(projectId); - expectedProblem.setTitle("Title"); - expectedProblem.setStatus(ProblemStatus.OPEN); - expectedProblem.setSeverity(ProblemSeverity.MEDIUM); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.of(expectedProblem)); - - // Act - Problem result = getProblemByIdUseCase.execute(projectId, problemId); - - // Assert - assertNotNull(result); - assertEquals(expectedProblem, result); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getProblemByIdUseCase.execute(projectId, problemId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProblemDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getProblemByIdUseCase.execute(projectId, problemId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProblemDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - Problem problemOfOtherProject = new Problem(); - problemOfOtherProject.setId(problemId); - problemOfOtherProject.setProjectId(otherProjectId); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.of(problemOfOtherProject)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getProblemByIdUseCase.execute(projectId, problemId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/problem/UpdateProblemImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/problem/UpdateProblemImplTest.java deleted file mode 100644 index ac227cb..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/problem/UpdateProblemImplTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.problem.UpdateProblemCommand; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UpdateProblemImplTest { - - @Mock - private ProblemRepositoryPort problemRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private UpdateProblemImpl updateProblemUseCase; - - @Test - void shouldUpdateFieldsWhenValidCommand() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - Problem existingProblem = new Problem(); - existingProblem.setId(problemId); - existingProblem.setProjectId(projectId); - existingProblem.setTitle("Old Title"); - existingProblem.setErrorDescription("Old Error"); - existingProblem.setSolution("Old Sol"); - existingProblem.setSeverity(ProblemSeverity.LOW); - existingProblem.setStatus(ProblemStatus.OPEN); - - UpdateProblemCommand command = new UpdateProblemCommand( - problemId, - projectId, - "New Title", - "New Error", - "New Sol", - ProblemSeverity.HIGH - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.of(existingProblem)); - when(problemRepository.save(any(Problem.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Problem result = updateProblemUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("New Title", result.getTitle()); - assertEquals("New Error", result.getErrorDescription()); - assertEquals("New Sol", result.getSolution()); - assertEquals(ProblemSeverity.HIGH, result.getSeverity()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - verify(problemRepository, times(1)).save(existingProblem); - } - - @Test - void shouldNotUpdateFieldsWhenNull() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - Problem existingProblem = new Problem(); - existingProblem.setId(problemId); - existingProblem.setProjectId(projectId); - existingProblem.setTitle("Old Title"); - existingProblem.setErrorDescription("Old Error"); - existingProblem.setSolution("Old Sol"); - existingProblem.setSeverity(ProblemSeverity.LOW); - existingProblem.setStatus(ProblemStatus.OPEN); - - UpdateProblemCommand command = new UpdateProblemCommand( - problemId, - projectId, - null, - null, - null, - null - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.of(existingProblem)); - when(problemRepository.save(any(Problem.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Problem result = updateProblemUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("Old Title", result.getTitle()); - assertEquals("Old Error", result.getErrorDescription()); - assertEquals("Old Sol", result.getSolution()); - assertEquals(ProblemSeverity.LOW, result.getSeverity()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - verify(problemRepository, times(1)).save(existingProblem); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - UpdateProblemCommand command = new UpdateProblemCommand(problemId, projectId, "Title", null, null, null); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateProblemUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProblemDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - UpdateProblemCommand command = new UpdateProblemCommand(problemId, projectId, "Title", null, null, null); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateProblemUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProblemDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - Problem problemOfOtherProject = new Problem(); - problemOfOtherProject.setId(problemId); - problemOfOtherProject.setProjectId(otherProjectId); - UpdateProblemCommand command = new UpdateProblemCommand(problemId, projectId, "Title", null, null, null); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.of(problemOfOtherProject)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateProblemUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - verify(problemRepository, never()).save(any(Problem.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/problem/UpdateProblemStatusImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/problem/UpdateProblemStatusImplTest.java deleted file mode 100644 index 2d0c5c0..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/problem/UpdateProblemStatusImplTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.devaulty.backend.application.impl.problem; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.problem.UpdateProblemStatusCommand; -import com.devaulty.backend.application.port.out.persistence.ProblemRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Problem; -import com.devaulty.backend.domain.model.enums.ProblemSeverity; -import com.devaulty.backend.domain.model.enums.ProblemStatus; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UpdateProblemStatusImplTest { - - @Mock - private ProblemRepositoryPort problemRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private UpdateProblemStatusImpl updateProblemStatusUseCase; - - @Test - void shouldUpdateStatusSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - Problem existingProblem = new Problem(); - existingProblem.setId(problemId); - existingProblem.setProjectId(projectId); - existingProblem.setTitle("Title"); - existingProblem.setStatus(ProblemStatus.OPEN); - existingProblem.setSeverity(ProblemSeverity.MEDIUM); - - UpdateProblemStatusCommand command = new UpdateProblemStatusCommand(problemId, projectId, ProblemStatus.RESOLVED); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.of(existingProblem)); - when(problemRepository.save(any(Problem.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Problem result = updateProblemStatusUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals(ProblemStatus.RESOLVED, result.getStatus()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - verify(problemRepository, times(1)).save(existingProblem); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - UpdateProblemStatusCommand command = new UpdateProblemStatusCommand(problemId, projectId, ProblemStatus.RESOLVED); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateProblemStatusUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProblemDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - UpdateProblemStatusCommand command = new UpdateProblemStatusCommand(problemId, projectId, ProblemStatus.RESOLVED); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateProblemStatusUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProblemDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID problemId = UUID.randomUUID(); - Problem problemOfOtherProject = new Problem(); - problemOfOtherProject.setId(problemId); - problemOfOtherProject.setProjectId(otherProjectId); - UpdateProblemStatusCommand command = new UpdateProblemStatusCommand(problemId, projectId, ProblemStatus.RESOLVED); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(problemRepository.findById(problemId)).thenReturn(Optional.of(problemOfOtherProject)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateProblemStatusUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(problemRepository, times(1)).findById(problemId); - verify(problemRepository, never()).save(any(Problem.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/project/ArchiveProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/project/ArchiveProjectImplTest.java deleted file mode 100644 index 4ce323e..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/project/ArchiveProjectImplTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.BusinessRuleException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class ArchiveProjectImplTest { - - @Mock - private ProjectRepositoryPort projectRepositoryPort; - - @InjectMocks - private ArchiveProjectImpl archiveProjectUseCase; - - @Test - void shouldArchiveProjectSuccessfully() { - // Arrange - UUID id = UUID.randomUUID(); - Project project = new Project(id, "Test Project", "Desc", "icon", "#fff", false); - - when(projectRepositoryPort.findById(id)).thenReturn(Optional.of(project)); - when(projectRepositoryPort.save(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - archiveProjectUseCase.execute(id); - - // Assert - assertTrue(project.isArchived()); - assertNotNull(project.getUpdatedAt()); - - verify(projectRepositoryPort, times(1)).findById(id); - verify(projectRepositoryPort, times(1)).save(project); - } - - @Test - void shouldThrowBusinessRuleExceptionWhenAlreadyArchived() { - // Arrange - UUID id = UUID.randomUUID(); - Project project = new Project(id, "Test Project", "Desc", "icon", "#fff", true); - - when(projectRepositoryPort.findById(id)).thenReturn(Optional.of(project)); - - // Act & Assert - BusinessRuleException exception = assertThrows(BusinessRuleException.class, () -> { - archiveProjectUseCase.execute(id); - }); - - assertEquals("Project already archived", exception.getMessage()); - verify(projectRepositoryPort, times(1)).findById(id); - verify(projectRepositoryPort, never()).save(any(Project.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID id = UUID.randomUUID(); - when(projectRepositoryPort.findById(id)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - archiveProjectUseCase.execute(id); - }); - - verify(projectRepositoryPort, times(1)).findById(id); - verify(projectRepositoryPort, never()).save(any(Project.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/project/CreateProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/project/CreateProjectImplTest.java deleted file mode 100644 index 4d022ec..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/project/CreateProjectImplTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.port.in.project.CreateProjectCommand; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CreateProjectImplTest { - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private CreateProjectImpl createProjectUseCase; - - @Test - void shouldCreateProjectSuccessfully() { - // Arrange - CreateProjectCommand command = new CreateProjectCommand( - "New Project", - "Description of New Project", - "folder-icon", - "#FF5733" - ); - - when(projectRepository.save(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Project result = createProjectUseCase.execute(command); - - // Assert - assertNotNull(result); - assertNotNull(result.getId()); - assertEquals("New Project", result.getName()); - assertEquals("Description of New Project", result.getDescription()); - assertEquals("folder-icon", result.getIcon()); - assertEquals("#FF5733", result.getColor()); - assertFalse(result.isArchived()); - assertNotNull(result.getCreatedAt()); - - verify(projectRepository, times(1)).save(any(Project.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/project/DeleteProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/project/DeleteProjectImplTest.java deleted file mode 100644 index 5af8b15..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/project/DeleteProjectImplTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DeleteProjectImplTest { - - @Mock - private ProjectRepositoryPort projectRepositoryPort; - - @InjectMocks - private DeleteProjectImpl deleteProjectUseCase; - - @Test - void shouldDeleteProjectSuccessfully() { - // Arrange - UUID id = UUID.randomUUID(); - when(projectRepositoryPort.existsById(id)).thenReturn(true); - - // Act - deleteProjectUseCase.execute(id); - - // Assert - verify(projectRepositoryPort, times(1)).existsById(id); - verify(projectRepositoryPort, times(1)).deleteById(id); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID id = UUID.randomUUID(); - when(projectRepositoryPort.existsById(id)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteProjectUseCase.execute(id); - }); - - verify(projectRepositoryPort, times(1)).existsById(id); - verify(projectRepositoryPort, never()).deleteById(any(UUID.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/project/GetAllProjectsImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/project/GetAllProjectsImplTest.java deleted file mode 100644 index 43d8746..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/project/GetAllProjectsImplTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetAllProjectsImplTest { - - @Mock - private ProjectRepositoryPort projectRepositoryPort; - - @InjectMocks - private GetAllProjectsImpl getAllProjectsUseCase; - - @Test - void shouldReturnPageOfProjects() { - // Arrange - int page = 0; - int size = 10; - Project project = new Project(UUID.randomUUID(), "Test Project", "Desc", "icon", "#fff", false); - List projectList = Collections.singletonList(project); - Page expectedPage = new PageImpl<>(projectList); - - when(projectRepositoryPort.findAll(page, size)).thenReturn(expectedPage); - - // Act - Page result = getAllProjectsUseCase.execute(page, size); - - // Assert - assertNotNull(result); - assertEquals(expectedPage, result); - assertEquals(1, result.getTotalElements()); - assertEquals(project, result.getContent().getFirst()); - verify(projectRepositoryPort, times(1)).findAll(page, size); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/project/GetProjectByIdImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/project/GetProjectByIdImplTest.java deleted file mode 100644 index 2778ce1..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/project/GetProjectByIdImplTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetProjectByIdImplTest { - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetProjectByIdImpl getProjectByIdUseCase; - - @Test - void shouldReturnProjectWhenFound() { - // Arrange - UUID id = UUID.randomUUID(); - Project expectedProject = new Project(id, "Test Project", "Desc", "icon", "#fff", false); - - when(projectRepository.findById(id)).thenReturn(Optional.of(expectedProject)); - - // Act - Project result = getProjectByIdUseCase.execute(id); - - // Assert - assertNotNull(result); - assertEquals(expectedProject, result); - verify(projectRepository, times(1)).findById(id); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenNotFound() { - // Arrange - UUID id = UUID.randomUUID(); - when(projectRepository.findById(id)).thenReturn(Optional.empty()); - - // Act & Assert - ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { - getProjectByIdUseCase.execute(id); - }); - - assertEquals("Project not found with identifier " + id, exception.getMessage()); - verify(projectRepository, times(1)).findById(id); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/project/UnarchiveProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/project/UnarchiveProjectImplTest.java deleted file mode 100644 index 5090d06..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/project/UnarchiveProjectImplTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.BusinessRuleException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UnarchiveProjectImplTest { - - @Mock - private ProjectRepositoryPort projectRepositoryPort; - - @InjectMocks - private UnarchiveProjectImpl unarchiveProjectUseCase; - - @Test - void shouldUnarchiveProjectSuccessfully() { - // Arrange - UUID id = UUID.randomUUID(); - Project project = new Project(id, "Test Project", "Desc", "icon", "#fff", true); - - when(projectRepositoryPort.findById(id)).thenReturn(Optional.of(project)); - when(projectRepositoryPort.save(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - unarchiveProjectUseCase.execute(id); - - // Assert - assertFalse(project.isArchived()); - assertNotNull(project.getUpdatedAt()); - - verify(projectRepositoryPort, times(1)).findById(id); - verify(projectRepositoryPort, times(1)).save(project); - } - - @Test - void shouldThrowBusinessRuleExceptionWhenAlreadyUnarchived() { - // Arrange - UUID id = UUID.randomUUID(); - Project project = new Project(id, "Test Project", "Desc", "icon", "#fff", false); - - when(projectRepositoryPort.findById(id)).thenReturn(Optional.of(project)); - - // Act & Assert - BusinessRuleException exception = assertThrows(BusinessRuleException.class, () -> { - unarchiveProjectUseCase.execute(id); - }); - - assertEquals("Project already unarchived", exception.getMessage()); - verify(projectRepositoryPort, times(1)).findById(id); - verify(projectRepositoryPort, never()).save(any(Project.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID id = UUID.randomUUID(); - when(projectRepositoryPort.findById(id)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - unarchiveProjectUseCase.execute(id); - }); - - verify(projectRepositoryPort, times(1)).findById(id); - verify(projectRepositoryPort, never()).save(any(Project.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/project/UpdateProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/project/UpdateProjectImplTest.java deleted file mode 100644 index be046ac..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/project/UpdateProjectImplTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.devaulty.backend.application.impl.project; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.project.CreateProjectCommand; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.domain.model.Project; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UpdateProjectImplTest { - - @Mock - private ProjectRepositoryPort projectRepositoryPort; - - @InjectMocks - private UpdateProjectImpl updateProjectUseCase; - - @Test - void shouldUpdateAllFieldsWhenCommandHasAllValues() { - // Arrange - UUID id = UUID.randomUUID(); - Project existingProject = new Project(id, "Old Name", "Old Desc", "old-icon", "#000", false); - CreateProjectCommand command = new CreateProjectCommand("New Name", "New Desc", "new-icon", "#fff"); - - when(projectRepositoryPort.findById(id)).thenReturn(Optional.of(existingProject)); - when(projectRepositoryPort.save(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Project result = updateProjectUseCase.execute(id, command); - - // Assert - assertNotNull(result); - assertEquals("New Name", result.getName()); - assertEquals("New Desc", result.getDescription()); - assertEquals("new-icon", result.getIcon()); - assertEquals("#fff", result.getColor()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepositoryPort, times(1)).findById(id); - verify(projectRepositoryPort, times(1)).save(existingProject); - } - - @Test - void shouldNotUpdateFieldsWhenCommandHasNullValues() { - // Arrange - UUID id = UUID.randomUUID(); - Project existingProject = new Project(id, "Old Name", "Old Desc", "old-icon", "#000", false); - CreateProjectCommand command = new CreateProjectCommand(null, null, null, null); - - when(projectRepositoryPort.findById(id)).thenReturn(Optional.of(existingProject)); - when(projectRepositoryPort.save(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Project result = updateProjectUseCase.execute(id, command); - - // Assert - assertNotNull(result); - assertEquals("Old Name", result.getName()); - assertEquals("Old Desc", result.getDescription()); - assertEquals("old-icon", result.getIcon()); - assertEquals("#000", result.getColor()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepositoryPort, times(1)).findById(id); - verify(projectRepositoryPort, times(1)).save(existingProject); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID id = UUID.randomUUID(); - CreateProjectCommand command = new CreateProjectCommand("New Name", "New Desc", "new-icon", "#fff"); - - when(projectRepositoryPort.findById(id)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateProjectUseCase.execute(id, command); - }); - - verify(projectRepositoryPort, times(1)).findById(id); - verify(projectRepositoryPort, never()).save(any(Project.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImplTest.java deleted file mode 100644 index e2755d8..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImplTest.java +++ /dev/null @@ -1,222 +0,0 @@ -package com.devaulty.backend.application.impl.release; - -import com.devaulty.backend.application.port.in.release.AppUpdateInfo; -import com.devaulty.backend.application.port.out.external.release.ReleasePort; -import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; -import com.devaulty.backend.application.port.out.external.release.dto.ReleaseAssetInfo; -import com.devaulty.backend.infrastructure.properties.DevaultyProperties; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.Instant; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CheckForUpdatesImplTest { - - @Mock - private ReleasePort releasePort; - - @Mock - private DevaultyProperties devaultyProperties; - - @InjectMocks - private CheckForUpdatesImpl checkForUpdatesUseCase; - - @BeforeEach - void setUp() { - lenient().when(devaultyProperties.getVersion()).thenReturn("0.1.0-alpha"); - } - - @Test - @DisplayName("Should return updateAvailable = true when GitHub has a newer version tag") - void shouldReturnUpdateAvailable_whenNewerReleaseExistsOnGitHub() { - // Arrange - String currentOs = System.getProperty("os.name").toLowerCase(); - String extension = currentOs.contains("win") ? ".msi" : currentOs.contains("mac") ? ".dmg" : ".deb"; - - ReleaseAssetInfo matchingAsset = new ReleaseAssetInfo( - "devaulty-installer" + extension, - "https://download.url/devaulty" + extension, - 50000000L, - "application/octet-stream" - ); - LatestReleaseInfo latestRelease = new LatestReleaseInfo( - "v0.2.0", - "Release 0.2.0", - "Bug fixes and performance improvements", - "https://github.com/MathCunha16/Devaulty/releases/tag/v0.2.0", - Instant.now(), - false, - List.of(matchingAsset) - ); - - when(releasePort.getLatestRelease()).thenReturn(latestRelease); - - // Act - AppUpdateInfo result = checkForUpdatesUseCase.execute(); - - // Assert - assertNotNull(result); - assertTrue(result.updateAvailable()); - assertEquals("0.1.0-alpha", result.currentVersion()); - assertEquals("v0.2.0", result.latestVersion()); - assertEquals("Release 0.2.0", result.releaseTitle()); - assertEquals("Bug fixes and performance improvements", result.releaseNotes()); - assertEquals("https://download.url/devaulty" + extension, result.downloadUrl()); - assertEquals(50000000L, result.downloadSizeInBytes()); - - verify(releasePort, times(1)).getLatestRelease(); - } - - @Test - @DisplayName("Should return updateAvailable = false when current version is equal to GitHub release tag") - void shouldReturnNoUpdateAvailable_whenCurrentVersionIsUpToDate() { - // Arrange - LatestReleaseInfo latestRelease = new LatestReleaseInfo( - "v0.1.0-alpha", - "Release 0.1.0-alpha", - "Initial alpha release", - "https://github.com/MathCunha16/Devaulty/releases/tag/v0.1.0-alpha", - Instant.now(), - false, - List.of() - ); - - when(releasePort.getLatestRelease()).thenReturn(latestRelease); - - // Act - AppUpdateInfo result = checkForUpdatesUseCase.execute(); - - // Assert - assertNotNull(result); - assertFalse(result.updateAvailable()); - assertEquals("0.1.0-alpha", result.currentVersion()); - assertEquals("v0.1.0-alpha", result.latestVersion()); - - verify(releasePort, times(1)).getLatestRelease(); - } - - @Test - @DisplayName("Should return updateAvailable = false when local version is newer than GitHub release tag (prevents downgrade)") - void shouldReturnNoUpdateAvailable_whenLocalVersionIsNewerThanGitHubRelease() { - // Arrange - LatestReleaseInfo olderRelease = new LatestReleaseInfo( - "v0.0.9", - "Release 0.0.9", - "Older release", - "https://github.com/MathCunha16/Devaulty/releases/tag/v0.0.9", - Instant.now(), - false, - List.of() - ); - - when(releasePort.getLatestRelease()).thenReturn(olderRelease); - - // Act - AppUpdateInfo result = checkForUpdatesUseCase.execute(); - - // Assert - assertNotNull(result); - assertFalse(result.updateAvailable()); - assertEquals("0.1.0-alpha", result.currentVersion()); - assertEquals("v0.0.9", result.latestVersion()); - - verify(releasePort, times(1)).getLatestRelease(); - } - - @Test - @DisplayName("Should return null downloadUrl without throwing NullPointerException when release assets is null") - void shouldHandleNullAssets_withoutThrowingNullPointerException() { - // Arrange - LatestReleaseInfo nullAssetsRelease = new LatestReleaseInfo( - "v0.2.0", - "Release 0.2.0", - "Release notes", - "https://github.com/MathCunha16/Devaulty/releases/tag/v0.2.0", - Instant.now(), - false, - null - ); - - when(releasePort.getLatestRelease()).thenReturn(nullAssetsRelease); - - // Act - AppUpdateInfo result = checkForUpdatesUseCase.execute(); - - // Assert - assertNotNull(result); - assertTrue(result.updateAvailable()); - assertNull(result.downloadUrl()); - assertEquals(0L, result.downloadSizeInBytes()); - - verify(releasePort, times(1)).getLatestRelease(); - } - - @Test - @DisplayName("Should return updateAvailable = false and dynamic currentVersion when GitHub release is null") - void shouldReturnNoUpdateAvailable_whenNoReleaseExistsOnGitHub() { - // Arrange - when(devaultyProperties.getVersion()).thenReturn("2.5.0-beta"); - when(releasePort.getLatestRelease()).thenReturn(null); - - // Act - AppUpdateInfo result = checkForUpdatesUseCase.execute(); - - // Assert - assertNotNull(result); - assertFalse(result.updateAvailable()); - assertEquals("2.5.0-beta", result.currentVersion()); - assertNull(result.latestVersion()); - assertNull(result.downloadUrl()); - assertEquals(0L, result.downloadSizeInBytes()); - - verify(releasePort, times(1)).getLatestRelease(); - } - - @ParameterizedTest(name = "{0} -> {1} should be recognised as an update") - @CsvSource({ - "0.1.0-alpha.1, v0.1.0-alpha.2", - "0.1.0-alpha, v0.1.0-beta", - "0.1.0-alpha, v0.1.0" - }) - void shouldReturnUpdateAvailable_whenPreReleaseProgresses(String currentVersion, String latestTag) { - when(devaultyProperties.getVersion()).thenReturn(currentVersion); - LatestReleaseInfo latestRelease = new LatestReleaseInfo( - latestTag, "Release " + latestTag, "", "", - Instant.now(), false, List.of() - ); - when(releasePort.getLatestRelease()).thenReturn(latestRelease); - - AppUpdateInfo result = checkForUpdatesUseCase.execute(); - - assertTrue(result.updateAvailable()); - } - - @Test - @DisplayName("Should return updateAvailable = false when stable is not superseded by a pre-release") - void shouldReturnNoUpdateAvailable_whenStableIsNotSupersededByPreRelease() { - // Arrange: current = 0.1.0 (stable), latest = 0.1.0-beta (pre-release) - when(devaultyProperties.getVersion()).thenReturn("0.1.0"); - LatestReleaseInfo latestRelease = new LatestReleaseInfo( - "v0.1.0-beta", "Release 0.1.0-beta", "", "", - Instant.now(), false, List.of() - ); - when(releasePort.getLatestRelease()).thenReturn(latestRelease); - - AppUpdateInfo result = checkForUpdatesUseCase.execute(); - - assertFalse(result.updateAvailable()); - } -} - diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/release/DownloadUpdateImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/release/DownloadUpdateImplTest.java deleted file mode 100644 index cc71138..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/release/DownloadUpdateImplTest.java +++ /dev/null @@ -1,211 +0,0 @@ -package com.devaulty.backend.application.impl.release; - -import com.devaulty.backend.application.exception.UpdateNotAvailableException; -import com.devaulty.backend.application.port.in.release.AppUpdateInfo; -import com.devaulty.backend.application.port.in.release.CheckForUpdatesUseCase; -import com.devaulty.backend.application.port.in.release.InstallUpdateUseCase; -import com.devaulty.backend.application.port.in.release.UpdateProgressInfo; -import com.devaulty.backend.application.port.in.release.enums.UpdateStatus; -import com.devaulty.backend.application.port.out.external.release.ReleasePort; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DownloadUpdateImplTest { - - @TempDir - Path tempFolder; - - @Mock - private ReleasePort releasePort; - - @Mock - private CheckForUpdatesUseCase checkForUpdatesUseCase; - - @Mock - private InstallUpdateUseCase installUpdateUseCase; - - @InjectMocks - private DownloadUpdateImpl downloadUpdateUseCase; - - @BeforeEach - void setUp() { - System.setProperty("devaulty.temp.dir", tempFolder.toString()); - } - - @AfterEach - void tearDown() { - System.clearProperty("devaulty.temp.dir"); - } - - @Test - @DisplayName("Should throw UpdateNotAvailableException when updateAvailable is false") - void shouldThrowUpdateNotAvailableException_whenNoUpdateAvailable() { - // Arrange - AppUpdateInfo noUpdateInfo = new AppUpdateInfo( - false, - "0.1.0-alpha", - "v0.1.0-alpha", - "Title", - "Notes", - null, - 0L, - Instant.now() - ); - - when(checkForUpdatesUseCase.execute()).thenReturn(noUpdateInfo); - - // Act & Assert - assertThrows(UpdateNotAvailableException.class, () -> downloadUpdateUseCase.execute(progress -> {})); - verify(checkForUpdatesUseCase, times(1)).execute(); - verifyNoInteractions(releasePort); - } - - @Test - @DisplayName("Should throw UpdateNotAvailableException when downloadUrl is null") - void shouldThrowUpdateNotAvailableException_whenDownloadUrlIsNull() { - // Arrange - AppUpdateInfo nullUrlInfo = new AppUpdateInfo( - true, - "0.1.0-alpha", - "v0.2.0", - "Title", - "Notes", - null, - 0L, - Instant.now() - ); - - when(checkForUpdatesUseCase.execute()).thenReturn(nullUrlInfo); - - // Act & Assert - assertThrows(UpdateNotAvailableException.class, () -> downloadUpdateUseCase.execute(progress -> {})); - verify(checkForUpdatesUseCase, times(1)).execute(); - verifyNoInteractions(releasePort); - } - - @Test - @DisplayName("Should emit download progress and trigger installation when download succeeds") - void shouldEmitProgressAndTriggerInstallation_whenDownloadSucceeds() { - // Arrange - String downloadUrl = "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0.deb"; - long totalBytes = 11L; - - AppUpdateInfo validUpdateInfo = new AppUpdateInfo( - true, - "0.1.0-alpha", - "v0.2.0", - "Title", - "Notes", - downloadUrl, - totalBytes, - Instant.now() - ); - - when(checkForUpdatesUseCase.execute()).thenReturn(validUpdateInfo); - - InputStream fakeAssetStream = new ByteArrayInputStream("hello world".getBytes(StandardCharsets.UTF_8)); - when(releasePort.downloadAsset(downloadUrl)).thenReturn(fakeAssetStream); - doNothing().when(installUpdateUseCase).execute(any()); - - // Act - List results = new ArrayList<>(); - downloadUpdateUseCase.execute(results::add); - - // Assert - assertFalse(results.isEmpty()); - assertTrue(results.stream().anyMatch(p -> p.status() == UpdateStatus.DOWNLOADING)); - assertTrue(results.stream().anyMatch(p -> p.status() == UpdateStatus.INSTALLING)); - assertTrue(results.stream().anyMatch(p -> p.status() == UpdateStatus.COMPLETED && p.percentage() == 100)); - - verify(checkForUpdatesUseCase, times(1)).execute(); - verify(releasePort, times(1)).downloadAsset(downloadUrl); - verify(installUpdateUseCase, times(1)).execute(any()); - } - - @Test - @DisplayName("Should emit FAILED progress and clean up file when download stream throws") - void shouldEmitFailedProgress_whenDownloadStreamThrows() { - // Arrange - String downloadUrl = "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0.deb"; - - AppUpdateInfo validUpdateInfo = new AppUpdateInfo( - true, - "0.1.0-alpha", - "v0.2.0", - "Title", - "Notes", - downloadUrl, - 11L, - Instant.now() - ); - - when(checkForUpdatesUseCase.execute()).thenReturn(validUpdateInfo); - - InputStream brokenStream = new InputStream() { - @Override - public int read() throws java.io.IOException { - throw new java.io.IOException("Connection reset"); - } - }; - when(releasePort.downloadAsset(downloadUrl)).thenReturn(brokenStream); - - // Act - List results = new ArrayList<>(); - downloadUpdateUseCase.execute(results::add); - - // Assert - assertTrue(results.stream().anyMatch(p -> p.status() == UpdateStatus.FAILED)); - verify(installUpdateUseCase, never()).execute(any()); - } - - @Test - @DisplayName("Should emit FAILED progress and clean up file when downloaded bytes does not match expected size") - void shouldEmitFailedProgress_whenDownloadedBytesDoesNotMatchTotalBytes() { - // Arrange - String downloadUrl = "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0.deb"; - - AppUpdateInfo validUpdateInfo = new AppUpdateInfo( - true, - "0.1.0-alpha", - "v0.2.0", - "Title", - "Notes", - downloadUrl, - 500L, // Expected 500 bytes, but stream only provides 11 bytes - Instant.now() - ); - - when(checkForUpdatesUseCase.execute()).thenReturn(validUpdateInfo); - - InputStream truncatedStream = new ByteArrayInputStream("hello world".getBytes(StandardCharsets.UTF_8)); - when(releasePort.downloadAsset(downloadUrl)).thenReturn(truncatedStream); - - // Act - List results = new ArrayList<>(); - downloadUpdateUseCase.execute(results::add); - - // Assert - assertTrue(results.stream().anyMatch(p -> p.status() == UpdateStatus.FAILED && p.errorMessage().contains("Download incomplete"))); - verify(installUpdateUseCase, never()).execute(any()); - } -} \ No newline at end of file diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImplTest.java deleted file mode 100644 index e50def6..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImplTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.devaulty.backend.application.impl.release; - -import com.devaulty.backend.infrastructure.properties.DevaultyProperties; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetCurrentVersionImplTest { - - @Mock - private DevaultyProperties devaultyProperties; - - @InjectMocks - private GetCurrentVersionImpl getCurrentVersionUseCase; - - @Test - @DisplayName("Should return current application version string from DevaultyProperties") - void shouldReturnCurrentAppVersion_whenExecuted() { - // Arrange - when(devaultyProperties.getVersion()).thenReturn("0.1.0-alpha"); - - // Act - String result = getCurrentVersionUseCase.execute(); - - // Assert - assertNotNull(result); - assertEquals("0.1.0-alpha", result); - verify(devaultyProperties, times(1)).getVersion(); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/release/InstallUpdateImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/release/InstallUpdateImplTest.java deleted file mode 100644 index d49e165..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/release/InstallUpdateImplTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.devaulty.backend.application.impl.release; - -import com.devaulty.backend.application.exception.UpdateNotAvailableException; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ConfigurableApplicationContext; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class InstallUpdateImplTest { - - @TempDir - Path tempFolder; - - @Mock - private ConfigurableApplicationContext applicationContext; - - private InstallUpdateImpl installUpdateUseCase; - - @BeforeEach - void setUp() { - System.setProperty("devaulty.temp.dir", tempFolder.toString()); - installUpdateUseCase = spy(new InstallUpdateImpl(applicationContext)); - } - - @AfterEach - void tearDown() { - System.clearProperty("devaulty.temp.dir"); - } - - @Test - @DisplayName("Should throw UpdateNotAvailableException when installer file does not exist") - void shouldThrowUpdateNotAvailableException_whenInstallerFileDoesNotExist() { - // Arrange - Path nonExistentPath = Path.of("non_existent_file.deb"); - - // Act & Assert - assertThrows(UpdateNotAvailableException.class, () -> installUpdateUseCase.execute(nonExistentPath)); - } - - @Test - @DisplayName("Should throw IllegalStateException when installer file path is outside temp directory") - void shouldThrowIllegalStateException_whenInstallerFileOutsideTempDir() throws IOException { - // Arrange - File outsideTempFile = File.createTempFile("outside_test", ".deb"); - outsideTempFile.deleteOnExit(); - - // Act & Assert - assertThrows(IllegalStateException.class, () -> installUpdateUseCase.execute(outsideTempFile.toPath())); - } - - @Test - @DisplayName("Should launch installer process without executing real system commands during unit tests") - void shouldLaunchInstallerProcess_withoutExecutingRealSystemCommands() throws IOException { - // Arrange - File tempDir = tempFolder.toFile(); - File testInstaller = new File(tempDir, "test_installer.deb"); - testInstaller.createNewFile(); - - // Prevent real pkexec / msiexec execution and System.exit during unit test - doNothing().when(installUpdateUseCase).startDetached(anyList()); - doNothing().when(installUpdateUseCase).scheduleApplicationShutdown(); - - // Act & Assert - assertDoesNotThrow(() -> installUpdateUseCase.execute(testInstaller.toPath())); - verify(installUpdateUseCase, times(1)).startDetached(anyList()); - verify(installUpdateUseCase, times(1)).scheduleApplicationShutdown(); - } -} - diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/security/CheckMasterPasswordSetupImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/security/CheckMasterPasswordSetupImplTest.java deleted file mode 100644 index ec942dc..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/security/CheckMasterPasswordSetupImplTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.port.out.persistence.AppSettingRepositoryPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CheckMasterPasswordSetupImplTest { - - @Mock - private AppSettingRepositoryPort appSettingRepositoryPort; - - @InjectMocks - private CheckMasterPasswordSetupImpl checkMasterPasswordSetupUseCase; - - private static final String MASTER_PASSWORD_HASH_KEY = "master_password_hash"; - - @Test - void shouldReturnTrueWhenMasterPasswordHashDoesNotExist() { - // Arrange - when(appSettingRepositoryPort.existsByKey(MASTER_PASSWORD_HASH_KEY)).thenReturn(false); - - // Act - boolean result = checkMasterPasswordSetupUseCase.isSetupRequired(); - - // Assert - assertTrue(result); - verify(appSettingRepositoryPort, times(1)).existsByKey(MASTER_PASSWORD_HASH_KEY); - } - - @Test - void shouldReturnFalseWhenMasterPasswordHashExists() { - // Arrange - when(appSettingRepositoryPort.existsByKey(MASTER_PASSWORD_HASH_KEY)).thenReturn(true); - - // Act - boolean result = checkMasterPasswordSetupUseCase.isSetupRequired(); - - // Assert - assertFalse(result); - verify(appSettingRepositoryPort, times(1)).existsByKey(MASTER_PASSWORD_HASH_KEY); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/security/GetSessionStatusImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/security/GetSessionStatusImplTest.java deleted file mode 100644 index 1a550fd..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/security/GetSessionStatusImplTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.port.in.security.SessionStatus; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetSessionStatusImplTest { - - @Mock - private MasterKeySessionPort sessionHolder; - - @InjectMocks - private GetSessionStatusImpl getSessionStatusUseCase; - - @Test - void shouldReturnSessionStatusCorrectly() { - // Arrange - Duration timeout = Duration.ofMinutes(15); - when(sessionHolder.hasKey()).thenReturn(true); - when(sessionHolder.getSecondsRemaining(timeout)).thenReturn(900L); - - // Act - SessionStatus status = getSessionStatusUseCase.execute(); - - // Assert - assertNotNull(status); - assertTrue(status.active()); - assertEquals(900L, status.secondsLeft()); - - verify(sessionHolder, times(1)).hasKey(); - verify(sessionHolder, times(1)).getSecondsRemaining(timeout); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/security/LockVaultImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/security/LockVaultImplTest.java deleted file mode 100644 index 53d5dc4..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/security/LockVaultImplTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class LockVaultImplTest { - - @Mock - private MasterKeySessionPort sessionHolder; - - @InjectMocks - private LockVaultImpl lockVaultUseCase; - - @Test - void shouldClearSessionWhenExecutingLock() { - // Act - lockVaultUseCase.execute(); - - // Assert - verify(sessionHolder, times(1)).clear(); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/security/SetupMasterPasswordImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/security/SetupMasterPasswordImplTest.java deleted file mode 100644 index ea670ec..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/security/SetupMasterPasswordImplTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.exception.MasterPasswordAlreadyConfiguredException; -import com.devaulty.backend.application.port.out.persistence.AppSettingRepositoryPort; -import com.devaulty.backend.application.port.out.security.KeyDerivationPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.domain.model.AppSetting; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import javax.crypto.SecretKey; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class SetupMasterPasswordImplTest { - - @Mock - private AppSettingRepositoryPort appSettingRepositoryPort; - - @Mock - private MasterKeySessionPort sessionHolder; - - @Mock - private KeyDerivationPort keyDerivationPort; - - @InjectMocks - private SetupMasterPasswordImpl setupMasterPasswordUseCase; - - private static final String MASTER_PASSWORD_HASH_KEY = "master_password_hash"; - - @Test - void shouldSetupMasterPasswordSuccessfully() { - // Arrange - char[] password = "mySuperSecretMasterPassword".toCharArray(); - SecretKey mockSecretKey = mock(SecretKey.class); - - when(appSettingRepositoryPort.existsByKey(MASTER_PASSWORD_HASH_KEY)).thenReturn(false); - when(keyDerivationPort.deriveKey(eq(password), any(byte[].class))).thenReturn(mockSecretKey); - - // Act - setupMasterPasswordUseCase.execute(password); - - // Assert - verify(appSettingRepositoryPort, times(1)).existsByKey(MASTER_PASSWORD_HASH_KEY); - verify(keyDerivationPort, times(1)).deriveKey(eq(password), any(byte[].class)); - verify(appSettingRepositoryPort, times(2)).save(any(AppSetting.class)); // Salt and Hash - verify(sessionHolder, times(1)).setKey(mockSecretKey); - - // Input password should be zeroed out in the finally block - assertEquals('\0', password[0]); - } - - @Test - void shouldThrowMasterPasswordAlreadyConfiguredExceptionWhenAlreadyConfigured() { - // Arrange - char[] password = "mySuperSecretMasterPassword".toCharArray(); - - when(appSettingRepositoryPort.existsByKey(MASTER_PASSWORD_HASH_KEY)).thenReturn(true); - - // Act & Assert - assertThrows(MasterPasswordAlreadyConfiguredException.class, () -> { - setupMasterPasswordUseCase.execute(password); - }); - - verify(appSettingRepositoryPort, times(1)).existsByKey(MASTER_PASSWORD_HASH_KEY); - verify(keyDerivationPort, never()).deriveKey(any(), any()); - verify(appSettingRepositoryPort, never()).save(any()); - verify(sessionHolder, never()).setKey(any()); - - // Password is zeroed out successfully because validations are now inside the try-finally block - assertEquals('\0', password[0]); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/security/UnlockVaultImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/security/UnlockVaultImplTest.java deleted file mode 100644 index 420dec2..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/security/UnlockVaultImplTest.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.devaulty.backend.application.impl.security; - -import com.devaulty.backend.application.exception.InvalidMasterPasswordException; -import com.devaulty.backend.application.exception.MasterPasswordNotConfiguredException; -import com.devaulty.backend.application.port.out.persistence.AppSettingRepositoryPort; -import com.devaulty.backend.application.port.out.security.KeyDerivationPort; -import com.devaulty.backend.application.port.out.security.MasterKeySessionPort; -import com.devaulty.backend.domain.model.AppSetting; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; - -import javax.crypto.SecretKey; -import java.nio.CharBuffer; -import java.util.Base64; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UnlockVaultImplTest { - - @Mock - private AppSettingRepositoryPort appSettingRepositoryPort; - - @Mock - private MasterKeySessionPort sessionHolder; - - @Mock - private KeyDerivationPort keyDerivationPort; - - @InjectMocks - private UnlockVaultImpl unlockVaultUseCase; - - private static final String MASTER_PASSWORD_HASH_KEY = "master_password_hash"; - private static final String MASTER_PASSWORD_SALT_KEY = "master_password_salt"; - - @Test - void shouldUnlockVaultSuccessfully() { - // Arrange - char[] password = "myMasterPassword123".toCharArray(); - String mockHash = "mockHashEncodedWithArgon2"; - byte[] saltBytes = new byte[]{1, 2, 3}; - String mockSaltBase64 = Base64.getEncoder().encodeToString(saltBytes); - SecretKey mockSecretKey = mock(SecretKey.class); - - // Mock Argon2PasswordEncoder inside UnlockVaultImpl using reflection - Argon2PasswordEncoder mockEncoder = mock(Argon2PasswordEncoder.class); - ReflectionTestUtils.setField(unlockVaultUseCase, "argon2PasswordEncoder", mockEncoder); - - when(appSettingRepositoryPort.findByKey(MASTER_PASSWORD_HASH_KEY)) - .thenReturn(Optional.of(new AppSetting(MASTER_PASSWORD_HASH_KEY, mockHash))); - when(mockEncoder.matches(any(CharBuffer.class), eq(mockHash))).thenReturn(true); - when(appSettingRepositoryPort.findByKey(MASTER_PASSWORD_SALT_KEY)) - .thenReturn(Optional.of(new AppSetting(MASTER_PASSWORD_SALT_KEY, mockSaltBase64))); - when(keyDerivationPort.deriveKey(eq(password), any(byte[].class))).thenReturn(mockSecretKey); - - // Act - boolean result = unlockVaultUseCase.execute(password); - - // Assert - assertTrue(result); - verify(appSettingRepositoryPort, times(1)).findByKey(MASTER_PASSWORD_HASH_KEY); - verify(mockEncoder, times(1)).matches(any(CharBuffer.class), eq(mockHash)); - verify(appSettingRepositoryPort, times(1)).findByKey(MASTER_PASSWORD_SALT_KEY); - verify(keyDerivationPort, times(1)).deriveKey(eq(password), any(byte[].class)); - verify(sessionHolder, times(1)).setKey(mockSecretKey); - - // Password is zeroed out in finally - assertEquals('\0', password[0]); - } - - @Test - void shouldThrowInvalidMasterPasswordExceptionWhenPasswordDoesNotMatch() { - // Arrange - char[] password = "wrongMasterPassword".toCharArray(); - String mockHash = "mockHashEncodedWithArgon2"; - - Argon2PasswordEncoder mockEncoder = mock(Argon2PasswordEncoder.class); - ReflectionTestUtils.setField(unlockVaultUseCase, "argon2PasswordEncoder", mockEncoder); - - when(appSettingRepositoryPort.findByKey(MASTER_PASSWORD_HASH_KEY)) - .thenReturn(Optional.of(new AppSetting(MASTER_PASSWORD_HASH_KEY, mockHash))); - when(mockEncoder.matches(any(CharBuffer.class), eq(mockHash))).thenReturn(false); - - // Act & Assert - assertThrows(InvalidMasterPasswordException.class, () -> { - unlockVaultUseCase.execute(password); - }); - - verify(appSettingRepositoryPort, times(1)).findByKey(MASTER_PASSWORD_HASH_KEY); - verify(mockEncoder, times(1)).matches(any(CharBuffer.class), eq(mockHash)); - verify(appSettingRepositoryPort, never()).findByKey(MASTER_PASSWORD_SALT_KEY); - verify(keyDerivationPort, never()).deriveKey(any(), any()); - verify(sessionHolder, never()).setKey(any()); - - // Password is zeroed out - assertEquals('\0', password[0]); - } - - @Test - void shouldThrowMasterPasswordNotConfiguredExceptionWhenHashIsMissing() { - // Arrange - char[] password = "myMasterPassword123".toCharArray(); - - when(appSettingRepositoryPort.findByKey(MASTER_PASSWORD_HASH_KEY)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(MasterPasswordNotConfiguredException.class, () -> { - unlockVaultUseCase.execute(password); - }); - - verify(appSettingRepositoryPort, times(1)).findByKey(MASTER_PASSWORD_HASH_KEY); - verify(appSettingRepositoryPort, never()).findByKey(MASTER_PASSWORD_SALT_KEY); - verify(keyDerivationPort, never()).deriveKey(any(), any()); - verify(sessionHolder, never()).setKey(any()); - - // Password is zeroed out - assertEquals('\0', password[0]); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/CreateSnippetImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/snippet/CreateSnippetImplTest.java deleted file mode 100644 index 6fc5feb..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/CreateSnippetImplTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.snippet.CreateSnippetCommand; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import com.devaulty.backend.domain.model.Snippet; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CreateSnippetImplTest { - - @Mock - private SnippetRepositoryPort snippetRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private CreateSnippetImpl createSnippetUseCase; - - @Test - void shouldCreateSnippetSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateSnippetCommand command = new CreateSnippetCommand( - projectId, - "Test Title", - "Test Desc", - "echo 'test'", - SnippetLanguage.BASH, - SnippetType.COMMAND - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.save(any(Snippet.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Snippet result = createSnippetUseCase.execute(command); - - // Assert - assertNotNull(result); - assertNotNull(result.getId()); - assertEquals(projectId, result.getProjectId()); - assertEquals("Test Title", result.getTitle()); - assertEquals("Test Desc", result.getDescription()); - assertEquals("echo 'test'", result.getContent()); - assertEquals(SnippetLanguage.BASH, result.getLanguage()); - assertEquals(SnippetType.COMMAND, result.getSnippetType()); - assertNotNull(result.getCreatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).save(any(Snippet.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateSnippetCommand command = new CreateSnippetCommand( - projectId, - "Test Title", - "Test Desc", - "echo 'test'", - SnippetLanguage.BASH, - SnippetType.COMMAND - ); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - createSnippetUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, never()).save(any(Snippet.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/DeleteSnippetImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/snippet/DeleteSnippetImplTest.java deleted file mode 100644 index 1b3c655..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/DeleteSnippetImplTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DeleteSnippetImplTest { - - @Mock - private SnippetRepositoryPort snippetRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private ItemTagRepositoryPort itemTagRepository; - - @InjectMocks - private DeleteSnippetImpl deleteSnippetUseCase; - - @Test - void shouldDeleteSnippetSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.existsByIdAndProjectId(snippetId, projectId)).thenReturn(true); - - // Act - deleteSnippetUseCase.execute(projectId, snippetId); - - // Assert - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).existsByIdAndProjectId(snippetId, projectId); - verify(itemTagRepository, times(1)).removeAllTagsFromItem("snippet", snippetId); - verify(snippetRepository, times(1)).deleteById(snippetId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteSnippetUseCase.execute(projectId, snippetId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, never()).existsByIdAndProjectId(any(UUID.class), any(UUID.class)); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(snippetRepository, never()).deleteById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenSnippetDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.existsByIdAndProjectId(snippetId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteSnippetUseCase.execute(projectId, snippetId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).existsByIdAndProjectId(snippetId, projectId); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(snippetRepository, never()).deleteById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenSnippetDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - // existsByIdAndProjectId returns false because it belongs to another project - when(snippetRepository.existsByIdAndProjectId(snippetId, projectId)).thenReturn(false); - - // Act & Assert - ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { - deleteSnippetUseCase.execute(projectId, snippetId); - }); - assertEquals("Snippet not found with identifier " + snippetId, exception.getMessage()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).existsByIdAndProjectId(snippetId, projectId); - verify(itemTagRepository, never()).removeAllTagsFromItem(any(), any()); - verify(snippetRepository, never()).deleteById(any(UUID.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/GetAllSnippetsByProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/snippet/GetAllSnippetsByProjectImplTest.java deleted file mode 100644 index c27d2e6..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/GetAllSnippetsByProjectImplTest.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import com.devaulty.backend.domain.model.Snippet; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetAllSnippetsByProjectImplTest { - - @Mock - private SnippetRepositoryPort snippetRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetAllSnippetsByProjectImpl getAllSnippetsUseCase; - - @Test - void shouldReturnPageOfSnippets() { - // Arrange - UUID projectId = UUID.randomUUID(); - int page = 0; - int size = 10; - Snippet snippet = new Snippet(UUID.randomUUID(), projectId, "Title", "Desc", "ls", SnippetLanguage.BASH, SnippetType.COMMAND); - List list = Collections.singletonList(snippet); - Page expectedPage = new PageImpl<>(list); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.findAllByProject(projectId, page, size)).thenReturn(expectedPage); - - // Act - Page result = getAllSnippetsUseCase.execute(projectId, page, size); - - // Assert - assertNotNull(result); - assertEquals(expectedPage, result); - assertEquals(1, result.getTotalElements()); - assertEquals(snippet, result.getContent().getFirst()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findAllByProject(projectId, page, size); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getAllSnippetsUseCase.execute(projectId, 0, 10); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, never()).findAllByProject(any(UUID.class), anyInt(), anyInt()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/GetSnippetByIdImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/snippet/GetSnippetByIdImplTest.java deleted file mode 100644 index c08ad35..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/GetSnippetByIdImplTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import com.devaulty.backend.domain.model.Snippet; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetSnippetByIdImplTest { - - @Mock - private SnippetRepositoryPort snippetRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetSnippetByIdImpl getSnippetByIdUseCase; - - @Test - void shouldReturnSnippetWhenFoundAndBelongsToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - Snippet expectedSnippet = new Snippet(snippetId, projectId, "Title", "Desc", "ls", SnippetLanguage.BASH, SnippetType.COMMAND); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.findById(snippetId)).thenReturn(Optional.of(expectedSnippet)); - - // Act - Snippet result = getSnippetByIdUseCase.execute(projectId, snippetId); - - // Assert - assertNotNull(result); - assertEquals(expectedSnippet, result); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findById(snippetId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getSnippetByIdUseCase.execute(projectId, snippetId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenSnippetDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.findById(snippetId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getSnippetByIdUseCase.execute(projectId, snippetId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findById(snippetId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenSnippetDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - Snippet snippetOfOtherProject = new Snippet(snippetId, otherProjectId, "Title", "Desc", "ls", SnippetLanguage.BASH, SnippetType.COMMAND); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.findById(snippetId)).thenReturn(Optional.of(snippetOfOtherProject)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getSnippetByIdUseCase.execute(projectId, snippetId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findById(snippetId); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/UpdateSnippetImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/snippet/UpdateSnippetImplTest.java deleted file mode 100644 index 4dde721..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/snippet/UpdateSnippetImplTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.devaulty.backend.application.impl.snippet; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.snippet.UpdateSnippetCommand; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.SnippetRepositoryPort; -import com.devaulty.backend.domain.model.Snippet; -import com.devaulty.backend.domain.model.enums.SnippetLanguage; -import com.devaulty.backend.domain.model.enums.SnippetType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UpdateSnippetImplTest { - - @Mock - private SnippetRepositoryPort snippetRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private UpdateSnippetImpl updateSnippetUseCase; - - @Test - void shouldUpdateFieldsWhenValidCommand() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - Snippet existingSnippet = new Snippet(snippetId, projectId, "Old Title", "Old Desc", "old content", SnippetLanguage.JAVA, SnippetType.CODE); - UpdateSnippetCommand command = new UpdateSnippetCommand( - snippetId, - projectId, - "New Title", - "New Desc", - "new content", - SnippetLanguage.KOTLIN, - SnippetType.CODE - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.findById(snippetId)).thenReturn(Optional.of(existingSnippet)); - when(snippetRepository.save(any(Snippet.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Snippet result = updateSnippetUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("New Title", result.getTitle()); - assertEquals("New Desc", result.getDescription()); - assertEquals("new content", result.getContent()); - assertEquals(SnippetLanguage.KOTLIN, result.getLanguage()); - assertEquals(SnippetType.CODE, result.getSnippetType()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findById(snippetId); - verify(snippetRepository, times(1)).save(existingSnippet); - } - - @Test - void shouldNotUpdateFieldsWhenNull() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - Snippet existingSnippet = new Snippet(snippetId, projectId, "Old Title", "Old Desc", "old content", SnippetLanguage.JAVA, SnippetType.CODE); - UpdateSnippetCommand command = new UpdateSnippetCommand( - snippetId, - projectId, - null, - null, - null, - null, - null - ); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.findById(snippetId)).thenReturn(Optional.of(existingSnippet)); - when(snippetRepository.save(any(Snippet.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Snippet result = updateSnippetUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("Old Title", result.getTitle()); - assertEquals("Old Desc", result.getDescription()); - assertEquals("old content", result.getContent()); - assertEquals(SnippetLanguage.JAVA, result.getLanguage()); - assertEquals(SnippetType.CODE, result.getSnippetType()); - assertNotNull(result.getUpdatedAt()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findById(snippetId); - verify(snippetRepository, times(1)).save(existingSnippet); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - UpdateSnippetCommand command = new UpdateSnippetCommand(snippetId, projectId, "Title", null, null, null, null); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateSnippetUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, never()).findById(any(UUID.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenSnippetDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - UpdateSnippetCommand command = new UpdateSnippetCommand(snippetId, projectId, "Title", null, null, null, null); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.findById(snippetId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateSnippetUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findById(snippetId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenSnippetDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID snippetId = UUID.randomUUID(); - Snippet snippetOfOtherProject = new Snippet(snippetId, otherProjectId, "Title", "Desc", "ls", SnippetLanguage.BASH, SnippetType.COMMAND); - UpdateSnippetCommand command = new UpdateSnippetCommand(snippetId, projectId, "Title", null, null, null, null); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.findById(snippetId)).thenReturn(Optional.of(snippetOfOtherProject)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateSnippetUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findById(snippetId); - verify(snippetRepository, never()).save(any(Snippet.class)); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/CreateTagImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/CreateTagImplTest.java deleted file mode 100644 index 58ea19a..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/CreateTagImplTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceAlreadyExistsException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.CreateTagCommand; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CreateTagImplTest { - - @Mock - private TagRepositoryPort tagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private CreateTagImpl createTagUseCase; - - @Test - void shouldCreateTagSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateTagCommand command = new CreateTagCommand(projectId, "java", "#3A3A3A"); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByNameAndProjectId(projectId, "java")).thenReturn(false); - - Tag expectedTag = new Tag(); - expectedTag.setProjectId(projectId); - expectedTag.setName("java"); - expectedTag.setColor("#3A3A3A"); - when(tagRepository.save(any(Tag.class))).thenReturn(expectedTag); - - // Act - Tag result = createTagUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("java", result.getName()); - assertEquals("#3A3A3A", result.getColor()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByNameAndProjectId(projectId, "java"); - verify(tagRepository, times(1)).save(any(Tag.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateTagCommand command = new CreateTagCommand(projectId, "java", "#3A3A3A"); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - createTagUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, never()).existsByNameAndProjectId(any(), any()); - verify(tagRepository, never()).save(any()); - } - - @Test - void shouldThrowResourceAlreadyExistsExceptionWhenTagAlreadyExistsInProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - CreateTagCommand command = new CreateTagCommand(projectId, "java", "#3A3A3A"); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByNameAndProjectId(projectId, "java")).thenReturn(true); - - // Act & Assert - assertThrows(ResourceAlreadyExistsException.class, () -> { - createTagUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByNameAndProjectId(projectId, "java"); - verify(tagRepository, never()).save(any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/DeleteTagImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/DeleteTagImplTest.java deleted file mode 100644 index 17ce4db..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/DeleteTagImplTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class DeleteTagImplTest { - - @Mock - private TagRepositoryPort tagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private DeleteTagImpl deleteTagUseCase; - - @Test - void shouldDeleteTagSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByIdAndProjectId(tagId, projectId)).thenReturn(true); - - // Act - deleteTagUseCase.execute(projectId, tagId); - - // Assert - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByIdAndProjectId(tagId, projectId); - verify(tagRepository, times(1)).delete(tagId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteTagUseCase.execute(projectId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, never()).existsByIdAndProjectId(any(), any()); - verify(tagRepository, never()).delete(any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenTagDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByIdAndProjectId(tagId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - deleteTagUseCase.execute(projectId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByIdAndProjectId(tagId, projectId); - verify(tagRepository, never()).delete(any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/GetAllTagsByProjectImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/GetAllTagsByProjectImplTest.java deleted file mode 100644 index 269583e..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/GetAllTagsByProjectImplTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetAllTagsByProjectImplTest { - - @Mock - private TagRepositoryPort tagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetAllTagsByProjectImpl getAllTagsUseCase; - - @Test - void shouldReturnListofTags() { - // Arrange - UUID projectId = UUID.randomUUID(); - Tag tag = new Tag(); - tag.setProjectId(projectId); - tag.setName("docker"); - List expectedList = Collections.singletonList(tag); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.findAllByProject(projectId)).thenReturn(expectedList); - - // Act - List result = getAllTagsUseCase.execute(projectId); - - // Assert - assertNotNull(result); - assertEquals(expectedList, result); - assertEquals(1, result.size()); - assertEquals("docker", result.getFirst().getName()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).findAllByProject(projectId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getAllTagsUseCase.execute(projectId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, never()).findAllByProject(any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/GetTagByIdImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/GetTagByIdImplTest.java deleted file mode 100644 index 0b3934f..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/GetTagByIdImplTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetTagByIdImplTest { - - @Mock - private TagRepositoryPort tagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private GetTagByIdImpl getTagByIdUseCase; - - @Test - void shouldReturnTagSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - Tag tag = new Tag(); - tag.setId(tagId); - tag.setProjectId(projectId); - tag.setName("docker"); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag)); - - // Act - Tag result = getTagByIdUseCase.execute(projectId, tagId); - - // Assert - assertNotNull(result); - assertEquals(tag, result); - assertEquals("docker", result.getName()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).findById(tagId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getTagByIdUseCase.execute(projectId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, never()).findById(any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenTagDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.findById(tagId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getTagByIdUseCase.execute(projectId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).findById(tagId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenTagBelongsToAnotherProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - Tag tag = new Tag(); - tag.setId(tagId); - tag.setProjectId(otherProjectId); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getTagByIdUseCase.execute(projectId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).findById(tagId); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/SearchTagByNameImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/SearchTagByNameImplTest.java deleted file mode 100644 index 76b3deb..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/SearchTagByNameImplTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class SearchTagByNameImplTest { - - @Mock - private TagRepositoryPort tagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private SearchTagByNameImpl searchTagByNameUseCase; - - @Test - void shouldReturnMatchingTags() { - // Arrange - UUID projectId = UUID.randomUUID(); - Tag tag = new Tag(); - tag.setProjectId(projectId); - tag.setName("java-development"); - List expectedList = Collections.singletonList(tag); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.searchByNameAndProjectId(projectId, "java")).thenReturn(expectedList); - - // Act - List result = searchTagByNameUseCase.execute(projectId, "java"); - - // Assert - assertNotNull(result); - assertEquals(expectedList, result); - assertEquals(1, result.size()); - assertEquals("java-development", result.getFirst().getName()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).searchByNameAndProjectId(projectId, "java"); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - searchTagByNameUseCase.execute(projectId, "java"); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, never()).searchByNameAndProjectId(any(), any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/UpdateTagImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/UpdateTagImplTest.java deleted file mode 100644 index fbef19d..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/UpdateTagImplTest.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.devaulty.backend.application.impl.tag; - -import com.devaulty.backend.application.exception.ResourceAlreadyExistsException; -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.in.tag.UpdateTagCommand; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UpdateTagImplTest { - - @Mock - private TagRepositoryPort tagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @InjectMocks - private UpdateTagImpl updateTagUseCase; - - @Test - void shouldUpdateTagSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UpdateTagCommand command = new UpdateTagCommand(tagId, projectId, "new-name", "#000"); - - Tag tag = new Tag(); - tag.setId(tagId); - tag.setProjectId(projectId); - tag.setName("old-name"); - tag.setColor("#FFF"); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag)); - when(tagRepository.existsByNameAndProjectId(projectId, "new-name")).thenReturn(false); - when(tagRepository.save(any(Tag.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // Act - Tag result = updateTagUseCase.execute(command); - - // Assert - assertNotNull(result); - assertEquals("new-name", result.getName()); - assertEquals("#000", result.getColor()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).findById(tagId); - verify(tagRepository, times(1)).existsByNameAndProjectId(projectId, "new-name"); - verify(tagRepository, times(1)).save(any(Tag.class)); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UpdateTagCommand command = new UpdateTagCommand(tagId, projectId, "new-name", "#000"); - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateTagUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, never()).findById(any()); - verify(tagRepository, never()).save(any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenTagDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UpdateTagCommand command = new UpdateTagCommand(tagId, projectId, "new-name", "#000"); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.findById(tagId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateTagUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).findById(tagId); - verify(tagRepository, never()).save(any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenTagBelongsToAnotherProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID otherProjectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UpdateTagCommand command = new UpdateTagCommand(tagId, projectId, "new-name", "#000"); - - Tag tag = new Tag(); - tag.setId(tagId); - tag.setProjectId(otherProjectId); // Belongs to otherProjectId - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag)); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - updateTagUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).findById(tagId); - verify(tagRepository, never()).save(any()); - } - - @Test - void shouldThrowResourceAlreadyExistsExceptionWhenUpdatedNameAlreadyExists() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UpdateTagCommand command = new UpdateTagCommand(tagId, projectId, "existing-name", "#000"); - - Tag tag = new Tag(); - tag.setId(tagId); - tag.setProjectId(projectId); - tag.setName("old-name"); - tag.setColor("#FFF"); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag)); - when(tagRepository.existsByNameAndProjectId(projectId, "existing-name")).thenReturn(true); - - // Act & Assert - assertThrows(ResourceAlreadyExistsException.class, () -> { - updateTagUseCase.execute(command); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).findById(tagId); - verify(tagRepository, times(1)).existsByNameAndProjectId(projectId, "existing-name"); - verify(tagRepository, never()).save(any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/AssociateTagToItemImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/AssociateTagToItemImplTest.java deleted file mode 100644 index 34bd906..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/AssociateTagToItemImplTest.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.devaulty.backend.application.impl.tag.item; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectScopedRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Collections; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class AssociateTagToItemImplTest { - - @Mock - private ItemTagRepositoryPort itemTagRepository; - - @Mock - private TagRepositoryPort tagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private ProjectScopedRepositoryPort snippetRepository; - - private AssociateTagToItemImpl associateTagToItemUseCase; - - @BeforeEach - void setUp() { - associateTagToItemUseCase = new AssociateTagToItemImpl( - itemTagRepository, - tagRepository, - projectRepository, - Collections.singletonList(snippetRepository) - ); - } - - @Test - void shouldAssociateTagToItemSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByIdAndProjectId(tagId, projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - when(snippetRepository.existsByIdAndProjectId(itemId, projectId)).thenReturn(true); - - // Act - associateTagToItemUseCase.execute(projectId, itemType, itemId, tagId); - - // Assert - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByIdAndProjectId(tagId, projectId); - verify(snippetRepository, times(1)).existsByIdAndProjectId(itemId, projectId); - verify(itemTagRepository, times(1)).associateTagToItem(tagId, itemType, itemId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - associateTagToItemUseCase.execute(projectId, itemType, itemId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, never()).existsByIdAndProjectId(any(), any()); - verify(itemTagRepository, never()).associateTagToItem(any(), any(), any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenTagDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByIdAndProjectId(tagId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - associateTagToItemUseCase.execute(projectId, itemType, itemId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByIdAndProjectId(tagId, projectId); - verify(snippetRepository, never()).existsByIdAndProjectId(any(), any()); - verify(itemTagRepository, never()).associateTagToItem(any(), any(), any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenItemDoesNotExistOrDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByIdAndProjectId(tagId, projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - when(snippetRepository.existsByIdAndProjectId(itemId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - associateTagToItemUseCase.execute(projectId, itemType, itemId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByIdAndProjectId(tagId, projectId); - verify(snippetRepository, times(1)).existsByIdAndProjectId(itemId, projectId); - verify(itemTagRepository, never()).associateTagToItem(any(), any(), any()); - } - - @Test - void shouldThrowIllegalArgumentExceptionWhenItemTypeIsUnsupported() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "unsupported-type"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByIdAndProjectId(tagId, projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn("snippet"); - - // Act & Assert - assertThrows(IllegalArgumentException.class, () -> { - associateTagToItemUseCase.execute(projectId, itemType, itemId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByIdAndProjectId(tagId, projectId); - verify(itemTagRepository, never()).associateTagToItem(any(), any(), any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemImplTest.java deleted file mode 100644 index c419472..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemImplTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.devaulty.backend.application.impl.tag.item; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectScopedRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetTagsForItemImplTest { - - @Mock - private ItemTagRepositoryPort itemTagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private ProjectScopedRepositoryPort snippetRepository; - - private GetTagsForItemImpl getTagsForItemUseCase; - - @BeforeEach - void setUp() { - getTagsForItemUseCase = new GetTagsForItemImpl( - itemTagRepository, - projectRepository, - Collections.singletonList(snippetRepository) - ); - } - - @Test - void shouldReturnTagsForItem_whenProjectAndItemExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - Tag tag = new Tag(); - tag.setProjectId(projectId); - tag.setName("docker"); - List expectedList = Collections.singletonList(tag); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - when(snippetRepository.existsByIdAndProjectId(itemId, projectId)).thenReturn(true); - when(itemTagRepository.findTagsForItem(itemType, projectId, itemId)).thenReturn(expectedList); - - // Act - List result = getTagsForItemUseCase.execute(itemType, projectId, itemId); - - // Assert - assertNotNull(result); - assertEquals(expectedList, result); - assertEquals(1, result.size()); - assertEquals("docker", result.getFirst().getName()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).existsByIdAndProjectId(itemId, projectId); - verify(itemTagRepository, times(1)).findTagsForItem(itemType, projectId, itemId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getTagsForItemUseCase.execute(itemType, projectId, itemId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(itemTagRepository, never()).findTagsForItem(any(), any(), any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenItemDoesNotExistOrDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - when(snippetRepository.existsByIdAndProjectId(itemId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getTagsForItemUseCase.execute(itemType, projectId, itemId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).existsByIdAndProjectId(itemId, projectId); - verify(itemTagRepository, never()).findTagsForItem(any(), any(), any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemsImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemsImplTest.java deleted file mode 100644 index fdd0e42..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/GetTagsForItemsImplTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.devaulty.backend.application.impl.tag.item; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectScopedRepositoryPort; -import com.devaulty.backend.domain.model.Tag; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GetTagsForItemsImplTest { - - @Mock - private ItemTagRepositoryPort itemTagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private ProjectScopedRepositoryPort snippetRepository; - - private GetTagsForItemsImpl getTagsForItemsUseCase; - - @BeforeEach - void setUp() { - getTagsForItemsUseCase = new GetTagsForItemsImpl( - itemTagRepository, - projectRepository, - Collections.singletonList(snippetRepository) - ); - } - - @Test - void shouldReturnTagsForItems_whenProjectAndItemsExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - List itemIds = Collections.singletonList(itemId); - String itemType = "snippet"; - - Tag tag = new Tag(); - tag.setProjectId(projectId); - tag.setName("docker"); - List tagsList = Collections.singletonList(tag); - - Map> expectedMap = new HashMap<>(); - expectedMap.put(itemId, tagsList); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - when(snippetRepository.findExistingIdsByProject(itemIds, projectId)).thenReturn(Collections.singletonList(itemId)); - when(itemTagRepository.findTagsForItems(itemType, projectId, itemIds)).thenReturn(expectedMap); - - // Act - Map> result = getTagsForItemsUseCase.execute(itemType, projectId, itemIds); - - // Assert - assertNotNull(result); - assertEquals(expectedMap, result); - assertTrue(result.containsKey(itemId)); - assertEquals(1, result.get(itemId).size()); - assertEquals("docker", result.get(itemId).getFirst().getName()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findExistingIdsByProject(itemIds, projectId); - verify(itemTagRepository, times(1)).findTagsForItems(itemType, projectId, itemIds); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - List itemIds = Collections.singletonList(itemId); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getTagsForItemsUseCase.execute(itemType, projectId, itemIds); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(itemTagRepository, never()).findTagsForItems(any(), any(), any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenAnyItemDoesNotExistOrDoesNotBelongToProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - List itemIds = Collections.singletonList(itemId); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - when(snippetRepository.findExistingIdsByProject(itemIds, projectId)).thenReturn(Collections.emptyList()); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - getTagsForItemsUseCase.execute(itemType, projectId, itemIds); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findExistingIdsByProject(itemIds, projectId); - verify(itemTagRepository, never()).findTagsForItems(any(), any(), any()); - } - - @Test - void shouldReturnTagsForItems_whenInputContainsDuplicates() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - List itemIds = List.of(itemId, itemId); // Duplicate - String itemType = "snippet"; - - Tag tag = new Tag(); - tag.setProjectId(projectId); - tag.setName("docker"); - List tagsList = Collections.singletonList(tag); - - Map> expectedMap = new HashMap<>(); - expectedMap.put(itemId, tagsList); - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - // The repository will return distinct existing ID - when(snippetRepository.findExistingIdsByProject(itemIds, projectId)).thenReturn(List.of(itemId)); - when(itemTagRepository.findTagsForItems(itemType, projectId, itemIds)).thenReturn(expectedMap); - - // Act - Map> result = getTagsForItemsUseCase.execute(itemType, projectId, itemIds); - - // Assert - assertNotNull(result); - assertEquals(expectedMap, result); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findExistingIdsByProject(itemIds, projectId); - verify(itemTagRepository, times(1)).findTagsForItems(itemType, projectId, itemIds); - } - - @Test - void shouldReturnEmptyMap_whenInputIsEmpty() { - // Arrange - UUID projectId = UUID.randomUUID(); - List itemIds = Collections.emptyList(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - when(snippetRepository.findExistingIdsByProject(itemIds, projectId)).thenReturn(Collections.emptyList()); - when(itemTagRepository.findTagsForItems(itemType, projectId, itemIds)).thenReturn(Collections.emptyMap()); - - // Act - Map> result = getTagsForItemsUseCase.execute(itemType, projectId, itemIds); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - - verify(projectRepository, times(1)).existsById(projectId); - verify(snippetRepository, times(1)).findExistingIdsByProject(itemIds, projectId); - verify(itemTagRepository, times(1)).findTagsForItems(itemType, projectId, itemIds); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/RemoveTagFromItemImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/RemoveTagFromItemImplTest.java deleted file mode 100644 index c0f27f0..0000000 --- a/backend/src/test/java/com/devaulty/backend/application/impl/tag/item/RemoveTagFromItemImplTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.devaulty.backend.application.impl.tag.item; - -import com.devaulty.backend.application.exception.ResourceNotFoundException; -import com.devaulty.backend.application.port.out.persistence.ItemTagRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.ProjectScopedRepositoryPort; -import com.devaulty.backend.application.port.out.persistence.TagRepositoryPort; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Collections; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class RemoveTagFromItemImplTest { - - @Mock - private ItemTagRepositoryPort itemTagRepository; - - @Mock - private ProjectRepositoryPort projectRepository; - - @Mock - private TagRepositoryPort tagRepository; - - @Mock - private ProjectScopedRepositoryPort snippetRepository; - - private RemoveTagFromItemImpl removeTagFromItemUseCase; - - @BeforeEach - void setUp() { - removeTagFromItemUseCase = new RemoveTagFromItemImpl( - itemTagRepository, - projectRepository, - tagRepository, - Collections.singletonList(snippetRepository) - ); - } - - @Test - void shouldRemoveTagFromItemSuccessfully() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByIdAndProjectId(tagId, projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - when(snippetRepository.existsByIdAndProjectId(itemId, projectId)).thenReturn(true); - - // Act - removeTagFromItemUseCase.execute(projectId, itemType, itemId, tagId); - - // Assert - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByIdAndProjectId(tagId, projectId); - verify(snippetRepository, times(1)).existsByIdAndProjectId(itemId, projectId); - verify(itemTagRepository, times(1)).disassembleTagFromItem(projectId, tagId, itemType, itemId); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenProjectDoesNotExist() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - removeTagFromItemUseCase.execute(projectId, itemType, itemId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, never()).existsByIdAndProjectId(any(), any()); - verify(itemTagRepository, never()).disassembleTagFromItem(any(), any(), any(), any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenTagDoesNotExistInProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByIdAndProjectId(tagId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - removeTagFromItemUseCase.execute(projectId, itemType, itemId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByIdAndProjectId(tagId, projectId); - verify(snippetRepository, never()).existsByIdAndProjectId(any(), any()); - verify(itemTagRepository, never()).disassembleTagFromItem(any(), any(), any(), any()); - } - - @Test - void shouldThrowResourceNotFoundExceptionWhenItemDoesNotExistInProject() { - // Arrange - UUID projectId = UUID.randomUUID(); - UUID tagId = UUID.randomUUID(); - UUID itemId = UUID.randomUUID(); - String itemType = "snippet"; - - when(projectRepository.existsById(projectId)).thenReturn(true); - when(tagRepository.existsByIdAndProjectId(tagId, projectId)).thenReturn(true); - when(snippetRepository.getSupportedType()).thenReturn(itemType); - when(snippetRepository.existsByIdAndProjectId(itemId, projectId)).thenReturn(false); - - // Act & Assert - assertThrows(ResourceNotFoundException.class, () -> { - removeTagFromItemUseCase.execute(projectId, itemType, itemId, tagId); - }); - - verify(projectRepository, times(1)).existsById(projectId); - verify(tagRepository, times(1)).existsByIdAndProjectId(tagId, projectId); - verify(snippetRepository, times(1)).existsByIdAndProjectId(itemId, projectId); - verify(itemTagRepository, never()).disassembleTagFromItem(any(), any(), any(), any()); - } -} diff --git a/backend/src/test/java/com/devaulty/backend/infrastructure/BaseIntegrationTest.java b/backend/src/test/java/com/devaulty/backend/infrastructure/BaseIntegrationTest.java deleted file mode 100644 index 4a6cd45..0000000 --- a/backend/src/test/java/com/devaulty/backend/infrastructure/BaseIntegrationTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.devaulty.backend.infrastructure; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@SpringBootTest -@ActiveProfiles("test") -public abstract class BaseIntegrationTest { - - protected MockMvc mockMvc; - - @Autowired - private WebApplicationContext webApplicationContext; - - protected final ObjectMapper objectMapper = new ObjectMapper() - .registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); - - @BeforeEach - void setupMockMvc() { - this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } -} diff --git a/backend/src/test/resources/application-test.yaml b/backend/src/test/resources/application-test.yaml deleted file mode 100644 index 350f28c..0000000 --- a/backend/src/test/resources/application-test.yaml +++ /dev/null @@ -1,16 +0,0 @@ -spring: - datasource: - url: jdbc:sqlite:file::memory:?cache=shared - driver-class-name: org.sqlite.JDBC - hikari: - maximum-pool-size: 1 - jpa: - database-platform: org.hibernate.community.dialect.SQLiteDialect - hibernate: - ddl-auto: validate - show-sql: true - properties: - hibernate: - format_sql: true - liquibase: - enabled: true diff --git a/docs/architecture/tags.md b/docs/architecture/tags.md index 54d5f00..e157a9a 100644 --- a/docs/architecture/tags.md +++ b/docs/architecture/tags.md @@ -1,139 +1,102 @@ -# Tag System Architecture +# Tag System Architecture (Go Backend) -This document explains how tags work across Devaulty, why they're built the -way they are, and what to do when adding tag support to a new entity. +This document explains how tags work across Devaulty's Go backend (`backend`), why they're built the way they are, and what to do when adding tag support to a new entity. ## The problem tags solve -Tags are a **cross-cutting concern**: a single tag (`"docker"`, `"urgent"`) -can be attached to items of completely different types — a snippet, a -problem, a link, a credential, a note. Modeling this the "obvious" way (a -`List tags` field on every domain entity) would mean every domain model, -every use case, and every persistence adapter would need to know about tags. -That's the wrong trade-off: it turns an add-on feature into a dependency -baked into everything else. +Tags are a **cross-cutting concern**: a single tag (`"docker"`, `"urgent"`) can be attached to items of completely different types — a snippet, a problem, a link, a credential, a note. Modeling this with a direct field in every domain model would pollute every core domain entity, use case, and repository interface with tag logic. -Instead, tags are kept **out of the domain models entirely** and are -attached/read as a separate step, orchestrated at the web layer. +Instead, domain entities (`model.Snippet`, `model.Link`, `model.Problem`, etc.) remain clean and tag-agnostic in `internal/domain/model`. Tags are stored via a polymorphic association table (`item_tags`) and attached to response DTOs (`internal/dto`) inside the application use cases. ## The core design decision -> Domain models (`Snippet`, `Problem`, `Link`, `Credential`, `Note`, ...) -> never carry a `tags` field. Tags are looked up on demand, only when -> building an HTTP response, and merged in at that point. +> Core domain models (`model.Snippet`, `model.Problem`, `model.Link`, etc.) never carry a `Tags` field. +> Tags are fetched on demand by use cases using an injected `port.ItemTagRepository` and populated into response DTOs (`dto.SnippetView`, `dto.LinkView`, `dto.ProblemView`, `dto.ProblemSummary`) before returning to the web layer. -This keeps every existing use case (`CreateSnippetImpl`, `UpdateProblemImpl`, -etc.) completely untouched. Tag support is additive, not invasive. +This keeps domain rules and database models simple while centralizing application DTOs in package `internal/dto`. ## The moving parts -### 1. Storage: polymorphic association, not a domain object +### 1. Storage: polymorphic association (`item_tags`) -`item_tags` is a join table with a composite key (`tag_id`, `item_type`, -`item_id`) and **no real foreign key to the tagged item** — `item_type` + -`item_id` is just a loose pointer, because a single join table can't have a -proper FK to five different tables. +`item_tags` is a join table with a composite primary key (`tag_id`, `item_type`, `item_id`) and no foreign key constraint on `item_id` (since `item_id` can refer to multiple tables): -Consequence: the database can't cascade-delete `item_tags` rows when a -snippet/problem/link/credential/note is deleted. Every `Delete*Impl` for a -taggable entity **must** explicitly clean up its tag associations -(`ItemTagRepositoryPort#removeAllTagsFromItem`) after deleting the item -itself. This is the one place ownership-by-database breaks down and the -application has to enforce it manually. +```sql +CREATE TABLE item_tags ( + tag_id VARCHAR(36) NOT NULL, + item_type VARCHAR(20) NOT NULL, + item_id VARCHAR(36) NOT NULL, + PRIMARY KEY (tag_id, item_type, item_id), + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); +``` -`ItemTagEntity` is a JPA implementation detail — nothing outside -`adapter/out/persistence/tag` ever sees it. The rest of the app works with -the domain `Tag` type only. +**Cascading Cleanup:** Because SQLite cannot enforce FK cascades on `item_id` across different tables, every `Delete` method for a taggable entity in `internal/usecase/` (e.g. `SnippetUseCase.Delete`, `LinkUseCase.Delete`, `ProblemUseCase.Delete`) explicitly calls `itemTagRepo.RemoveAllTagsFromItem(ctx, itemType, itemID)`. -### 2. Reading tags: two use cases, chosen by how many items you're showing +### 2. DTO Centralization (`internal/dto/`) -- `GetTagsForItemUseCase` — one item, one call. Used on `getById` endpoints. -- `GetTagsForItemsUseCase` — many items, one batched call, returns - `Map>` keyed by item id. Used on `getAll`/paginated - endpoints. +All request commands and response views live in package `devaulty-backend/internal/dto`: -**Never call the singular use case in a loop over a list of items** — that's -an N+1 query bug. If you're building a list response, use the plural, -batched version. +- **`dto.TagSummary`**: Lightweight DTO (`ID`, `Name`, `Color`) embedded in item response views. +- **`dto.TagView`**: Full DTO (`ID`, `ProjectID`, `Name`, `Color`, `CreatedAt`, `UpdatedAt`) used for tag management responses. +- **Item Views**: `SnippetView`, `LinkView`, `ProblemView`, and `ProblemSummary` contain `Tags []dto.TagSummary`. -### 3. Writing tags: a separate controller, not part of create/update +### 3. Reading tags: two query patterns in Use Cases -Associating or removing a tag is its own endpoint, decoupled from the -entity's own create/update flow: +Use cases inject `port.ItemTagRepository` and fetch tags when building DTO views: -``` -PUT /api/v1/projects/{projectId}/items/{itemType}/{itemId}/tags/{tagId} -DELETE /api/v1/projects/{projectId}/items/{itemType}/{itemId}/tags/{tagId} -``` +- **Single Item (`GetByID`, `Update`)**: Calls `itemTagRepo.FindTagsForItem(ctx, itemType, projectID, itemID)`. +- **Paginated List (`GetAllByProjectID`)**: Collects item IDs from the page content and calls `itemTagRepo.FindTagsForItems(ctx, itemType, projectID, itemIDs)` in a **single batched SQL query** returning `map[uuid.UUID][]model.Tag`. + +> **Rule:** Never execute single-item tag queries in a loop over paginated results to avoid N+1 query performance bottlenecks. -`PUT` (not `POST`) because attaching a tag is idempotent — attaching the -same tag twice should be a no-op, not an error or a duplicate. +### 4. Writing tags: dedicated `ItemTagHandler` endpoints -A newly created item never has tags yet, so `create` endpoints skip the tag -lookup entirely and pass an empty list to the mapper. +Tag association and disassociation are decoupled from item creation and updates: -### 4. Merging tags into the response: mapper takes an extra parameter +```text +PUT /api/v1/projects/:project_id/items/:item_type/:item_id/tags/:tag_id +DELETE /api/v1/projects/:project_id/items/:item_type/:item_id/tags/:tag_id +``` + +- `PUT` associates a tag to an item (`204 No Content`). It is idempotent. +- `DELETE` disassociates a tag from an item (`204 No Content`). +- Query path param `:item_type` is parsed in uppercase (`model.ItemType(strings.ToUpper(c.Param("item_type")))`) to match registered domain constants (`SNIPPET`, `LINK`, `PROBLEM`, `NOTE`, `CREDENTIAL`). -DTOs for taggable entities carry a `List tags` field. -The relevant `*WebMapper` gets a second method parameter for the tag list, -and delegates the `Tag → TagSummaryResponse` conversion to `TagWebMapper` via -MapStruct's `uses`: +### 5. Tag Management: `TagHandler` endpoints -```java -@Mapper(componentModel = "spring", uses = TagWebMapper.class) -public interface SnippetWebMapper { +Full CRUD and search for project tags: - @Mapping(target = "tags", source = "tags") - SnippetViewResponse toViewResponse(Snippet snippet, List tags); -} +```text +POST /api/v1/projects/:project_id/tags (Create tag) +GET /api/v1/projects/:project_id/tags (Get all tags) +GET /api/v1/projects/:project_id/tags/search (Search tags by name prefix/substring) +GET /api/v1/projects/:project_id/tags/:tag_id (Get tag by ID) +PATCH /api/v1/projects/:project_id/tags/:tag_id (Update tag) +DELETE /api/v1/projects/:project_id/tags/:tag_id (Delete tag) ``` -The controller is responsible for fetching the tags and passing them in — -mappers never reach into a repository themselves. +Tag names are unique per project (`strings.TrimSpace(cmd.Name)`), returning `400 Bad Request` if a tag with the same name already exists. -## Full flow example: `GET /projects/{projectId}/snippets` +## Full flow example: `GET /projects/{project_id}/snippets` +```text +SnippetHandler.GetAll + → SnippetUseCase.GetAllByProjectID(ctx, projectID, page, size) + → snippetRepo.FindAllByProjectID(...) // Fetch page of snippets + → collect snippet IDs + → itemTagRepo.FindTagsForItems(...) // ONE batched query for tags + → assemble dto.SnippetView slice with []dto.TagSummary + → returns model.Page[dto.SnippetView] as JSON ``` -SnippetController.getAllByProject - → getAllSnippetsByProjectUseCase.execute(...) // fetch the page of snippets - → collect snippet IDs from that page - → getTagsForItemsUseCase.execute("snippet", projectId, ids) // ONE batched query - → snippets.map(s -> webMapper.toSummaryResponse(s, tagsByItem.get(s.getId()))) - → return -``` -## Checklist: adding tag support to a new taggable entity - -- [ ] Domain model stays untouched — no `tags` field. -- [ ] Add `List tags` to its `*ViewResponse` and - `*SummaryResponse` DTOs. -- [ ] `*WebMapper`: add `uses = TagWebMapper.class` and a second parameter - (`List tags`) to the `toViewResponse`/`toSummaryResponse` methods. -- [ ] Controller: inject `GetTagsForItemUseCase` and - `GetTagsForItemsUseCase`. - - `getById` → call the singular use case. - - `getAll` → collect IDs, call the plural/batched use case once. - - `create` → pass `List.of()`, no lookup needed. - - `update` → call the singular use case (the item already existed, it may - already have tags). -- [ ] `Delete*Impl`: after deleting the item, call - `itemTagRepositoryPort.removeAllTagsFromItem(itemType, id)` to avoid - orphaned `item_tags` rows (see storage section above — the database - won't do this for you). -- [ ] Use the entity's lowercase name as the `item_type` string consistently - (e.g. `"snippet"`, `"credential"`) — this string is the only thing - tying `item_tags` rows back to the right table, so a typo here - silently breaks the association. Prefer a `private static final - String ITEM_TYPE = "..."` constant in the controller over inlining - the literal at each call site. - -## Why not a `Taggable` interface on domain models? - -It was considered and rejected. Giving every taggable domain model a -`List tags` field (via a shared interface) would mean every existing -`Create*Impl`/`Update*Impl` would need to manage that list, every -persistence adapter would need to populate it on every read, and the -"cheap" read-only nature of tag lookups (batch them only when building a -response) would be lost. The chosen approach keeps tag support entirely -additive: it touches the web layer only, and existing application/domain -code for every entity remains exactly as it was before tags existed. +## Checklist: adding tag support to a new entity + +- [ ] Core domain model remains clean (no `Tags` field in `internal/domain/model`). +- [ ] Define its DTO views in `internal/dto/` and include `Tags []dto.TagSummary`. +- [ ] Inject `port.ItemTagRepository` into its UseCase constructor. +- [ ] In UseCase `GetByID` & `Update`: call `itemTagRepo.FindTagsForItem` and attach tags. +- [ ] In UseCase `GetAllByProjectID`: collect IDs, call `itemTagRepo.FindTagsForItems` (batched), and attach tags. +- [ ] In UseCase `Create`: pass empty tag slice `[]dto.TagSummary{}`. +- [ ] In UseCase `Delete`: call `itemTagRepo.RemoveAllTagsFromItem(ctx, itemType, id)` after deleting the item. +- [ ] Register repository in `NewItemTagUseCase` and add corresponding `model.ItemType` enum constant (e.g. `SNIPPET`, `LINK`, `PROBLEM`, `NOTE`). diff --git a/docs/security/local-development-tokens.md b/docs/security/local-development-tokens.md index 7c415e1..7757bcc 100644 --- a/docs/security/local-development-tokens.md +++ b/docs/security/local-development-tokens.md @@ -1,69 +1,66 @@ -# Local Development & Process Security Tokens +# Local Development & Process Security Tokens (Go Backend) -This document explains how the internal process security token (`X-Devaulty-Internal-Token`) operates, why it exists, and how to develop and test endpoints locally using Swagger UI and cURL. +This document explains how the internal process security token (`DEVAULTY_INTERNAL_TOKEN`) operates in the Go backend (`backend`), why it exists, and how to develop and test endpoints locally using Scalar API Docs and cURL. ## The Problem Addressed -Devaulty runs a local Spring Boot server on an ephemeral port inside the user's desktop machine. Since local HTTP ports can theoretically be probed by other local applications running under the same user session, all `/api/v1/**` endpoints require an internal process authentication header: +Devaulty runs a local Go HTTP server on a local port inside the user's desktop machine. Since local HTTP ports can theoretically be probed by other local applications running under the same user session, all `/api/v1/*` endpoints require an internal process authentication header: ```http -X-Devaulty-Internal-Token: +DEVAULTY_INTERNAL_TOKEN: ``` ## Production vs Development Behavior -The token validation logic is enforced by `InternalAppTokenFilter`. +The token validation logic is enforced by `middleware.AuthMiddleware(apiToken)` using `crypto/subtle.ConstantTimeCompare`. -``` +```text Production Mode (prod) Development Mode (dev) ====================== ====================== -- Random UUID generated - Accepts static token from application-dev.yml - in-memory at process boot ("dev-secret-token") -- Injected via JavaFX into - Accepts random UUID as fallback - WebView before React mounts - Enables Swagger UI & cURL testing +- Random UUID injected by - Fallback to static token: + desktop parent process "dev-token" (APP_ENV=dev) + at startup - Enables Scalar UI at /docs +- Enforces token isolation - Enables local cURL testing ``` -### 1. Production Mode (`application-prod.yml`) -- `AppTokenContext.PROCESS_TOKEN` generates a cryptographically unique UUID (`UUID.randomUUID()`) in-memory when the JVM boots. -- `DevaultyDesktop.java` injects this token directly into the JavaFX `WebView` JS context (`window.DEVAULTY_INTERNAL_TOKEN`) during the `Worker.State.RUNNING` state before React components mount. -- External requests without this exact in-memory UUID receive `HTTP 403 Forbidden`. - -### 2. Local Development Mode (`application-dev.yml`) -- `src/main/resources/application-dev.yml` declares a development token: +> **Note on `dev-token`**: In development mode (`APP_ENV=dev`), `"dev-token"` is provided as a convenience default for local developer testing. In production mode, the desktop parent process generates a unique random UUID token and injects it via environment variable. -```yaml -devaulty: - dev: - token: "dev-secret-token" -``` +### 1. Security Middleware (`middleware.AuthMiddleware`) +- The middleware receives `apiToken` initialized at server startup in `cmd/api/main.go`. +- In `main.go`: + ```go + apiToken := os.Getenv("DEVAULTY_INTERNAL_TOKEN") + if apiToken == "" && os.Getenv("APP_ENV") == "dev" { + apiToken = "dev-token" + } + ``` +- Performs a constant-time comparison on incoming `DEVAULTY_INTERNAL_TOKEN` headers to prevent timing side-channel attacks. +- Requests without a valid token receive `HTTP 401 Unauthorized`. -- `InternalAppTokenFilter` reads `@Value("${devaulty.dev.token:#{null}}")`. If present, it permits requests carrying `"dev-secret-token"`. -- In `application-prod.yml`, `devaulty.dev.token` is completely absent. `devToken` evaluates to `null`, ensuring development tokens can **never** be used in production builds. +### 2. Interactive Documentation (Scalar API Reference at `/docs`) +- When running in `APP_ENV=dev`, `/docs` serves the interactive Scalar API documentation UI. +- Interactive documentation dynamically loads `./docs/openapi.yaml`. ## How to Test Endpoints Locally -### Option A: Using Swagger UI +### Option A: Using Scalar API Docs (`/docs`) -1. Open Swagger UI in your browser: - `http://localhost:8080/swagger-ui.html` -2. Click the green **Authorize 🔓** button in the top right corner. -3. In the `DevaultyInternalToken` value input, enter: - `dev-secret-token` -4. Click **Authorize** and close the modal. -5. All subsequent "Try it out" API calls from Swagger UI will automatically include the `X-Devaulty-Internal-Token: dev-secret-token` header. +1. Open Scalar API Docs in your browser: + `http://localhost:8080/docs` +2. Enter `dev-token` in the security authorization header input. +3. All subsequent "Test Request" calls from Scalar UI will automatically send `DEVAULTY_INTERNAL_TOKEN: dev-token`. ### Option B: Using cURL / HTTP Clients -Pass the `X-Devaulty-Internal-Token` header in your request: +Pass the `DEVAULTY_INTERNAL_TOKEN` header in your request: ```bash -curl -X GET "http://localhost:8080/api/v1/release/check" \ - -H "X-Devaulty-Internal-Token: dev-secret-token" \ +curl -X GET "http://localhost:8080/api/v1/projects" \ + -H "DEVAULTY_INTERNAL_TOKEN: dev-token" \ -H "Accept: application/json" ``` ## Security Best Practices Checklist -- [ ] `application-prod.yml` must **never** contain a `devaulty.dev.token` property. -- [ ] `InternalAppTokenFilter` must validate `devToken` only when non-null and non-blank (`devToken != null && !devToken.isBlank()`). -- [ ] Production builds (`jpackage`) must always execute with `-Dspring.profiles.active=prod`. +- [ ] Production desktop launcher must always generate a random UUID token and inject it as `DEVAULTY_INTERNAL_TOKEN`. +- [ ] `middleware.AuthMiddleware` must use constant-time byte comparison (`subtle.ConstantTimeCompare`) to prevent timing side-channel attacks. diff --git a/docs/security/memory-hygiene.md b/docs/security/memory-hygiene.md index ecbe211..0625011 100644 --- a/docs/security/memory-hygiene.md +++ b/docs/security/memory-hygiene.md @@ -1,76 +1,105 @@ -# Sensitive Memory Ownership Rule +# Sensitive Memory Ownership & Hygiene Rule (Go Backend) -This document defines how `byte[]` / `char[]` arrays holding sensitive -cryptographic material (master passwords, decrypted credential payloads) must -be handled across the Devaulty backend. +This document defines how `[]byte` slices holding sensitive cryptographic material (master passwords, derived AES keys, decrypted credential payloads) must be handled across Devaulty's Go backend (`backend`). -## The rule +## The Rule -At any point in a call chain, a sensitive array has exactly **one current -owner** — the last piece of code holding a live reference to it. +At any point in a execution path, a sensitive byte slice has exactly **one current owner** — the function or struct currently holding a live reference to it. -- Only the **owner** may zero the array (`Arrays.fill(array, (byte) 0)` for - `byte[]`, `Arrays.fill(array, '\0')` for `char[]`). -- Passing the array into another object (a record field, a return value, - a collaborator method) **transfers ownership**. The original holder must - **not** zero it afterward — doing so corrupts the data for whoever - received it. -- Ownership transfers **once**. The new owner either passes it along again - (transferring ownership further) or is the **terminal consumer**, in - which case it must zero the array once it's done extracting what it needs. +- Use **mutable `[]byte` slices** instead of Go `string`s for sensitive cryptographic data wherever possible (Go `string`s are immutable and cannot be zeroed in memory). +- **Single Ownership Transfer**: Passing a sensitive `[]byte` slice into a downstream method (e.g. passing `password` from `SecurityHandler` to `VaultUseCase`) **transfers ownership** to that component. +- Only the **terminal consumer owner** zeroes the slice memory using `defer clear(slice)`. Callers must **not** perform duplicate zeroing after transferring ownership, unless they explicitly pass a defensive copy. +- When storing a byte slice in a long-lived component (e.g. `MasterKeySession.SetKey`), the receiver must make a defensive copy (`copy(dst, src)`) or explicitly assume ownership. In short: +```text +HTTP Handler (converts JSON to []byte, clears DTO string, transfers ownership) + → UseCase (terminal consumer owner: runs `defer clear(password)`) + → Key Derivation (computes derived key []byte, runs `defer clear(secretKey)`) + → Session Holder (copies bytes into RAM, zeroes old key via `clear(oldKey)`) ``` -Creator → [passes along, does NOT zero] → ... → Terminal consumer → ZEROES + +## Why This Matters + +Leaving sensitive passwords or cryptographic keys in memory RAM indefinitely creates vulnerability windows for heap-dumping attacks or cold-boot attacks. + +In Go, `clear(slice)` (available in Go 1.21+) overwrites all byte elements with zero values (`0x00`) immediately, significantly reducing the retention window of sensitive data in RAM (though it zeroes the provided slice specifically rather than immutable strings or intermediate CPU/system buffers). + +## Code Patterns in `backend` + +### 1. HTTP Handlers (`SecurityHandler`) + +In `internal/adapter/in/web/handler/security_handler.go`: + +```go +func (h *SecurityHandler) SetupMasterPassword(c *gin.Context) { + var req dto.MasterPassword + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + password := []byte(req.MasterPassword) + req.MasterPassword = "" // clear reference to string DTO (ownership of password slice transfers to VaultUseCase) + + err := h.vaultUseCase.SetupMasterPassword(c.Request.Context(), password) + // ... +} +``` + +### 2. Use Cases (`VaultUseCase`) + +In `internal/usecase/vault_usecase.go`: + +```go +func (uc *VaultUseCase) SetupMasterPassword(ctx context.Context, password []byte) error { + defer clear(password) // terminal consumer of input password + + saltBytes, err := uc.keyDeriver.GenerateSalt(SaltLength) + if err != nil { + return err + } + defer clear(saltBytes) // terminal consumer of salt bytes + + // ... +} ``` -## Why this matters - -Zeroing an array that another object still references doesn't throw an -exception — it silently overwrites the data everyone else sees. The bug is -invisible until something downstream tries to read the (now-blank) value, -often producing a confusing, unrelated-looking error (e.g. a JSON parser -choking on null bytes) far from the line that actually caused it. - -## Real example from this codebase - -**Bug (July 2026):** `CredentialWebMapper#toCreateCredentialCommand` built a -`char[] serializedPayload`, passed it into `CreateCredentialCommand`, and -then zeroed `serializedPayload` in its own `finally` block "for safety." -Since `CreateCredentialCommand.payload()` held the *same* array reference, -the command reached `CreateCredentialImpl` already blanked out. The -resulting ciphertext encrypted null bytes, and decryption later failed to -parse as JSON — the error surfaced in a completely different class -(`CredentialWebMapper#jsonToMap`) than the one that caused it. - -**Fix:** the creator of `serializedPayload` stopped zeroing it. Ownership -now flows cleanly: `toCreateCredentialCommand` → `CreateCredentialImpl` (uses -it to encrypt, then zeroes it, since nothing downstream needs the plaintext -payload anymore) → the *decrypted* result flows separately to -`DecryptedCredential` → `CredentialWebMapper#jsonToMap` (the terminal -consumer, which zeroes it after parsing). - -## Checklist when writing code that touches sensitive arrays - -- [ ] Does this method receive a sensitive array as a parameter? - - If yes: am I the terminal consumer, or am I passing it further - (directly, or wrapped inside another object/record)? -- [ ] If I'm passing it further: do **not** zero it in my `finally` block. - Say so explicitly in a comment, so the next person doesn't "helpfully" - add a zero-fill. -- [ ] If I'm the terminal consumer: zero it in a `finally` block, - unconditionally, right after I've extracted what I need. -- [ ] Document the ownership contract in the method's Javadoc if the - method is a public/protected entry point others will call — see - `CreateCredentialImpl#execute` for the expected format. - -## Where this currently applies - -- `SetupMasterPasswordImpl` / `UnlockVaultImpl` — the `char[] password` input -- `CreateCredentialImpl` — `command.payload()` (input), `decryptedBytes` - (output, ownership transferred via `DecryptedCredential`) -- `CredentialWebMapper` — `serializedPayload` (transferred to the command), - `decryptedBytes` in `jsonToMap` (terminal — zeroed after parsing) -- Any future use case that decrypts a credential (e.g. `GetCredentialImpl`, - `UpdateCredentialImpl`) must follow the same pattern. +### 3. Session Holder (`MasterKeySessionHolderAdapter`) + +In `internal/adapter/out/security/master_key_session_holder.go`: + +```go +func (m *MasterKeySessionHolderAdapter) clearLocked() { + clear(m.masterKey) // zeroes provided slice bytes to reduce retention window + m.masterKey = nil + m.lastActivityAt = nil +} +``` + +### 4. Secret Payload DTOs (`dto.SecretBytes`) + +In `internal/dto/credential_dto.go`: + +```go +type SecretBytes []byte + +func (sb *SecretBytes) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *sb = SecretBytes(s) + return nil +} +``` + +`SecretBytes` allows JSON unmarshaling of plain strings directly into byte slices, enabling immediate zeroing with `defer clear(cmd.Password)`, `defer clear(cmd.APIKey)`, etc. in `CredentialUseCase`. + +## Checklist for Sensitive Data Handling + +- [ ] Does this function receive or construct a sensitive `[]byte` / `SecretBytes` slice (password, salt, raw key, secret payload)? +- [ ] Is `defer clear(slice)` called immediately after slice creation/reception? +- [ ] If storing key bytes in a long-lived struct, does `Clear()` / `SetKey()` overwrite existing bytes with `clear(m.key)` before nil-assigning? +- [ ] Are input DTO string references cleared or unmarshaled directly to `SecretBytes` for zeroing in memory? diff --git a/frontend/.gitignore b/frontend/.gitignore index da2274b..5576ffc 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -23,5 +23,10 @@ dist-ssr *.sln *.sw? +# Tauri / Rust build targets +src-tauri/target/ +src-tauri/gen/ +src-tauri/resources/devaulty-backend* + .skills api-docs.json \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b0097f..7659c4b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", + "@tauri-apps/api": "^2.11.1", "axios": "^1.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -26,6 +27,7 @@ "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.2", "@tanstack/router-vite-plugin": "^1.167.19", + "@tauri-apps/cli": "^2.11.4", "@types/dompurify": "^3.0.5", "@types/marked": "^5.0.2", "@types/node": "^24.13.2", @@ -1385,6 +1387,233 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index ad44c31..0c4392b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,18 +1,23 @@ { "name": "frontend", "private": true, - "version": "0.0.0", + "version": "0.1.7-alpha", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "tauri": "tauri", + "version:sync": "node scripts/sync-version.js", + "build:all": "node scripts/build-all.js", + "build:deb": "tauri build --bundles deb" }, "dependencies": { "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", + "@tauri-apps/api": "^2.11.1", "axios": "^1.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -28,6 +33,7 @@ "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.2", "@tanstack/router-vite-plugin": "^1.167.19", + "@tauri-apps/cli": "^2.11.4", "@types/dompurify": "^3.0.5", "@types/marked": "^5.0.2", "@types/node": "^24.13.2", diff --git a/backend/src/main/resources/static/devaulty-splash-screen-logo.png b/frontend/public/devaulty-splash-screen-logo.png similarity index 100% rename from backend/src/main/resources/static/devaulty-splash-screen-logo.png rename to frontend/public/devaulty-splash-screen-logo.png diff --git a/frontend/public/splash.html b/frontend/public/splash.html new file mode 100644 index 0000000..00142e5 --- /dev/null +++ b/frontend/public/splash.html @@ -0,0 +1,30 @@ + + + + + Devaulty Loading + + + + Devaulty Splash Screen + + \ No newline at end of file diff --git a/frontend/scripts/build-all.js b/frontend/scripts/build-all.js new file mode 100644 index 0000000..9430f2f --- /dev/null +++ b/frontend/scripts/build-all.js @@ -0,0 +1,66 @@ +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const backendDir = path.resolve(__dirname, "../../backend"); +const frontendDir = path.resolve(__dirname, ".."); +const resourcesDir = path.resolve(__dirname, "../src-tauri/resources"); + +const isWindows = process.platform === "win32"; +const binaryName = isWindows ? "devaulty-backend.exe" : "devaulty-backend"; + +function runCommand(command, cwd) { + console.log(`\nRunning: "${command}" in ${cwd}`); + execSync(command, { cwd, stdio: "inherit" }); +} + +function buildAll() { + // 1. Compile Go backend into a production-optimized native binary. + // SQL migrations are embedded into the binary via go:embed at compile time, + // so no external migration files need to be shipped. + console.log("Step 1: Building Go backend binary..."); + const targetBinaryPath = path.join(resourcesDir, binaryName); + + if (!fs.existsSync(resourcesDir)) { + fs.mkdirSync(resourcesDir, { recursive: true }); + } else { + // Clean any residual backend binaries from previous builds to prevent cross-platform file pollution + const existingFiles = fs.readdirSync(resourcesDir); + for (const file of existingFiles) { + if (file.startsWith("devaulty-backend")) { + try { + fs.unlinkSync(path.join(resourcesDir, file)); + } catch { + // Ignore deletion errors + } + } + } + } + + // -ldflags="-s -w" strips debug symbols and DWARF info for a smaller binary + const goBuildCmd = `go build -ldflags="-s -w" -o "${targetBinaryPath}" ./cmd/api/`; + runCommand(goBuildCmd, backendDir); + + // Set executable permissions on Unix systems + if (!isWindows) { + fs.chmodSync(targetBinaryPath, 0o755); + } + + console.log(`Go backend compiled -> src-tauri/resources/${binaryName}`); + + // 2. Synchronize version across all project manifests + console.log("\nStep 2: Synchronizing project versions..."); + runCommand("node scripts/sync-version.js", frontendDir); + + // 3. Build frontend web assets (Vite) + console.log("\nStep 3: Building React/Vite web bundle..."); + runCommand("npx vite build", frontendDir); + + console.log("\nCross-Platform Build Prep Complete! Ready for Tauri packaging.\n"); +} + +buildAll(); diff --git a/frontend/scripts/sync-version.js b/frontend/scripts/sync-version.js new file mode 100644 index 0000000..aadd313 --- /dev/null +++ b/frontend/scripts/sync-version.js @@ -0,0 +1,53 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Source of truth: package.json version field +const packageJsonPath = path.resolve(__dirname, "../package.json"); +const cargoTomlPath = path.resolve(__dirname, "../src-tauri/Cargo.toml"); +const tauriConfPath = path.resolve(__dirname, "../src-tauri/tauri.conf.json"); + +function syncVersion() { + if (!fs.existsSync(packageJsonPath)) { + console.error(`Source of truth file not found at: ${packageJsonPath}`); + process.exit(1); + } + + // 1. Read version from package.json (source of truth) + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + const version = pkg.version; + + if (!version) { + console.error("Could not find 'version' field in package.json"); + process.exit(1); + } + + console.log(`Source version (package.json): "${version}"`); + + // 2. Update Cargo.toml (supports full SemVer including pre-release tags like 0.1.6-alpha.1) + if (fs.existsSync(cargoTomlPath)) { + let cargoContent = fs.readFileSync(cargoTomlPath, "utf-8"); + cargoContent = cargoContent.replace(/^version = ".*?"/m, `version = "${version}"`); + fs.writeFileSync(cargoTomlPath, cargoContent); + console.log(`Updated src-tauri/Cargo.toml -> ${version}`); + } + + // 3. Update tauri.conf.json + // Windows MSI (WiX Toolset) & macOS bundle require a strict numeric Major.Minor.Patch version. + // We extract the numeric prefix (e.g. "0.1.6-alpha.1" -> "0.1.6") for tauri.conf.json. + const numericVersion = version.split("-")[0]; + + if (fs.existsSync(tauriConfPath)) { + const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, "utf-8")); + tauriConf.version = numericVersion; + fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + "\n"); + console.log(`Updated src-tauri/tauri.conf.json -> ${numericVersion} (normalized from ${version})`); + } + + console.log("\nAll frontend & Tauri version fields are synchronized!"); +} + +syncVersion(); diff --git a/frontend/src-tauri/.gitignore b/frontend/src-tauri/.gitignore new file mode 100644 index 0000000..502406b --- /dev/null +++ b/frontend/src-tauri/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +/gen/schemas diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock new file mode 100644 index 0000000..a5251a9 --- /dev/null +++ b/frontend/src-tauri/Cargo.lock @@ -0,0 +1,4780 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "devaulty" +version = "0.1.6" +dependencies = [ + "dirs", + "log", + "reqwest 0.12.28", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-log", + "tokio", + "uuid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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 = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.19", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-log" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" +dependencies = [ + "android_logger", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.19", + "toml 1.1.3+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml new file mode 100644 index 0000000..4d05967 --- /dev/null +++ b/frontend/src-tauri/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "devaulty" +version = "0.1.7-alpha" +description = "Devaulty Desktop Client" +authors = ["Matheus Cunha"] +license = "" +repository = "https://github.com/MathCunha16/Devaulty" +edition = "2021" +rust-version = "1.77.2" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "devaulty_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.6.3", features = [] } + +[dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +log = "0.4" +tauri = { version = "2.11.3", features = [] } +tauri-plugin-log = "2" +dirs = "6.0" +tokio = { version = "1", features = ["time"] } +uuid = { version = "1", features = ["v4"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs new file mode 100644 index 0000000..795b9b7 --- /dev/null +++ b/frontend/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json new file mode 100644 index 0000000..c135d7f --- /dev/null +++ b/frontend/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main" + ], + "permissions": [ + "core:default" + ] +} diff --git a/frontend/src-tauri/icons/128x128.png b/frontend/src-tauri/icons/128x128.png new file mode 100644 index 0000000..f8d7fcc Binary files /dev/null and b/frontend/src-tauri/icons/128x128.png differ diff --git a/frontend/src-tauri/icons/128x128@2x.png b/frontend/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..119127b Binary files /dev/null and b/frontend/src-tauri/icons/128x128@2x.png differ diff --git a/frontend/src-tauri/icons/32x32.png b/frontend/src-tauri/icons/32x32.png new file mode 100644 index 0000000..2b4e87d Binary files /dev/null and b/frontend/src-tauri/icons/32x32.png differ diff --git a/frontend/src-tauri/icons/64x64.png b/frontend/src-tauri/icons/64x64.png new file mode 100644 index 0000000..a74dfb5 Binary files /dev/null and b/frontend/src-tauri/icons/64x64.png differ diff --git a/frontend/src-tauri/icons/Square107x107Logo.png b/frontend/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..e3fc861 Binary files /dev/null and b/frontend/src-tauri/icons/Square107x107Logo.png differ diff --git a/frontend/src-tauri/icons/Square142x142Logo.png b/frontend/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..8529482 Binary files /dev/null and b/frontend/src-tauri/icons/Square142x142Logo.png differ diff --git a/frontend/src-tauri/icons/Square150x150Logo.png b/frontend/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..aa24b07 Binary files /dev/null and b/frontend/src-tauri/icons/Square150x150Logo.png differ diff --git a/frontend/src-tauri/icons/Square284x284Logo.png b/frontend/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..c42da93 Binary files /dev/null and b/frontend/src-tauri/icons/Square284x284Logo.png differ diff --git a/frontend/src-tauri/icons/Square30x30Logo.png b/frontend/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..d66b51e Binary files /dev/null and b/frontend/src-tauri/icons/Square30x30Logo.png differ diff --git a/frontend/src-tauri/icons/Square310x310Logo.png b/frontend/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..e72661b Binary files /dev/null and b/frontend/src-tauri/icons/Square310x310Logo.png differ diff --git a/frontend/src-tauri/icons/Square44x44Logo.png b/frontend/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..65c9ea8 Binary files /dev/null and b/frontend/src-tauri/icons/Square44x44Logo.png differ diff --git a/frontend/src-tauri/icons/Square71x71Logo.png b/frontend/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..5467d47 Binary files /dev/null and b/frontend/src-tauri/icons/Square71x71Logo.png differ diff --git a/frontend/src-tauri/icons/Square89x89Logo.png b/frontend/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..7236f7a Binary files /dev/null and b/frontend/src-tauri/icons/Square89x89Logo.png differ diff --git a/frontend/src-tauri/icons/StoreLogo.png b/frontend/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..2e3a77a Binary files /dev/null and b/frontend/src-tauri/icons/StoreLogo.png differ diff --git a/backend/src/main/resources/static/icon/devaulty-icon.png b/frontend/src-tauri/icons/devaulty-icon.png similarity index 100% rename from backend/src/main/resources/static/icon/devaulty-icon.png rename to frontend/src-tauri/icons/devaulty-icon.png diff --git a/frontend/src-tauri/icons/icon.icns b/frontend/src-tauri/icons/icon.icns new file mode 100644 index 0000000..03adfdf Binary files /dev/null and b/frontend/src-tauri/icons/icon.icns differ diff --git a/frontend/src-tauri/icons/icon.ico b/frontend/src-tauri/icons/icon.ico new file mode 100644 index 0000000..59ce910 Binary files /dev/null and b/frontend/src-tauri/icons/icon.ico differ diff --git a/frontend/src-tauri/icons/icon.png b/frontend/src-tauri/icons/icon.png new file mode 100644 index 0000000..d194976 Binary files /dev/null and b/frontend/src-tauri/icons/icon.png differ diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs new file mode 100644 index 0000000..0802ecb --- /dev/null +++ b/frontend/src-tauri/src/lib.rs @@ -0,0 +1,259 @@ +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tauri::Manager; +use uuid::Uuid; + +// In-memory state shared between Tauri commands and the backend child process. +#[derive(Default)] +pub struct SessionState { + pub port: Mutex>, + pub token: Mutex>, + pub child_process: Mutex>, + pub is_bundled_mode: Mutex, +} + +// Response sent to React when it calls `invoke("get_backend_info")` +#[derive(serde::Serialize, Clone)] +pub struct BackendInfo { + pub port: u16, + pub token: String, +} + +// Minimum duration the splash screen is displayed, ensuring a polished +// startup experience even when the Go backend initializes instantly. +const MIN_SPLASH_DURATION: Duration = Duration::from_secs(2); + +// Async IPC command: +// - Bundled mode (production): waits up to 30s for Go backend to print [DEVAULTY_SESSION], +// then performs a health check, ensuring a minimum 2s splash display. +// - Dev mode (no bundled binary): returns dev fallback immediately +#[tauri::command] +async fn get_backend_info(state: tauri::State<'_, Arc>) -> Result { + let is_bundled = *state.is_bundled_mode.lock().unwrap(); + + // Dev mode: no bundled binary — return immediately so splash closes fast + if !is_bundled { + return Ok(BackendInfo { + port: 8080, + token: "dev-token".to_string(), + }); + } + + let splash_start = Instant::now(); + let timeout = Duration::from_secs(30); + + // Phase 1: Wait for the Go backend to emit its session handshake via stdout + let info = loop { + { + let port = state.port.lock().unwrap(); + let token = state.token.lock().unwrap(); + + if let (Some(p), Some(ref t)) = (*port, token.as_ref()) { + break BackendInfo { + port: p, + token: t.to_string(), + }; + } + } + + if splash_start.elapsed() > timeout { + return Err("Backend initialization timeout after 30s".to_string()); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + }; + + // Phase 2: Wait for the HTTP health check endpoint to respond + let health_url = format!("http://127.0.0.1:{}/health", info.port); + let health_timeout = Duration::from_secs(10); + let health_start = Instant::now(); + + loop { + match reqwest::Client::new() + .get(&health_url) + .timeout(Duration::from_secs(2)) + .send() + .await + { + Ok(resp) if resp.status().is_success() => break, + _ => { + if health_start.elapsed() > health_timeout { + return Err("Backend health check timeout after 10s".to_string()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + + // Phase 3: Ensure the splash screen is shown for at least MIN_SPLASH_DURATION + let elapsed = splash_start.elapsed(); + if elapsed < MIN_SPLASH_DURATION { + tokio::time::sleep(MIN_SPLASH_DURATION - elapsed).await; + } + + Ok(info) +} + +#[tauri::command] +fn close_splash(app_handle: tauri::AppHandle) { + if let Some(splash_window) = app_handle.get_webview_window("splash") { + let _ = splash_window.close(); + } + + if let Some(main_window) = app_handle.get_webview_window("main") { + let _ = main_window.maximize(); + let _ = main_window.show(); + let _ = main_window.set_focus(); + } +} + +// Resolves the native Go backend binary name based on the current platform. +fn backend_binary_name() -> &'static str { + if cfg!(target_os = "windows") { + "devaulty-backend.exe" + } else { + "devaulty-backend" + } +} + +// Locates the bundled backend binary inside the Tauri resource directory. +// Tries both `resources/` (Tauri v2 default) and `` (flat layout). +fn find_backend_binary(resource_dir: &std::path::Path) -> Option { + let name = backend_binary_name(); + + let candidates = [ + resource_dir.join("resources").join(name), + resource_dir.join(name), + ]; + + candidates.into_iter().find(|p| p.exists()) +} + +// Sets executable permission on Unix systems (Linux & macOS). +// On Windows this is a no-op since executability is determined by file extension. +#[cfg(unix)] +fn ensure_executable(path: &std::path::Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms) +} + +#[cfg(not(unix))] +fn ensure_executable(_path: &std::path::Path) -> std::io::Result<()> { + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + // Ensure the user config/data directory exists for database storage + let devaulty_data_dir = dirs::config_dir() + .or_else(dirs::data_local_dir) + .or_else(dirs::home_dir) + .map(|path| path.join("devaulty")) + .unwrap_or_else(|| std::env::temp_dir().join("devaulty")); + + if !devaulty_data_dir.exists() { + if let Err(e) = std::fs::create_dir_all(&devaulty_data_dir) { + log::error!("Failed to create devaulty data directory {:?}: {}", devaulty_data_dir, e); + eprintln!("Failed to create devaulty data directory {:?}: {}", devaulty_data_dir, e); + } + } + + let session_state = Arc::new(SessionState::default()); + let state_clone = Arc::clone(&session_state); + + tauri::Builder::default() + .manage(session_state) + .invoke_handler(tauri::generate_handler![close_splash, get_backend_info]) + .setup(move |app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + + let resource_dir = app.path().resource_dir().ok(); + if let Some(ref res_path) = resource_dir { + if let Some(binary_path) = find_backend_binary(res_path) { + // Ensure executable permission on Unix platforms + if let Err(e) = ensure_executable(&binary_path) { + log::error!("Failed to set executable permission on backend binary: {}", e); + } + + // Generate a cryptographically secure random session token. + // This token is injected exclusively via the child process environment + // and never exposed to disk, logs, or other OS processes. + let session_token = Uuid::new_v4().to_string(); + + let data_dir_str = devaulty_data_dir.to_string_lossy().to_string(); + + // Spawn the native Go backend process. + // Migrations are embedded in the Go binary via go:embed, + // so no external migration files need to be shipped. + match Command::new(&binary_path) + .env("APP_ENV", "prod") + .env("DEVAULTY_INTERNAL_TOKEN", &session_token) + .env("DEVAULTY_DATA_DIR", &data_dir_str) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(mut child) => { + // Mark as bundled production mode only after successful process spawn + *state_clone.is_bundled_mode.lock().unwrap() = true; + + if let Some(stdout) = child.stdout.take() { + let state_inner = Arc::clone(&state_clone); + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().flatten() { + if line.contains("[DEVAULTY_SESSION]") { + let parts: Vec<&str> = line.split_whitespace().collect(); + let mut port_val = None; + let mut token_val = None; + for part in parts { + if let Some(stripped) = part.strip_prefix("PORT=") { + port_val = stripped.parse::().ok(); + } else if let Some(stripped) = part.strip_prefix("TOKEN=") { + token_val = Some(stripped.to_string()); + } + } + if let (Some(p), Some(t)) = (port_val, token_val) { + *state_inner.port.lock().unwrap() = Some(p); + *state_inner.token.lock().unwrap() = Some(t); + } + } + } + }); + } + *state_clone.child_process.lock().unwrap() = Some(child); + } + Err(e) => { + *state_clone.is_bundled_mode.lock().unwrap() = false; + log::error!("Failed to spawn Go backend process: {}", e); + } + } + } + } + + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + if window.label() == "main" { + let state = window.state::>(); + let mut guard = state.child_process.lock().unwrap(); + if let Some(mut child) = guard.take() { + let _ = child.kill(); + } + } + } + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs new file mode 100644 index 0000000..5794f10 --- /dev/null +++ b/frontend/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + devaulty_lib::run(); +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json new file mode 100644 index 0000000..a4a9e9b --- /dev/null +++ b/frontend/src-tauri/tauri.conf.json @@ -0,0 +1,60 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Devaulty", + "version": "0.1.7", + "identifier": "com.devaulty.app", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "node scripts/build-all.js" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "Devaulty", + "visible": false, + "maximized": true, + "minWidth": 1000, + "minHeight": 650, + "resizable": true, + "fullscreen": false + }, + { + "label": "splash", + "url": "/splash.html", + "title": "Devaulty", + "width": 550, + "height": 400, + "center": true, + "decorations": false, + "transparent": true, + "alwaysOnTop": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": [ + "deb", + "appimage", + "rpm", + "msi", + "dmg" + ], + "resources": [ + "resources/devaulty-backend*" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7700a3e..cb02083 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,6 +4,7 @@ import type { ApiErrorResponse } from "../types/api"; declare global { interface Window { DEVAULTY_INTERNAL_TOKEN?: string; + DEVAULTY_API_BASE_URL?: string; } } @@ -23,21 +24,24 @@ export const getInternalToken = (): string | undefined => { if (typeof window !== "undefined" && window.DEVAULTY_INTERNAL_TOKEN) { return window.DEVAULTY_INTERNAL_TOKEN; } - // Strictly in development mode (import.meta.env.DEV), fallback to dev secret token. + // Strictly in development mode (import.meta.env.DEV), fallback to dev token. // In production builds, Vite tree-shakes and completely eliminates this fallback. if (import.meta.env.DEV) { - return "dev-secret-token"; + return "dev-token"; } return undefined; }; export const getApiBaseUrl = (): string => { + if (typeof window !== "undefined" && window.DEVAULTY_API_BASE_URL) { + return window.DEVAULTY_API_BASE_URL; + } if (import.meta.env.VITE_API_BASE_URL) { return import.meta.env.VITE_API_BASE_URL; } - if (typeof window !== "undefined" && window.location.origin && window.location.origin !== "null") { - return `${window.location.origin}/api/v1`; - } + // Always fall back to localhost:8080 — never use window.location.origin + // because in a Tauri webview that would return "tauri://localhost" which + // is not a valid HTTP base URL for fetch/axios requests. return "http://localhost:8080/api/v1"; }; @@ -53,7 +57,7 @@ apiClient.interceptors.request.use((config) => { const internalToken = getInternalToken(); if (internalToken) { - config.headers["X-Devaulty-Internal-Token"] = internalToken; + config.headers["DEVAULTY_INTERNAL_TOKEN"] = internalToken; } return config; @@ -67,7 +71,7 @@ apiClient.interceptors.response.use( // that falls out of the range of 2xx const status = error.response.status; const data = error.response.data; - const message = data?.message || error.message || "Request failed"; + const message = data?.error || data?.message || error.message || "Request failed"; throw new ApiError(message, status, data); } else if (error.request) { diff --git a/frontend/src/components/RootLayout.tsx b/frontend/src/components/RootLayout.tsx index 2103621..8c52721 100644 --- a/frontend/src/components/RootLayout.tsx +++ b/frontend/src/components/RootLayout.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useState } from "react"; +import React, { Suspense, useState, useEffect } from "react"; import { Link, Outlet } from "@tanstack/react-router"; import { Toaster, toast } from "sonner"; import { ThemeProvider } from "./ThemeProvider"; @@ -18,6 +18,7 @@ import { } from "~features/releases/hooks/useReleases"; import { UpdateModal } from "~features/releases/components/UpdateModal"; import { formatVersionTag } from "../utils/versionUtils"; +import { invoke } from "@tauri-apps/api/core"; import type { AppUpdateInfoResponse } from "~types/api"; @@ -207,6 +208,25 @@ const RootLayoutInner: React.FC = () => { const { isOpen } = useSidebar(); const { data: autoUpdateData } = useCheckUpdatesQuery(true); + useEffect(() => { + const initNativeSession = async () => { + try { + const info = await invoke<{ port: number; token: string }>("get_backend_info"); + if (info) { + window.DEVAULTY_INTERNAL_TOKEN = info.token; + window.DEVAULTY_API_BASE_URL = `http://localhost:${info.port}/api/v1`; + await invoke("close_splash").catch(() => {}); + } + } catch (err) { + console.error("Failed to initialize backend native session:", err); + // Fallback for non-Tauri or dev environment + await invoke("close_splash").catch(() => {}); + } + }; + + void initNativeSession(); + }, []); + const [dismissedVersion, setDismissedVersion] = useState(null); const [manualUpdateInfo, setManualUpdateInfo] = useState(null); diff --git a/frontend/src/features/notes/api/notesApi.ts b/frontend/src/features/notes/api/notesApi.ts index 7e915f3..7fda140 100644 --- a/frontend/src/features/notes/api/notesApi.ts +++ b/frontend/src/features/notes/api/notesApi.ts @@ -49,12 +49,4 @@ export const notesApi = { delete: async (projectId: string, noteId: string): Promise => { await apiClient.delete(`/projects/${projectId}/notes/${noteId}`); }, - - archive: async (projectId: string, noteId: string): Promise => { - await apiClient.patch(`/projects/${projectId}/notes/${noteId}/archive`); - }, - - unarchive: async (projectId: string, noteId: string): Promise => { - await apiClient.patch(`/projects/${projectId}/notes/${noteId}/unarchive`); - }, }; diff --git a/frontend/src/features/notes/components/NotesWorkspace.tsx b/frontend/src/features/notes/components/NotesWorkspace.tsx index 87755f7..905abad 100644 --- a/frontend/src/features/notes/components/NotesWorkspace.tsx +++ b/frontend/src/features/notes/components/NotesWorkspace.tsx @@ -15,8 +15,6 @@ import { useNoteQuery, useUpdateNoteMutation, useDeleteNoteMutation, - useArchiveNoteMutation, - useUnarchiveNoteMutation, } from "../hooks/useNotes"; import styles from "../../../routes/projects.$projectId.module.css"; @@ -31,8 +29,6 @@ export const NotesWorkspace: React.FC = ({ }) => { const { data: notesData } = useNotesQuery(projectId); const deleteNoteMutation = useDeleteNoteMutation(projectId); - const archiveNoteMutation = useArchiveNoteMutation(projectId); - const unarchiveNoteMutation = useUnarchiveNoteMutation(projectId); const [selectedNoteId, setSelectedNoteId] = useState(undefined); const [noteSearchQuery, setNoteSearchQuery] = useState(""); @@ -104,20 +100,6 @@ export const NotesWorkspace: React.FC = ({ }); - const handleToggleArchiveNote = async (noteId: string, currentArchived: boolean) => { - try { - if (currentArchived) { - await unarchiveNoteMutation.mutateAsync(noteId); - toast.success("Note restored from archive"); - } else { - await archiveNoteMutation.mutateAsync(noteId); - toast.success("Note archived successfully"); - } - } catch { - toast.error("Failed to update note status"); - } - }; - const handleDeleteNote = (noteId: string, title: string) => { setConfirmModal({ isOpen: true, @@ -368,14 +350,6 @@ export const NotesWorkspace: React.FC = ({ Edit Metadata - )) )} diff --git a/frontend/src/features/tags/api/tagsApi.ts b/frontend/src/features/tags/api/tagsApi.ts index 97c0c34..160b209 100644 --- a/frontend/src/features/tags/api/tagsApi.ts +++ b/frontend/src/features/tags/api/tagsApi.ts @@ -40,7 +40,7 @@ export const tagsApi = { search: async (projectId: string, name: string): Promise => { const response = await apiClient.get( `/projects/${projectId}/tags/search`, - { params: { name } } + { params: { tag_name: name } } ); return response.data; }, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 68d6dff..9fef2ad 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -142,9 +142,10 @@ export interface PagedModelSnippetViewResponse { } export interface ApiErrorResponse { - status: number; - message: string; - timestamp: string; + error?: string; + message?: string; + status?: number; + timestamp?: string; errors?: Array<{ field: string; message: string; @@ -298,6 +299,10 @@ export interface MasterPasswordRequest { masterPassword: string; } +export interface MasterPasswordSetupRequiredView { + isRequired: boolean; +} + export interface SessionStatus { active: boolean; secondsLeft: number;