add tests
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clearEnv neutralise toute influence de l'environnement / .env pour le test.
|
||||||
|
func clearEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, name := range []string{
|
||||||
|
"PORT", "DATABASE_URL", "UPLOAD_DIR", "MAX_FILE_SIZE_MB",
|
||||||
|
"OCR_LANG", "AUTH_SECRET", "ADMIN_USERNAME", "ADMIN_PASSWORD",
|
||||||
|
} {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadApplicationDefaults(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
|
||||||
|
err, cfg := LoadApplicationConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadApplicationConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Port != 8080 {
|
||||||
|
t.Errorf("Port = %d, attendu 8080", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.MaxFileSizeMB != 50 {
|
||||||
|
t.Errorf("MaxFileSizeMB = %d, attendu 50", cfg.MaxFileSizeMB)
|
||||||
|
}
|
||||||
|
if cfg.DatabaseURL != "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_dev?sslmode=disable" {
|
||||||
|
t.Errorf("DatabaseURL défaut inattendu: %q", cfg.DatabaseURL)
|
||||||
|
}
|
||||||
|
if cfg.UploadDir != "./uploads" {
|
||||||
|
t.Errorf("UploadDir défaut inattendu: %q", cfg.UploadDir)
|
||||||
|
}
|
||||||
|
if cfg.OcrLang != "fra+eng" {
|
||||||
|
t.Errorf("OcrLang défaut inattendu: %q", cfg.OcrLang)
|
||||||
|
}
|
||||||
|
if cfg.AuthSecret != "dev-secret-change-me" {
|
||||||
|
t.Errorf("AuthSecret défaut inattendu: %q", cfg.AuthSecret)
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername != "" || cfg.AdminPassword != "" {
|
||||||
|
t.Errorf("identifiants admin doivent être vides par défaut: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadApplicationEnvOverrides(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
t.Setenv("PORT", "9090")
|
||||||
|
t.Setenv("MAX_FILE_SIZE_MB", "120")
|
||||||
|
t.Setenv("DATABASE_URL", "postgres://u:p@host:5433/db?sslmode=disable")
|
||||||
|
t.Setenv("UPLOAD_DIR", "/tmp/up")
|
||||||
|
t.Setenv("OCR_LANG", "eng")
|
||||||
|
t.Setenv("AUTH_SECRET", "super-secret")
|
||||||
|
t.Setenv("ADMIN_USERNAME", "root")
|
||||||
|
t.Setenv("ADMIN_PASSWORD", "toor")
|
||||||
|
|
||||||
|
err, cfg := LoadApplicationConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadApplicationConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Port != 9090 || cfg.MaxFileSizeMB != 120 || cfg.DatabaseURL != "postgres://u:p@host:5433/db?sslmode=disable" ||
|
||||||
|
cfg.UploadDir != "/tmp/up" || cfg.OcrLang != "eng" || cfg.AuthSecret != "super-secret" ||
|
||||||
|
cfg.AdminUsername != "root" || cfg.AdminPassword != "toor" {
|
||||||
|
t.Errorf("overrides non appliqués: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadApplicationRejectsInvalidInt(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
|
||||||
|
t.Setenv("PORT", "not-a-number")
|
||||||
|
if err, _ := LoadApplicationConfig(); err == nil {
|
||||||
|
t.Error("PORT invalide : attendu une erreur")
|
||||||
|
}
|
||||||
|
|
||||||
|
clearEnv(t)
|
||||||
|
t.Setenv("MAX_FILE_SIZE_MB", "99999999999999999999999")
|
||||||
|
if err, _ := LoadApplicationConfig(); err == nil {
|
||||||
|
t.Error("MAX_FILE_SIZE_MB invalide : attendu une erreur")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -328,6 +328,30 @@ func TestSearchFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchFilesEscapesPercent(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sp"), "search-test-password", device)
|
||||||
|
|
||||||
|
if err := repo.Resources.InsertFile(user, repository.NewID(), "half%price.txt", "", 1, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert percent file: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.Resources.InsertFile(user, repository.NewID(), "plain.txt", "", 1, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert plain file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// q=% (encodé %25) ne doit matcher QUE le nom contenant un '%' littéral.
|
||||||
|
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/search?q=%25", token, nil, "")
|
||||||
|
env := expectOK(t, rec, "search-percent")
|
||||||
|
var files []fileDTO
|
||||||
|
if err := json.Unmarshal(env.Data, &files); err != nil {
|
||||||
|
t.Fatalf("search-percent: unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 || files[0].Name != "half%price.txt" {
|
||||||
|
t.Errorf("'%%' littéral : attendu 1 résultat, got %+v", files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUploadTooLarge(t *testing.T) {
|
func TestUploadTooLarge(t *testing.T) {
|
||||||
r, _, repo := setup(t)
|
r, _, repo := setup(t)
|
||||||
device := repository.NewID()
|
device := repository.NewID()
|
||||||
@@ -337,6 +361,30 @@ func TestUploadTooLarge(t *testing.T) {
|
|||||||
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big")
|
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadIntoUnknownFolder(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "uf"), "upload-test-password", device)
|
||||||
|
|
||||||
|
rec := uploadMultipart(t, r, token, repository.NewID(), "orphan.txt", []byte("hi"))
|
||||||
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "upload-unknown-folder")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadNameConflict(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "uc"), "upload-test-password", device)
|
||||||
|
|
||||||
|
rec := uploadMultipart(t, r, token, "", "dupe.txt", []byte("hi"))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("premier upload: status %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Même nom à la racine → conflit d'unicité (parent_id NULL)
|
||||||
|
rec = uploadMultipart(t, r, token, "", "dupe.txt", []byte("hi"))
|
||||||
|
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe")
|
||||||
|
}
|
||||||
|
|
||||||
func TestFoldersListAndScoping(t *testing.T) {
|
func TestFoldersListAndScoping(t *testing.T) {
|
||||||
r, _, repo := setup(t)
|
r, _, repo := setup(t)
|
||||||
deviceA := repository.NewID()
|
deviceA := repository.NewID()
|
||||||
@@ -367,3 +415,81 @@ func TestFoldersListAndScoping(t *testing.T) {
|
|||||||
t.Errorf("folders A: %+v", folders)
|
t.Errorf("folders A: %+v", folders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilesPaginationDefaultsAndClamp(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "pg"), "pagination-test-password", device)
|
||||||
|
|
||||||
|
// pageSize au-delà de 200 → clampé à 200
|
||||||
|
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files?pageSize=9999", token, nil, "")
|
||||||
|
env := expectOK(t, rec, "pageSize-clamp")
|
||||||
|
if env.Meta == nil || env.Meta.Page != 1 || env.Meta.PageSize != 200 {
|
||||||
|
t.Errorf("clamp pageSize: %+v", env.Meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paramètres invalides → défauts (page=1, pageSize=50)
|
||||||
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files?page=0&pageSize=-5", token, nil, "")
|
||||||
|
env = expectOK(t, rec, "invalid-params")
|
||||||
|
if env.Meta == nil || env.Meta.Page != 1 || env.Meta.PageSize != 50 {
|
||||||
|
t.Errorf("defauts page/pageSize: %+v", env.Meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort/order inconnus → pas d'erreur (défaut created_at desc)
|
||||||
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files?sort=zzz&order=up&pageSize=10", token, nil, "")
|
||||||
|
expectOK(t, rec, "invalid-sort")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesListSortBySize(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sr"), "sort-test-password", device)
|
||||||
|
|
||||||
|
// Tri volontairement désordonné : 30, 10, 20.
|
||||||
|
if err := repo.Resources.InsertFile(user, repository.NewID(), "a.txt", "", 30, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert a: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.Resources.InsertFile(user, repository.NewID(), "b.txt", "", 10, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert b: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.Resources.InsertFile(user, repository.NewID(), "c.txt", "", 20, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert c: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files?sort=size&order=asc", token, nil, "")
|
||||||
|
env := expectOK(t, rec, "sort-size")
|
||||||
|
var files []fileDTO
|
||||||
|
if err := json.Unmarshal(env.Data, &files); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 3 || files[0].Size != 10 || files[1].Size != 20 || files[2].Size != 30 {
|
||||||
|
t.Errorf("ordre size asc inattendu: %+v", files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesGetDeleteRejectInvalidID(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "ii"), "invalid-id-test-password", device)
|
||||||
|
|
||||||
|
// ID non 32-hex → 404 avant toute requête.
|
||||||
|
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/NOT-HEX-ID", token, nil, "")
|
||||||
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-invalid-id")
|
||||||
|
|
||||||
|
rec, _ = doRequest(t, r, http.MethodDelete, "/api/v1/files/xyz", token, nil, "")
|
||||||
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "delete-invalid-id")
|
||||||
|
|
||||||
|
// ID 32-hex mais inconnu (du même user) → 404.
|
||||||
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+repository.NewID(), token, nil, "")
|
||||||
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-unknown")
|
||||||
|
|
||||||
|
// Le user ne voit jamais les fichiers d'un autre même avec un ID valide.
|
||||||
|
fileID := repository.NewID()
|
||||||
|
if err := repo.Resources.InsertFile(user, fileID, "mine.txt", "", 4, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
otherDevice := repository.NewID()
|
||||||
|
otherToken, _ := registerAndLogin(t, r, repo, testUserUsername(otherDevice, "ii2"), "invalid-id-test-password-b", otherDevice)
|
||||||
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+fileID, otherToken, nil, "")
|
||||||
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-user")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealth(t *testing.T) {
|
||||||
|
r, _, _ := setup(t)
|
||||||
|
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/health", "", nil, "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("health: status %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Data struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("health: unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if env.Data.Status != "healthy" {
|
||||||
|
t.Errorf("health status = %q, attendu healthy", env.Data.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownRouteReturnsJsonEnvelope(t *testing.T) {
|
||||||
|
r, _, _ := setup(t)
|
||||||
|
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/does-not-exist", "", nil, "")
|
||||||
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "unknown-route")
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterRoutes wires the full /api/v1 surface (public + protected).
|
// RegisterRoutes wires the full /api/v1 surface (public + protected).
|
||||||
@@ -36,6 +39,11 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
protected.POST("/sync/ops", SyncOpsPush)
|
protected.POST("/sync/ops", SyncOpsPush)
|
||||||
protected.GET("/sync/permissions", SyncPermissionsGet)
|
protected.GET("/sync/permissions", SyncPermissionsGet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route inconnue → enveloppe d'erreur du contrat (jamais de HTML).
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
api.Error(c, http.StatusNotFound, "NOT_FOUND", "route not found")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// userID lit la clé de scoping posée par RequireAuth. Un empty string est
|
// userID lit la clé de scoping posée par RequireAuth. Un empty string est
|
||||||
|
|||||||
@@ -151,6 +151,109 @@ func TestSyncOpsCreateResourceWithParent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncOpsRejectsMalformedBody(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sm"), "sync-test-password", device)
|
||||||
|
|
||||||
|
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, []byte(`{invalid`), "application/json")
|
||||||
|
expectError(t, rec, http.StatusBadRequest, "INVALID_REQUEST", "sync-malformed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncOpsEmptyBatch(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "se"), "sync-test-password", device)
|
||||||
|
|
||||||
|
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(nil), "application/json")
|
||||||
|
env := expectOK(t, rec, "sync-empty")
|
||||||
|
var result struct {
|
||||||
|
Applied int `json:"applied"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &result); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if result.Applied != 0 {
|
||||||
|
t.Errorf("batch vide: applied attendu 0, got %d", result.Applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncOpsReplayCreateIsNoop(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "srn"), "sync-test-password", device)
|
||||||
|
|
||||||
|
fileID := repository.NewID()
|
||||||
|
|
||||||
|
send := func(batch []map[string]any) int {
|
||||||
|
t.Helper()
|
||||||
|
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(batch), "application/json")
|
||||||
|
env := expectOK(t, rec, "sync-send")
|
||||||
|
var result struct {
|
||||||
|
Applied int `json:"applied"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &result); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
return result.Applied
|
||||||
|
}
|
||||||
|
|
||||||
|
ops := []map[string]any{
|
||||||
|
op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "note.txt"}),
|
||||||
|
}
|
||||||
|
if applied := send(ops); applied != 1 {
|
||||||
|
t.Fatalf("premier envoi: applied attendu 1, got %d", applied)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejeu avec le MÊME resource_id mais un operation_id neuf → idempotent
|
||||||
|
// (la ressource existe déjà : no-op), pas de doublon côté ressources.
|
||||||
|
if applied := send([]map[string]any{
|
||||||
|
op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "note.txt"}),
|
||||||
|
}); applied != 1 {
|
||||||
|
t.Fatalf("rejeu: applied attendu 1, got %d", applied)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, total, err := repo.Resources.ListFiles(user, "", 10, 0, "created_at", "desc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(files) != 1 || files[0].ID != fileID {
|
||||||
|
t.Errorf("pas de doublon attendu: total=%d files=%+v", total, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncOpsLargeBatchAccepted(t *testing.T) {
|
||||||
|
r, _, repo := setup(t)
|
||||||
|
device := repository.NewID()
|
||||||
|
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sl"), "sync-test-password", device)
|
||||||
|
|
||||||
|
ops := make([]map[string]any, 0, 25)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
ops = append(ops, op(repository.NewID(), repository.NewID(), "create_resource", "file", map[string]any{"name": fmt.Sprintf("f%02d.txt", i)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json")
|
||||||
|
env := expectOK(t, rec, "sync-large")
|
||||||
|
var result struct {
|
||||||
|
Applied int `json:"applied"`
|
||||||
|
Failed any `json:"failed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &result); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if result.Applied != 25 || result.Failed != nil {
|
||||||
|
t.Errorf("attendu applied=25 failed=null, got %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, total, err := repo.Resources.ListFiles(user, "", 50, 0, "created_at", "desc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if total != 25 || len(files) != 25 {
|
||||||
|
t.Errorf("les 25 ressources doivent être persistées, total=%d files=%d", total, len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
|
func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
|
||||||
r, _, repo := setup(t)
|
r, _, repo := setup(t)
|
||||||
device := repository.NewID()
|
device := repository.NewID()
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupRouter() *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/ok", func(c *gin.Context) { OK(c, gin.H{"id": "abc"}) })
|
||||||
|
r.GET("/list", func(c *gin.Context) { OKList(c, []int{1, 2}, 3, 50, 123) })
|
||||||
|
r.GET("/err", func(c *gin.Context) { Error(c, 400, "BAD_REQUEST", "some message") })
|
||||||
|
r.GET("/nope", func(c *gin.Context) { NotImplemented(c) })
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopeShapes(t *testing.T) {
|
||||||
|
r := setupRouter()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/ok", nil))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status: %d", rec.Code)
|
||||||
|
}
|
||||||
|
var okBody struct {
|
||||||
|
Data struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &okBody); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if okBody.Data.ID != "abc" {
|
||||||
|
t.Errorf("data inattendu: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/list", nil))
|
||||||
|
var listBody struct {
|
||||||
|
Data []int `json:"data"`
|
||||||
|
Meta struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &listBody); err != nil {
|
||||||
|
t.Fatalf("unmarshal list: %v", err)
|
||||||
|
}
|
||||||
|
if len(listBody.Data) != 2 || listBody.Meta.Page != 3 || listBody.Meta.PageSize != 50 || listBody.Meta.Total != 123 {
|
||||||
|
t.Errorf("enveloppe list inattendue: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/err", nil))
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Fatalf("status err: %d", rec.Code)
|
||||||
|
}
|
||||||
|
var errBody struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
|
||||||
|
t.Fatalf("unmarshal err: %v", err)
|
||||||
|
}
|
||||||
|
if errBody.Error.Code != "BAD_REQUEST" || errBody.Error.Message != "some message" {
|
||||||
|
t.Errorf("enveloppe erreur inattendue: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
|
||||||
|
if rec.Code != 501 || errorCodeOf(rec) != NotImplementedCode {
|
||||||
|
t.Errorf("not implemented: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorCodeOf(rec *httptest.ResponseRecorder) string {
|
||||||
|
var body struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||||
|
return body.Error.Code
|
||||||
|
}
|
||||||
@@ -95,3 +95,26 @@ func TestVerifyRequiresDeviceClaim(t *testing.T) {
|
|||||||
t.Fatal("expected token without device claim to be rejected")
|
t.Fatal("expected token without device claim to be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIssueSetsExpiration(t *testing.T) {
|
||||||
|
m, _ := NewManager("test-secret")
|
||||||
|
before := time.Now()
|
||||||
|
signed, err := m.Issue(testUserID, testDeviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
exp, err := parsed.GetExpiration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetExpiration: %v", err)
|
||||||
|
}
|
||||||
|
// Le TTL est fixé à 7 jours (docs/api-v1.md) — marge de 1 min par sécurité.
|
||||||
|
lower := before.Add(TokenTTL - time.Minute)
|
||||||
|
upper := before.Add(TokenTTL + time.Minute)
|
||||||
|
if exp.Before(lower) || exp.After(upper) {
|
||||||
|
t.Errorf("expiration = %v, attendu ≈ now+%v (fenêtre [%v, %v])", exp, TokenTTL, lower, upper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDevices(t *testing.T) *Devices {
|
||||||
|
t.Helper()
|
||||||
|
conn := dbtest.OpenTestDatabase(t, repositoryTestURL)
|
||||||
|
return &Devices{DB: conn}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevicesUpsertAndExists(t *testing.T) {
|
||||||
|
d := newTestDevices(t)
|
||||||
|
deviceID := NewID()
|
||||||
|
|
||||||
|
exists, err := d.Exists(deviceID)
|
||||||
|
if err != nil || exists {
|
||||||
|
t.Fatalf("device non enregistré : exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.Upsert(deviceID); err != nil {
|
||||||
|
t.Fatalf("upsert: %v", err)
|
||||||
|
}
|
||||||
|
exists, err = d.Exists(deviceID)
|
||||||
|
if err != nil || !exists {
|
||||||
|
t.Fatalf("après upsert : exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert idempotent (ON CONFLICT) → pas d'erreur ni de doublon.
|
||||||
|
if err := d.Upsert(deviceID); err != nil {
|
||||||
|
t.Fatalf("second upsert: %v", err)
|
||||||
|
}
|
||||||
|
exists, err = d.Exists(deviceID)
|
||||||
|
if err != nil || !exists {
|
||||||
|
t.Fatalf("après second upsert : exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
if err := d.DB.QueryRow(`SELECT COUNT(*) FROM devices WHERE device_id = $1`, deviceID).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("device dupliqué, count = %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevicesMarkUser(t *testing.T) {
|
||||||
|
d := newTestDevices(t)
|
||||||
|
deviceID := NewID()
|
||||||
|
if err := d.Upsert(deviceID); err != nil {
|
||||||
|
t.Fatalf("upsert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users := &Users{DB: d.DB}
|
||||||
|
userID, err := users.Create("device-user", "device-user", "hash", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.MarkUser(deviceID, userID); err != nil {
|
||||||
|
t.Fatalf("mark user: %v", err)
|
||||||
|
}
|
||||||
|
var storedUser string
|
||||||
|
if err := d.DB.QueryRow(`SELECT user_id FROM devices WHERE device_id = $1`, deviceID).Scan(&storedUser); err != nil {
|
||||||
|
t.Fatalf("read user_id: %v", err)
|
||||||
|
}
|
||||||
|
if storedUser != userID {
|
||||||
|
t.Errorf("user_id mémorisé = %q, attendu %q", storedUser, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestRepo(t *testing.T) *Repository {
|
||||||
|
t.Helper()
|
||||||
|
conn := dbtest.OpenTestDatabase(t, repositoryTestURL)
|
||||||
|
return NewRepository(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedOcrJobFixture(t *testing.T) (*Repository, string, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
repo := newTestRepo(t)
|
||||||
|
userID, err := repo.Users.Create("ocr-user", "ocr-user", "hash", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
deviceID := NewID()
|
||||||
|
if err := repo.Devices.Upsert(deviceID); err != nil {
|
||||||
|
t.Fatalf("register device: %v", err)
|
||||||
|
}
|
||||||
|
fileID := NewID()
|
||||||
|
if err := repo.Resources.InsertFile(userID, fileID, "scan.png", "", 10, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert file: %v", err)
|
||||||
|
}
|
||||||
|
return repo, userID, deviceID, fileID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOcrJobsLifecycleTransitions(t *testing.T) {
|
||||||
|
repo, _, deviceID, fileID := seedOcrJobFixture(t)
|
||||||
|
jobID := NewID()
|
||||||
|
|
||||||
|
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
row, err := repo.OcrJobs.Get(deviceID, jobID)
|
||||||
|
if err != nil || row.Status != "queued" {
|
||||||
|
t.Fatalf("get (queued): %+v err=%v", row, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.OcrJobs.TouchProcessing(deviceID, jobID); err != nil {
|
||||||
|
t.Fatalf("touch processing: %v", err)
|
||||||
|
}
|
||||||
|
row, _ = repo.OcrJobs.Get(deviceID, jobID)
|
||||||
|
if row.Status != "processing" {
|
||||||
|
t.Errorf("status attendu processing, got %q", row.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.OcrJobs.Complete(deviceID, jobID, "HELLO"); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
row, _ = repo.OcrJobs.Get(deviceID, jobID)
|
||||||
|
if row.Status != "done" || row.Text == nil || *row.Text != "HELLO" {
|
||||||
|
t.Errorf("after complete: %+v", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOcrJobsFail(t *testing.T) {
|
||||||
|
repo, _, deviceID, fileID := seedOcrJobFixture(t)
|
||||||
|
jobID := NewID()
|
||||||
|
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.OcrJobs.Fail(deviceID, jobID, "tesseract exploded"); err != nil {
|
||||||
|
t.Fatalf("fail: %v", err)
|
||||||
|
}
|
||||||
|
row, err := repo.OcrJobs.Get(deviceID, jobID)
|
||||||
|
if err != nil || row.Status != "failed" || row.Error == nil || *row.Error != "tesseract exploded" {
|
||||||
|
t.Errorf("après fail : %+v err=%v", row, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOcrJobsGetScopedByDevice(t *testing.T) {
|
||||||
|
repo, _, deviceID, fileID := seedOcrJobFixture(t)
|
||||||
|
jobID := NewID()
|
||||||
|
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherDevice := NewID()
|
||||||
|
if err := repo.Devices.Upsert(otherDevice); err != nil {
|
||||||
|
t.Fatalf("register other device: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.OcrJobs.Get(otherDevice, jobID); !errors.Is(err, ErrJobNotFound) {
|
||||||
|
t.Errorf("get par un autre device : attendu ErrJobNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestOperations(t *testing.T) (*Operations, *Devices) {
|
||||||
|
t.Helper()
|
||||||
|
conn := dbtest.OpenTestDatabase(t, repositoryTestURL)
|
||||||
|
return &Operations{DB: conn}, &Devices{DB: conn}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationsRecordAndApplied(t *testing.T) {
|
||||||
|
o, devices := newTestOperations(t)
|
||||||
|
deviceID := NewID()
|
||||||
|
if err := devices.Upsert(deviceID); err != nil {
|
||||||
|
t.Fatalf("register device: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opID := NewID()
|
||||||
|
applied, err := o.Applied(deviceID, opID)
|
||||||
|
if err != nil || applied {
|
||||||
|
t.Fatalf("avant record : applied=%v err=%v", applied, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.Record(deviceID, opID, "create_resource", "resource", nil, NewID(), []byte(`{"name":"x"}`)); err != nil {
|
||||||
|
t.Fatalf("record: %v", err)
|
||||||
|
}
|
||||||
|
applied, err = o.Applied(deviceID, opID)
|
||||||
|
if err != nil || !applied {
|
||||||
|
t.Fatalf("après record : applied=%v err=%v", applied, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejeu → ON CONFLICT DO NOTHING, aucune erreur.
|
||||||
|
if err := o.Record(deviceID, opID, "create_resource", "resource", nil, NewID(), []byte(`null`)); err != nil {
|
||||||
|
t.Fatalf("rejeu: %v", err)
|
||||||
|
}
|
||||||
|
if applied, _ := o.Applied(deviceID, opID); !applied {
|
||||||
|
t.Error("rejeu : l'op doit rester appliquée")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotence par (device_id, operation_id) : un autre device peut réutiliser
|
||||||
|
// le même operation_id sans conflit (les deux rows sont distinctes).
|
||||||
|
device02 := NewID()
|
||||||
|
if err := devices.Upsert(device02); err != nil {
|
||||||
|
t.Fatalf("register device 2: %v", err)
|
||||||
|
}
|
||||||
|
if err := o.Record(device02, opID, "create_resource", "resource", nil, NewID(), []byte(`{}`)); err != nil {
|
||||||
|
t.Errorf("même op_id sur un autre device: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationsRecordRejectsInvalidOperationID(t *testing.T) {
|
||||||
|
o, devices := newTestOperations(t)
|
||||||
|
if err := devices.Upsert(NewID()); err != nil {
|
||||||
|
t.Fatalf("register device: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := o.Record(NewID(), "UPPERCASENOT32", "create_resource", "resource", nil, NewID(), []byte(`{}`))
|
||||||
|
if !errors.Is(err, ErrInvalidOperationID) {
|
||||||
|
t.Errorf("operation_id invalide : attendu ErrInvalidOperationID, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationsRecordNormalizesInvalidPayload(t *testing.T) {
|
||||||
|
o, devices := newTestOperations(t)
|
||||||
|
deviceID := NewID()
|
||||||
|
if err := devices.Upsert(deviceID); err != nil {
|
||||||
|
t.Fatalf("register device: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opID := NewID()
|
||||||
|
// Un payload non-JSON ne doit pas faire échouer la trace (jsonBytes → {}).
|
||||||
|
if err := o.Record(deviceID, opID, "create_resource", "resource", nil, NewID(), []byte(`not-json`)); err != nil {
|
||||||
|
t.Fatalf("record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored string
|
||||||
|
if err := o.DB.QueryRow(`SELECT payload::text FROM operations WHERE device_id = $1 AND operation_id = $2`, deviceID, opID).Scan(&stored); err != nil {
|
||||||
|
t.Fatalf("read payload: %v", err)
|
||||||
|
}
|
||||||
|
var parsed any
|
||||||
|
if err := json.Unmarshal([]byte(stored), &parsed); err != nil {
|
||||||
|
t.Fatalf("payload stocké invalide: %q", stored)
|
||||||
|
}
|
||||||
|
if stored != "{}" {
|
||||||
|
t.Errorf("payload normalisé attendu {}, got %q", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -305,7 +305,7 @@ func (r *Resources) SyncDelete(ownerID, resourceID string) error {
|
|||||||
_, err := r.DB.Exec(
|
_, err := r.DB.Exec(
|
||||||
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
|
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
|
||||||
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
||||||
ownerID, resourceID,
|
resourceID, ownerID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/vaultdrop/backend/dbtest"
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
)
|
)
|
||||||
@@ -117,3 +118,117 @@ func TestNameConflictAndUnknownFolder(t *testing.T) {
|
|||||||
t.Errorf("folder inconnu doit être NOT_FOUND, got %v", err)
|
t.Errorf("folder inconnu doit être NOT_FOUND, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchFilesEscapesWildcards(t *testing.T) {
|
||||||
|
repo := newTestResources(t)
|
||||||
|
owner := NewID()
|
||||||
|
mustInsertUser(t, repo, owner)
|
||||||
|
|
||||||
|
insert := func(name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := repo.InsertFile(owner, NewID(), name, "", 1, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert %q: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
insert("half%price.txt")
|
||||||
|
insert("plain.txt")
|
||||||
|
insert("a_b.txt")
|
||||||
|
insert("abx.txt")
|
||||||
|
insert(`win\file.txt`)
|
||||||
|
|
||||||
|
// '%' littéral → uniquement le nom contenant un '%'
|
||||||
|
files, total, err := repo.SearchFiles(owner, "%", 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search %%: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(files) != 1 || files[0].Name != "half%price.txt" {
|
||||||
|
t.Errorf("'%%' littéral : total=%d files=%+v", total, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// '_' littéral → uniquement a_b.txt (pas abx.txt)
|
||||||
|
files, total, err = repo.SearchFiles(owner, "_", 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search _: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || files[0].Name != "a_b.txt" {
|
||||||
|
t.Errorf("'_' littéral : total=%d files=%+v", total, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// '\' littéral → uniquement win\file.txt
|
||||||
|
files, total, err = repo.SearchFiles(owner, `win\file`, 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search backslash: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || files[0].Name != `win\file.txt` {
|
||||||
|
t.Errorf("'\\' littéral : total=%d files=%+v", total, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListFilesPagination(t *testing.T) {
|
||||||
|
repo := newTestResources(t)
|
||||||
|
owner := NewID()
|
||||||
|
mustInsertUser(t, repo, owner)
|
||||||
|
|
||||||
|
for _, name := range []string{"a.txt", "b.txt", "c.txt"} {
|
||||||
|
if err := repo.InsertFile(owner, NewID(), name, "", 1, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert %q: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files, total, err := repo.ListFiles(owner, "", 2, 0, "name", "asc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list page 1: %v", err)
|
||||||
|
}
|
||||||
|
if total != 3 || len(files) != 2 || files[0].Name != "a.txt" || files[1].Name != "b.txt" {
|
||||||
|
t.Errorf("page 1 : total=%d files=%+v", total, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, total, err = repo.ListFiles(owner, "", 2, 2, "name", "asc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list page 2: %v", err)
|
||||||
|
}
|
||||||
|
if total != 3 || len(files) != 1 || files[0].Name != "c.txt" {
|
||||||
|
t.Errorf("page 2 : total=%d files=%+v", total, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOwnedDelta(t *testing.T) {
|
||||||
|
repo := newTestResources(t)
|
||||||
|
owner := NewID()
|
||||||
|
mustInsertUser(t, repo, owner)
|
||||||
|
|
||||||
|
if err := repo.InsertFolder(owner, NewID(), "Docs", ""); err != nil {
|
||||||
|
t.Fatalf("insert folder: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.InsertFile(owner, NewID(), "old.txt", "", 1, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert old: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aprèsMs=0 → tout
|
||||||
|
all, err := repo.ListOwned(owner, 0)
|
||||||
|
if err != nil || len(all) != 2 {
|
||||||
|
t.Fatalf("snapshot complet: %+v err=%v", all, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aprèsMs dans le futur → vide
|
||||||
|
far, err := repo.ListOwned(owner, time.Now().Add(time.Hour).UnixMilli())
|
||||||
|
if err != nil || len(far) != 0 {
|
||||||
|
t.Errorf("aprèsMs futur : attendu vide, got %+v err=%v", far, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delta : une ressource insérée APRÈS baseline → uniquement celle-là.
|
||||||
|
baseline := time.Now()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
freshID := NewID()
|
||||||
|
if err := repo.InsertFile(owner, freshID, "fresh.txt", "", 1, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert fresh: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delta, err := repo.ListOwned(owner, baseline.UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delta: %v", err)
|
||||||
|
}
|
||||||
|
if len(delta) != 1 || delta[0].ID != freshID {
|
||||||
|
t.Errorf("delta attendu uniquement fresh.txt, got %+v", delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestUsers(t *testing.T) *Users {
|
||||||
|
t.Helper()
|
||||||
|
conn := dbtest.OpenTestDatabase(t, repositoryTestURL)
|
||||||
|
return &Users{DB: conn}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersCreateGetAndCount(t *testing.T) {
|
||||||
|
u := newTestUsers(t)
|
||||||
|
|
||||||
|
id, err := u.Create("Alice", "alice", "hash-1", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := u.GetByUsernameNormalized("alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by normalized: %v", err)
|
||||||
|
}
|
||||||
|
if row.ID != id || row.Username != "Alice" || row.UsernameNormalized != "alice" || row.IsAdmin || row.PasswordHash != "hash-1" {
|
||||||
|
t.Errorf("row inattendue: %+v", row)
|
||||||
|
}
|
||||||
|
|
||||||
|
byID, err := u.GetByID(id)
|
||||||
|
if err != nil || byID.ID != id {
|
||||||
|
t.Fatalf("get by id: %+v err=%v", byID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := u.ResolveExact("alice")
|
||||||
|
if err != nil || resolved.ID != id {
|
||||||
|
t.Fatalf("resolve exact: %+v err=%v", resolved, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := u.Count()
|
||||||
|
if err != nil || n != 1 {
|
||||||
|
t.Errorf("count = %d err=%v, attendu 1", n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doublon de username normalisé → ErrNameConflict.
|
||||||
|
if _, err := u.Create("alice2", "alice", "hash-X", false); !errors.Is(err, ErrNameConflict) {
|
||||||
|
t.Errorf("doublon normalisé : attendu ErrNameConflict, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersUpdatePassword(t *testing.T) {
|
||||||
|
u := newTestUsers(t)
|
||||||
|
id, err := u.Create("bob", "bob", "hash-1", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.UpdatePassword(id, "hash-2"); err != nil {
|
||||||
|
t.Fatalf("update password: %v", err)
|
||||||
|
}
|
||||||
|
row, err := u.GetByID(id)
|
||||||
|
if err != nil || row.PasswordHash != "hash-2" {
|
||||||
|
t.Errorf("hash après update = %q (err=%v)", row.PasswordHash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersMarkDeletedHidesAccount(t *testing.T) {
|
||||||
|
u := newTestUsers(t)
|
||||||
|
id, err := u.Create("carol", "carol", "hash-1", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.MarkDeleted(id); err != nil {
|
||||||
|
t.Fatalf("mark deleted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := u.GetByID(id); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("get by id après suppression : attendu ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := u.GetByUsernameNormalized("carol"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("get par username après suppression : attendu ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if n, _ := u.Count(); n != 1 {
|
||||||
|
t.Errorf("count doit compter aussi les supprimés (bootstrap), got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/pkg/passwd"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureAdminCreatesFirstAdmin(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
repo := s.Repository
|
||||||
|
|
||||||
|
if err := EnsureAdmin(repo, " Admin ", "admin-secret-123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := repo.Users.GetByUsernameNormalized("admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin non trouvé: %v", err)
|
||||||
|
}
|
||||||
|
if !user.IsAdmin {
|
||||||
|
t.Error("le compte créé doit être admin")
|
||||||
|
}
|
||||||
|
if user.UsernameNormalized != "admin" || user.Username != "admin" {
|
||||||
|
t.Errorf("identité anormale: %+v", user)
|
||||||
|
}
|
||||||
|
if err := passwd.Verify("admin-secret-123", user.PasswordHash); err != nil {
|
||||||
|
t.Errorf("le mot de passe doit vérifier: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeUsername(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
" admin ": "admin",
|
||||||
|
" Alice": "alice",
|
||||||
|
"A B C": "a b c",
|
||||||
|
"": "",
|
||||||
|
" ": "",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := NormalizeUsername(in); got != want {
|
||||||
|
t.Errorf("NormalizeUsername(%q) = %q, attendu %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAdminRefusesOnEmptyDBWithoutEnv(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
|
||||||
|
err := EnsureAdmin(s.Repository, "", "")
|
||||||
|
if !errors.Is(err, ErrAdminRequired) {
|
||||||
|
t.Errorf("env absent : attendu ErrAdminRequired, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = EnsureAdmin(s.Repository, " ", "some-password")
|
||||||
|
if !errors.Is(err, ErrAdminRequired) {
|
||||||
|
t.Errorf("username blanc : attendu ErrAdminRequired, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAdminNeverOverwritesExistingAccounts(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
repo := s.Repository
|
||||||
|
|
||||||
|
// La base n'est PAS vide → l'env ne doit rien créer, même avec des valeurs.
|
||||||
|
if _, err := repo.Users.Create("alice", "alice", "existing-hash", false); err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EnsureAdmin(repo, "ADMIN", "would-be-admin-secret"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin sur base non vide : %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aucun admin n'a été ajouté, l'existant est intact.
|
||||||
|
if _, err := repo.Users.GetByUsernameNormalized("admin"); !errors.Is(err, repository.ErrNotFound) {
|
||||||
|
t.Errorf("admin ne doit pas exister sur base non vide (err=%v)", err)
|
||||||
|
}
|
||||||
|
alice, err := repo.Users.GetByUsernameNormalized("alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("alice doit être intacte: %v", err)
|
||||||
|
}
|
||||||
|
if alice.PasswordHash != "existing-hash" || alice.IsAdmin {
|
||||||
|
t.Errorf("l'existant doit être préservé: %+v", alice)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/ocr"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubEngine again (package service) — même contrat que handlers/ocr_test.
|
||||||
|
type stubEngine struct {
|
||||||
|
text string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubEngine) ExtractText(_ context.Context, _ string, _ string) (string, error) {
|
||||||
|
if s.err != nil {
|
||||||
|
return "", s.err
|
||||||
|
}
|
||||||
|
return s.text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestOcr builds an Ocr over a fresh DB and returns it with the ids.
|
||||||
|
func newTestOcr(t *testing.T, uploadDir string, engine ocr.Engine) (*Ocr, string, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "ocr-failed")
|
||||||
|
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||||
|
return NewOcr(s.Repository, uploadDir, "fra+eng", engine), userID, deviceID, uploadDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitTillTerminal poll jusqu'à un statut terminal (done/failed).
|
||||||
|
func waitTillTerminal(t *testing.T, o *Ocr, deviceID, jobID string) repository.OcrJobRow {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
row, err := o.Repository.OcrJobs.Get(deviceID, jobID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get job: %v", err)
|
||||||
|
}
|
||||||
|
if row.Status == "done" || row.Status == "failed" {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("job jamais terminal")
|
||||||
|
return repository.OcrJobRow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertOcrFile(t *testing.T, o *Ocr, userID, fileID string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := o.Repository.Resources.InsertFile(userID, fileID, "scan.png", "", 128, nil, nil); err != nil {
|
||||||
|
t.Fatalf("insert file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOcrPhysicalPath(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
userID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
fileID := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Join(uploadDir, userID), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
// Vérifie que l'extension du fichier trouvé est acceptée (glob = ordre
|
||||||
|
// alphabétique, on ne présume pas du nom choisi).
|
||||||
|
if err := os.WriteFile(filepath.Join(uploadDir, userID, fileID+".png"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write png: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
o := NewOcr(nil, uploadDir, "fra+eng", nil)
|
||||||
|
|
||||||
|
found, err := o.physicalPath(userID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("physicalPath: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(filepath.Base(found), fileID+".") {
|
||||||
|
t.Errorf("path = %q, attendu préfixe %q", found, fileID+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := o.physicalPath(userID, "cccccccccccccccccccccccccccccccc"); !errors.Is(err, repository.ErrNotFound) {
|
||||||
|
t.Errorf("fichier absent : attendu ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOcrJobFailsWhenPhysicalFileMissing(t *testing.T) {
|
||||||
|
o, userID, deviceID, _ := newTestOcr(t, t.TempDir(), stubEngine{text: "x"})
|
||||||
|
|
||||||
|
fileID := repository.NewID()
|
||||||
|
insertOcrFile(t, o, userID, fileID)
|
||||||
|
|
||||||
|
job, err := o.Create(userID, deviceID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
row := waitTillTerminal(t, o, deviceID, job.ID)
|
||||||
|
if row.Status != "failed" || row.Error == nil {
|
||||||
|
t.Errorf("fichier physique absent : attendu failed avec erreur, got %+v", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOcrJobFailsOnEngineError(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
o, userID, deviceID, _ := newTestOcr(t, uploadDir, stubEngine{err: errors.New("tesseract boom")})
|
||||||
|
|
||||||
|
fileID := repository.NewID()
|
||||||
|
insertOcrFile(t, o, userID, fileID)
|
||||||
|
// Le fichier physique doit exister pour atteindre l'engine.
|
||||||
|
if err := os.MkdirAll(filepath.Join(uploadDir, userID), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(uploadDir, userID, fileID+".png"), []byte("img"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := o.Create(userID, deviceID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
row := waitTillTerminal(t, o, deviceID, job.ID)
|
||||||
|
if row.Status != "failed" || row.Error == nil || !strings.Contains(*row.Error, "tesseract boom") {
|
||||||
|
t.Errorf("erreur engine : attendu failed avec message engine, got %+v", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func multipartFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("file", filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create form: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(content); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := multipart.NewReader(&body, writer.Boundary())
|
||||||
|
form, err := reader.ReadForm(1 << 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read form: %v", err)
|
||||||
|
}
|
||||||
|
files := form.File["file"]
|
||||||
|
if len(files) == 0 {
|
||||||
|
t.Fatal("aucun fichier dans la forme")
|
||||||
|
}
|
||||||
|
return files[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPersistsPhysicalFile(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "upload-happy")
|
||||||
|
|
||||||
|
dto, err := s.Upload(userID, multipartFileHeader(t, "docs.txt", []byte("hello")), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
if dto.ID == "" || dto.Name != "docs.txt" {
|
||||||
|
t.Errorf("dto inattendu: %+v", dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le fichier physique existe sous UploadDir/<user>/<id>.<ext>.
|
||||||
|
path := filepath.Join(s.UploadDir, userID, dto.ID+".txt")
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fichier physique absent: %v", err)
|
||||||
|
}
|
||||||
|
if info.Size() != 5 {
|
||||||
|
t.Errorf("taille physique = %d, attendu 5", info.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "upload-conflict")
|
||||||
|
folderID := repository.NewID()
|
||||||
|
if err := s.Repository.Resources.InsertFolder(userID, folderID, "Docs", ""); err != nil {
|
||||||
|
t.Fatalf("insert folder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := multipartFileHeader(t, "dupe.txt", []byte("hello"))
|
||||||
|
first, err := s.Upload(userID, target, folderID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("premier upload: %v", err)
|
||||||
|
}
|
||||||
|
if first.ID == "" {
|
||||||
|
t.Fatal("aucun id au premier upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Même nom, même dossier → NAME_CONFLICT et pas de second fichier physique.
|
||||||
|
if _, err := s.Upload(userID, target, folderID); !errors.Is(err, repository.ErrNameConflict) {
|
||||||
|
t.Fatalf("second upload : attendu ErrNameConflict, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(filepath.Join(s.UploadDir, userID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readdir: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("fichier orphelin laissé après NAME_CONFLICT : %d fichiers", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const serviceTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_service_test?sslmode=disable"
|
||||||
|
|
||||||
|
// newServiceStore returns a Resources service over a fresh migrated test DB.
|
||||||
|
func newServiceStore(t *testing.T) *Resources {
|
||||||
|
t.Helper()
|
||||||
|
conn := dbtest.OpenTestDatabase(t, serviceTestURL)
|
||||||
|
repo := repository.NewRepository(conn)
|
||||||
|
return NewResources(repo, t.TempDir(), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustCreateUser creates a plain (non-admin) account and returns its id.
|
||||||
|
func mustCreateUser(t *testing.T, repo *repository.Repository, username string) string {
|
||||||
|
t.Helper()
|
||||||
|
id, err := repo.Users.Create(username, username, "test-hash", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user %q: %v", username, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRegisterDevice registers a device (idempotent) and returns its id.
|
||||||
|
func mustRegisterDevice(t *testing.T, repo *repository.Repository, deviceID string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := repo.Devices.Upsert(deviceID); err != nil {
|
||||||
|
t.Fatalf("register device: %v", err)
|
||||||
|
}
|
||||||
|
return deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
func strp(v string) *string { return &v }
|
||||||
|
|
||||||
|
// syncOp builds a SyncOperation with a payload serialized as JSON.
|
||||||
|
func syncOp(operationID, resourceID, operation, resourceType string, payload any) SyncOperation {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
return SyncOperation{
|
||||||
|
OperationID: operationID,
|
||||||
|
ResourceID: strp(resourceID),
|
||||||
|
ResourceType: strp(resourceType),
|
||||||
|
Operation: operation,
|
||||||
|
Payload: raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSyncOp(t *testing.T) {
|
||||||
|
hexID := repository.NewID()
|
||||||
|
|
||||||
|
valid := syncOp(hexID, hexID, OpCreateResource, "file", map[string]any{"name": "x.txt"})
|
||||||
|
if err := validateSyncOp(&valid); err != nil {
|
||||||
|
t.Errorf("op valide rejetée: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
op SyncOperation
|
||||||
|
}{
|
||||||
|
{"operation_id non 32-hex", syncOp("UPPERCASE", hexID, OpCreateResource, "file", map[string]any{"name": "x"})},
|
||||||
|
{"operation_id trop court", syncOp("abc", hexID, OpCreateResource, "file", map[string]any{"name": "x"})},
|
||||||
|
{"operation vide", func() SyncOperation {
|
||||||
|
o := syncOp(hexID, hexID, OpCreateResource, "file", map[string]any{"name": "x"})
|
||||||
|
o.Operation = ""
|
||||||
|
return o
|
||||||
|
}()},
|
||||||
|
{"operation inconnue", syncOp(hexID, hexID, "explode", "file", map[string]any{"name": "x"})},
|
||||||
|
{"resource_id non 32-hex", syncOp(hexID, "not-hex", OpCreateResource, "file", map[string]any{"name": "x"})},
|
||||||
|
{"resource_type invalide", syncOp(hexID, hexID, OpCreateResource, "document", map[string]any{"name": "x"})},
|
||||||
|
{"resource_type manquant", func() SyncOperation {
|
||||||
|
o := syncOp(hexID, hexID, OpCreateResource, "file", map[string]any{"name": "x"})
|
||||||
|
o.ResourceType = nil
|
||||||
|
return o
|
||||||
|
}()},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if err := validateSyncOp(&tc.op); err == nil {
|
||||||
|
t.Errorf("%s: validation attendue à échouer", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSyncOpAckOnlyOpsSkipResourceValidation(t *testing.T) {
|
||||||
|
// Les ops partage/lien sont accusées réception SANS état serveur : ni
|
||||||
|
// resource_id ni resource_type ne sont exigés.
|
||||||
|
for _, opType := range []string{OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink} {
|
||||||
|
op := SyncOperation{
|
||||||
|
OperationID: repository.NewID(),
|
||||||
|
Operation: opType,
|
||||||
|
Payload: json.RawMessage(`{}`),
|
||||||
|
}
|
||||||
|
if err := validateSyncOp(&op); err != nil {
|
||||||
|
t.Errorf("%s: ack-only op rejetée: %v", opType, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyBatchRejectsInvalidOperationID(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "sync-invalid")
|
||||||
|
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||||
|
|
||||||
|
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
|
||||||
|
syncOp("NOT-32-HEX", repository.NewID(), OpCreateResource, "file", map[string]any{"name": "x.txt"}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyBatch: %v", err)
|
||||||
|
}
|
||||||
|
if result.Applied != 0 || result.Failed == nil {
|
||||||
|
t.Fatalf("attendu applied=0 + échec, got %+v", result)
|
||||||
|
}
|
||||||
|
if result.Failed.OperationID != "NOT-32-HEX" || result.Failed.Code != "INVALID_REQUEST" {
|
||||||
|
t.Errorf("failed inattendu: %+v", result.Failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyBatchAckOnlyOpsRecordedWithoutState(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "sync-ack")
|
||||||
|
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||||
|
|
||||||
|
ops := []SyncOperation{
|
||||||
|
syncOp(repository.NewID(), repository.NewID(), OpShare, "file", map[string]any{}),
|
||||||
|
syncOp(repository.NewID(), repository.NewID(), OpCreateLink, "file", map[string]any{}),
|
||||||
|
}
|
||||||
|
result, err := s.ApplyBatch(userID, deviceID, ops)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyBatch: %v", err)
|
||||||
|
}
|
||||||
|
if result.Applied != 2 || result.Failed != nil {
|
||||||
|
t.Errorf("attendu applied=2, got %+v", result)
|
||||||
|
}
|
||||||
|
// Aucune ressource ne doit avoir été créée.
|
||||||
|
files, total, _ := s.Repo.ListFiles(userID, "", 10, 0, "created_at", "desc")
|
||||||
|
if len(files) != 0 || total != 0 {
|
||||||
|
t.Errorf("aucune ressource attendue, got %d", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejeu → toujours accusé, jamais visible.
|
||||||
|
result, err = s.ApplyBatch(userID, deviceID, ops)
|
||||||
|
if err != nil || result.Applied != 2 {
|
||||||
|
t.Errorf("rejeu: applied=%d err=%v", result.Applied, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyBatchUpdateMetadata(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "sync-rename")
|
||||||
|
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||||
|
|
||||||
|
fileID := repository.NewID()
|
||||||
|
ops := []SyncOperation{
|
||||||
|
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "before.txt"}),
|
||||||
|
syncOp(repository.NewID(), fileID, OpUpdateMetadata, "file", map[string]any{"name": "after.txt"}),
|
||||||
|
}
|
||||||
|
result, err := s.ApplyBatch(userID, deviceID, ops)
|
||||||
|
if err != nil || result.Applied != 2 || result.Failed != nil {
|
||||||
|
t.Fatalf("ApplyBatch: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err)
|
||||||
|
}
|
||||||
|
row, err := s.Repo.GetFile(userID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFile: %v", err)
|
||||||
|
}
|
||||||
|
if row.Name != "after.txt" {
|
||||||
|
t.Errorf("nom après update_metadata = %q, attendu after.txt", row.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyBatchMoveIntoItselfRejected(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "sync-selfmove")
|
||||||
|
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||||
|
|
||||||
|
fileID := repository.NewID()
|
||||||
|
if _, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
|
||||||
|
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("apply create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
|
||||||
|
syncOp(repository.NewID(), fileID, OpMoveResource, "file", map[string]any{"toFolderResourceId": fileID}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyBatch: %v", err)
|
||||||
|
}
|
||||||
|
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
|
||||||
|
t.Errorf("move dans soi-même: attendu applied=0 INVALID_REQUEST, got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyBatchMoveToMissingFolder(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "sync-move-missing")
|
||||||
|
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||||
|
|
||||||
|
fileID := repository.NewID()
|
||||||
|
if _, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
|
||||||
|
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("apply create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := repository.NewID()
|
||||||
|
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
|
||||||
|
syncOp(repository.NewID(), fileID, OpMoveResource, "file", map[string]any{"toFolderResourceId": missing}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyBatch: %v", err)
|
||||||
|
}
|
||||||
|
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "NOT_FOUND" {
|
||||||
|
t.Errorf("move vers dossier absent: attendu applied=0 NOT_FOUND, got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyBatchRejectsInvalidPayloadJSON(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "sync-badpayload")
|
||||||
|
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||||
|
|
||||||
|
op := syncOp(repository.NewID(), repository.NewID(), OpCreateResource, "file", map[string]any{})
|
||||||
|
op.Payload = json.RawMessage(`{"name":`)
|
||||||
|
|
||||||
|
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{op})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyBatch: %v", err)
|
||||||
|
}
|
||||||
|
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
|
||||||
|
t.Errorf("payload JSON invalide: attendu applied=0 INVALID_REQUEST, got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyBatchRejectsMissingName(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "sync-noname")
|
||||||
|
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||||
|
|
||||||
|
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
|
||||||
|
syncOp(repository.NewID(), repository.NewID(), OpCreateResource, "file", map[string]any{}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyBatch: %v", err)
|
||||||
|
}
|
||||||
|
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
|
||||||
|
t.Errorf("create sans name: attendu applied=0 INVALID_REQUEST, got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyBatchDeleteIsIdempotent(t *testing.T) {
|
||||||
|
s := newServiceStore(t)
|
||||||
|
userID := mustCreateUser(t, s.Repository, "sync-del")
|
||||||
|
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||||
|
|
||||||
|
fileID := repository.NewID()
|
||||||
|
ops := []SyncOperation{
|
||||||
|
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
|
||||||
|
syncOp(repository.NewID(), fileID, OpDeleteResource, "file", map[string]any{}),
|
||||||
|
}
|
||||||
|
result, err := s.ApplyBatch(userID, deviceID, ops)
|
||||||
|
if err != nil || result.Applied != 2 || result.Failed != nil {
|
||||||
|
t.Fatalf("ApplyBatch: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err)
|
||||||
|
}
|
||||||
|
if _, err := s.Repo.GetFile(userID, fileID); !errors.Is(err, repository.ErrNotFound) {
|
||||||
|
t.Errorf("fichier supprimé attendu NOT_FOUND, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppression d'une ressource déjà absente → no-op réussi.
|
||||||
|
result, err = s.ApplyBatch(userID, deviceID, []SyncOperation{
|
||||||
|
syncOp(repository.NewID(), fileID, OpDeleteResource, "file", map[string]any{}),
|
||||||
|
})
|
||||||
|
if err != nil || result.Applied != 1 || result.Failed != nil {
|
||||||
|
t.Errorf("delete absent: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -37,6 +37,7 @@ import androidx.compose.material3.ModalBottomSheet
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -48,19 +49,25 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vaultdrop.mobile.R
|
import com.vaultdrop.mobile.R
|
||||||
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
||||||
|
import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -82,6 +89,7 @@ fun PdfBuilderScreen(
|
|||||||
var showNoteDialog by remember { mutableStateOf(false) }
|
var showNoteDialog by remember { mutableStateOf(false) }
|
||||||
var showFileSheet by remember { mutableStateOf(false) }
|
var showFileSheet by remember { mutableStateOf(false) }
|
||||||
var showNameDialog by remember { mutableStateOf(false) }
|
var showNameDialog by remember { mutableStateOf(false) }
|
||||||
|
var previewFile by remember { mutableStateOf<FileEntity?>(null) }
|
||||||
|
|
||||||
// Nom suggéré par défaut : date du jour au format jour-mois-année.
|
// Nom suggéré par défaut : date du jour au format jour-mois-année.
|
||||||
val defaultPdfName = remember {
|
val defaultPdfName = remember {
|
||||||
@@ -171,6 +179,13 @@ fun PdfBuilderScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previewFile?.let { file ->
|
||||||
|
FilePreviewDialog(
|
||||||
|
file = file,
|
||||||
|
onDismiss = { previewFile = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -198,6 +213,9 @@ fun PdfBuilderScreen(
|
|||||||
onMoveUp = { viewModel.moveUp(item.id) },
|
onMoveUp = { viewModel.moveUp(item.id) },
|
||||||
onMoveDown = { viewModel.moveDown(item.id) },
|
onMoveDown = { viewModel.moveDown(item.id) },
|
||||||
onRemove = { viewModel.removeItem(item.id) },
|
onRemove = { viewModel.removeItem(item.id) },
|
||||||
|
onPreview = (item as? PdfBuilderItem.FileItem)?.let {
|
||||||
|
{ previewFile = it.file }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,7 +389,9 @@ private fun BuilderItemRow(
|
|||||||
onMoveUp: () -> Unit,
|
onMoveUp: () -> Unit,
|
||||||
onMoveDown: () -> Unit,
|
onMoveDown: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
|
onPreview: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
val previewModifier = if (onPreview != null) Modifier.clickable(onClick = onPreview) else Modifier
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
@@ -379,6 +399,7 @@ private fun BuilderItemRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.then(previewModifier)
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -438,6 +459,62 @@ private fun BuilderItemRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun FilePreviewDialog(
|
||||||
|
file: FileEntity,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val onOpenExternalFailed: () -> Unit = {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.getString(R.string.document_open_error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = file.name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.pdf_builder_close_preview),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
DocumentContentViewer(
|
||||||
|
file = file,
|
||||||
|
onOpenExternalFailed = onOpenExternalFailed,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BuilderActions(
|
private fun BuilderActions(
|
||||||
onAddFile: () -> Unit,
|
onAddFile: () -> Unit,
|
||||||
|
|||||||
@@ -176,5 +176,7 @@
|
|||||||
<string name="pdf_builder_name_hint">ex. factures_janvier</string>
|
<string name="pdf_builder_name_hint">ex. factures_janvier</string>
|
||||||
<string name="pdf_builder_name_save">Enregistrer</string>
|
<string name="pdf_builder_name_save">Enregistrer</string>
|
||||||
<string name="pdf_builder_saved">PDF créé</string>
|
<string name="pdf_builder_saved">PDF créé</string>
|
||||||
|
<string name="pdf_builder_preview">Aperçu</string>
|
||||||
|
<string name="pdf_builder_close_preview">Fermer l\'aperçu</string>
|
||||||
<string name="pdf_generated_folder">PDF générés</string>
|
<string name="pdf_generated_folder">PDF générés</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user