add ocr pipeline

This commit is contained in:
m
2026-09-15 23:52:19 +02:00
parent 102cc425b9
commit 050434325a
21 changed files with 1028 additions and 17 deletions
+8 -1
View File
@@ -85,6 +85,13 @@ func FilesUpload(c *gin.Context) {
}
userID := c.GetString(UserIDKey)
folderID := c.PostForm("folderId")
// resourceId optionnel : cible un fichier déjà connu (metadata-only de
// l'outbox) pour lui attacher ses octets physiques (ex. OCR serveur).
resourceID := c.PostForm("resourceId")
if resourceID != "" && !deviceIDPattern.MatchString(resourceID) {
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, Store.MaxFileSize+1)
file, err := c.FormFile("file")
@@ -102,7 +109,7 @@ func FilesUpload(c *gin.Context) {
return
}
dto, err := Store.Upload(userID, file, folderID)
dto, err := Store.Upload(userID, file, folderID, resourceID)
if err != nil {
writeError(c, err)
return
+84
View File
@@ -183,6 +183,29 @@ func uploadMultipart(t *testing.T, r *gin.Engine, token, folderID, filename stri
return rec
}
// uploadMultipartToResource uploads bytes targeted at an existing resource_id
// (metadata-only file from the outbox), le cas d'usage OCR.
func uploadMultipartToResource(t *testing.T, r *gin.Engine, token, resourceID, filename string, content []byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write body: %v", err)
}
if err := writer.WriteField("resourceId", resourceID); err != nil {
t.Fatalf("write resourceId: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/files/upload", token, body.Bytes(), writer.FormDataContentType())
return rec
}
func TestFilesFlow(t *testing.T) {
r, _, repo := setup(t)
deviceA := repository.NewID()
@@ -385,6 +408,67 @@ func TestUploadNameConflict(t *testing.T) {
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe")
}
func TestUploadTargetsExistingResource(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "tr"), "upload-test-password", device)
// Fiche metadata-only (même sémantique que l'outbox create_resource).
resourceID := repository.NewID()
if err := repo.Resources.InsertFile(user, resourceID, "scan.png", "", 0, nil, nil); err != nil {
t.Fatalf("insert metadata-only file: %v", err)
}
rec := uploadMultipartToResource(t, r, token, resourceID, "scan.png", []byte("PNGDATA"))
env := expectOK(t, rec, "upload-target")
var uploaded fileDTO
if err := json.Unmarshal(env.Data, &uploaded); err != nil {
t.Fatalf("upload-target: unmarshal: %v", err)
}
if uploaded.ID != resourceID {
t.Errorf("id attendu %s, got %s", resourceID, uploaded.ID)
}
if uploaded.Size != 7 {
t.Errorf("taille = %d, attendu 7", uploaded.Size)
}
// Toujours une seule ligne (pas de doublon).
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files", token, nil, "")
env = expectOK(t, rec, "list-after-targeted-upload")
var files []fileDTO
if err := json.Unmarshal(env.Data, &files); err != nil {
t.Fatalf("list: unmarshal: %v", err)
}
if len(files) != 1 || files[0].ID != resourceID {
t.Errorf("aucun doublon attendu, got %+v", files)
}
}
func TestUploadTargetForeignResourceNotFound(t *testing.T) {
r, _, repo := setup(t)
deviceA := repository.NewID()
deviceB := repository.NewID()
tokenA, _ := registerAndLogin(t, r, repo, testUserUsername(deviceA, "tf"), "upload-test-password", deviceA)
_, userB := registerAndLogin(t, r, repo, testUserUsername(deviceB, "tg"), "upload-test-password-b", deviceB)
resourceID := repository.NewID()
if err := repo.Resources.InsertFile(userB, resourceID, "mine.png", "", 0, nil, nil); err != nil {
t.Fatalf("insert B file: %v", err)
}
rec := uploadMultipartToResource(t, r, tokenA, resourceID, "mine.png", []byte("DATA"))
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "upload-foreign")
}
func TestUploadTargetInvalidID(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "ti"), "upload-test-password", device)
rec := uploadMultipartToResource(t, r, token, "NOT-HEX", "x.png", []byte("DATA"))
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "upload-invalid-id")
}
func TestFoldersListAndScoping(t *testing.T) {
r, _, repo := setup(t)
deviceA := repository.NewID()
+23
View File
@@ -433,6 +433,29 @@ func (r *Resources) UpdateNameByID(resourceID, name string) error {
return nil
}
// UpdatePhysical refreshes an existing file row's physical metadata
// (size_bytes / mime_type / extension) after a targeted re-upload — used so a
// resource created metadata-only (outbox create_resource) receives its bytes
// for server-side OCR. Absent / not owned → ErrNotFound. Never renames or moves.
func (r *Resources) UpdatePhysical(ownerID, resourceID string, size int64, mimeType, extension *string) error {
result, err := r.DB.Exec(
`UPDATE resources SET size_bytes = $3, mime_type = $4, extension = $5, updated_at = NOW()
WHERE resource_id = $1 AND user_id = $2 AND type = 'file' AND deleted_at IS NULL`,
resourceID, ownerID, size, mimeType, extension,
)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
// SyncDelete soft-deletes a resource; absence is NOT an error (idempotent
// terminal state for the outbox).
func (r *Resources) SyncDelete(ownerID, resourceID string) error {
+26 -5
View File
@@ -104,30 +104,51 @@ func (s *Resources) SearchFiles(ownerID, q string, page, pageSize int) ([]FileDT
// Upload persists the multipart-sourced file under UploadDir/<user> and
// records its metadata, returning the FileDTO. The physical file is removed
// if metadata persistence fails (e.g. name conflict).
func (s *Resources) Upload(ownerID string, file *multipart.FileHeader, folderID string) (FileDTO, error) {
//
// When resourceID is non-empty the upload TARGETS an existing file owned by
// the user (metadata-only resource from the outbox, or a previously uploaded
// file): the bytes are written under UploadDir/<user>/<resourceID>.<ext> and
// only the physical metadata (size/mime/extension) is refreshed — never a
// rename, never a move, never a duplicate row. Unknown / foreign resource →
// repo.ErrNotFound.
func (s *Resources) Upload(ownerID string, file *multipart.FileHeader, folderID, resourceID string) (FileDTO, error) {
if file.Size > s.MaxFileSize {
return FileDTO{}, ErrFileTooLarge
}
id := repository.NewID()
targetExisting := resourceID != ""
if targetExisting {
if _, err := s.Repo.GetFile(ownerID, resourceID); err != nil {
return FileDTO{}, err
}
} else {
resourceID = repository.NewID()
}
extension := strings.TrimPrefix(filepath.Ext(file.Filename), ".")
destDir := filepath.Join(s.UploadDir, ownerID)
if err := os.MkdirAll(destDir, 0o755); err != nil {
return FileDTO{}, fmt.Errorf("create upload dir: %w", err)
}
destPath := filepath.Join(destDir, id+"."+extension)
destPath := filepath.Join(destDir, resourceID+"."+extension)
if err := copyMultipart(file, destPath); err != nil {
return FileDTO{}, err
}
mimeType := file.Header.Get("Content-Type")
if err := s.Repo.InsertFile(ownerID, id, file.Filename, folderID, file.Size, &mimeType, &extension); err != nil {
var err error
if targetExisting {
err = s.Repo.UpdatePhysical(ownerID, resourceID, file.Size, &mimeType, &extension)
} else {
err = s.Repo.InsertFile(ownerID, resourceID, file.Filename, folderID, file.Size, &mimeType, &extension)
}
if err != nil {
_ = os.Remove(destPath)
return FileDTO{}, err
}
row, err := s.Repo.GetFile(ownerID, id)
row, err := s.Repo.GetFile(ownerID, resourceID)
if err != nil {
return FileDTO{}, err
}
+74 -3
View File
@@ -42,7 +42,7 @@ 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")), "")
dto, err := s.Upload(userID, multipartFileHeader(t, "docs.txt", []byte("hello")), "", "")
if err != nil {
t.Fatalf("upload: %v", err)
}
@@ -70,7 +70,7 @@ func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) {
}
target := multipartFileHeader(t, "dupe.txt", []byte("hello"))
first, err := s.Upload(userID, target, folderID)
first, err := s.Upload(userID, target, folderID, "")
if err != nil {
t.Fatalf("premier upload: %v", err)
}
@@ -79,7 +79,7 @@ func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) {
}
// 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) {
if _, err := s.Upload(userID, target, folderID, ""); !errors.Is(err, repository.ErrNameConflict) {
t.Fatalf("second upload : attendu ErrNameConflict, got %v", err)
}
@@ -91,3 +91,74 @@ func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) {
t.Errorf("fichier orphelin laissé après NAME_CONFLICT : %d fichiers", len(entries))
}
}
func TestUploadTargetsExistingResource(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "upload-target")
// Fiche metadata-only créée par l'outbox côté client.
resourceID := repository.NewID()
ext := "png"
if err := s.Repository.Resources.InsertFile(userID, resourceID, "scan.png", "", 0, nil, &ext); err != nil {
t.Fatalf("insert metadata-only file: %v", err)
}
// Re-upload ciblé : mêmes octets, même resource_id, pas de doublon.
dto, err := s.Upload(userID, multipartFileHeader(t, "scan.png", []byte("PNGDATA")), "", resourceID)
if err != nil {
t.Fatalf("targeted upload: %v", err)
}
if dto.ID != resourceID {
t.Errorf("id attendu %s, got %s", resourceID, dto.ID)
}
if dto.Size != 7 {
t.Errorf("taille = %d, attendu 7 (rafraîchie)", dto.Size)
}
path := filepath.Join(s.UploadDir, userID, resourceID+".png")
info, err := os.Stat(path)
if err != nil {
t.Fatalf("fichier physique absent: %v", err)
}
if info.Size() != 7 {
t.Errorf("taille physique = %d, attendu 7", info.Size())
}
// Une seule ligne serveur (pas de doublon créé par le re-upload).
rows, total, err := s.Repo.ListFiles(userID, "", 50, 0, "", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 1 || len(rows) != 1 || rows[0].ID != resourceID {
t.Errorf("liste après re-upload: total=%d rows=%+v", total, rows)
}
}
func TestUploadTargetsOwnedResourceOnly(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "upload-owner")
otherUser := mustCreateUser(t, s.Repository, "upload-owner-other")
resourceID := repository.NewID()
if err := s.Repository.Resources.InsertFile(otherUser, resourceID, "mine.png", "", 0, nil, nil); err != nil {
t.Fatalf("insert other's file: %v", err)
}
// Un resource_id d'un autre user → ErrNotFound, aucun octet écrit.
if _, err := s.Upload(userID, multipartFileHeader(t, "mine.png", []byte("DATA")), "", resourceID); !errors.Is(err, repository.ErrNotFound) {
t.Fatalf("upload cross-user : attendu ErrNotFound, got %v", err)
}
// Le dossier upload du user ne doit pas avoir été créé (échec avant écriture).
if _, err := os.Stat(filepath.Join(s.UploadDir, userID)); !os.IsNotExist(err) {
t.Errorf("dossier upload créé à tort ou erreur inattendue: %v", err)
}
}
func TestUploadTargetUnknownIsNotFound(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "upload-unknown")
if _, err := s.Upload(userID, multipartFileHeader(t, "ghost.png", []byte("DATA")), "", repository.NewID()); !errors.Is(err, repository.ErrNotFound) {
t.Fatalf("upload inconnu : attendu ErrNotFound, got %v", err)
}
}