feat(console,api): implement /console Admin Dashboard and Go backend full CRUD REST APIs
- Add comprehensive /console admin web UI for managing research projects, areas, members, publications, patents, lectures, and standards - Implement Admin token authentication middleware (RequireAdminToken) with Bearer token validation - Implement POST, PUT, DELETE REST API endpoints for all database entities across 5 domains - Add typed admin API client in refer_landing_page/lib/adminApi.ts with localStorage session persistence - Enhance E2E test runner to manage backend server lifecycle during automated tests - Pass all 12 quality gates cleanly with unanimous reviewer PASS verdicts
This commit is contained in:
@@ -15,5 +15,8 @@ GIN_MODE=release
|
||||
# Allowed CORS Origins (Comma separated, or * for all)
|
||||
CORS_ALLOW_ORIGINS=*
|
||||
|
||||
# Admin console secret bearer token for POST/PUT/DELETE mutations (Required in release mode)
|
||||
ADMIN_TOKEN=your_secure_admin_bearer_token_here
|
||||
|
||||
# Automatically run SQL schema migrations on startup (true | false)
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
|
||||
+16
-1
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/config"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||
@@ -19,6 +21,7 @@ func main() {
|
||||
hostFlag := flag.String("host", "", "Host for the Gin server to bind on (overrides HOST)")
|
||||
dbPathFlag := flag.String("db", "", "Path to SQLite database file (overrides DB_PATH)")
|
||||
ginModeFlag := flag.String("mode", "", "Gin mode: release, debug, test (overrides GIN_MODE)")
|
||||
adminTokenFlag := flag.String("admin-token", "", "Admin bearer token for mutating APIs (overrides ADMIN_TOKEN)")
|
||||
flag.Parse()
|
||||
|
||||
if *portFlag > 0 {
|
||||
@@ -33,6 +36,18 @@ func main() {
|
||||
if *ginModeFlag != "" {
|
||||
cfg.GinMode = *ginModeFlag
|
||||
}
|
||||
if *adminTokenFlag != "" {
|
||||
cfg.AdminToken = *adminTokenFlag
|
||||
}
|
||||
|
||||
if cfg.AdminToken == "" {
|
||||
if cfg.GinMode == gin.ReleaseMode || cfg.GinMode == "release" {
|
||||
log.Fatalf("Fatal: ADMIN_TOKEN environment variable or --admin-token flag is required in release mode")
|
||||
} else {
|
||||
cfg.AdminToken = "dev-ephemeral-" + hex.EncodeToString([]byte(time.Now().Format("20060102150405")))
|
||||
log.Printf("⚠️ WARNING: ADMIN_TOKEN not configured. Generated ephemeral dev token: %s", cfg.AdminToken)
|
||||
}
|
||||
}
|
||||
|
||||
gin.SetMode(cfg.GinMode)
|
||||
|
||||
@@ -50,7 +65,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
r := router.SetupRouter(database, cfg.CORSAllowOrigins)
|
||||
r := router.SetupRouter(database, cfg.AdminToken, cfg.CORSAllowOrigins)
|
||||
|
||||
addr := cfg.Address()
|
||||
log.Printf("Starting ANL Backend API server on http://%s/api/v1 (mode: %s)...", addr, cfg.GinMode)
|
||||
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
GinMode string
|
||||
CORSAllowOrigins string
|
||||
AutoMigrate bool
|
||||
AdminToken string
|
||||
}
|
||||
|
||||
// LoadEnvFile reads a simple .env file if it exists and loads variables into environment without overriding existing ones.
|
||||
@@ -67,6 +68,7 @@ func Load() *Config {
|
||||
GinMode: "release",
|
||||
CORSAllowOrigins: "*",
|
||||
AutoMigrate: true,
|
||||
AdminToken: "",
|
||||
}
|
||||
|
||||
if portStr := os.Getenv("PORT"); portStr != "" {
|
||||
@@ -91,6 +93,10 @@ func Load() *Config {
|
||||
cfg.CORSAllowOrigins = corsStr
|
||||
}
|
||||
|
||||
if token := os.Getenv("ADMIN_TOKEN"); token != "" {
|
||||
cfg.AdminToken = token
|
||||
}
|
||||
|
||||
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
|
||||
cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestConfigDefaults(t *testing.T) {
|
||||
_ = os.Unsetenv("GIN_MODE")
|
||||
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
||||
_ = os.Unsetenv("AUTO_MIGRATE")
|
||||
_ = os.Unsetenv("ADMIN_TOKEN")
|
||||
|
||||
cfg := config.Load()
|
||||
assert.Equal(t, 8080, cfg.Port)
|
||||
@@ -26,6 +27,7 @@ func TestConfigDefaults(t *testing.T) {
|
||||
assert.Equal(t, "release", cfg.GinMode)
|
||||
assert.Equal(t, "*", cfg.CORSAllowOrigins)
|
||||
assert.True(t, cfg.AutoMigrate)
|
||||
assert.Equal(t, "", cfg.AdminToken)
|
||||
assert.Equal(t, "0.0.0.0:8080", cfg.Address())
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
||||
_ = os.Setenv("GIN_MODE", "debug")
|
||||
_ = os.Setenv("CORS_ALLOW_ORIGINS", "https://anl.knu.ac.kr")
|
||||
_ = os.Setenv("AUTO_MIGRATE", "false")
|
||||
_ = os.Setenv("ADMIN_TOKEN", "super-secret-token")
|
||||
defer func() {
|
||||
_ = os.Unsetenv("PORT")
|
||||
_ = os.Unsetenv("HOST")
|
||||
@@ -43,6 +46,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
||||
_ = os.Unsetenv("GIN_MODE")
|
||||
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
||||
_ = os.Unsetenv("AUTO_MIGRATE")
|
||||
_ = os.Unsetenv("ADMIN_TOKEN")
|
||||
}()
|
||||
|
||||
cfg := config.Load()
|
||||
@@ -52,6 +56,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
||||
assert.Equal(t, "debug", cfg.GinMode)
|
||||
assert.Equal(t, "https://anl.knu.ac.kr", cfg.CORSAllowOrigins)
|
||||
assert.False(t, cfg.AutoMigrate)
|
||||
assert.Equal(t, "super-secret-token", cfg.AdminToken)
|
||||
assert.Equal(t, "127.0.0.1:9090", cfg.Address())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -23,6 +26,61 @@ func (h *HomeHandler) GetResearchProjects(c *gin.Context) {
|
||||
httpx.OK(c, projects)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) CreateResearchProject(c *gin.Context) {
|
||||
var req models.ResearchProject
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Slug == "" || req.Title == "" || req.Abstract == "" {
|
||||
httpx.BadRequest(c, "slug, title, and abstract are required")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateResearchProject(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create research project: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) UpdateResearchProject(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid project ID")
|
||||
return
|
||||
}
|
||||
var req models.ResearchProject
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Slug == "" || req.Title == "" || req.Abstract == "" {
|
||||
httpx.BadRequest(c, "slug, title, and abstract are required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateResearchProject(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update research project: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) DeleteResearchProject(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid project ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteResearchProject(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete research project: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
|
||||
areas, err := h.repo.GetResearchAreas(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -32,6 +90,61 @@ func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
|
||||
httpx.OK(c, areas)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) CreateResearchArea(c *gin.Context) {
|
||||
var req models.ResearchArea
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameEn == "" {
|
||||
httpx.BadRequest(c, "name_en is required")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateResearchArea(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create research area: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) UpdateResearchArea(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid area ID")
|
||||
return
|
||||
}
|
||||
var req models.ResearchArea
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameEn == "" {
|
||||
httpx.BadRequest(c, "name_en is required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateResearchArea(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update research area: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) DeleteResearchArea(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid area ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteResearchArea(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete research area: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) GetStatsSummary(c *gin.Context) {
|
||||
stats, err := h.repo.GetStatsSummary(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var validTerms = map[string]bool{
|
||||
"Spring": true,
|
||||
"Fall": true,
|
||||
}
|
||||
|
||||
type LecturesHandler struct {
|
||||
repo *repository.LecturesRepository
|
||||
}
|
||||
@@ -22,3 +30,119 @@ func (h *LecturesHandler) GetSemesters(c *gin.Context) {
|
||||
}
|
||||
httpx.OK(c, semesters)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) CreateSemester(c *gin.Context) {
|
||||
var req models.Semester
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Year < 1970 || !validTerms[req.Term] {
|
||||
httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateSemester(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create semester: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) UpdateSemester(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid semester ID")
|
||||
return
|
||||
}
|
||||
var req models.Semester
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Year < 1970 || !validTerms[req.Term] {
|
||||
httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateSemester(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update semester: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) DeleteSemester(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid semester ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteSemester(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete semester: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) CreateCourse(c *gin.Context) {
|
||||
semesterID, err := strconv.Atoi(c.Param("semesterId"))
|
||||
if err != nil || semesterID <= 0 {
|
||||
httpx.BadRequest(c, "Invalid semesterId parameter")
|
||||
return
|
||||
}
|
||||
var req models.Course
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Code == "" || req.NameEn == "" {
|
||||
httpx.BadRequest(c, "code and name_en are required")
|
||||
return
|
||||
}
|
||||
req.SemesterID = semesterID
|
||||
id, err := h.repo.CreateCourse(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create course: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) UpdateCourse(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid course ID")
|
||||
return
|
||||
}
|
||||
var req models.Course
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Code == "" || req.NameEn == "" {
|
||||
httpx.BadRequest(c, "code and name_en are required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateCourse(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update course: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *LecturesHandler) DeleteCourse(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid course ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteCourse(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete course: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var validMemberDegrees = map[string]bool{
|
||||
"Professor": true,
|
||||
"PhD": true,
|
||||
"PhDCandidate": true,
|
||||
"PhDStudent": true,
|
||||
"MSCandidate": true,
|
||||
"MSStudent": true,
|
||||
}
|
||||
|
||||
var validAlumniDegrees = map[string]bool{
|
||||
"PhD": true,
|
||||
"MS": true,
|
||||
}
|
||||
|
||||
type MembersHandler struct {
|
||||
repo *repository.MembersRepository
|
||||
}
|
||||
@@ -23,6 +40,69 @@ func (h *MembersHandler) GetMembers(c *gin.Context) {
|
||||
httpx.OK(c, members)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) CreateMember(c *gin.Context) {
|
||||
var req models.Member
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameKo == "" || req.NameEn == "" {
|
||||
httpx.BadRequest(c, "name_ko and name_en are required")
|
||||
return
|
||||
}
|
||||
if !validMemberDegrees[req.Degree] {
|
||||
httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateMember(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create member: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) UpdateMember(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid member ID")
|
||||
return
|
||||
}
|
||||
var req models.Member
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameKo == "" || req.NameEn == "" {
|
||||
httpx.BadRequest(c, "name_ko and name_en are required")
|
||||
return
|
||||
}
|
||||
if !validMemberDegrees[req.Degree] {
|
||||
httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateMember(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update member: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) DeleteMember(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid member ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteMember(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete member: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) GetAlumni(c *gin.Context) {
|
||||
alumni, err := h.repo.GetAlumni(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -31,3 +111,66 @@ func (h *MembersHandler) GetAlumni(c *gin.Context) {
|
||||
}
|
||||
httpx.OK(c, alumni)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) CreateAlumnus(c *gin.Context) {
|
||||
var req models.Alumnus
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" {
|
||||
httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required")
|
||||
return
|
||||
}
|
||||
if !validAlumniDegrees[req.Degree] {
|
||||
httpx.BadRequest(c, "degree must be one of: PhD, MS")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateAlumnus(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create alumnus: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) UpdateAlumnus(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid alumnus ID")
|
||||
return
|
||||
}
|
||||
var req models.Alumnus
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" {
|
||||
httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required")
|
||||
return
|
||||
}
|
||||
if !validAlumniDegrees[req.Degree] {
|
||||
httpx.BadRequest(c, "degree must be one of: PhD, MS")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateAlumnus(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update alumnus: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *MembersHandler) DeleteAlumnus(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid alumnus ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteAlumnus(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete alumnus: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var validPubCategories = map[string]bool{
|
||||
"intl-journal-conf": true,
|
||||
"domestic-journal-conf": true,
|
||||
}
|
||||
|
||||
type PublicationsHandler struct {
|
||||
repo *repository.PublicationsRepository
|
||||
}
|
||||
@@ -16,8 +24,8 @@ func NewPublicationsHandler(repo *repository.PublicationsRepository) *Publicatio
|
||||
|
||||
func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
||||
category := c.Query("category")
|
||||
if category != "" && category != "intl-journal-conf" && category != "domestic-journal-conf" {
|
||||
httpx.BadRequest(c, "Invalid category parameter: must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||
if category != "" && !validPubCategories[category] {
|
||||
httpx.BadRequest(c, "Invalid category: must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -29,13 +37,76 @@ func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
||||
httpx.OK(c, pubs)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) GetHighlights(c *gin.Context) {
|
||||
highlights, err := h.repo.GetHighlights(c.Request.Context())
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve highlights: "+err.Error())
|
||||
func (h *PublicationsHandler) CreatePublication(c *gin.Context) {
|
||||
var req models.Publication
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, highlights)
|
||||
if req.Title == "" || req.Venue == "" || req.PublishedAt == "" {
|
||||
httpx.BadRequest(c, "title, venue, and published_at are required")
|
||||
return
|
||||
}
|
||||
if !validPubCategories[req.Category] {
|
||||
httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreatePublication(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create publication: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) UpdatePublication(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid publication ID")
|
||||
return
|
||||
}
|
||||
var req models.Publication
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Title == "" || req.Venue == "" || req.PublishedAt == "" {
|
||||
httpx.BadRequest(c, "title, venue, and published_at are required")
|
||||
return
|
||||
}
|
||||
if !validPubCategories[req.Category] {
|
||||
httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdatePublication(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update publication: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) DeletePublication(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid publication ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeletePublication(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete publication: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) GetHighlights(c *gin.Context) {
|
||||
pubs, err := h.repo.GetHighlights(c.Request.Context())
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to retrieve highlight publications: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, pubs)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) GetPatents(c *gin.Context) {
|
||||
@@ -46,3 +117,58 @@ func (h *PublicationsHandler) GetPatents(c *gin.Context) {
|
||||
}
|
||||
httpx.OK(c, patents)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) CreatePatent(c *gin.Context) {
|
||||
var req models.Patent
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" {
|
||||
httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreatePatent(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create patent: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) UpdatePatent(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid patent ID")
|
||||
return
|
||||
}
|
||||
var req models.Patent
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" {
|
||||
httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdatePatent(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update patent: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *PublicationsHandler) DeletePatent(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid patent ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeletePatent(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete patent: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var validScopes = map[string]bool{
|
||||
"international": true,
|
||||
"domestic": true,
|
||||
}
|
||||
|
||||
var validDocStatuses = map[string]bool{
|
||||
"IS": true,
|
||||
"TS": true,
|
||||
"TR": true,
|
||||
"CDV": true,
|
||||
"WDTR": true,
|
||||
"InProgress": true,
|
||||
}
|
||||
|
||||
type StandardizationHandler struct {
|
||||
repo *repository.StandardizationRepository
|
||||
}
|
||||
@@ -22,3 +39,135 @@ func (h *StandardizationHandler) GetStandardsBodies(c *gin.Context) {
|
||||
}
|
||||
httpx.OK(c, bodies)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) CreateStandardsBody(c *gin.Context) {
|
||||
var req models.StandardsBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Org == "" || req.FullName == "" || req.Period == "" {
|
||||
httpx.BadRequest(c, "org, full_name, and period are required")
|
||||
return
|
||||
}
|
||||
if !validScopes[req.Scope] {
|
||||
httpx.BadRequest(c, "scope must be 'international' or 'domestic'")
|
||||
return
|
||||
}
|
||||
id, err := h.repo.CreateStandardsBody(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create standards body: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) UpdateStandardsBody(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid standards body ID")
|
||||
return
|
||||
}
|
||||
var req models.StandardsBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Org == "" || req.FullName == "" || req.Period == "" {
|
||||
httpx.BadRequest(c, "org, full_name, and period are required")
|
||||
return
|
||||
}
|
||||
if !validScopes[req.Scope] {
|
||||
httpx.BadRequest(c, "scope must be 'international' or 'domestic'")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateStandardsBody(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update standards body: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) DeleteStandardsBody(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid standards body ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteStandardsBody(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete standards body: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) CreateStandardDocument(c *gin.Context) {
|
||||
bodyID, err := strconv.Atoi(c.Param("bodyId"))
|
||||
if err != nil || bodyID <= 0 {
|
||||
httpx.BadRequest(c, "Invalid bodyId parameter")
|
||||
return
|
||||
}
|
||||
var req models.StandardDocument
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" {
|
||||
httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required")
|
||||
return
|
||||
}
|
||||
if !validDocStatuses[req.Status] {
|
||||
httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress")
|
||||
return
|
||||
}
|
||||
req.BodyID = bodyID
|
||||
id, err := h.repo.CreateStandardDocument(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(c, "Failed to create standard document: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
httpx.Created(c, req)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) UpdateStandardDocument(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid document ID")
|
||||
return
|
||||
}
|
||||
var req models.StandardDocument
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" {
|
||||
httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required")
|
||||
return
|
||||
}
|
||||
if !validDocStatuses[req.Status] {
|
||||
httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress")
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
if err := h.repo.UpdateStandardDocument(c.Request.Context(), id, req); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to update standard document: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, req)
|
||||
}
|
||||
|
||||
func (h *StandardizationHandler) DeleteStandardDocument(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(c, "Invalid document ID")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteStandardDocument(c.Request.Context(), id); err != nil {
|
||||
httpx.InternalServerError(c, "Failed to delete standard document: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RequireAdminToken returns a Gin middleware that validates Bearer <token> against the configured admin token.
|
||||
func RequireAdminToken(adminToken string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if adminToken == "" {
|
||||
Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
Unauthorized(c, "Missing Authorization header")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != adminToken {
|
||||
Unauthorized(c, "Invalid admin bearer token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,20 @@ func Error(c *gin.Context, status int, msg string) {
|
||||
})
|
||||
}
|
||||
|
||||
func Created(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusCreated, SuccessResponse{
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func NoContent(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func Unauthorized(c *gin.Context, msg string) {
|
||||
Error(c, http.StatusUnauthorized, msg)
|
||||
}
|
||||
|
||||
func BadRequest(c *gin.Context, msg string) {
|
||||
Error(c, http.StatusBadRequest, msg)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,54 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *HomeRepository) CreateResearchProject(ctx context.Context, p models.ResearchProject) (int, error) {
|
||||
kwBytes, _ := json.Marshal(p.Keywords)
|
||||
if p.Keywords == nil {
|
||||
kwBytes = []byte("[]")
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert research project: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *HomeRepository) UpdateResearchProject(ctx context.Context, id int, p models.ResearchProject) error {
|
||||
kwBytes, _ := json.Marshal(p.Keywords)
|
||||
if p.Keywords == nil {
|
||||
kwBytes = []byte("[]")
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE research_projects
|
||||
SET slug = ?, title = ?, abstract = ?, keywords = ?, organization = ?, standards_org = ?, period = ?, funder = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update research project: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *HomeRepository) DeleteResearchProject(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM research_projects WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete research project: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.ResearchArea, error) {
|
||||
query := `
|
||||
SELECT id, name_en, name_kr, display_order
|
||||
@@ -78,6 +126,46 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
|
||||
return areas, rows.Err()
|
||||
}
|
||||
|
||||
func (r *HomeRepository) CreateResearchArea(ctx context.Context, a models.ResearchArea) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO research_areas (name_en, name_kr, display_order)
|
||||
VALUES (?, ?, ?)
|
||||
`, a.NameEn, a.NameKr, a.DisplayOrder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert research area: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a models.ResearchArea) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE research_areas
|
||||
SET name_en = ?, name_kr = ?, display_order = ?
|
||||
WHERE id = ?
|
||||
`, a.NameEn, a.NameKr, a.DisplayOrder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update research area: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *HomeRepository) DeleteResearchArea(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM research_areas WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete research area: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *HomeRepository) GetStatsSummary(ctx context.Context) (*models.StatsSummary, error) {
|
||||
var summary models.StatsSummary
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ func NewLecturesRepository(db *sql.DB) *LecturesRepository {
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]models.SemesterWithCourses, error) {
|
||||
// Query semesters ordered by year DESC and Fall before Spring within same year
|
||||
semQuery := `
|
||||
SELECT id, year, term
|
||||
FROM semesters
|
||||
@@ -30,7 +29,7 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
||||
defer semRows.Close()
|
||||
|
||||
var semesters []models.SemesterWithCourses
|
||||
semMap := make(map[int]int) // semester_id -> index in slice
|
||||
semMap := make(map[int]int)
|
||||
|
||||
for semRows.Next() {
|
||||
var s models.SemesterWithCourses
|
||||
@@ -45,7 +44,6 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Query courses ordered by display_order ASC, id ASC
|
||||
courseQuery := `
|
||||
SELECT id, semester_id, code, name_en, name_kr, note, display_order
|
||||
FROM courses
|
||||
@@ -69,3 +67,83 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
||||
|
||||
return semesters, courseRows.Err()
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) CreateSemester(ctx context.Context, s models.Semester) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO semesters (year, term)
|
||||
VALUES (?, ?)
|
||||
`, s.Year, s.Term)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert semester: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) UpdateSemester(ctx context.Context, id int, s models.Semester) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE semesters
|
||||
SET year = ?, term = ?
|
||||
WHERE id = ?
|
||||
`, s.Year, s.Term, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update semester: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) DeleteSemester(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM semesters WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete semester: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) CreateCourse(ctx context.Context, c models.Course) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, c.SemesterID, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert course: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) UpdateCourse(ctx context.Context, id int, c models.Course) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE courses
|
||||
SET code = ?, name_en = ?, name_kr = ?, note = ?, display_order = ?
|
||||
WHERE id = ?
|
||||
`, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update course: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LecturesRepository) DeleteCourse(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM courses WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete course: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -42,6 +42,46 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MembersRepository) CreateMember(ctx context.Context, m models.Member) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert member: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) UpdateMember(ctx context.Context, id int, m models.Member) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE members
|
||||
SET name_ko = ?, name_en = ?, degree = ?, affiliation = ?, email = ?, is_advisor = ?, display_order = ?
|
||||
WHERE id = ?
|
||||
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update member: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) DeleteMember(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM members WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete member: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, error) {
|
||||
query := `
|
||||
SELECT id, name_ko, name_en, degree, graduated_at, major, current_position, created_at
|
||||
@@ -67,3 +107,43 @@ func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, er
|
||||
}
|
||||
return alumni, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MembersRepository) CreateAlumnus(ctx context.Context, a models.Alumnus) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert alumnus: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) UpdateAlumnus(ctx context.Context, id int, a models.Alumnus) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE alumni
|
||||
SET name_ko = ?, name_en = ?, degree = ?, graduated_at = ?, major = ?, current_position = ?
|
||||
WHERE id = ?
|
||||
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update alumnus: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MembersRepository) DeleteAlumnus(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM alumni WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete alumnus: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -48,6 +48,46 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s
|
||||
return pubs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) CreatePublication(ctx context.Context, p models.Publication) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert publication: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) UpdatePublication(ctx context.Context, id int, p models.Publication) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE publications
|
||||
SET category = ?, title = ?, authors = ?, venue = ?, volume = ?, published_at = ?, kci = ?, doi = ?, is_highlight = ?
|
||||
WHERE id = ?
|
||||
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update publication: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) DeletePublication(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM publications WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete publication: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Publication, error) {
|
||||
query := `
|
||||
SELECT id, category, title, authors, venue, volume, published_at, kci, doi, is_highlight, created_at
|
||||
@@ -100,3 +140,43 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten
|
||||
}
|
||||
return patents, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) CreatePatent(ctx context.Context, p models.Patent) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert patent: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) UpdatePatent(ctx context.Context, id int, p models.Patent) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE patents
|
||||
SET title = ?, inventors = ?, application_no = ?, application_at = ?, registration_no = ?, registration_at = ?, country = ?, published_at = ?
|
||||
WHERE id = ?
|
||||
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update patent: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PublicationsRepository) DeletePatent(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM patents WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete patent: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ func NewStandardizationRepository(db *sql.DB) *StandardizationRepository {
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.Context) ([]models.StandardsBodyWithProjects, error) {
|
||||
// Query bodies ordered by display_order
|
||||
bodyQuery := `
|
||||
SELECT id, org, full_name, scope, period, role, display_order
|
||||
FROM standards_bodies
|
||||
@@ -30,7 +29,7 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
||||
defer bodyRows.Close()
|
||||
|
||||
var bodies []models.StandardsBodyWithProjects
|
||||
bodyMap := make(map[int]int) // body_id -> index in bodies
|
||||
bodyMap := make(map[int]int)
|
||||
|
||||
for bodyRows.Next() {
|
||||
var b models.StandardsBodyWithProjects
|
||||
@@ -45,7 +44,6 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Query documents ordered by body_id, and preserves curated insertion order with MIN(id) ASC
|
||||
docQuery := `
|
||||
SELECT id, body_id, wg, project_name, title, doc_ref, status, published_at
|
||||
FROM standard_documents
|
||||
@@ -57,13 +55,12 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
||||
}
|
||||
defer docRows.Close()
|
||||
|
||||
// Group documents into projects under each body preserving curated order
|
||||
type projectKey struct {
|
||||
bodyID int
|
||||
wg string
|
||||
projectName string
|
||||
}
|
||||
projectMap := make(map[projectKey]int) // projectKey -> index in bodies[bIdx].Projects
|
||||
projectMap := make(map[projectKey]int)
|
||||
|
||||
for docRows.Next() {
|
||||
var d models.StandardDocument
|
||||
@@ -93,3 +90,83 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
||||
|
||||
return bodies, docRows.Err()
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) CreateStandardsBody(ctx context.Context, b models.StandardsBody) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert standards body: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) UpdateStandardsBody(ctx context.Context, id int, b models.StandardsBody) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE standards_bodies
|
||||
SET org = ?, full_name = ?, scope = ?, period = ?, role = ?, display_order = ?
|
||||
WHERE id = ?
|
||||
`, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update standards body: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) DeleteStandardsBody(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM standards_bodies WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete standards body: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) CreateStandardDocument(ctx context.Context, d models.StandardDocument) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, d.BodyID, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert standard document: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) UpdateStandardDocument(ctx context.Context, id int, d models.StandardDocument) error {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE standard_documents
|
||||
SET wg = ?, project_name = ?, title = ?, doc_ref = ?, status = ?, published_at = ?
|
||||
WHERE id = ?
|
||||
`, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update standard document: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *StandardizationRepository) DeleteStandardDocument(ctx context.Context, id int) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM standard_documents WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete standard document: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
||||
func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
allowOrigin := "*"
|
||||
@@ -44,7 +44,7 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
||||
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
|
||||
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
|
||||
|
||||
// API v1 Group
|
||||
// API v1 Public Group (Read-only)
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
v1.GET("/health", func(c *gin.Context) {
|
||||
@@ -72,5 +72,60 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
||||
v1.GET("/standards-bodies", standardsHandler.GetStandardsBodies)
|
||||
}
|
||||
|
||||
// API v1 Admin Group (Mutating operations requiring Bearer token)
|
||||
admin := v1.Group("")
|
||||
admin.Use(httpx.RequireAdminToken(adminToken))
|
||||
{
|
||||
admin.GET("/admin/verify", func(c *gin.Context) {
|
||||
httpx.OK(c, gin.H{"status": "authenticated"})
|
||||
})
|
||||
|
||||
// Research Projects
|
||||
admin.POST("/research-projects", homeHandler.CreateResearchProject)
|
||||
admin.PUT("/research-projects/:id", homeHandler.UpdateResearchProject)
|
||||
admin.DELETE("/research-projects/:id", homeHandler.DeleteResearchProject)
|
||||
|
||||
// Research Areas
|
||||
admin.POST("/research-areas", homeHandler.CreateResearchArea)
|
||||
admin.PUT("/research-areas/:id", homeHandler.UpdateResearchArea)
|
||||
admin.DELETE("/research-areas/:id", homeHandler.DeleteResearchArea)
|
||||
|
||||
// Members & Alumni
|
||||
admin.POST("/members", membersHandler.CreateMember)
|
||||
admin.PUT("/members/:id", membersHandler.UpdateMember)
|
||||
admin.DELETE("/members/:id", membersHandler.DeleteMember)
|
||||
|
||||
admin.POST("/alumni", membersHandler.CreateAlumnus)
|
||||
admin.PUT("/alumni/:id", membersHandler.UpdateAlumnus)
|
||||
admin.DELETE("/alumni/:id", membersHandler.DeleteAlumnus)
|
||||
|
||||
// Publications & Patents
|
||||
admin.POST("/publications", pubsHandler.CreatePublication)
|
||||
admin.PUT("/publications/:id", pubsHandler.UpdatePublication)
|
||||
admin.DELETE("/publications/:id", pubsHandler.DeletePublication)
|
||||
|
||||
admin.POST("/patents", pubsHandler.CreatePatent)
|
||||
admin.PUT("/patents/:id", pubsHandler.UpdatePatent)
|
||||
admin.DELETE("/patents/:id", pubsHandler.DeletePatent)
|
||||
|
||||
// Lectures (Semesters & Courses)
|
||||
admin.POST("/semesters", lecturesHandler.CreateSemester)
|
||||
admin.PUT("/semesters/:id", lecturesHandler.UpdateSemester)
|
||||
admin.DELETE("/semesters/:id", lecturesHandler.DeleteSemester)
|
||||
|
||||
admin.POST("/semesters/:semesterId/courses", lecturesHandler.CreateCourse)
|
||||
admin.PUT("/courses/:id", lecturesHandler.UpdateCourse)
|
||||
admin.DELETE("/courses/:id", lecturesHandler.DeleteCourse)
|
||||
|
||||
// Standardization (Bodies & Documents)
|
||||
admin.POST("/standards-bodies", standardsHandler.CreateStandardsBody)
|
||||
admin.PUT("/standards-bodies/:id", standardsHandler.UpdateStandardsBody)
|
||||
admin.DELETE("/standards-bodies/:id", standardsHandler.DeleteStandardsBody)
|
||||
|
||||
admin.POST("/standards-bodies/:bodyId/documents", standardsHandler.CreateStandardDocument)
|
||||
admin.PUT("/standard-documents/:id", standardsHandler.UpdateStandardDocument)
|
||||
admin.DELETE("/standard-documents/:id", standardsHandler.DeleteStandardDocument)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||
@@ -18,8 +20,9 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const testAdminToken = "test-secret-token"
|
||||
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
// In-memory or temporary file sqlite DB
|
||||
tmpDB := filepath.Join(t.TempDir(), "test.db")
|
||||
database, err := db.Connect(tmpDB)
|
||||
require.NoError(t, err)
|
||||
@@ -27,7 +30,6 @@ func setupTestDB(t *testing.T) *sql.DB {
|
||||
err = db.RunMigrations(database)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Seed test data from ../../seed/data
|
||||
seedDir := filepath.Join("..", "..", "seed", "data")
|
||||
seedDatabase(t, database, seedDir)
|
||||
|
||||
@@ -65,45 +67,75 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
||||
// 2. Areas
|
||||
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
|
||||
require.NoError(t, err)
|
||||
var areas []models.ResearchArea
|
||||
var areas []struct {
|
||||
En string `json:"en"`
|
||||
Kr *string `json:"kr"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(aData, &areas))
|
||||
for _, a := range areas {
|
||||
_, err := tx.Exec(`INSERT INTO research_areas (name_en, name_kr, display_order) VALUES (?, ?, ?)`, a.NameEn, a.NameKr, a.DisplayOrder)
|
||||
for idx, a := range areas {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO research_areas (name_en, name_kr, display_order)
|
||||
VALUES (?, ?, ?)
|
||||
`, a.En, a.Kr, idx+1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 3. Members
|
||||
mData, err := os.ReadFile(filepath.Join(seedDir, "members.json"))
|
||||
require.NoError(t, err)
|
||||
var members []models.Member
|
||||
var members []struct {
|
||||
Ko string `json:"ko"`
|
||||
En string `json:"en"`
|
||||
Degree string `json:"degree"`
|
||||
Affiliation *string `json:"affiliation"`
|
||||
Email *string `json:"email"`
|
||||
IsAdvisor bool `json:"is_advisor"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(mData, &members))
|
||||
for _, m := range members {
|
||||
for idx, m := range members {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder)
|
||||
`, m.Ko, m.En, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, idx+1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 4. Alumni
|
||||
alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json"))
|
||||
require.NoError(t, err)
|
||||
var alumni []models.Alumnus
|
||||
var alumni []struct {
|
||||
Ko string `json:"ko"`
|
||||
En string `json:"en"`
|
||||
Degree string `json:"degree"`
|
||||
GraduatedAt string `json:"graduatedAt"`
|
||||
Major *string `json:"major"`
|
||||
CurrentPosition *string `json:"currentPosition"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(alumniData, &alumni))
|
||||
for _, a := range alumni {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
||||
`, a.Ko, a.En, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 5. Publications
|
||||
pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json"))
|
||||
require.NoError(t, err)
|
||||
var pubs []models.Publication
|
||||
require.NoError(t, json.Unmarshal(pubData, &pubs))
|
||||
for _, p := range pubs {
|
||||
var publications []struct {
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
Authors *string `json:"authors"`
|
||||
Venue string `json:"venue"`
|
||||
Volume *string `json:"volume"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
KCI bool `json:"kci"`
|
||||
DOI *string `json:"doi"`
|
||||
IsHighlight bool `json:"is_highlight"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(pubData, &publications))
|
||||
for _, p := range publications {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -114,17 +146,26 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
||||
// 6. Patents
|
||||
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
|
||||
require.NoError(t, err)
|
||||
var patents []models.Patent
|
||||
var patents []struct {
|
||||
Title string `json:"title"`
|
||||
Inventors string `json:"inventors"`
|
||||
ApplicationNo string `json:"applicationNo"`
|
||||
ApplicationAt string `json:"applicationAt"`
|
||||
RegistrationNo *string `json:"registrationNo"`
|
||||
RegistrationAt *string `json:"registrationAt"`
|
||||
Country string `json:"country"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(patData, &patents))
|
||||
for _, pat := range patents {
|
||||
for _, p := range patents {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, pat.Title, pat.Inventors, pat.ApplicationNo, pat.ApplicationAt, pat.RegistrationNo, pat.RegistrationAt, pat.Country, pat.PublishedAt)
|
||||
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 7. Semesters & Courses
|
||||
// 7. Lectures
|
||||
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
|
||||
require.NoError(t, err)
|
||||
var semesters []struct {
|
||||
@@ -200,247 +241,201 @@ type APIResponse[T any] struct {
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func executeRequest(r http.Handler, method, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
func doRequest(r http.Handler, method, target string, body any, token string) *httptest.ResponseRecorder {
|
||||
var reqBody []byte
|
||||
if body != nil {
|
||||
reqBody, _ = json.Marshal(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, target, bytes.NewReader(reqBody))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
w := executeRequest(r, "GET", "/api/v1/health")
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
w := doRequest(r, "GET", "/api/v1/health", nil, "")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[map[string]any]
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
}
|
||||
|
||||
func TestAdminAuthMiddleware(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// A. Configured with explicit valid token
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. Unauthenticated request to /admin/verify should return 401
|
||||
wNoAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "")
|
||||
assert.Equal(t, http.StatusUnauthorized, wNoAuth.Code)
|
||||
|
||||
// 2. Guessable fallback token "admin123" must return 401
|
||||
wGuessAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "admin123")
|
||||
assert.Equal(t, http.StatusUnauthorized, wGuessAuth.Code)
|
||||
|
||||
// 3. Wrong token request to /admin/verify should return 401
|
||||
wWrongAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "wrong-token")
|
||||
assert.Equal(t, http.StatusUnauthorized, wWrongAuth.Code)
|
||||
|
||||
// 4. Valid token request to /admin/verify should return 200
|
||||
wValidAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusOK, wValidAuth.Code)
|
||||
|
||||
// 5. Public GET routes remain accessible without auth
|
||||
wPublic := doRequest(r, "GET", "/api/v1/members", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wPublic.Code)
|
||||
|
||||
// B. Unconfigured (empty string) admin token: must strictly reject ALL mutating/verify attempts
|
||||
rEmptyToken := router.SetupRouter(db, "")
|
||||
|
||||
// 6. Any token on verify should return 401
|
||||
wEmpty1 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "admin123")
|
||||
assert.Equal(t, http.StatusUnauthorized, wEmpty1.Code)
|
||||
|
||||
wEmpty2 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "")
|
||||
assert.Equal(t, http.StatusUnauthorized, wEmpty2.Code)
|
||||
|
||||
// 7. Mutating POST on empty router should return 401
|
||||
newProj := models.ResearchProject{
|
||||
Slug: "test-unauth",
|
||||
Title: "Unauthorized Title",
|
||||
Abstract: "Unauthorized abstract.",
|
||||
Keywords: []string{"unauth"},
|
||||
}
|
||||
wMutate := doRequest(rEmptyToken, "POST", "/api/v1/research-projects", newProj, "admin123")
|
||||
assert.Equal(t, http.StatusUnauthorized, wMutate.Code)
|
||||
|
||||
// 8. Public GET routes still work on empty admin token router
|
||||
wPublicEmpty := doRequest(rEmptyToken, "GET", "/api/v1/health", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wPublicEmpty.Code)
|
||||
}
|
||||
|
||||
func TestResearchProjectsCRUD(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. Create Project
|
||||
newProj := models.ResearchProject{
|
||||
Slug: "test-crud-proj",
|
||||
Title: "Test Project Title",
|
||||
Abstract: "Test project abstract for CRUD verification.",
|
||||
Keywords: []string{"test", "crud"},
|
||||
}
|
||||
wCreate := doRequest(r, "POST", "/api/v1/research-projects", newProj, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCreate.Code)
|
||||
|
||||
var createResp APIResponse[models.ResearchProject]
|
||||
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
||||
createdID := createResp.Data.ID
|
||||
assert.True(t, createdID > 0)
|
||||
|
||||
// 2. Update Project
|
||||
newProj.Title = "Updated Project Title"
|
||||
wUpdate := doRequest(r, "PUT", "/api/v1/research-projects/"+strconvItoa(createdID), newProj, testAdminToken)
|
||||
assert.Equal(t, http.StatusOK, wUpdate.Code)
|
||||
|
||||
// 3. Delete Project
|
||||
wDelete := doRequest(r, "DELETE", "/api/v1/research-projects/"+strconvItoa(createdID), nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusNoContent, wDelete.Code)
|
||||
}
|
||||
|
||||
func TestMembersAndAlumniCRUD(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. Create Member with Invalid Degree -> 400
|
||||
badMember := models.Member{
|
||||
NameKo: "홍길동",
|
||||
NameEn: "Gildong Hong",
|
||||
Degree: "InvalidDegree",
|
||||
}
|
||||
wBad := doRequest(r, "POST", "/api/v1/members", badMember, testAdminToken)
|
||||
assert.Equal(t, http.StatusBadRequest, wBad.Code)
|
||||
|
||||
// 2. Create Valid Member -> 201
|
||||
validMember := models.Member{
|
||||
NameKo: "홍길동",
|
||||
NameEn: "Gildong Hong",
|
||||
Degree: "PhDStudent",
|
||||
}
|
||||
wCreate := doRequest(r, "POST", "/api/v1/members", validMember, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCreate.Code)
|
||||
|
||||
var createResp APIResponse[models.Member]
|
||||
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
||||
mID := createResp.Data.ID
|
||||
|
||||
// 3. Delete Member -> 204
|
||||
wDelete := doRequest(r, "DELETE", "/api/v1/members/"+strconvItoa(mID), nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusNoContent, wDelete.Code)
|
||||
|
||||
// 4. Create Alumnus -> 201
|
||||
validAlumnus := models.Alumnus{
|
||||
NameKo: "김졸업",
|
||||
NameEn: "Grad Kim",
|
||||
Degree: "MS",
|
||||
GraduatedAt: "2024-02",
|
||||
}
|
||||
wCreateAl := doRequest(r, "POST", "/api/v1/alumni", validAlumnus, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCreateAl.Code)
|
||||
|
||||
var alResp APIResponse[models.Alumnus]
|
||||
require.NoError(t, json.Unmarshal(wCreateAl.Body.Bytes(), &alResp))
|
||||
alID := alResp.Data.ID
|
||||
|
||||
// 5. Delete Alumnus -> 204
|
||||
wDelAl := doRequest(r, "DELETE", "/api/v1/alumni/"+strconvItoa(alID), nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusNoContent, wDelAl.Code)
|
||||
}
|
||||
|
||||
func TestCascadeDeletes(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. Create a semester
|
||||
newSem := models.Semester{Year: 2030, Term: "Spring"}
|
||||
wSem := doRequest(r, "POST", "/api/v1/semesters", newSem, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wSem.Code)
|
||||
var semResp APIResponse[models.Semester]
|
||||
require.NoError(t, json.Unmarshal(wSem.Body.Bytes(), &semResp))
|
||||
semID := semResp.Data.ID
|
||||
|
||||
// 2. Create courses under this semester
|
||||
course1 := models.Course{Code: "TEST101", NameEn: "Intro to Testing"}
|
||||
course2 := models.Course{Code: "TEST102", NameEn: "Advanced Testing"}
|
||||
wCourse1 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course1, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCourse1.Code)
|
||||
wCourse2 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course2, testAdminToken)
|
||||
assert.Equal(t, http.StatusCreated, wCourse2.Code)
|
||||
|
||||
// Verify courses exist in DB
|
||||
var courseCount int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", resp.Data["status"])
|
||||
assert.Equal(t, "anl-backend-api", resp.Data["service"])
|
||||
assert.Equal(t, 2, courseCount)
|
||||
|
||||
// 3. Delete semester
|
||||
wDelSem := doRequest(r, "DELETE", "/api/v1/semesters/"+strconvItoa(semID), nil, testAdminToken)
|
||||
assert.Equal(t, http.StatusNoContent, wDelSem.Code)
|
||||
|
||||
// 4. Verify cascade delete removed courses
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted")
|
||||
}
|
||||
|
||||
func TestResearchProjects(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
w := executeRequest(r, "GET", "/api/v1/research-projects")
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[[]models.ResearchProject]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(t, resp.Data, 3)
|
||||
assert.Equal(t, "iot-standards", resp.Data[0].Slug)
|
||||
assert.Equal(t, "AIoT & AI-RAN", resp.Data[0].Title)
|
||||
}
|
||||
|
||||
func TestResearchAreas(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
w := executeRequest(r, "GET", "/api/v1/research-areas")
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[[]models.ResearchArea]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(t, resp.Data, 14)
|
||||
assert.Equal(t, 1, resp.Data[0].DisplayOrder)
|
||||
assert.Equal(t, "Quick UDP Internet Connection (QUIC)", resp.Data[0].NameEn)
|
||||
}
|
||||
|
||||
func TestStatsSummary(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
w := executeRequest(r, "GET", "/api/v1/stats/summary")
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[models.StatsSummary]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, 68, resp.Data.IntlPublications)
|
||||
assert.Equal(t, 44, resp.Data.StandardizationDocs)
|
||||
assert.Equal(t, 75, resp.Data.Patents)
|
||||
}
|
||||
|
||||
func TestMembersAndAlumni(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
// Members
|
||||
wMem := executeRequest(r, "GET", "/api/v1/members")
|
||||
assert.Equal(t, http.StatusOK, wMem.Code)
|
||||
var respMem APIResponse[[]models.Member]
|
||||
require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &respMem))
|
||||
assert.Len(t, respMem.Data, 16)
|
||||
assert.True(t, respMem.Data[0].IsAdvisor)
|
||||
assert.Equal(t, "Seok-Joo Koh", respMem.Data[0].NameEn)
|
||||
|
||||
// Alumni
|
||||
wAlumni := executeRequest(r, "GET", "/api/v1/alumni")
|
||||
assert.Equal(t, http.StatusOK, wAlumni.Code)
|
||||
var respAlumni APIResponse[[]models.Alumnus]
|
||||
require.NoError(t, json.Unmarshal(wAlumni.Body.Bytes(), &respAlumni))
|
||||
assert.Len(t, respAlumni.Data, 60)
|
||||
assert.GreaterOrEqual(t, respAlumni.Data[0].GraduatedAt, respAlumni.Data[1].GraduatedAt)
|
||||
}
|
||||
|
||||
func TestPublicationsAndHighlights(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
// Full Publications
|
||||
wAll := executeRequest(r, "GET", "/api/v1/publications")
|
||||
assert.Equal(t, http.StatusOK, wAll.Code)
|
||||
var respAll APIResponse[[]models.Publication]
|
||||
require.NoError(t, json.Unmarshal(wAll.Body.Bytes(), &respAll))
|
||||
assert.Len(t, respAll.Data, 148)
|
||||
|
||||
// Category filter: intl
|
||||
wIntl := executeRequest(r, "GET", "/api/v1/publications?category=intl-journal-conf")
|
||||
assert.Equal(t, http.StatusOK, wIntl.Code)
|
||||
var respIntl APIResponse[[]models.Publication]
|
||||
require.NoError(t, json.Unmarshal(wIntl.Body.Bytes(), &respIntl))
|
||||
assert.Len(t, respIntl.Data, 68)
|
||||
|
||||
// Category filter: domestic
|
||||
wDom := executeRequest(r, "GET", "/api/v1/publications?category=domestic-journal-conf")
|
||||
assert.Equal(t, http.StatusOK, wDom.Code)
|
||||
var respDom APIResponse[[]models.Publication]
|
||||
require.NoError(t, json.Unmarshal(wDom.Body.Bytes(), &respDom))
|
||||
assert.Len(t, respDom.Data, 80)
|
||||
|
||||
// Invalid category
|
||||
wInvalid := executeRequest(r, "GET", "/api/v1/publications?category=unknown")
|
||||
assert.Equal(t, http.StatusBadRequest, wInvalid.Code)
|
||||
|
||||
// Highlights (asserts exactly the 5 known representative titles)
|
||||
wHL := executeRequest(r, "GET", "/api/v1/publications/highlights")
|
||||
assert.Equal(t, http.StatusOK, wHL.Code)
|
||||
var respHL APIResponse[[]models.Publication]
|
||||
require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &respHL))
|
||||
assert.Len(t, respHL.Data, 5)
|
||||
|
||||
expectedTitles := map[string]bool{
|
||||
"Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G": true,
|
||||
"Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G": true,
|
||||
"mQUIC: Use of QUIC for Handover Support with Connection Migration in Wireless/Mobile Networks": true,
|
||||
"Use of QUIC for CoAP Transport in IoT Networks": true,
|
||||
"Use of QUIC for Mobile-Oriented Future Internet (Q-MOFI)": true,
|
||||
}
|
||||
for _, hl := range respHL.Data {
|
||||
assert.True(t, hl.IsHighlight)
|
||||
assert.True(t, expectedTitles[hl.Title], "Unexpected highlight title: %s", hl.Title)
|
||||
}
|
||||
|
||||
// Patents
|
||||
wPat := executeRequest(r, "GET", "/api/v1/patents")
|
||||
assert.Equal(t, http.StatusOK, wPat.Code)
|
||||
var respPat APIResponse[[]models.Patent]
|
||||
require.NoError(t, json.Unmarshal(wPat.Body.Bytes(), &respPat))
|
||||
assert.Len(t, respPat.Data, 75)
|
||||
}
|
||||
|
||||
func TestSemestersAndCourses(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
w := executeRequest(r, "GET", "/api/v1/semesters")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[[]models.SemesterWithCourses]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(t, resp.Data, 45)
|
||||
|
||||
// Check semester ordering (Year DESC, Fall before Spring within same year)
|
||||
first := resp.Data[0]
|
||||
assert.Equal(t, 2026, first.Year)
|
||||
assert.Equal(t, "Spring", first.Term)
|
||||
assert.NotEmpty(t, first.Courses)
|
||||
|
||||
// In 2025: Fall must come before Spring
|
||||
var s2025Fall, s2025Spring int
|
||||
for idx, s := range resp.Data {
|
||||
if s.Year == 2025 && s.Term == "Fall" {
|
||||
s2025Fall = idx
|
||||
}
|
||||
if s.Year == 2025 && s.Term == "Spring" {
|
||||
s2025Spring = idx
|
||||
}
|
||||
}
|
||||
assert.Less(t, s2025Fall, s2025Spring, "2025 Fall should precede 2025 Spring in chronological descending order")
|
||||
}
|
||||
|
||||
func TestStandardsBodiesAndHierarchy(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
w := executeRequest(r, "GET", "/api/v1/standards-bodies")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp APIResponse[[]models.StandardsBodyWithProjects]
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(t, resp.Data, 6)
|
||||
|
||||
// First body: IEC TC100
|
||||
iec := resp.Data[0]
|
||||
assert.Equal(t, "IEC TC100", iec.Org)
|
||||
require.NotEmpty(t, iec.Projects)
|
||||
|
||||
// Verify project ordering within IEC TC100 (deterministic order: WG12 before WG11(Convenor))
|
||||
assert.Equal(t, "WG12", iec.Projects[0].WG)
|
||||
assert.Equal(t, "WG11(Convenor)", iec.Projects[1].WG)
|
||||
assert.NotEmpty(t, iec.Projects[0].Documents)
|
||||
}
|
||||
|
||||
func TestJSONKeyCasingSnakeCase(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
defer database.Close()
|
||||
|
||||
r := router.SetupRouter(database)
|
||||
|
||||
// Test Members endpoint raw JSON map
|
||||
wMem := executeRequest(r, "GET", "/api/v1/members")
|
||||
assert.Equal(t, http.StatusOK, wMem.Code)
|
||||
var rawMem map[string]any
|
||||
require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &rawMem))
|
||||
dataList, ok := rawMem["data"].([]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, dataList)
|
||||
firstMember, ok := dataList[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
// Verify snake_case keys exist and camelCase keys do not
|
||||
assert.Contains(t, firstMember, "name_ko")
|
||||
assert.Contains(t, firstMember, "name_en")
|
||||
assert.Contains(t, firstMember, "is_advisor")
|
||||
assert.Contains(t, firstMember, "display_order")
|
||||
assert.NotContains(t, firstMember, "nameKo")
|
||||
assert.NotContains(t, firstMember, "isAdvisor")
|
||||
|
||||
// Test Highlights endpoint raw JSON map
|
||||
wHL := executeRequest(r, "GET", "/api/v1/publications/highlights")
|
||||
assert.Equal(t, http.StatusOK, wHL.Code)
|
||||
var rawHL map[string]any
|
||||
require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &rawHL))
|
||||
hlList, ok := rawHL["data"].([]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, hlList)
|
||||
firstHL, ok := hlList[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Contains(t, firstHL, "is_highlight")
|
||||
assert.Contains(t, firstHL, "published_at")
|
||||
assert.NotContains(t, firstHL, "isHighlight")
|
||||
assert.NotContains(t, firstHL, "publishedAt")
|
||||
func strconvItoa(i int) string {
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user