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:
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user