~evanj/cms

5387b45629e56d5ddfa622a5955f655b92d05b72 — Evan M Jones 7 months ago f11ba66
Fix(db_test): Adding more DB integration tests. Fixed content delete bug
causing data loss.
M internal/m/content/content.go => internal/m/content/content.go +1 -0
@@ 6,6 6,7 @@ import (

type Content interface {
	ID() string
	Type() string // ContentType
	Values() []value.Value
	ValueByName(name string) (value.Value, bool)
	MustValueByName(name string) value.Value

M internal/m/value/value.go => internal/m/value/value.go +6 -0
@@ 7,6 7,12 @@ type Value interface {
	Value() string

	// Specific per valuetype
	RefID() string
	RefName() string

	RefListIDs() []string
	RefListNames() string

	// Can't do because of no cycles allowed
	// RefList() []content.Content
}

M internal/s/db/content.go => internal/s/db/content.go +53 -7
@@ 58,10 58,40 @@ var (
	`

	queryContentDelete = []string{
		"DELETE FROM cms_value_string_small WHERE ID IN ( SELECT ID FROM cms_value WHERE CONTENT_ID = ? );",
		"DELETE FROM cms_value_string_big WHERE ID IN ( SELECT ID FROM cms_value WHERE CONTENT_ID = ? );",
		"DELETE FROM cms_value_date WHERE ID IN ( SELECT ID FROM cms_value WHERE CONTENT_ID = ? );",
		"DELETE FROM cms_value WHERE CONTENT_ID = ?;",

		// Need delete from w/ joins per final cms_value_* table.

		`DELETE FROM cms_value_string_small WHERE ID IN ( 
			SELECT VALUE_ID FROM cms_value 
			JOIN cms_contenttype_to_valuetype
			ON cms_contenttype_to_valuetype.ID = CONTENTTYPE_TO_VALUETYPE_ID
			JOIN cms_valuetype
			ON cms_valuetype.ID = VALUETYPE_ID 
			WHERE CONTENT_ID = ?
			AND cms_valuetype.VALUE = 'StringSmall'
		)`,

		`DELETE FROM cms_value_string_big WHERE ID IN ( 
			SELECT VALUE_ID FROM cms_value 
			JOIN cms_contenttype_to_valuetype
			ON cms_contenttype_to_valuetype.ID = CONTENTTYPE_TO_VALUETYPE_ID
			JOIN cms_valuetype
			ON cms_valuetype.ID = VALUETYPE_ID 
			WHERE CONTENT_ID = ?
			AND cms_valuetype.VALUE = 'StringBig'
		)`,

		`DELETE FROM cms_value_date WHERE ID IN ( 
			SELECT VALUE_ID FROM cms_value 
			JOIN cms_contenttype_to_valuetype
			ON cms_contenttype_to_valuetype.ID = CONTENTTYPE_TO_VALUETYPE_ID
			JOIN cms_valuetype
			ON cms_valuetype.ID = VALUETYPE_ID 
			WHERE CONTENT_ID = ?
			AND cms_valuetype.VALUE = 'Date'
		)`,

		"DELETE FROM cms_value WHERE CONTENT_ID = ?;", // TODO: I don't think we actually need this query due to cascades.
		"DELETE FROM cms_content WHERE ID = ?;",
	}



@@ 372,9 402,9 @@ func (db *DB) valueReferenceListUpdate(s space.Space, ct contenttype.ContentType
		if err != nil {
			return err
		}
		db.contentValueAttachRefList(t, &value, depth)
	}

	db.contentValueAttachRefList(t, &value, depth)
	c.ContentValues = append(c.ContentValues, value)
	return nil
}


@@ 417,9 447,9 @@ func (db *DB) valueReferenceListNew(s space.Space, ct contenttype.ContentType, c
		if err != nil {
			return err
		}
		db.contentValueAttachRefList(t, &value, depth)
	}

	db.contentValueAttachRefList(t, &value, depth)
	c.ContentValues = append(c.ContentValues, value)
	return nil
}


@@ 567,7 597,7 @@ func (db *DB) ContentDelete(space space.Space, ct contenttype.ContentType, conte
	defer t.Rollback()

	for _, q := range queryContentDelete {
		_, err := db.Exec(q, content.ID())
		_, err := t.Exec(q, content.ID())
		if err != nil {
			return err
		}


@@ 771,6 801,10 @@ func (c *Content) ID() string {
	return c.ContentID
}

func (c *Content) Type() string {
	return c.ContentParentTypeID
}

func (c *Content) Values() []value.Value {
	var ret []value.Value
	for _, item := range c.ContentValues {


@@ 823,6 857,10 @@ func (c *ContentValue) RefName() string {
	return c.FieldReference.MustValueByName("name").Value()
}

func (c *ContentValue) RefID() string {
	return c.FieldReference.ID()
}

func (c *ContentValue) RefListNames() string {
	var names []string
	for _, item := range c.FieldReferenceList {


@@ 831,6 869,14 @@ func (c *ContentValue) RefListNames() string {
	return strings.Join(names, ", ")
}

func (c *ContentValue) RefListIDs() []string {
	var ids []string
	for _, item := range c.FieldReferenceList {
		ids = append(ids, item.ID())
	}
	return ids
}

func (c *ContentValue) MarshalJSON() (ret []byte, err error) {
	var v contentValueJSON


M internal/s/db/db.go => internal/s/db/db.go +154 -41
@@ 10,9 10,8 @@ import (
	_ "github.com/go-sql-driver/mysql"
)

const (
	perPage = 25
)
// Default pagination amount. For use in LIMIT/OFFSET.
const perPage = 25

type DB struct {
	*sql.DB


@@ 20,6 19,9 @@ type DB struct {
	sec securer
}

// securer provides us two things:
// 	 1. Creating user tokens (for use in cookie/other).
// 	 2. Creating salt+hashes for passwords.
type securer interface {
	TokenCreate(val security.TokenMap) (string, error)
	TokenFrom(tokenString string) (security.TokenMap, error)


@@ 27,6 29,9 @@ type securer interface {
	HashCompare(salt, pass, hash string) error
}

// New, does as one might expect, given a logger, type of database, database
// connection string, and securer interface, opens a pool'd connection to a
// mysql database and pings. If ping fails we return error and nil *DB.
func New(log *log.Logger, typ, creds string, sec securer) (*DB, error) {
	conn, err := sql.Open(typ, creds)
	if err != nil {


@@ 50,32 55,61 @@ func New(log *log.Logger, typ, creds string, sec securer) (*DB, error) {
	return db, nil
}

func (db *DB) CreateTables() error {
// NewWithConn creates a *DB type and pings the MySQL server. If ping fails we
// return error.
func NewWithConn(log *log.Logger, sec securer, conn *sql.DB) (*DB, error) {
	if err := conn.Ping(); err != nil {
		return nil, err
	}

	// TODO: Best numbers?
	conn.SetMaxIdleConns(10)
	conn.SetMaxOpenConns(100)

	db := &DB{
		conn,
		log,
		sec,
	}

	return db, nil
}

// createTables does our "migration" -migration in quotes as we just dummy
// attempt to create tables on every server startup and ignore "table already
// exists" errors.
func (db *DB) createTables() []error {
	var errors []error
	var err error
	var _ interface{}

	_ = err
	var _ interface{}

	// user
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_user (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) UNIQUE NOT NULL,
			HASH varchar(256) NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// space
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_space (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) NOT NULL,
			DESCRIPTION varchar(256) NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// user to space
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_user_to_space (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			USER_ID INTEGER NOT NULL,


@@ 84,9 118,12 @@ func (db *DB) CreateTables() error {
			FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// contenttype
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_contenttype (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) NOT NULL,


@@ 95,19 132,25 @@ func (db *DB) CreateTables() error {
			CONSTRAINT UNIQUEPERCONN UNIQUE(SPACE_ID, NAME)
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// valuetype
	// This will never be created by users.
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_valuetype (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE varchar(256) UNIQUE NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// contenttype to valuetype
	// TODO: Make name + contenttype_id unique.
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_contenttype_to_valuetype (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			NAME varchar(256) NOT NULL,


@@ 117,18 160,24 @@ func (db *DB) CreateTables() error {
			FOREIGN KEY(VALUETYPE_ID) REFERENCES cms_valuetype(ID)
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// content
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_content (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			CONTENTTYPE_ID INTEGER NOT NULL,
			FOREIGN KEY(CONTENTTYPE_ID) REFERENCES cms_contenttype(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// content_to_value
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_value (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			CONTENT_ID INTEGER NOT NULL,


@@ 138,25 187,34 @@ func (db *DB) CreateTables() error {
			FOREIGN KEY(CONTENTTYPE_TO_VALUETYPE_ID) REFERENCES cms_contenttype_to_valuetype(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// value StringSmall, File
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_value_string_small ( 
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE VARCHAR(256) NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// value StringBig, InputHTML, InputMarkdown
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_value_string_big (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE TEXT NOT NULL
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// value Date
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_value_date (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE DATE NOT NULL


@@ 166,16 224,29 @@ func (db *DB) CreateTables() error {
	// TODO: Reconsider these ON DELETE CASCADES after this point.

	// value Reference
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_value_reference (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE INTEGER NOT NULL,
			FOREIGN KEY(VALUE) REFERENCES cms_content(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// value ReferenceList
	_, err = db.Exec(`
		CREATE TABLE cms_value_reference_list (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// augment to ReferenceList
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_value_reference_list_values (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			VALUE_ID INTEGER NOT NULL,


@@ 184,16 255,12 @@ func (db *DB) CreateTables() error {
			FOREIGN KEY(CONTENT_ID) REFERENCES cms_content(ID) ON DELETE CASCADE
		);
	`)

	// value ReferenceList
	_, _ = db.Exec(`
		CREATE TABLE cms_value_reference_list (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// Webhook
	_, _ = db.Exec(`
	_, err = db.Exec(`
		CREATE TABLE cms_hooks (
			ID INTEGER PRIMARY KEY AUTO_INCREMENT,
			URL varchar(256) UNIQUE NOT NULL,


@@ 201,25 268,71 @@ func (db *DB) CreateTables() error {
			FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID) ON DELETE CASCADE
		);
	`)
	if err != nil {
		errors = append(errors, err)
	}

	// Only valuetypes cms supports.
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringSmall)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringBig)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputHTML)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputMarkdown)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.File)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.Date)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.Reference)
	_, _ = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.ReferenceList)
	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringSmall)
	if err != nil {
		errors = append(errors, err)
	}

	return nil
	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.StringBig)
	if err != nil {
		errors = append(errors, err)
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputHTML)
	if err != nil {
		errors = append(errors, err)
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.InputMarkdown)
	if err != nil {
		errors = append(errors, err)
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.File)
	if err != nil {
		errors = append(errors, err)
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.Date)
	if err != nil {
		errors = append(errors, err)
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.Reference)
	if err != nil {
		errors = append(errors, err)
	}

	_, err = db.Exec(`INSERT INTO cms_valuetype (VALUE) values (?);`, valuetype.ReferenceList)
	if err != nil {
		errors = append(errors, err)
	}

	return errors
}

// Ensure we have the tables we require. If we receive an error other
// than "table already exists" and also "duplicate entry" for built in data
// types that error will be returned.
// Mainly, we are runnin CREATE TABLE and some INSERT INTO of predefined
// value types.
func (db *DB) EnsureSetup() error {
	err := db.CreateTables()
	if err != nil && strings.Contains(err.Error(), "Table ") && strings.Contains(err.Error(), "already exists") {
		db.log.Println(err)
		err = nil
	for _, err := range db.createTables() {
		if err != nil && strings.Contains(err.Error(), "Table ") && strings.Contains(err.Error(), "already exists") {
			continue
		}

		if err != nil && strings.Contains(err.Error(), "Duplicate entry ") && strings.Contains(err.Error(), "cms_valuetype.VALUE") {
			continue
		}

		return err
	}
	return err

	return nil
}

M internal/s/db/db_test.go => internal/s/db/db_test.go +108 -13
@@ 1,22 1,38 @@
package db_test

import (
	"fmt"
	"log"
	"os"
	"strings"
	"testing"

	"git.sr.ht/~evanj/cms/internal/m/content"
	"git.sr.ht/~evanj/cms/internal/m/contenttype"
	"git.sr.ht/~evanj/cms/internal/m/space"
	"git.sr.ht/~evanj/cms/internal/m/valuetype"
	"git.sr.ht/~evanj/cms/internal/s/db"
	"git.sr.ht/~evanj/security"
	"github.com/go-playground/assert/v2"
)

var conn, dberr = db.New(
	log.New(os.Stdout, "", 0),
	os.Getenv("TEST_DBTYPE"),
	os.Getenv("TEST_DB"),
	security.Default(os.Getenv("TEST_SECRET")),
)
var conn, dberr = setup()

func setup() (*db.DB, error) {
	dbname := os.Getenv("TEST_DB_NAME")
	conn, dberr := db.New(
		log.New(os.Stdout, "", 0),
		os.Getenv("TEST_DBTYPE"),
		os.Getenv("TEST_DB"),
		security.Default(os.Getenv("TEST_SECRET")),
	)

	// Get over it. It's a test databse.
	conn.Exec(fmt.Sprintf(`DROP DATABASE %s`, dbname))
	conn.Exec(fmt.Sprintf(`CREATE DATABASE %s`, dbname))
	conn.Exec(fmt.Sprintf(`USE %s`, dbname))
	return conn, dberr
}

func TestBasic(t *testing.T) {
	t.Parallel()


@@ 31,17 47,96 @@ func TestBasic(t *testing.T) {
	assert.Equal(t, "tester", user.Name())

	// Create space
	space, err := conn.SpaceNew(user, "spacer", "descer")
	space, err := conn.SpaceNew(user, "spacer", "desc")
	assert.Equal(t, nil, err)
	assert.Equal(t, "spacer", space.Name())
	assert.Equal(t, "descer", space.Desc())
	assert.Equal(t, "desc", space.Desc())

	// Create contenttype
	ct, err := conn.ContentTypeNew(space, "blogger", []db.ContentTypeNewParam{
		db.ContentTypeNewParam{"titler", valuetype.StringSmall},
		db.ContentTypeNewParam{"slugger", valuetype.StringSmall},
		db.ContentTypeNewParam{"descer", valuetype.StringBig},
	ct1, err := conn.ContentTypeNew(space, "blogger", []db.ContentTypeNewParam{
		db.ContentTypeNewParam{"name", valuetype.StringSmall},
		db.ContentTypeNewParam{"slug", valuetype.StringSmall},
		db.ContentTypeNewParam{"desc", valuetype.StringBig},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, "blogger", ct1.Name())

	// Create content of "blogger"
	c1, err := conn.ContentNew(space, ct1, []db.ContentNewParam{
		db.ContentNewParam{valuetype.StringSmall, "name", "content1"},
		db.ContentNewParam{valuetype.StringSmall, "slug", "content-1"},
		db.ContentNewParam{valuetype.StringBig, "desc", "long-desc"},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, ct1.ID(), c1.Type())
	assert.Equal(t, 3, len(c1.Values()))
	assert.Equal(t, "content1", c1.MustValueByName("name").Value())
	assert.Equal(t, "content-1", c1.MustValueByName("slug").Value())
	assert.Equal(t, "long-desc", c1.MustValueByName("desc").Value())

	// Create content of "blogger"
	c2, err := conn.ContentNew(space, ct1, []db.ContentNewParam{
		db.ContentNewParam{valuetype.StringSmall, "name", "content2"},
		db.ContentNewParam{valuetype.StringSmall, "slug", "content-2"},
		db.ContentNewParam{valuetype.StringBig, "desc", "long-desc-2"},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, ct1.ID(), c2.Type())
	assert.Equal(t, 3, len(c2.Values()))
	assert.Equal(t, "content2", c2.MustValueByName("name").Value())
	assert.Equal(t, "content-2", c2.MustValueByName("slug").Value())
	assert.Equal(t, "long-desc-2", c2.MustValueByName("desc").Value())

	// Create content type "category" with ref to "blogger"
	ct2, err := conn.ContentTypeNew(space, "category", []db.ContentTypeNewParam{
		db.ContentTypeNewParam{"name", valuetype.StringSmall},
		db.ContentTypeNewParam{"blog list", valuetype.ReferenceList},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, "blogger", ct.Name())
	assert.Equal(t, "category", ct2.Name())

	// Create content of "category"
	c3, err := conn.ContentNew(space, ct2, []db.ContentNewParam{
		db.ContentNewParam{valuetype.StringSmall, "name", "category1"},
		// A string of content IDs seperated by "-" (dash).
		db.ContentNewParam{valuetype.ReferenceList, "blog list", strings.Join([]string{c1.ID(), c2.ID()}, "-")},
	})
	assert.Equal(t, nil, err)
	assert.Equal(t, ct2.ID(), c3.Type())
	assert.Equal(t, 2, len(c3.Values()))
	assert.Equal(t, "category1", c3.MustValueByName("name").Value())
	assert.Equal(t, 2, len(c3.MustValueByName("blog list").RefListIDs()))

	// Delete one of the referenced types.
	err = conn.ContentDelete(space, ct1, c1)
	assert.Equal(t, nil, err)
	isdeleted(t, space, ct1, c1)

	// Fetch the content with references.
	c4, err := conn.ContentGet(space, ct2, c3.ID())
	assert.Equal(t, nil, err)
	assert.Equal(t, ct2.ID(), c4.Type())
	assert.Equal(t, 2, len(c4.Values()))
	assert.Equal(t, "category1", c4.MustValueByName("name").Value())
	assert.Equal(t, 1, len(c4.MustValueByName("blog list").RefListIDs()))

	// Delete the content with references
	err = conn.ContentDelete(space, ct2, c4)
	assert.Equal(t, nil, err)
	isdeleted(t, space, ct2, c4)

	// Fetch a contnet that still exists and was referenced.
	c5, err := conn.ContentGet(space, ct1, c2.ID())
	assert.Equal(t, nil, err)
	assert.Equal(t, ct1.ID(), c5.Type())
	assert.Equal(t, 3, len(c5.Values()))
	assert.Equal(t, "content2", c5.MustValueByName("name").Value())
	assert.Equal(t, "content-2", c5.MustValueByName("slug").Value())
	assert.Equal(t, "long-desc-2", c5.MustValueByName("desc").Value())
}

func isdeleted(t *testing.T, s space.Space, ct contenttype.ContentType, c content.Content) {
	c, err := conn.ContentGet(s, ct, c.ID())
	assert.NotEqual(t, nil, err)
	assert.Equal(t, nil, c)
}

M makefile => makefile +1 -1
@@ 16,7 16,7 @@ gen:
	go generate ./...

test: 
	go test -count 1 ./...
	go test ./...

run: all
	./$(BIN)