feat(backend,docker,docs): implement Go/Gin/SQLite REST API server, Docker containerization, and architecture specifications

- Implement standalone Go backend API server using Gin and pure-Go SQLite (modernc.org/sqlite)
- Add 11-table schema DDL migrations (000001_init.up.sql) based on DATA_TABLE.md
- Add seed data ingestion tool (cmd/seed) and static TypeScript content exporter (cmd/export-content)
- Add configuration loader (internal/config) supporting environment variables and .env files (HOST, PORT, DB_PATH, GIN_MODE, CORS_ALLOW_ORIGINS, AUTO_MIGRATE)
- Add lightweight multi-stage Dockerfile and docker-compose.yml with persistent SQLite volume
- Add comprehensive architecture specification (ARCHITECTURE.md) and REST API interface specification (API_INTERFACE.md)
- Harmonize frontend publications page to consume isHighlight flag from database-synced content
This commit is contained in:
2026-08-24 17:34:51 +09:00
parent 75bf334fec
commit fb6881070c
53 changed files with 8432 additions and 488 deletions
+104
View File
@@ -0,0 +1,104 @@
package config
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
// Config holds all server configuration derived from environment variables and flags.
type Config struct {
Port int
Host string
DBPath string
GinMode string
CORSAllowOrigins string
AutoMigrate bool
}
// LoadEnvFile reads a simple .env file if it exists and loads variables into environment without overriding existing ones.
func LoadEnvFile(filenames ...string) {
if len(filenames) == 0 {
filenames = []string{".env", "../.env"}
}
for _, filename := range filenames {
file, err := os.Open(filename)
if err != nil {
continue
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
// Strip optional surrounding quotes
val = strings.Trim(val, `"'`)
// Only set if not already present in environment
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, val)
}
}
_ = file.Close()
}
}
// Load reads and parses all configuration settings from the environment.
func Load() *Config {
LoadEnvFile()
cfg := &Config{
Port: 8080,
Host: "0.0.0.0",
DBPath: "anl.db",
GinMode: "release",
CORSAllowOrigins: "*",
AutoMigrate: true,
}
if portStr := os.Getenv("PORT"); portStr != "" {
if p, err := strconv.Atoi(portStr); err == nil && p > 0 {
cfg.Port = p
}
}
if hostStr := os.Getenv("HOST"); hostStr != "" {
cfg.Host = hostStr
}
if dbPathStr := os.Getenv("DB_PATH"); dbPathStr != "" {
cfg.DBPath = dbPathStr
}
if ginModeStr := os.Getenv("GIN_MODE"); ginModeStr != "" {
cfg.GinMode = ginModeStr
}
if corsStr := os.Getenv("CORS_ALLOW_ORIGINS"); corsStr != "" {
cfg.CORSAllowOrigins = corsStr
}
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0"
}
return cfg
}
// Address returns the combined host:port address string.
func (c *Config) Address() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
+80
View File
@@ -0,0 +1,80 @@
package config_test
import (
"os"
"path/filepath"
"testing"
"git.godopu.com/lab/landing_page/backend/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigDefaults(t *testing.T) {
// Clear env
_ = os.Unsetenv("PORT")
_ = os.Unsetenv("HOST")
_ = os.Unsetenv("DB_PATH")
_ = os.Unsetenv("GIN_MODE")
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
_ = os.Unsetenv("AUTO_MIGRATE")
cfg := config.Load()
assert.Equal(t, 8080, cfg.Port)
assert.Equal(t, "0.0.0.0", cfg.Host)
assert.Equal(t, "anl.db", cfg.DBPath)
assert.Equal(t, "release", cfg.GinMode)
assert.Equal(t, "*", cfg.CORSAllowOrigins)
assert.True(t, cfg.AutoMigrate)
assert.Equal(t, "0.0.0.0:8080", cfg.Address())
}
func TestConfigEnvOverrides(t *testing.T) {
_ = os.Setenv("PORT", "9090")
_ = os.Setenv("HOST", "127.0.0.1")
_ = os.Setenv("DB_PATH", "/tmp/custom.db")
_ = os.Setenv("GIN_MODE", "debug")
_ = os.Setenv("CORS_ALLOW_ORIGINS", "https://anl.knu.ac.kr")
_ = os.Setenv("AUTO_MIGRATE", "false")
defer func() {
_ = os.Unsetenv("PORT")
_ = os.Unsetenv("HOST")
_ = os.Unsetenv("DB_PATH")
_ = os.Unsetenv("GIN_MODE")
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
_ = os.Unsetenv("AUTO_MIGRATE")
}()
cfg := config.Load()
assert.Equal(t, 9090, cfg.Port)
assert.Equal(t, "127.0.0.1", cfg.Host)
assert.Equal(t, "/tmp/custom.db", cfg.DBPath)
assert.Equal(t, "debug", cfg.GinMode)
assert.Equal(t, "https://anl.knu.ac.kr", cfg.CORSAllowOrigins)
assert.False(t, cfg.AutoMigrate)
assert.Equal(t, "127.0.0.1:9090", cfg.Address())
}
func TestLoadEnvFile(t *testing.T) {
tmpDir := t.TempDir()
envPath := filepath.Join(tmpDir, ".env")
envContent := `
# Sample comment
TEST_CUSTOM_KEY="hello_world"
TEST_PORT_KEY=8888
INVALID_LINE_WITHOUT_EQUALS
`
err := os.WriteFile(envPath, []byte(envContent), 0644)
require.NoError(t, err)
_ = os.Unsetenv("TEST_CUSTOM_KEY")
_ = os.Unsetenv("TEST_PORT_KEY")
defer func() {
_ = os.Unsetenv("TEST_CUSTOM_KEY")
_ = os.Unsetenv("TEST_PORT_KEY")
}()
config.LoadEnvFile(envPath)
assert.Equal(t, "hello_world", os.Getenv("TEST_CUSTOM_KEY"))
assert.Equal(t, "8888", os.Getenv("TEST_PORT_KEY"))
}
+50
View File
@@ -0,0 +1,50 @@
package db
import (
"database/sql"
_ "embed"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
//go:embed migrations/000001_init.up.sql
var initMigrationUp string
//go:embed migrations/000001_init.down.sql
var initMigrationDown string
// Connect opens an SQLite database connection with appropriate pragmas
func Connect(dbPath string) (*sql.DB, error) {
dsn := dbPath
if !strings.Contains(dsn, "?") {
dsn += "?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return db, nil
}
// RunMigrations applies up migrations
func RunMigrations(db *sql.DB) error {
if _, err := db.Exec(initMigrationUp); err != nil {
return fmt.Errorf("failed to apply migration up: %w", err)
}
return nil
}
// RollbackMigrations applies down migrations
func RollbackMigrations(db *sql.DB) error {
if _, err := db.Exec(initMigrationDown); err != nil {
return fmt.Errorf("failed to apply migration down: %w", err)
}
return nil
}
@@ -0,0 +1,10 @@
DROP TABLE IF EXISTS standard_documents;
DROP TABLE IF EXISTS standards_bodies;
DROP TABLE IF EXISTS courses;
DROP TABLE IF EXISTS semesters;
DROP TABLE IF EXISTS patents;
DROP TABLE IF EXISTS publications;
DROP TABLE IF EXISTS alumni;
DROP TABLE IF EXISTS members;
DROP TABLE IF EXISTS research_areas;
DROP TABLE IF EXISTS research_projects;
@@ -0,0 +1,114 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS research_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
abstract TEXT NOT NULL,
keywords TEXT NOT NULL DEFAULT '[]',
organization TEXT,
standards_org TEXT,
period TEXT,
funder TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS research_areas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_en TEXT NOT NULL,
name_kr TEXT,
display_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_ko TEXT NOT NULL,
name_en TEXT NOT NULL,
degree TEXT NOT NULL CHECK (degree IN ('Professor','PhD','PhDCandidate','PhDStudent','MSCandidate','MSStudent')),
affiliation TEXT,
email TEXT,
is_advisor BOOLEAN NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS alumni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_ko TEXT NOT NULL,
name_en TEXT NOT NULL,
degree TEXT NOT NULL CHECK (degree IN ('PhD','MS')),
graduated_at TEXT NOT NULL,
major TEXT,
current_position TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS publications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL CHECK (category IN ('intl-journal-conf','domestic-journal-conf')),
title TEXT NOT NULL,
authors TEXT,
venue TEXT NOT NULL,
volume TEXT,
published_at TEXT NOT NULL,
kci BOOLEAN NOT NULL DEFAULT 0,
doi TEXT,
is_highlight BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_publications_category ON publications(category);
CREATE INDEX IF NOT EXISTS idx_publications_highlight ON publications(is_highlight) WHERE is_highlight = 1;
CREATE TABLE IF NOT EXISTS patents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
inventors TEXT NOT NULL,
application_no TEXT NOT NULL,
application_at TEXT NOT NULL,
registration_no TEXT,
registration_at TEXT,
country TEXT NOT NULL,
published_at TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS semesters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL,
term TEXT NOT NULL CHECK (term IN ('Spring','Fall')),
UNIQUE(year, term)
);
CREATE TABLE IF NOT EXISTS courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
semester_id INTEGER NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
code TEXT NOT NULL,
name_en TEXT NOT NULL,
name_kr TEXT,
note TEXT,
display_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_courses_semester ON courses(semester_id);
CREATE TABLE IF NOT EXISTS standards_bodies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org TEXT NOT NULL,
full_name TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('international','domestic')),
period TEXT NOT NULL,
role TEXT,
display_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS standard_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id INTEGER NOT NULL REFERENCES standards_bodies(id) ON DELETE CASCADE,
wg TEXT NOT NULL,
project_name TEXT NOT NULL,
title TEXT NOT NULL,
doc_ref TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('IS','TS','TR','CDV','WDTR','InProgress')),
published_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_stddocs_body ON standard_documents(body_id);
+42
View File
@@ -0,0 +1,42 @@
package handlers
import (
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
type HomeHandler struct {
repo *repository.HomeRepository
}
func NewHomeHandler(repo *repository.HomeRepository) *HomeHandler {
return &HomeHandler{repo: repo}
}
func (h *HomeHandler) GetResearchProjects(c *gin.Context) {
projects, err := h.repo.GetResearchProjects(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve research projects: "+err.Error())
return
}
httpx.OK(c, projects)
}
func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
areas, err := h.repo.GetResearchAreas(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve research areas: "+err.Error())
return
}
httpx.OK(c, areas)
}
func (h *HomeHandler) GetStatsSummary(c *gin.Context) {
stats, err := h.repo.GetStatsSummary(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve statistics summary: "+err.Error())
return
}
httpx.OK(c, stats)
}
+24
View File
@@ -0,0 +1,24 @@
package handlers
import (
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
type LecturesHandler struct {
repo *repository.LecturesRepository
}
func NewLecturesHandler(repo *repository.LecturesRepository) *LecturesHandler {
return &LecturesHandler{repo: repo}
}
func (h *LecturesHandler) GetSemesters(c *gin.Context) {
semesters, err := h.repo.GetSemestersWithCourses(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve semesters: "+err.Error())
return
}
httpx.OK(c, semesters)
}
+33
View File
@@ -0,0 +1,33 @@
package handlers
import (
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
type MembersHandler struct {
repo *repository.MembersRepository
}
func NewMembersHandler(repo *repository.MembersRepository) *MembersHandler {
return &MembersHandler{repo: repo}
}
func (h *MembersHandler) GetMembers(c *gin.Context) {
members, err := h.repo.GetMembers(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve members: "+err.Error())
return
}
httpx.OK(c, members)
}
func (h *MembersHandler) GetAlumni(c *gin.Context) {
alumni, err := h.repo.GetAlumni(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve alumni: "+err.Error())
return
}
httpx.OK(c, alumni)
}
+48
View File
@@ -0,0 +1,48 @@
package handlers
import (
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
type PublicationsHandler struct {
repo *repository.PublicationsRepository
}
func NewPublicationsHandler(repo *repository.PublicationsRepository) *PublicationsHandler {
return &PublicationsHandler{repo: repo}
}
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'")
return
}
pubs, err := h.repo.GetPublications(c.Request.Context(), category)
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve publications: "+err.Error())
return
}
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())
return
}
httpx.OK(c, highlights)
}
func (h *PublicationsHandler) GetPatents(c *gin.Context) {
patents, err := h.repo.GetPatents(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve patents: "+err.Error())
return
}
httpx.OK(c, patents)
}
@@ -0,0 +1,24 @@
package handlers
import (
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
type StandardizationHandler struct {
repo *repository.StandardizationRepository
}
func NewStandardizationHandler(repo *repository.StandardizationRepository) *StandardizationHandler {
return &StandardizationHandler{repo: repo}
}
func (h *StandardizationHandler) GetStandardsBodies(c *gin.Context) {
bodies, err := h.repo.GetStandardsBodiesWithProjects(c.Request.Context())
if err != nil {
httpx.InternalServerError(c, "Failed to retrieve standards bodies: "+err.Error())
return
}
httpx.OK(c, bodies)
}
+45
View File
@@ -0,0 +1,45 @@
package httpx
import (
"net/http"
"github.com/gin-gonic/gin"
)
type ErrorDetail struct {
Message string `json:"message"`
}
type ErrorResponse struct {
Error ErrorDetail `json:"error"`
}
type SuccessResponse struct {
Data any `json:"data"`
}
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, SuccessResponse{
Data: data,
})
}
func Error(c *gin.Context, status int, msg string) {
c.JSON(status, ErrorResponse{
Error: ErrorDetail{
Message: msg,
},
})
}
func BadRequest(c *gin.Context, msg string) {
Error(c, http.StatusBadRequest, msg)
}
func NotFound(c *gin.Context, msg string) {
Error(c, http.StatusNotFound, msg)
}
func InternalServerError(c *gin.Context, msg string) {
Error(c, http.StatusInternalServerError, msg)
}
+30
View File
@@ -0,0 +1,30 @@
package models
import "time"
type ResearchProject struct {
ID int `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Abstract string `json:"abstract"`
Keywords []string `json:"keywords"`
Organization *string `json:"organization,omitempty"`
StandardsOrg *string `json:"standards_org,omitempty"`
Period *string `json:"period,omitempty"`
Funder *string `json:"funder,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ResearchArea struct {
ID int `json:"id"`
NameEn string `json:"name_en"`
NameKr *string `json:"name_kr,omitempty"`
DisplayOrder int `json:"display_order"`
}
type StatsSummary struct {
IntlPublications int `json:"intl_publications"`
StandardizationDocs int `json:"standardization_docs"`
Patents int `json:"patents"`
}
+24
View File
@@ -0,0 +1,24 @@
package models
type Course struct {
ID int `json:"id"`
SemesterID int `json:"semester_id"`
Code string `json:"code"`
NameEn string `json:"name_en"`
NameKr *string `json:"name_kr,omitempty"`
Note *string `json:"note,omitempty"`
DisplayOrder int `json:"display_order"`
}
type Semester struct {
ID int `json:"id"`
Year int `json:"year"`
Term string `json:"term"`
}
type SemesterWithCourses struct {
ID int `json:"id"`
Year int `json:"year"`
Term string `json:"term"`
Courses []Course `json:"courses"`
}
+26
View File
@@ -0,0 +1,26 @@
package models
import "time"
type Member struct {
ID int `json:"id"`
NameKo string `json:"name_ko"`
NameEn string `json:"name_en"`
Degree string `json:"degree"`
Affiliation *string `json:"affiliation,omitempty"`
Email *string `json:"email,omitempty"`
IsAdvisor bool `json:"is_advisor"`
DisplayOrder int `json:"display_order"`
CreatedAt time.Time `json:"created_at"`
}
type Alumnus struct {
ID int `json:"id"`
NameKo string `json:"name_ko"`
NameEn string `json:"name_en"`
Degree string `json:"degree"`
GraduatedAt string `json:"graduated_at"`
Major *string `json:"major,omitempty"`
CurrentPosition *string `json:"current_position,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+30
View File
@@ -0,0 +1,30 @@
package models
import "time"
type Publication struct {
ID int `json:"id"`
Category string `json:"category"`
Title string `json:"title"`
Authors *string `json:"authors,omitempty"`
Venue string `json:"venue"`
Volume *string `json:"volume,omitempty"`
PublishedAt string `json:"published_at"`
KCI bool `json:"kci"`
DOI *string `json:"doi,omitempty"`
IsHighlight bool `json:"is_highlight"`
CreatedAt time.Time `json:"created_at"`
}
type Patent struct {
ID int `json:"id"`
Title string `json:"title"`
Inventors string `json:"inventors"`
ApplicationNo string `json:"application_no"`
ApplicationAt string `json:"application_at"`
RegistrationNo *string `json:"registration_no,omitempty"`
RegistrationAt *string `json:"registration_at,omitempty"`
Country string `json:"country"`
PublishedAt string `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
}
@@ -0,0 +1,39 @@
package models
type StandardDocument struct {
ID int `json:"id"`
BodyID int `json:"body_id"`
WG string `json:"wg"`
ProjectName string `json:"project_name"`
Title string `json:"title"`
DocRef string `json:"doc_ref"`
Status string `json:"status"`
PublishedAt *string `json:"published_at,omitempty"`
}
type StandardProjectGroup struct {
WG string `json:"wg"`
Name string `json:"name"`
Documents []StandardDocument `json:"documents"`
}
type StandardsBody struct {
ID int `json:"id"`
Org string `json:"org"`
FullName string `json:"full_name"`
Scope string `json:"scope"`
Period string `json:"period"`
Role *string `json:"role,omitempty"`
DisplayOrder int `json:"display_order"`
}
type StandardsBodyWithProjects struct {
ID int `json:"id"`
Org string `json:"org"`
FullName string `json:"full_name"`
Scope string `json:"scope"`
Period string `json:"period"`
Role *string `json:"role,omitempty"`
DisplayOrder int `json:"display_order"`
Projects []StandardProjectGroup `json:"projects"`
}
+100
View File
@@ -0,0 +1,100 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.godopu.com/lab/landing_page/backend/internal/models"
)
type HomeRepository struct {
db *sql.DB
}
func NewHomeRepository(db *sql.DB) *HomeRepository {
return &HomeRepository{db: db}
}
func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.ResearchProject, error) {
query := `
SELECT id, slug, title, abstract, keywords, organization, standards_org, period, funder, created_at, updated_at
FROM research_projects
ORDER BY id ASC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query research projects: %w", err)
}
defer rows.Close()
var projects []models.ResearchProject
for rows.Next() {
var p models.ResearchProject
var kwJSON string
err := rows.Scan(
&p.ID, &p.Slug, &p.Title, &p.Abstract, &kwJSON,
&p.Organization, &p.StandardsOrg, &p.Period, &p.Funder,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("scan research project: %w", err)
}
if kwJSON != "" {
_ = json.Unmarshal([]byte(kwJSON), &p.Keywords)
}
if p.Keywords == nil {
p.Keywords = []string{}
}
projects = append(projects, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return projects, nil
}
func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.ResearchArea, error) {
query := `
SELECT id, name_en, name_kr, display_order
FROM research_areas
ORDER BY display_order ASC, id ASC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query research areas: %w", err)
}
defer rows.Close()
var areas []models.ResearchArea
for rows.Next() {
var a models.ResearchArea
if err := rows.Scan(&a.ID, &a.NameEn, &a.NameKr, &a.DisplayOrder); err != nil {
return nil, fmt.Errorf("scan research area: %w", err)
}
areas = append(areas, a)
}
return areas, rows.Err()
}
func (r *HomeRepository) GetStatsSummary(ctx context.Context) (*models.StatsSummary, error) {
var summary models.StatsSummary
err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM publications WHERE category = 'intl-journal-conf'").Scan(&summary.IntlPublications)
if err != nil {
return nil, fmt.Errorf("count intl publications: %w", err)
}
err = r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM standard_documents").Scan(&summary.StandardizationDocs)
if err != nil {
return nil, fmt.Errorf("count standard documents: %w", err)
}
err = r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM patents").Scan(&summary.Patents)
if err != nil {
return nil, fmt.Errorf("count patents: %w", err)
}
return &summary, nil
}
+71
View File
@@ -0,0 +1,71 @@
package repository
import (
"context"
"database/sql"
"fmt"
"git.godopu.com/lab/landing_page/backend/internal/models"
)
type LecturesRepository struct {
db *sql.DB
}
func NewLecturesRepository(db *sql.DB) *LecturesRepository {
return &LecturesRepository{db: db}
}
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
ORDER BY year DESC, CASE term WHEN 'Fall' THEN 1 WHEN 'Spring' THEN 2 ELSE 3 END, id DESC
`
semRows, err := r.db.QueryContext(ctx, semQuery)
if err != nil {
return nil, fmt.Errorf("query semesters: %w", err)
}
defer semRows.Close()
var semesters []models.SemesterWithCourses
semMap := make(map[int]int) // semester_id -> index in slice
for semRows.Next() {
var s models.SemesterWithCourses
if err := semRows.Scan(&s.ID, &s.Year, &s.Term); err != nil {
return nil, fmt.Errorf("scan semester: %w", err)
}
s.Courses = []models.Course{}
semMap[s.ID] = len(semesters)
semesters = append(semesters, s)
}
if err := semRows.Err(); err != nil {
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
ORDER BY semester_id ASC, display_order ASC, id ASC
`
courseRows, err := r.db.QueryContext(ctx, courseQuery)
if err != nil {
return nil, fmt.Errorf("query courses: %w", err)
}
defer courseRows.Close()
for courseRows.Next() {
var c models.Course
if err := courseRows.Scan(&c.ID, &c.SemesterID, &c.Code, &c.NameEn, &c.NameKr, &c.Note, &c.DisplayOrder); err != nil {
return nil, fmt.Errorf("scan course: %w", err)
}
if idx, exists := semMap[c.SemesterID]; exists {
semesters[idx].Courses = append(semesters[idx].Courses, c)
}
}
return semesters, courseRows.Err()
}
+69
View File
@@ -0,0 +1,69 @@
package repository
import (
"context"
"database/sql"
"fmt"
"git.godopu.com/lab/landing_page/backend/internal/models"
)
type MembersRepository struct {
db *sql.DB
}
func NewMembersRepository(db *sql.DB) *MembersRepository {
return &MembersRepository{db: db}
}
func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, error) {
query := `
SELECT id, name_ko, name_en, degree, affiliation, email, is_advisor, display_order, created_at
FROM members
ORDER BY display_order ASC, id ASC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query members: %w", err)
}
defer rows.Close()
var members []models.Member
for rows.Next() {
var m models.Member
if err := rows.Scan(
&m.ID, &m.NameKo, &m.NameEn, &m.Degree,
&m.Affiliation, &m.Email, &m.IsAdvisor, &m.DisplayOrder, &m.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan member: %w", err)
}
members = append(members, m)
}
return members, rows.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
FROM alumni
ORDER BY graduated_at DESC, id ASC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query alumni: %w", err)
}
defer rows.Close()
var alumni []models.Alumnus
for rows.Next() {
var a models.Alumnus
if err := rows.Scan(
&a.ID, &a.NameKo, &a.NameEn, &a.Degree,
&a.GraduatedAt, &a.Major, &a.CurrentPosition, &a.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan alumnus: %w", err)
}
alumni = append(alumni, a)
}
return alumni, rows.Err()
}
+102
View File
@@ -0,0 +1,102 @@
package repository
import (
"context"
"database/sql"
"fmt"
"git.godopu.com/lab/landing_page/backend/internal/models"
)
type PublicationsRepository struct {
db *sql.DB
}
func NewPublicationsRepository(db *sql.DB) *PublicationsRepository {
return &PublicationsRepository{db: db}
}
func (r *PublicationsRepository) GetPublications(ctx context.Context, category string) ([]models.Publication, error) {
query := `
SELECT id, category, title, authors, venue, volume, published_at, kci, doi, is_highlight, created_at
FROM publications
`
var args []any
if category != "" {
query += " WHERE category = ?"
args = append(args, category)
}
query += " ORDER BY published_at DESC, id ASC"
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query publications: %w", err)
}
defer rows.Close()
var pubs []models.Publication
for rows.Next() {
var p models.Publication
if err := rows.Scan(
&p.ID, &p.Category, &p.Title, &p.Authors,
&p.Venue, &p.Volume, &p.PublishedAt, &p.KCI, &p.DOI, &p.IsHighlight, &p.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan publication: %w", err)
}
pubs = append(pubs, p)
}
return pubs, rows.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
FROM publications
WHERE is_highlight = 1
ORDER BY published_at DESC, id ASC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query highlights: %w", err)
}
defer rows.Close()
var pubs []models.Publication
for rows.Next() {
var p models.Publication
if err := rows.Scan(
&p.ID, &p.Category, &p.Title, &p.Authors,
&p.Venue, &p.Volume, &p.PublishedAt, &p.KCI, &p.DOI, &p.IsHighlight, &p.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan highlight: %w", err)
}
pubs = append(pubs, p)
}
return pubs, rows.Err()
}
func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Patent, error) {
query := `
SELECT id, title, inventors, application_no, application_at, registration_no, registration_at, country, published_at, created_at
FROM patents
ORDER BY published_at DESC, id ASC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query patents: %w", err)
}
defer rows.Close()
var patents []models.Patent
for rows.Next() {
var p models.Patent
if err := rows.Scan(
&p.ID, &p.Title, &p.Inventors, &p.ApplicationNo,
&p.ApplicationAt, &p.RegistrationNo, &p.RegistrationAt, &p.Country, &p.PublishedAt, &p.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan patent: %w", err)
}
patents = append(patents, p)
}
return patents, rows.Err()
}
@@ -0,0 +1,95 @@
package repository
import (
"context"
"database/sql"
"fmt"
"git.godopu.com/lab/landing_page/backend/internal/models"
)
type StandardizationRepository struct {
db *sql.DB
}
func NewStandardizationRepository(db *sql.DB) *StandardizationRepository {
return &StandardizationRepository{db: db}
}
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
ORDER BY display_order ASC, id ASC
`
bodyRows, err := r.db.QueryContext(ctx, bodyQuery)
if err != nil {
return nil, fmt.Errorf("query standards bodies: %w", err)
}
defer bodyRows.Close()
var bodies []models.StandardsBodyWithProjects
bodyMap := make(map[int]int) // body_id -> index in bodies
for bodyRows.Next() {
var b models.StandardsBodyWithProjects
if err := bodyRows.Scan(&b.ID, &b.Org, &b.FullName, &b.Scope, &b.Period, &b.Role, &b.DisplayOrder); err != nil {
return nil, fmt.Errorf("scan standards body: %w", err)
}
b.Projects = []models.StandardProjectGroup{}
bodyMap[b.ID] = len(bodies)
bodies = append(bodies, b)
}
if err := bodyRows.Err(); err != nil {
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
ORDER BY body_id ASC, id ASC
`
docRows, err := r.db.QueryContext(ctx, docQuery)
if err != nil {
return nil, fmt.Errorf("query standard documents: %w", err)
}
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
for docRows.Next() {
var d models.StandardDocument
if err := docRows.Scan(&d.ID, &d.BodyID, &d.WG, &d.ProjectName, &d.Title, &d.DocRef, &d.Status, &d.PublishedAt); err != nil {
return nil, fmt.Errorf("scan standard document: %w", err)
}
bIdx, bodyExists := bodyMap[d.BodyID]
if !bodyExists {
continue
}
key := projectKey{bodyID: d.BodyID, wg: d.WG, projectName: d.ProjectName}
pIdx, projExists := projectMap[key]
if !projExists {
pIdx = len(bodies[bIdx].Projects)
bodies[bIdx].Projects = append(bodies[bIdx].Projects, models.StandardProjectGroup{
WG: d.WG,
Name: d.ProjectName,
Documents: []models.StandardDocument{},
})
projectMap[key] = pIdx
}
bodies[bIdx].Projects[pIdx].Documents = append(bodies[bIdx].Projects[pIdx].Documents, d)
}
return bodies, docRows.Err()
}
+76
View File
@@ -0,0 +1,76 @@
package router
import (
"database/sql"
"net/http"
"git.godopu.com/lab/landing_page/backend/internal/handlers"
"git.godopu.com/lab/landing_page/backend/internal/httpx"
"git.godopu.com/lab/landing_page/backend/internal/repository"
"github.com/gin-gonic/gin"
)
func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
r := gin.Default()
allowOrigin := "*"
if len(corsOrigins) > 0 && corsOrigins[0] != "" {
allowOrigin = corsOrigins[0]
}
// CORS Middleware
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", allowOrigin)
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
})
// Repositories
homeRepo := repository.NewHomeRepository(db)
membersRepo := repository.NewMembersRepository(db)
pubsRepo := repository.NewPublicationsRepository(db)
lecturesRepo := repository.NewLecturesRepository(db)
standardsRepo := repository.NewStandardizationRepository(db)
// Handlers
homeHandler := handlers.NewHomeHandler(homeRepo)
membersHandler := handlers.NewMembersHandler(membersRepo)
pubsHandler := handlers.NewPublicationsHandler(pubsRepo)
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
// API v1 Group
v1 := r.Group("/api/v1")
{
v1.GET("/health", func(c *gin.Context) {
httpx.OK(c, gin.H{"status": "ok", "service": "anl-backend-api"})
})
// Home
v1.GET("/research-projects", homeHandler.GetResearchProjects)
v1.GET("/research-areas", homeHandler.GetResearchAreas)
v1.GET("/stats/summary", homeHandler.GetStatsSummary)
// Members & Alumni
v1.GET("/members", membersHandler.GetMembers)
v1.GET("/alumni", membersHandler.GetAlumni)
// Publications & Patents
v1.GET("/publications", pubsHandler.GetPublications)
v1.GET("/publications/highlights", pubsHandler.GetHighlights)
v1.GET("/patents", pubsHandler.GetPatents)
// Lectures
v1.GET("/semesters", lecturesHandler.GetSemesters)
// Standardization
v1.GET("/standards-bodies", standardsHandler.GetStandardsBodies)
}
return r
}
+446
View File
@@ -0,0 +1,446 @@
package router_test
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.godopu.com/lab/landing_page/backend/internal/db"
"git.godopu.com/lab/landing_page/backend/internal/models"
"git.godopu.com/lab/landing_page/backend/internal/router"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "modernc.org/sqlite"
)
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)
err = db.RunMigrations(database)
require.NoError(t, err)
// Seed test data from ../../seed/data
seedDir := filepath.Join("..", "..", "seed", "data")
seedDatabase(t, database, seedDir)
return database
}
func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
tx, err := database.BeginTx(context.Background(), nil)
require.NoError(t, err)
defer tx.Rollback()
// 1. Projects
pData, err := os.ReadFile(filepath.Join(seedDir, "research_projects.json"))
require.NoError(t, err)
var projects []struct {
Slug string `json:"slug"`
Title string `json:"title"`
Abstract string `json:"abstract"`
Keywords []string `json:"keywords"`
StandardsTrack *string `json:"standardsTrack"`
StandardsOrg *string `json:"standardsOrg"`
Period *string `json:"period"`
Funder *string `json:"funder"`
}
require.NoError(t, json.Unmarshal(pData, &projects))
for _, p := range projects {
kwJSON, _ := json.Marshal(p.Keywords)
_, err := tx.Exec(`
INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, p.Slug, p.Title, p.Abstract, string(kwJSON), p.StandardsTrack, p.StandardsOrg, p.Period, p.Funder)
require.NoError(t, err)
}
// 2. Areas
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
require.NoError(t, err)
var areas []models.ResearchArea
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)
require.NoError(t, err)
}
// 3. Members
mData, err := os.ReadFile(filepath.Join(seedDir, "members.json"))
require.NoError(t, err)
var members []models.Member
require.NoError(t, json.Unmarshal(mData, &members))
for _, 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)
require.NoError(t, err)
}
// 4. Alumni
alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json"))
require.NoError(t, err)
var alumni []models.Alumnus
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)
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 {
_, err := tx.Exec(`
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)
require.NoError(t, err)
}
// 6. Patents
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
require.NoError(t, err)
var patents []models.Patent
require.NoError(t, json.Unmarshal(patData, &patents))
for _, pat := 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)
require.NoError(t, err)
}
// 7. Semesters & Courses
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
require.NoError(t, err)
var semesters []struct {
Term string `json:"term"`
Year int `json:"year"`
Courses []struct {
Code string `json:"code"`
En string `json:"en"`
Note *string `json:"note"`
} `json:"courses"`
}
require.NoError(t, json.Unmarshal(lecData, &semesters))
for _, s := range semesters {
res, err := tx.Exec(`INSERT INTO semesters (year, term) VALUES (?, ?)`, s.Year, s.Term)
require.NoError(t, err)
semID, _ := res.LastInsertId()
for cIdx, c := range s.Courses {
_, err := tx.Exec(`
INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order)
VALUES (?, ?, ?, ?, ?, ?)
`, semID, c.Code, c.En, nil, c.Note, cIdx+1)
require.NoError(t, err)
}
}
// 8. Standards
stdData, err := os.ReadFile(filepath.Join(seedDir, "standards.json"))
require.NoError(t, err)
var standards []struct {
Org string `json:"org"`
Full string `json:"full"`
Scope string `json:"scope"`
Period string `json:"period"`
Role *string `json:"role"`
Projects []struct {
WG string `json:"wg"`
Name string `json:"name"`
Documents []struct {
Title string `json:"title"`
Ref string `json:"ref"`
Status string `json:"status"`
PublishedAt *string `json:"publishedAt"`
} `json:"documents"`
} `json:"projects"`
}
require.NoError(t, json.Unmarshal(stdData, &standards))
for bIdx, b := range standards {
res, err := tx.Exec(`
INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order)
VALUES (?, ?, ?, ?, ?, ?)
`, b.Org, b.Full, b.Scope, b.Period, b.Role, bIdx+1)
require.NoError(t, err)
bodyID, _ := res.LastInsertId()
for _, p := range b.Projects {
for _, d := range p.Documents {
_, err := tx.Exec(`
INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, bodyID, p.WG, p.Name, d.Title, d.Ref, d.Status, d.PublishedAt)
require.NoError(t, err)
}
}
}
require.NoError(t, tx.Commit())
}
type APIResponse[T any] struct {
Data T `json:"data"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func executeRequest(r http.Handler, method, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
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")
assert.Equal(t, http.StatusOK, w.Code)
var resp APIResponse[map[string]any]
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "ok", resp.Data["status"])
assert.Equal(t, "anl-backend-api", resp.Data["service"])
}
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")
}