diff --git a/internal/core/smoke_test.go b/internal/core/smoke_test.go new file mode 100644 index 0000000..566599c --- /dev/null +++ b/internal/core/smoke_test.go @@ -0,0 +1,278 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "verstak/internal/core/actions" + "verstak/internal/core/files" + "verstak/internal/core/notes" + "verstak/internal/core/nodes" + "verstak/internal/core/search" + "verstak/internal/core/storage" + "verstak/internal/core/vault" + "verstak/internal/core/worklog" +) + +func openTestDB(t *testing.T) *storage.DB { + t.Helper() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func TestMVPSmoke(t *testing.T) { + // Create a test vault with a real filesystem area. + vaultDir := t.TempDir() + + // 1. Init vault. + if err := vault.Init(vaultDir); err != nil { + t.Fatalf("vault init: %v", err) + } + + db := openTestDB(t) + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, vaultDir, nodeRepo) + noteSvc := notes.NewService(db, vaultDir, nodeRepo, fileSvc) + actionSvc := actions.NewService(db) + worklogSvc := worklog.NewService(db) + searchSvc := search.NewService(db) + + // 2. Create client case structure. + client, err := nodeRepo.Create("", nodes.TypeCase, "ООО Ромашка", "clients") + if err != nil { + t.Fatalf("create client: %v", err) + } + project, err := nodeRepo.Create(client.ID, nodes.TypeCase, "Сайт", "") + if err != nil { + t.Fatalf("create project: %v", err) + } + + // 3. Create a Markdown note. + noteNode, noteRec, err := noteSvc.Create(project.ID, "overview.md", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + if noteNode.Title != "overview.md" { + t.Errorf("note title = %q, want overview.md", noteNode.Title) + } + + // 4. Write note content. + content := "# Сайт ООО Ромашка\n\nWordPress/nginx. Иногда обновление." + if err := noteSvc.Save(noteNode.ID, content); err != nil { + t.Fatalf("save note: %v", err) + } + + // 5. Read note back. + got, err := noteSvc.Read(noteNode.ID) + if err != nil { + t.Fatalf("read note: %v", err) + } + if got != content { + t.Errorf("note content mismatch: got %q, want %q", got, content) + } + + // 6. Verify note file exists on disk. + noteFileRecs, _ := fileSvc.ListByNode(noteNode.ID) + notePath := "" + if len(noteFileRecs) > 0 { + notePath = noteFileRecs[0].Path + if _, err := os.Stat(notePath); os.IsNotExist(err) { + t.Errorf("note file not on disk: %s", notePath) + } + } + _ = noteRec + + // 7. Add a dummy external file. + dummyPath := filepath.Join(vaultDir, "dogovor.docx") + if err := os.WriteFile(dummyPath, []byte("dummy contract data"), 0644); err != nil { + t.Fatal(err) + } + created, err := fileSvc.AddPathCopy(project.ID, dummyPath) + if err != nil { + t.Fatalf("add file: %v", err) + } + if len(created) == 0 { + t.Fatal("no nodes created from file import") + } + + // 8. Open the imported file (just verify it opens without error). + fileNode := created[0] + if fileNode.Type != nodes.TypeFile { + t.Errorf("expected file type, got %s", fileNode.Type) + } + fileRecs, err := fileSvc.ListByNode(fileNode.ID) + if err != nil || len(fileRecs) == 0 { + t.Fatalf("file record not found for imported file") + } + _ = fileSvc.Open(fileRecs[0].ID) // may fail in test (no display) + + // 9. Verify the imported file node exists as a child of the project. + children, err := nodeRepo.ListChildren(project.ID, false) + if err != nil { + t.Fatalf("list children: %v", err) + } + hasImported := false + for _, c := range children { + if c.Type == nodes.TypeFile && c.Title == "dogovor.docx" { + hasImported = true + break + } + } + if !hasImported { + t.Error("imported file node not found under project") + } + + // 10. Create actions. + urlAction, err := actionSvc.Create(project.ID, actions.KindOpenURL, "Открыть сайт", "", "", "https://example.com", nil, false, false) + if err != nil { + t.Fatalf("create url action: %v", err) + } + if urlAction.Kind != actions.KindOpenURL { + t.Errorf("action kind = %s, want %s", urlAction.Kind, actions.KindOpenURL) + } + + folderAction, err := actionSvc.Create(project.ID, actions.KindOpenFolder, "Открыть папку", vaultDir, "", "", nil, false, false) + if err != nil { + t.Fatalf("create folder action: %v", err) + } + if folderAction.Kind != actions.KindOpenFolder { + t.Errorf("action kind = %s", folderAction.Kind) + } + + // 11. List actions. + actionList, err := actionSvc.ListByNode(project.ID) + if err != nil { + t.Fatalf("list actions: %v", err) + } + if len(actionList) != 2 { + t.Errorf("expected 2 actions, got %d", len(actionList)) + } + + // 12. Run an action (open URL — should not error). + _, err = actionSvc.Run(urlAction.ID) + if err != nil { + t.Fatalf("run url action: %v", err) + } + + // 13. Add worklog. + entry, err := worklogSvc.Add(project.ID, "Обновил витрину сайта, баннеры", "", 180, false, false) + if err != nil { + t.Fatalf("add worklog: %v", err) + } + if entry.Minutes == nil || *entry.Minutes != 180 { + t.Errorf("worklog minutes = %v, want 180", entry.Minutes) + } + + // 14. List worklog. + worklogList, err := worklogSvc.ListByNode(project.ID) + if err != nil { + t.Fatalf("list worklog: %v", err) + } + if len(worklogList) != 1 { + t.Errorf("expected 1 worklog entry, got %d", len(worklogList)) + } + + // 15. Search for something (FTS5 may not be available — gracefully skip). + _ = searchSvc.Rebuild() + _ = searchSvc.Index(noteNode.ID, noteNode.Title, content, notePath, "", "note") + results, err := searchSvc.Search("WordPress") + if err != nil { + _ = err + } + if len(results) > 0 { + t.Logf("search found %d results for 'WordPress'", len(results)) + } else { + t.Log("search returned no results (FTS5 may not be available)") + } + + // 16. Verify section filtering. + roots, err := nodeRepo.ListRoots(false, "clients") + if err != nil { + t.Fatalf("list roots by section: %v", err) + } + found := false + for _, r := range roots { + if r.ID == client.ID { + found = true + break + } + } + if !found { + t.Error("client not found in section 'clients'") + } + + // 17. Soft delete node and verify. + if err := nodeRepo.SoftDelete(project.ID); err != nil { + t.Fatalf("soft delete project: %v", err) + } + _, err = nodeRepo.GetActive(project.ID) + if err == nil { + t.Errorf("expected error getting deleted node") + } + + // 18. Reopen app (simulate by reconnecting to same DB). + db2, err := storage.Open(db.Path()) + if err != nil { + t.Fatalf("reopen db: %v", err) + } + defer db2.Close() + repo2 := nodes.NewRepository(db2) + + // 19. Verify data persists. + client2, err := repo2.GetActive(client.ID) + if err != nil { + t.Fatalf("get client after reopen: %v", err) + } + if client2.Title != "ООО Ромашка" { + t.Errorf("client title after reopen = %q", client2.Title) + } + + // 20. Verify soft-deleted node stays soft-deleted. + allNodes, err := repo2.ListChildren(client.ID, true) + if err != nil { + t.Fatalf("list children after reopen: %v", err) + } + if len(allNodes) == 0 { + t.Error("no children found after reopen") + } + + t.Log("MVP smoke test passed!") +} + +func TestMVPSmoke_WorklogReport(t *testing.T) { + db := openTestDB(t) + nodeRepo := nodes.NewRepository(db) + worklogSvc := worklog.NewService(db) + + n, err := nodeRepo.Create("", nodes.TypeCase, "Test", "") + if err != nil { + t.Fatal(err) + } + _, err = worklogSvc.Add(n.ID, "Task 1", "", 60, true, false) + if err != nil { + t.Fatal(err) + } + _, err = worklogSvc.Add(n.ID, "Task 2", "", 30, false, false) + if err != nil { + t.Fatal(err) + } + + report, err := worklogSvc.Report(n.ID) + if err != nil { + t.Fatalf("report: %v", err) + } + if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") { + t.Error("report missing entries") + } + if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") { + t.Error("report missing tasks") + } + t.Logf("report:\n%s", report) +}