Skip to content
41 changes: 41 additions & 0 deletions apps/admin/src/app/(authenticated)/posts/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,47 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import { Headline } from '@/components/ui/headline';
import { adaptApiPost, type ApiPost } from './page';

describe('adaptApiPost — author display fallback (issue #515)', () => {
it('falls back to last 8 chars of the UUID when the API omits a display name', () => {
// 36-char UUID — the realistic shape coming back from the list
// endpoint today (no `users` join, so no display_name).
const apiPost: ApiPost = {
id: 'post-1',
title: 'Hello',
status: 'publish',
author_id: '550e8400-e29b-41d4-a716-446655440000',
};
const adapted = adaptApiPost(apiPost);

expect(adapted.author.id).toBe('550e8400-e29b-41d4-a716-446655440000');
// Last 8 chars of the UUID — short enough for the table cell,
// long enough to disambiguate.
expect(adapted.author.displayName).toBe('55440000');
});

it('uses the API-supplied display name when present (no fallback)', () => {
const apiPost: ApiPost = {
id: 'post-2',
title: 'Hi',
status: 'publish',
author_id: '550e8400-e29b-41d4-a716-446655440000',
author: { display_name: 'Ada Lovelace' },
};
expect(adaptApiPost(apiPost).author.displayName).toBe('Ada Lovelace');
});

it('leaves commentsCount at 0 — list endpoint does not compute it', () => {
const apiPost: ApiPost = {
id: 'post-3',
title: 'C',
status: 'draft',
author_id: 'abcdefgh',
};
expect(adaptApiPost(apiPost).commentsCount).toBe(0);
});
});

describe('Posts page head', () => {
it('renders the brand "All posts." headline with the italic accent', () => {
Expand Down
78 changes: 61 additions & 17 deletions apps/admin/src/app/(authenticated)/posts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,66 @@ import styles from './posts.module.css';

export const dynamic = 'force-dynamic';

/**
* Last 8 chars of a UUID — enough to disambiguate authors in the list
* table without occupying half the column. Mirrors the helper of the
* same name in `posts/[id]/revisions/page.tsx` (issue #515 fixes the
* blank-Author-cell bug by routing through this fallback when the list
* API doesn't include an author display name).
*/
function shortAuthorId(id: string): string {
if (id.length <= 8) return id;
return id.slice(-8);
}

/** Wire shape we expect from `GET /api/v1/posts`. */
export type ApiPost = {
id: string;
title: string;
status: string;
published_at?: string | null;
updated_at?: string;
created_at?: string;
author_id?: string;
author?: { id?: string; display_name?: string; displayName?: string } | null;
};

/**
* Adapt an API post to the flatter `Post` shape the list UI expects.
*
* Pulled out of `fetchInitialPosts` so it can be unit tested without
* spinning up the whole server component (issue #515).
*
* Author display name: the list endpoint doesn't currently JOIN
* `users`, so `author.display_name` is usually absent. We fall back
* to the last 8 chars of the author UUID rather than rendering a
* blank Author cell — matches the pattern used on the revisions page
* (`shortId` helper).
*
* Comments count: a separate aggregate the list endpoint doesn't
* compute. Left at 0 until the API gains the column / sub-select
* (tracked as a follow-up in #515).
*/
export function adaptApiPost(p: ApiPost): PostListResponse['posts'][number] {
const apiName =
p.author?.display_name ?? p.author?.displayName ?? '';
const authorId = p.author?.id ?? p.author_id ?? '';
return {
id: p.id,
title: p.title ?? '(untitled)',
status:
(p.status as PostListResponse['posts'][number]['status']) ?? 'draft',
date: p.published_at ?? p.updated_at ?? p.created_at ?? '',
author: {
id: authorId,
displayName: apiName || (authorId ? shortAuthorId(authorId) : ''),
},
// Comments aggregate isn't part of the list payload — would
// require a SELECT COUNT(*) join we don't run yet. See #515.
commentsCount: 0,
};
}

/** Loading skeleton for the Suspense fallback. */
function PostsSkeleton(): ReactElement {
return (
Expand Down Expand Up @@ -101,15 +161,6 @@ async function fetchInitialPosts(): Promise<{
// `{posts, nextCursor, total}` form with a flatter Post shape, so
// adapt here. Be defensive: a missing field shouldn't crash the
// page (issue #76 — contract still evolving).
type ApiPost = {
id: string;
title: string;
status: string;
published_at?: string | null;
updated_at?: string;
created_at?: string;
author_id?: string;
};
type ApiEnvelope = {
data?: ApiPost[];
posts?: ApiPost[];
Expand All @@ -129,14 +180,7 @@ async function fetchInitialPosts(): Promise<{
json.nextCursor ??
null;
// Map ApiPost → the flatter Post shape the UI expects.
const posts = rows.map((p) => ({
id: p.id,
title: p.title ?? '(untitled)',
status: (p.status as PostListResponse['posts'][number]['status']) ?? 'draft',
date: p.published_at ?? p.updated_at ?? p.created_at ?? '',
author: { id: p.author_id ?? '', displayName: '' },
commentsCount: 0,
}));
const posts = rows.map(adaptApiPost);
return {
data: {
posts: posts as PostListResponse['posts'],
Expand Down
70 changes: 49 additions & 21 deletions apps/api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -792,18 +792,28 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se
// A read error is logged and surfaced as "no active theme" — the
// handler turns that into a 404 rather than a 500 because a
// transient DB hiccup should not bring down public CSS.
//
// The raw resolver hits Postgres on every call. Public CSS traffic
// scales with page views, so we wrap it in a 60s TTL cache (issue
// #526). The cache is best-effort: theme switches will lag by up
// to one TTL until a follow-up wires Invalidate() through the
// admin activate handler. Sixty seconds is short enough that an
// operator never waits long for a theme swap to propagate and long
// enough that bursty traffic doesn't punch through to the DB.
rawThemeActiveResolver := func() string {
slug, err := themeActiveStore.Get(context.Background())
if err != nil {
logger.Debug("themes/static: active resolver failed",
slog.Any("err", err))
return ""
}
return slug
}
cachedThemeActiveResolver := themesstatic.NewCachedResolver(rawThemeActiveResolver, 60*time.Second)
if err := themesstatic.Mount(mux, "/themes", themesstatic.Deps{
ThemeDir: themeDir,
ActiveResolver: func() string {
slug, err := themeActiveStore.Get(context.Background())
if err != nil {
logger.Debug("themes/static: active resolver failed",
slog.Any("err", err))
return ""
}
return slug
},
Logger: logger,
ThemeDir: themeDir,
ActiveResolver: cachedThemeActiveResolver.Get,
Logger: logger,
}); err != nil {
logger.Warn("themes/static: failed to mount", slog.Any("err", err))
} else {
Expand Down Expand Up @@ -1497,17 +1507,35 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se
}
if jobsInspector == nil {
logger.Warn("admin/jobs: skipping mount; asynq inspector unavailable")
} else if err := adminjobs.Mount(mux, "/api/v1/admin/jobs", adminjobs.Deps{
Inspector: jobsInspectorAdapter{insp: jobsInspector},
Redactions: adminjobs.NewMemoryRedactionStore(),
Policy: adminPolicy,
Logger: logger,
}); err != nil {
logger.Warn("admin/jobs: failed to mount", slog.Any("err", err))
} else {
logger.Info("admin/jobs: routes mounted",
slog.String("base", "/api/v1/admin/jobs"),
)
// Mount on a sub-mux and wrap with RequireSession so the handler's
// gate() sees a principal on context. The global middleware chain
// no longer carries OptionalSession (the regression fixed in #31),
// so admin endpoints have to do their own session validation —
// mirroring how /api/v1/settings and /api/v1/auth/sessions wire
// themselves. Without this wrap, every admin/jobs request landed
// at the gate with no principal and returned 401, hiding the
// inspector behind a permanent auth wall (issue #502).
jobsMux := http.NewServeMux()
if err := adminjobs.Mount(jobsMux, "/api/v1/admin/jobs", adminjobs.Deps{
Inspector: jobsInspectorAdapter{insp: jobsInspector},
Redactions: adminjobs.NewMemoryRedactionStore(),
Policy: adminPolicy,
Logger: logger,
}); err != nil {
logger.Warn("admin/jobs: failed to mount", slog.Any("err", err))
} else {
var jobsHandler http.Handler = jobsMux
if sessions != nil {
jobsHandler = authmw.RequireSession(sessions)(jobsMux)
} else {
logger.Warn("admin/jobs: session manager nil; mounting without RequireSession")
}
mux.Handle("/api/v1/admin/jobs/", jobsHandler)
logger.Info("admin/jobs: routes mounted",
slog.String("base", "/api/v1/admin/jobs"),
)
}
}

// Admin marketplace surface (/api/v1/admin/marketplace). The
Expand Down
32 changes: 23 additions & 9 deletions apps/api/internal/admin/comments/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,21 @@ import (

"github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router"
"github.com/Singleton-Solution/GoNext/packages/go/policy"
"github.com/Singleton-Solution/GoNext/packages/go/util/queryparse"
)

// validCommentStatuses is the lookup table queryparse.ParseStatus
// uses to validate the ?status= query parameter on the list endpoint.
// Built once at package load from AllStatuses so the source of truth
// for the moderation enum stays in model.go.
var validCommentStatuses = func() map[string]struct{} {
m := make(map[string]struct{}, len(AllStatuses))
for _, s := range AllStatuses {
m[string(s)] = struct{}{}
}
return m
}()

// listResponse is the envelope returned by GET /api/v1/admin/comments.
// We reuse router.Page so the shape matches the rest of the admin
// surface (posts, jobs, etc.). Cursor encoding: a plain page number
Expand Down Expand Up @@ -35,15 +48,16 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request, _ policy.Princip
// Status filter. Empty string and the literal "any" both mean
// "no filter"; any other value must be in AllStatuses or we 400
// so the client doesn't accidentally typo "approve" (the bulk
// verb) instead of "approved" (the state).
if s := q.Get("status"); s != "" && s != "any" {
st := Status(s)
if !IsValidStatus(st) {
router.WriteError(w, http.StatusBadRequest, "invalid_status",
"status must be one of pending, approved, spam, trash")
return
}
filter.Status = st
// verb) instead of "approved" (the state). queryparse.ParseStatus
// owns the alias rule so the three list endpoints can't drift.
parsedStatus, err := queryparse.ParseStatus(q.Get("status"), validCommentStatuses)
if err != nil {
router.WriteError(w, http.StatusBadRequest, "invalid_status",
"status must be one of pending, approved, spam, trash")
return
}
if parsedStatus != "" {
filter.Status = Status(parsedStatus)
}

if pid := q.Get("post_id"); pid != "" {
Expand Down
13 changes: 13 additions & 0 deletions apps/api/internal/admin/jobs/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request, _ policy.Princip
// its list response; the overshoot is the standard trick.
tasks, err := h.inspector.ListArchivedTasks(queue, asynq.PageSize(limit+1), asynq.Page(page))
if err != nil {
// On a freshly-booted system the queue's Redis keys don't exist
// yet because no task has ever been enqueued to it. Asynq treats
// that as ErrQueueNotFound, but for the admin DLQ surface it's
// semantically "no archived tasks" — return an empty page rather
// than a 500. Without this, the admin Jobs page renders its
// FailureState UI on a clean install (issue #502).
if errors.Is(err, asynq.ErrQueueNotFound) {
router.WriteJSON(w, http.StatusOK, router.Page[ArchivedTask]{
Data: []ArchivedTask{},
Pagination: router.PageInfo{},
})
return
}
h.logger.ErrorContext(r.Context(), "admin/jobs: list failed",
slog.String("queue", queue),
slog.Any("err", err),
Expand Down
36 changes: 36 additions & 0 deletions apps/api/internal/admin/jobs/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
Expand Down Expand Up @@ -313,6 +314,41 @@ func TestList_InspectorError(t *testing.T) {
}
}

// TestList_QueueNotFoundIsEmpty pins the fix for issue #502: a clean
// install has never enqueued anything to the "default" queue, so Asynq's
// ListArchivedTasks returns an error wrapping ErrQueueNotFound. The
// handler must translate that into an empty page (200), not a 500.
// Otherwise the admin Jobs page renders its FailureState UI for every
// fresh deployment.
func TestList_QueueNotFoundIsEmpty(t *testing.T) {
h := newTestHarness(t)
// Asynq wraps ErrQueueNotFound with fmt.Errorf, so mirror that here
// — errors.Is must still report a match.
h.inspector.listErr = fmt.Errorf("asynq: %w", asynq.ErrQueueNotFound)

req := httptest.NewRequest("GET", "/api/v1/admin/jobs/dlq?queue=default", nil)
rec := h.do(req, ptr(adminPrincipal()))
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var page router.Page[ArchivedTask]
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
t.Fatalf("unmarshal: %v; body=%s", err, rec.Body.String())
}
if len(page.Data) != 0 {
t.Errorf("data: got %d, want 0", len(page.Data))
}
// Empty page must serialise as [] (not null) so the UI can render it
// without a nil-check; router.Page already guarantees this, but pin
// the wire shape here too.
if !strings.Contains(rec.Body.String(), `"data":[]`) {
t.Errorf("body: want data:[] in body, got %s", rec.Body.String())
}
if page.Pagination.NextCursor != "" {
t.Errorf("next_cursor: got %q, want empty", page.Pagination.NextCursor)
}
}

// -----------------------------------------------------------------------------
// AUTH
// -----------------------------------------------------------------------------
Expand Down
26 changes: 26 additions & 0 deletions apps/api/internal/admin/pluginpages/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,29 @@ func TestList_AnonymousDenied(t *testing.T) {
t.Fatalf("expected 401, got %d", rec.Code)
}
}

// Clean install: no plugins are installed yet, so the underlying
// lifecycle.List call returns an empty slice. The sidebar polls this
// endpoint on every authenticated layout render; it must respond 200
// with {"pages":[]} rather than failing the layout. Regression for
// issue #503.
func TestList_EmptyOnCleanInstall(t *testing.T) {
mux := newHarness(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugin-pages", nil)
req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{UserID: "u:1"}))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var resp listResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Pages == nil {
t.Fatal("Pages should be a non-nil empty slice so JSON encodes as [], not null")
}
if len(resp.Pages) != 0 {
t.Fatalf("expected zero pages, got %d: %+v", len(resp.Pages), resp.Pages)
}
}
Loading