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
+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)
}