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:
2026-08-24 22:21:12 +09:00
parent dadd14feb1
commit 3f452468d6
32 changed files with 5466 additions and 279 deletions
+3
View File
@@ -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
+1
View File
@@ -13,3 +13,4 @@
.env
.env.*
!.env.example
*.log
+16 -1
View File
@@ -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)
+6
View File
@@ -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"
}
+5
View File
@@ -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())
}
+113
View File
@@ -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 {
+124
View File
@@ -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)
}
+143
View File
@@ -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)
}
+133 -7
View File
@@ -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)
}
+34
View File
@@ -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()
}
}
+14
View File
@@ -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)
}
+88
View File
@@ -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
+81 -3
View File
@@ -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
}
+80
View File
@@ -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
}
+82 -5
View File
@@ -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
}
+57 -2
View File
@@ -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
}
+245 -250
View File
@@ -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)
}