- 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
447 lines
15 KiB
Go
447 lines
15 KiB
Go
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")
|
|
}
|