add ocr pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user