blob: ae1392bfe5b655129b454d46707364abc9666b74 [file]
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package schema_e2e contains a cross-plugin regression guard that runs the
// REAL migration scripts of every built-in Go plugin and then asserts that the
// resulting database schema still matches what each runtime GORM model expects.
//
// It lives in an `e2e` package on purpose: it needs a real database
// (E2E_DB_URL) and is therefore only executed by `make e2e-test-go-plugins`
// (scripts/e2e-test-go-plugins.sh selects packages whose import path contains
// "e2e"), and excluded from the DB-less unit test run
// (scripts/unit-test-go.sh skips paths matching "e2e").
package schema_e2e
import (
"os"
"path/filepath"
"sync"
"testing"
"github.com/apache/devlake/core/config"
"github.com/apache/devlake/core/dal"
"github.com/apache/devlake/core/migration"
coreMigration "github.com/apache/devlake/core/models/migrationscripts"
"github.com/apache/devlake/core/plugin"
"github.com/apache/devlake/core/runner"
"github.com/apache/devlake/helpers/e2ehelper"
"github.com/apache/devlake/impls/dalgorm"
"github.com/apache/devlake/impls/logruslog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/schema"
ae "github.com/apache/devlake/plugins/ae/impl"
argocd "github.com/apache/devlake/plugins/argocd/impl"
asana "github.com/apache/devlake/plugins/asana/impl"
azuredevops "github.com/apache/devlake/plugins/azuredevops_go/impl"
bamboo "github.com/apache/devlake/plugins/bamboo/impl"
bitbucket "github.com/apache/devlake/plugins/bitbucket/impl"
bitbucket_server "github.com/apache/devlake/plugins/bitbucket_server/impl"
circleci "github.com/apache/devlake/plugins/circleci/impl"
claudeCode "github.com/apache/devlake/plugins/claude_code/impl"
clickup "github.com/apache/devlake/plugins/clickup/impl"
cursor "github.com/apache/devlake/plugins/cursor/impl"
customize "github.com/apache/devlake/plugins/customize/impl"
dora "github.com/apache/devlake/plugins/dora/impl"
feishu "github.com/apache/devlake/plugins/feishu/impl"
copilot "github.com/apache/devlake/plugins/gh-copilot/impl"
gitee "github.com/apache/devlake/plugins/gitee/impl"
gitextractor "github.com/apache/devlake/plugins/gitextractor/impl"
github "github.com/apache/devlake/plugins/github/impl"
githubGraphql "github.com/apache/devlake/plugins/github_graphql/impl"
gitlab "github.com/apache/devlake/plugins/gitlab/impl"
icla "github.com/apache/devlake/plugins/icla/impl"
incidentio "github.com/apache/devlake/plugins/incidentio/impl"
issueTrace "github.com/apache/devlake/plugins/issue_trace/impl"
jenkins "github.com/apache/devlake/plugins/jenkins/impl"
jira "github.com/apache/devlake/plugins/jira/impl"
kiro "github.com/apache/devlake/plugins/kiro/impl"
linear "github.com/apache/devlake/plugins/linear/impl"
linker "github.com/apache/devlake/plugins/linker/impl"
opsgenie "github.com/apache/devlake/plugins/opsgenie/impl"
org "github.com/apache/devlake/plugins/org/impl"
pagerduty "github.com/apache/devlake/plugins/pagerduty/impl"
refdiff "github.com/apache/devlake/plugins/refdiff/impl"
rootly "github.com/apache/devlake/plugins/rootly/impl"
slack "github.com/apache/devlake/plugins/slack/impl"
sonarqube "github.com/apache/devlake/plugins/sonarqube/impl"
starrocks "github.com/apache/devlake/plugins/starrocks/impl"
taiga "github.com/apache/devlake/plugins/taiga/impl"
tapd "github.com/apache/devlake/plugins/tapd/impl"
teambition "github.com/apache/devlake/plugins/teambition/impl"
tempo "github.com/apache/devlake/plugins/tempo/impl"
testmo "github.com/apache/devlake/plugins/testmo/impl"
trello "github.com/apache/devlake/plugins/trello/impl"
webhook "github.com/apache/devlake/plugins/webhook/impl"
zentao "github.com/apache/devlake/plugins/zentao/impl"
)
// allGoPlugins lists EVERY built-in Go plugin. Keep it in sync with the plugin
// directories under backend/plugins/ (the TestAllGoPluginsListed guard below
// fails if a new plugin's `impl` package is added but not registered here).
func allGoPlugins() []plugin.PluginMeta {
return []plugin.PluginMeta{
ae.AE{},
argocd.ArgoCD{},
asana.Asana{},
azuredevops.Azuredevops{},
bamboo.Bamboo{},
bitbucket.Bitbucket{},
bitbucket_server.BitbucketServer{},
circleci.Circleci{},
claudeCode.ClaudeCode{},
clickup.ClickUp{},
cursor.Cursor{},
customize.Customize{},
dora.Dora{},
feishu.Feishu{},
copilot.GhCopilot{},
gitee.Gitee{},
gitextractor.GitExtractor{},
github.Github{},
githubGraphql.GithubGraphql{},
gitlab.Gitlab{},
icla.Icla{},
incidentio.Incidentio{},
issueTrace.IssueTrace{},
jenkins.Jenkins{},
jira.Jira{},
kiro.Kiro{},
linear.Linear{},
linker.Linker{},
opsgenie.Opsgenie{},
org.Org{},
pagerduty.PagerDuty{},
refdiff.RefDiff{},
rootly.Rootly{},
slack.Slack{},
sonarqube.Sonarqube{},
starrocks.StarRocks{},
taiga.Taiga{},
tapd.Tapd{},
teambition.Teambition{},
tempo.Tempo{},
testmo.Testmo{},
trello.Trello{},
webhook.Webhook{},
zentao.Zentao{},
}
}
// TestAllGoPluginsListed guarantees allGoPlugins() stays complete: it counts the
// plugin directories that ship an `impl` package and fails if that number does
// not match the registered list. This makes the schema-drift guard below
// automatically cover any newly added plugin.
func TestAllGoPluginsListed(t *testing.T) {
entries, err := os.ReadDir("..")
require.NoError(t, err)
dirsWithImpl := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
if info, statErr := os.Stat(filepath.Join("..", e.Name(), "impl")); statErr == nil && info.IsDir() {
dirsWithImpl++
}
}
assert.Equalf(t, dirsWithImpl, len(allGoPlugins()),
"number of plugin dirs with an impl/ package (%d) != registered plugins (%d); "+
"add the new plugin to allGoPlugins() in plugins/schema_e2e/migration_schema_test.go",
dirsWithImpl, len(allGoPlugins()))
}
// TestMigrationSchemaMatchesModels applies the real framework + plugin migration
// scripts and then verifies, for every plugin model, that each column the
// runtime GORM model declares actually exists in the migrated table.
//
// This is a cross-plugin generalization of the Jira Sprint Report regression:
// a migration created `_tool_jira_sprint_reports` without embedding
// common.NoPKModel, so the `_raw_data_*` columns were missing and the
// ApiExtractor cleanup query failed at runtime with
// "Unknown column '_raw_data_table' in 'where clause'".
//
// Tables that no migration creates (e.g. models materialized lazily at runtime)
// are skipped, so the check specifically targets *drift* between an existing
// table and its model — which is exactly the failure mode above.
//
// The migrations run against a dedicated, empty database (see
// e2ehelper.NewIsolatedMigrationDb) because the shared e2e database is polluted
// by the other e2e tests, which AutoMigrate tables without recording anything
// in `_devlake_migration_history`.
func TestMigrationSchemaMatchesModels(t *testing.T) {
db := e2ehelper.NewIsolatedMigrationDb(t, "schema_drift")
dalInstance := dalgorm.NewDalgorm(db)
basicRes := runner.CreateBasicRes(config.GetConfig(), logruslog.Global, db)
// Apply the migrations exactly the way the server does on startup.
migrator, migErr := migration.NewMigrator(basicRes)
require.NoError(t, migErr)
migrator.Register(coreMigration.All(), "Framework")
for _, p := range allGoPlugins() {
if migratable, ok := p.(plugin.PluginMigration); ok {
migrator.Register(migratable.MigrationScripts(), p.Name())
}
}
require.NoError(t, migrator.Execute())
keepAll := func(dal.ColumnMeta) bool { return true }
for _, p := range allGoPlugins() {
modeler, ok := p.(plugin.PluginModel)
if !ok {
continue
}
p := p
t.Run(p.Name(), func(t *testing.T) {
for _, table := range modeler.GetTablesInfo() {
table := table
// Columns that actually exist in the migrated table.
actualColumns, colErr := dal.GetColumnNames(dalInstance, table, keepAll)
if colErr != nil || len(actualColumns) == 0 {
// No migration created this table (e.g. runtime-only /
// dynamic model) — nothing to validate for drift.
t.Logf("skip %q: table not present after migrations", table.TableName())
continue
}
existing := make(map[string]struct{}, len(actualColumns))
for _, c := range actualColumns {
existing[c] = struct{}{}
}
// Columns the runtime GORM model expects.
sch, parseErr := schema.Parse(table, &sync.Map{}, schema.NamingStrategy{})
require.NoErrorf(t, parseErr, "unable to parse schema for %T", table)
for _, field := range sch.Fields {
if field.DBName == "" || field.IgnoreMigration {
continue
}
_, present := existing[field.DBName]
assert.Truef(t, present,
"[%s] table %q is missing column %q expected by model %T — "+
"did a migration script forget to embed common.NoPKModel (raw-data columns) or add the field?",
p.Name(), table.TableName(), field.DBName, table)
}
}
})
}
}