Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions server/internal/database/taxonomy.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ func EnsureTaxonomyTable(ctx context.Context, conn *sql.DB) error {
return err
}
seeded = append(seeded, moved...)
// After the Food move as well as the legacy seeding: TV is unhidden here
// and the legacy seeding is what creates it, while Listicles and Books are
// left where the move put them. The two seeds touch disjoint rows, so the
// order between them is about legibility rather than correctness.
if err = SeedEntertainmentVisibility(ctx, conn); err != nil {
return err
}
if err = RefreshCategoryAliases(ctx, conn); err != nil {
return err
}
Expand Down Expand Up @@ -494,6 +501,105 @@ func SeedFoodSubsection(ctx context.Context, conn *sql.DB) ([]string, error) {
return touched, writeSettingRaw(ctx, conn, "food_subsection_seeded", "1")
}

// entertainmentVisibility is which A&E subsections earn a link in the section's
// strip, as decided by the desk: Listicles and Books come out, TV goes in.
//
// TV is the substantial one. It arrived hidden with the rest of the legacy
// WordPress sub-categories -- that seeding deliberately gave 48 categories a
// home without adding 48 nav links -- but it carries 135 published articles,
// more than most of the rows that do have a link. Listicles and Books have nine
// each.
//
// Visibility only. Every one of these keeps its page, its URL and its articles;
// a hidden row still feeds its section. The strip is a curation decision, which
// is exactly the kind of thing that belongs to an editor and not to a deploy --
// see the run-once note on SeedEntertainmentVisibility.
var entertainmentVisibility = map[string]bool{
"listicles": false,
"books": false,
"tv": true,
}

// SeedEntertainmentVisibility applies those decisions once.
//
// Once, and recorded in cms_settings, for the reason the other seeds are: the
// strip is editable from the sections screen, and an editor who puts Books back
// must not find it hidden again after the next deploy. The flag is set even when
// nothing changed, since "already in that state" and "already ran" want the same
// outcome.
//
// Each update is guarded on the CURRENT value, so a row somebody already set by
// hand is left alone rather than rewritten, and the log reports only what this
// actually moved.
//
// An empty table means the sections have not been imported yet, so it returns
// without recording the flag and tries again next boot -- otherwise a server
// that starts before its seed import would burn the one run it gets.
func SeedEntertainmentVisibility(ctx context.Context, conn *sql.DB) error {
if conn == nil {
return nil
}

var settingsTableExists int
if err := conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'cms_settings'",
).Scan(&settingsTableExists); err != nil {
return err
}
if settingsTableExists == 0 {
return nil
}

var done string
switch err := conn.QueryRowContext(ctx,
"SELECT value_text FROM cms_settings WHERE key_name = 'entertainment_visibility_seeded' LIMIT 1",
).Scan(&done); err {
case nil:
if strings.TrimSpace(done) == "1" {
return nil
}
case sql.ErrNoRows:
// Not yet seeded.
default:
return err
}

subsections, err := existingSlugsByKind(ctx, conn, "subsection")
if err != nil {
return err
}
if len(subsections) == 0 {
return nil
}

changed := make([]string, 0, len(entertainmentVisibility))
for slug, visible := range entertainmentVisibility {
if _, exists := subsections[slug]; !exists {
slog.Warn("skipping a visibility change for a subsection that does not exist", "slug", slug)
continue
}
result, err := conn.ExecContext(ctx, `
UPDATE site_taxonomy
SET is_visible = ?
WHERE kind = 'subsection' AND slug = ? AND is_visible = ?
`, visible, slug, !visible)
if err != nil {
return err
}
// RowsAffected is not proof of anything through MaxScale, which can
// report 0 for a write that landed. It is only used to keep the log
// honest about what moved, so a wrong answer costs a log line.
if affected, err := result.RowsAffected(); err == nil && affected > 0 {
changed = append(changed, slug)
}
}

if len(changed) > 0 {
slog.Info("applied the A&E subsection strip changes", "slugs", changed)
}
return writeSettingRaw(ctx, conn, "entertainment_visibility_seeded", "1")
}

func existingSlugsByKind(ctx context.Context, conn *sql.DB, kind string) (map[string]struct{}, error) {
rows, err := conn.QueryContext(ctx, "SELECT slug FROM site_taxonomy WHERE kind = ?", kind)
if err != nil {
Expand Down
82 changes: 82 additions & 0 deletions server/internal/database/taxonomy_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,85 @@ func TestFoodSubsectionSeedMovesTheReviewCategories(t *testing.T) {
t.Errorf("wine-reviews parent = %q, want entertainment -- the seed re-ran over an editor's change", got)
}
}

// TestEntertainmentVisibilitySeedRunsOnce covers the strip changes the desk
// asked for: Listicles and Books lose their link, TV gains one.
//
// The run-once half is the point. The strip is curated from the sections
// screen, so an editor who puts Books back must not find it hidden again on the
// next deploy.
func TestEntertainmentVisibilitySeedRunsOnce(t *testing.T) {
conn := taxonomyTestDB(t)
ctx := context.Background()

if err := EnsureSettingsTable(ctx, conn); err != nil {
t.Fatalf("ensure settings table: %v", err)
}
for _, key := range []string{"legacy_subsections_seeded", "entertainment_visibility_seeded"} {
if _, err := conn.ExecContext(ctx, "DELETE FROM cms_settings WHERE key_name = ?", key); err != nil {
t.Fatalf("clear %s: %v", key, err)
}
}

if err := EnsureTaxonomyTable(ctx, conn); err != nil {
t.Fatalf("ensure taxonomy table: %v", err)
}
insertTaxonomyRow(t, conn, 1, "section", "entertainment", "Entertainment")
// Listicles and Books are current, visible subsections; TV arrives hidden
// from the legacy seeding, which runs in the same boot.
for id, slug := range map[int64]string{2: "listicles", 3: "books"} {
insertTaxonomyRow(t, conn, id, "subsection", slug, strings.ToUpper(slug[:1])+slug[1:])
if _, err := conn.ExecContext(ctx,
"UPDATE site_taxonomy SET parent_slug = 'entertainment', is_visible = 1 WHERE slug = ?", slug,
); err != nil {
t.Fatalf("parent %s: %v", slug, err)
}
}

if err := EnsureTaxonomyTable(ctx, conn); err != nil {
t.Fatalf("second ensure: %v", err)
}

visibilityOf := func(slug string) int {
t.Helper()
var visible int
if err := conn.QueryRowContext(ctx,
"SELECT is_visible FROM site_taxonomy WHERE slug = ?", slug,
).Scan(&visible); err != nil {
t.Fatalf("read visibility of %s: %v", slug, err)
}
return visible
}

for slug, want := range entertainmentVisibility {
got := visibilityOf(slug)
if (got == 1) != want {
t.Errorf("%s is_visible = %d, want %v", slug, got, want)
}
}

// Hiding is only ever about the link: TV keeps its articles either way, and
// Books keeps its page. Prove the seed touched nothing else.
if parent := func() string {
var p sql.NullString
if err := conn.QueryRowContext(ctx, "SELECT parent_slug FROM site_taxonomy WHERE slug = 'books'").Scan(&p); err != nil {
t.Fatalf("read books parent: %v", err)
}
return p.String
}(); parent != "entertainment" {
t.Errorf("books parent = %q, want entertainment -- hiding must not re-file a row", parent)
}

// An editor puts Books back, and a restart leaves it visible.
if _, err := conn.ExecContext(ctx,
"UPDATE site_taxonomy SET is_visible = 1 WHERE slug = 'books'",
); err != nil {
t.Fatalf("unhide books: %v", err)
}
if err := EnsureTaxonomyTable(ctx, conn); err != nil {
t.Fatalf("third ensure: %v", err)
}
if got := visibilityOf("books"); got != 1 {
t.Errorf("books is_visible = %d after a restart, want 1 -- the seed re-ran over an editor's change", got)
}
}
Loading