From 7a7cbb4ce0d47f9326213e97c64129796091f65f Mon Sep 17 00:00:00 2001 From: tosuke <13393900+tosuke@users.noreply.github.com> Date: Sun, 2 Feb 2025 04:15:16 +0900 Subject: [PATCH 1/6] feat: add invitaion code domain model --- backend/scoreserver/domain/clock.go | 12 + backend/scoreserver/domain/error.go | 4 + backend/scoreserver/domain/invitation.go | 183 +++++++++++++++ backend/scoreserver/domain/invitation_test.go | 211 ++++++++++++++++++ backend/scoreserver/domain/team.go | 11 +- backend/scoreserver/domain/team_test.go | 13 ++ 6 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 backend/scoreserver/domain/clock.go create mode 100644 backend/scoreserver/domain/invitation.go create mode 100644 backend/scoreserver/domain/invitation_test.go create mode 100644 backend/scoreserver/domain/team_test.go diff --git a/backend/scoreserver/domain/clock.go b/backend/scoreserver/domain/clock.go new file mode 100644 index 000000000..60a410eb5 --- /dev/null +++ b/backend/scoreserver/domain/clock.go @@ -0,0 +1,12 @@ +package domain + +import "time" + +type Clock func() time.Time + +func (c Clock) Now() time.Time { + if c == nil { + return time.Now() + } + return c() +} diff --git a/backend/scoreserver/domain/error.go b/backend/scoreserver/domain/error.go index 906430d82..41c446b5b 100644 --- a/backend/scoreserver/domain/error.go +++ b/backend/scoreserver/domain/error.go @@ -44,6 +44,10 @@ func NewError(typ ErrType, err error) error { } } +func WrapAsInternal(err error, msg string) error { + return NewError(ErrTypeInternal, errors.WrapWithDepth(1, err, msg)) +} + func (e *Error) Error() string { return e.typ.String() + ": " + e.err.Error() } diff --git a/backend/scoreserver/domain/invitation.go b/backend/scoreserver/domain/invitation.go new file mode 100644 index 000000000..9776e71e8 --- /dev/null +++ b/backend/scoreserver/domain/invitation.go @@ -0,0 +1,183 @@ +package domain + +import ( + "context" + "crypto/rand" + "math/big" + "time" + + "github.com/cockroachdb/errors" + "github.com/gofrs/uuid/v5" +) + +type ( + InvitationCode struct { + id uuid.UUID + team *Team + code string + expiresAt time.Time + createdAt time.Time + } + InvitationCodeInput struct { + ID uuid.UUID + Team *Team + Code string + ExpiresAt time.Time + CreatedAt time.Time + } +) + +func NewInvitationCode(input InvitationCodeInput) (*InvitationCode, error) { + if input.ID.IsNil() { + return nil, NewError(ErrTypeInvalidArgument, errors.New("id is required")) + } + if input.Code == "" { + return nil, NewError(ErrTypeInvalidArgument, errors.New("code is required")) + } + if input.Team == nil { + return nil, NewError(ErrTypeInvalidArgument, errors.New("invitation code must belong to a team")) + } + if input.ExpiresAt == (time.Time{}) { + return nil, NewError(ErrTypeInvalidArgument, errors.New("expires_at is required")) + } + if input.CreatedAt == (time.Time{}) { + return nil, NewError(ErrTypeInvalidArgument, errors.New("created_at is required")) + } + if input.ExpiresAt.Before(input.CreatedAt) { + return nil, NewError(ErrTypeInvalidArgument, errors.New("expired before create")) + } + + return &InvitationCode{ + id: input.ID, + team: input.Team, + code: input.Code, + expiresAt: input.ExpiresAt, + createdAt: input.CreatedAt, + }, nil +} + +func (c *InvitationCode) ID() uuid.UUID { + return c.id +} + +func (c *InvitationCode) Team() *Team { + return c.team +} + +func (c *InvitationCode) Code() string { + return c.code +} + +func (c *InvitationCode) ExpiresAt() time.Time { + return c.expiresAt +} + +func (c *InvitationCode) CreatedAt() time.Time { + return c.createdAt +} + +const invitationCodeLength = 16 + +// 誤読しやすい文字を除外した文字セット +const invitationCodeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +func generateInvitationCode() (string, error) { + charsetLen := big.NewInt(int64(len(invitationCodeCharset))) + code := make([]byte, invitationCodeLength) + for i := range code { + n, err := rand.Int(rand.Reader, charsetLen) + if err != nil { + return "", errors.Wrap(err, "failed to generate random number") + } + code[i] = invitationCodeCharset[n.Int64()] + } + return string(code), nil +} + +func createInvitationCode(team *Team, expiresAt time.Time, now time.Time) (*InvitationCode, error) { + id, err := uuid.NewV4() + if err != nil { + return nil, NewError(ErrTypeInternal, errors.Wrap(err, "failed to generate uuid")) + } + + code, err := generateInvitationCode() + if err != nil { + return nil, NewError(ErrTypeInternal, err) + } + + return NewInvitationCode(InvitationCodeInput{ + ID: id, + Team: team, + Code: code, + ExpiresAt: expiresAt, + CreatedAt: now, + }) +} + +// 招待コードの一覧を取得するワークフロー +type ( + InvitationCodeListWorkflow struct { + Lister InvitationCodeLister + } +) + +func (w *InvitationCodeListWorkflow) Run(ctx context.Context) ([]*InvitationCode, error) { + ics, err := w.Lister.ListInvitationCodes(ctx, InvitationCodeFilter{}) + if err != nil { + return nil, err + } + return ics, nil +} + +// 招待コードを作成するワークフロー +type ( + InvitationCodeCreateWorkflow struct { + TeamGetter TeamGetter + RunTx TxFunc[InvitationCodeCreator] + Clock Clock + } + InvitationCodeCreateInput struct { + TeamCode int + ExpiresAt time.Time + } +) + +func (w *InvitationCodeCreateWorkflow) Run(ctx context.Context, input InvitationCodeCreateInput) (*InvitationCode, error) { + if input.TeamCode == 0 { + return nil, NewError(ErrTypeInvalidArgument, errors.New("teamCode is required")) + } + teamCode, err := NewTeamCode(input.TeamCode) + if err != nil { + return nil, err + } + + team, err := w.TeamGetter.GetTeamByCode(ctx, teamCode) + if err != nil { + return nil, err + } + + invitationCode, err := createInvitationCode(team, input.ExpiresAt, w.Clock.Now()) + if err != nil { + return nil, err + } + + if err := w.RunTx(ctx, func(effect InvitationCodeCreator) error { + return effect.CreateInvitationCode(ctx, invitationCode) + }); err != nil { + return nil, err + } + + return invitationCode, nil +} + +type ( + InvitationCodeFilter struct { + Code string + } + InvitationCodeLister interface { + ListInvitationCodes(ctx context.Context, filter InvitationCodeFilter) ([]*InvitationCode, error) + } + InvitationCodeCreator interface { + CreateInvitationCode(ctx context.Context, invitationCode *InvitationCode) error + } +) diff --git a/backend/scoreserver/domain/invitation_test.go b/backend/scoreserver/domain/invitation_test.go new file mode 100644 index 000000000..2c8a6ac5f --- /dev/null +++ b/backend/scoreserver/domain/invitation_test.go @@ -0,0 +1,211 @@ +package domain_test + +import ( + "context" + "testing" + "time" + + "github.com/cockroachdb/errors" + "github.com/gofrs/uuid/v5" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ictsc/ictsc-regalia/backend/scoreserver/domain" +) + +func Test_InvitationCodeListWorkflow(t *testing.T) { + t.Parallel() + + team1 := must(domain.NewTeam(domain.TeamInput{ + ID: must(uuid.NewV4()), + Code: 1, + Name: "team1", + Organization: "org1", + })) + + team2 := must(domain.NewTeam(domain.TeamInput{ + ID: must(uuid.NewV4()), + Code: 2, + Name: "team2", + Organization: "org2", + })) + + now := must(time.Parse(time.RFC3339, "2025-01-01T00:00:00Z")) + ic1 := must(domain.NewInvitationCode(domain.InvitationCodeInput{ + ID: must(uuid.NewV4()), + Team: team1, + Code: "ABCD1234EFGH5678", + ExpiresAt: now.Add(24 * time.Hour), + CreatedAt: now, + })) + + ic2 := must(domain.NewInvitationCode(domain.InvitationCodeInput{ + ID: must(uuid.NewV4()), + Team: team2, + Code: "WXYZ9876MNPQ5432", + ExpiresAt: now.Add(48 * time.Hour), + CreatedAt: now, + })) + + workflow := &domain.InvitationCodeListWorkflow{ + Lister: invitationCodeListerFunc(func(_ context.Context, _ domain.InvitationCodeFilter) ([]*domain.InvitationCode, error) { + return []*domain.InvitationCode{ic1, ic2}, nil + }), + } + + cases := map[string]struct { + w *domain.InvitationCodeListWorkflow + + wants []*domain.InvitationCode + }{ + "ok": { + w: workflow, + + wants: []*domain.InvitationCode{ic1, ic2}, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ics, err := tt.w.Run(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if diff := cmp.Diff( + tt.wants, ics, + cmp.AllowUnexported(domain.InvitationCode{}, domain.Team{}), + ); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func Test_InvitationCodeCreateWorkflow(t *testing.T) { + t.Parallel() + + team1 := must(domain.NewTeam(domain.TeamInput{ + ID: must(uuid.NewV4()), + Code: 1, + Name: "team1", + Organization: "org", + })) + icCreator := invitationCodeCreatorFunc( + func(context.Context, *domain.InvitationCode) error { return nil }) + workflow := &domain.InvitationCodeCreateWorkflow{ + TeamGetter: teamCodeGetterFunc( + func(_ context.Context, code domain.TeamCode) (*domain.Team, error) { + if code != 1 { + return nil, domain.NewError(domain.ErrTypeNotFound, errors.New("team not found")) + } + return team1, nil + }), + + RunTx: func(_ context.Context, f func(eff domain.InvitationCodeCreator) error) error { + return f(icCreator) + }, + Clock: func() time.Time { + return must(time.Parse(time.RFC3339, "2025-01-01T00:00:00Z")) + }, + } + + cases := map[string]struct { + w *domain.InvitationCodeCreateWorkflow + in domain.InvitationCodeCreateInput + + want *domain.InvitationCode + wantErr domain.ErrType + }{ + "ok": { + w: workflow, + in: domain.InvitationCodeCreateInput{ + TeamCode: 1, + ExpiresAt: must(time.Parse(time.RFC3339, "2025-04-03T09:00:00Z")), + }, + + want: must(domain.NewInvitationCode(domain.InvitationCodeInput{ + ID: must(uuid.NewV4()), + Code: "dummy", + Team: team1, + ExpiresAt: must(time.Parse(time.RFC3339, "2025-04-03T09:00:00Z")), + CreatedAt: must(time.Parse(time.RFC3339, "2025-01-01T00:00:00Z")), + })), + }, + "no team code": { + w: workflow, + in: domain.InvitationCodeCreateInput{ + ExpiresAt: must(time.Parse(time.RFC3339, "2025-04-03T09:00:00Z")), + }, + + wantErr: domain.ErrTypeInvalidArgument, + }, + "no expires at": { + w: workflow, + in: domain.InvitationCodeCreateInput{ + TeamCode: 1, + }, + + wantErr: domain.ErrTypeInvalidArgument, + }, + "team not found": { + w: workflow, + in: domain.InvitationCodeCreateInput{ + TeamCode: 2, + ExpiresAt: must(time.Parse(time.RFC3339, "2025-04-03T09:00:00Z")), + }, + + wantErr: domain.ErrTypeNotFound, + }, + "already expired": { + w: workflow, + in: domain.InvitationCodeCreateInput{ + TeamCode: 1, + ExpiresAt: must(time.Parse(time.RFC3339, "2024-04-01T00:00:00Z")), + }, + + wantErr: domain.ErrTypeInvalidArgument, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + actual, err := tt.w.Run(context.Background(), tt.in) + if domain.ErrTypeFrom(err) != tt.wantErr { + t.Errorf("want error typ %v, got %v", tt.wantErr, err) + } + if err != nil { + t.Logf("error: %v", err) + return + } + if diff := cmp.Diff( + tt.want, actual, + cmp.AllowUnexported(domain.InvitationCode{}, domain.Team{}), + cmpopts.IgnoreFields(domain.InvitationCode{}, "id", "code"), + ); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +type invitationCodeListerFunc func(ctx context.Context, filter domain.InvitationCodeFilter) ([]*domain.InvitationCode, error) + +func (f invitationCodeListerFunc) ListInvitationCodes(ctx context.Context, filter domain.InvitationCodeFilter) ([]*domain.InvitationCode, error) { + return f(ctx, filter) +} + +type invitationCodeCreatorFunc func(ctx context.Context, ic *domain.InvitationCode) error + +func (f invitationCodeCreatorFunc) CreateInvitationCode(ctx context.Context, ic *domain.InvitationCode) error { + return f(ctx, ic) +} + +func must[T any](t T, err error) T { + if err != nil { + panic(err) + } + return t +} diff --git a/backend/scoreserver/domain/team.go b/backend/scoreserver/domain/team.go index 8943560d5..5fa531c8e 100644 --- a/backend/scoreserver/domain/team.go +++ b/backend/scoreserver/domain/team.go @@ -118,7 +118,10 @@ type TeamListWorkflow struct { func (w *TeamListWorkflow) Run(ctx context.Context) ([]*Team, error) { teams, err := w.Lister.ListTeams(ctx) - return teams, NewError(ErrTypeInternal, err) + if err != nil { + return nil, NewError(ErrTypeInternal, err) + } + return teams, nil } type TeamGetWorkflow struct { @@ -188,7 +191,7 @@ type ( ) func (w *TeamUpdateWorkflow) Run(ctx context.Context, input TeamUpdateInput) (*Team, error) { - var teamResult *Team + var result *Team if err := w.RunTx(ctx, func(effect TeamUpdateTxEffect) error { getWf := TeamGetWorkflow{Getter: effect} team, err := getWf.Run(ctx, TeamGetInput{Code: input.Code}) @@ -208,13 +211,13 @@ func (w *TeamUpdateWorkflow) Run(ctx context.Context, input TeamUpdateInput) (*T return NewError(ErrTypeInternal, err) } - teamResult = updated + result = updated return nil }); err != nil { return nil, err } - return teamResult, nil + return result, nil } type ( diff --git a/backend/scoreserver/domain/team_test.go b/backend/scoreserver/domain/team_test.go new file mode 100644 index 000000000..1b71feea0 --- /dev/null +++ b/backend/scoreserver/domain/team_test.go @@ -0,0 +1,13 @@ +package domain_test + +import ( + "context" + + "github.com/ictsc/ictsc-regalia/backend/scoreserver/domain" +) + +type teamCodeGetterFunc func(ctx context.Context, code domain.TeamCode) (*domain.Team, error) + +func (f teamCodeGetterFunc) GetTeamByCode(ctx context.Context, code domain.TeamCode) (*domain.Team, error) { + return f(ctx, code) +} From ac04d7e3448d6023a48452be8f8222a41a899db6 Mon Sep 17 00:00:00 2001 From: tosuke <13393900+tosuke@users.noreply.github.com> Date: Sun, 2 Feb 2025 04:57:37 +0900 Subject: [PATCH 2/6] feat: implement invitation_codes table operations --- backend/schema.sql | 13 +++ .../scoreserver/infra/pg/invitation_code.go | 101 ++++++++++++++++ .../infra/pg/invitation_code_test.go | 109 ++++++++++++++++++ .../scoreserver/infra/pg/repository_test.go | 20 ++++ 4 files changed, 243 insertions(+) create mode 100644 backend/scoreserver/infra/pg/invitation_code.go create mode 100644 backend/scoreserver/infra/pg/invitation_code_test.go create mode 100644 backend/scoreserver/infra/pg/repository_test.go diff --git a/backend/schema.sql b/backend/schema.sql index bdcfe3d2f..0fda9c010 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -11,3 +11,16 @@ COMMENT ON COLUMN teams.id IS 'チーム ID'; COMMENT ON COLUMN teams.code IS 'チーム番号'; COMMENT ON COLUMN teams.name IS 'チーム名'; COMMENT ON COLUMN teams.organization IS 'チームの所属組織名'; + +CREATE TABLE invitation_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + code VARCHAR(255) NOT NULL UNIQUE, + expired_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +COMMENT ON TABLE invitation_codes IS '招待コード'; +COMMENT ON COLUMN invitation_codes.id IS '招待コード ID'; +COMMENT ON COLUMN invitation_codes.team_id IS 'チーム ID'; +COMMENT ON COLUMN invitation_codes.code IS '招待コード'; +COMMENT ON COLUMN invitation_codes.expired_at IS '有効期限'; diff --git a/backend/scoreserver/infra/pg/invitation_code.go b/backend/scoreserver/infra/pg/invitation_code.go new file mode 100644 index 000000000..e0234aaf2 --- /dev/null +++ b/backend/scoreserver/infra/pg/invitation_code.go @@ -0,0 +1,101 @@ +package pg + +import ( + "context" + "database/sql" + "time" + + "github.com/cockroachdb/errors" + "github.com/gofrs/uuid/v5" + "github.com/ictsc/ictsc-regalia/backend/scoreserver/domain" +) + +type invitationCodeRow struct { + ID uuid.UUID `db:"id"` + Code string `db:"code"` + ExpiredAt time.Time `db:"expired_at"` + CreatedAt time.Time `db:"created_at"` +} + +func (r *invitationCodeRow) asDomain(teamRow *teamRow) (*domain.InvitationCode, error) { + team, err := teamRow.asDomain() + if err != nil { + return nil, err + } + return domain.NewInvitationCode(domain.InvitationCodeInput{ + ID: r.ID, + Team: team, + Code: r.Code, + ExpiresAt: r.ExpiredAt, + CreatedAt: r.CreatedAt, + }) +} + +var _ domain.InvitationCodeLister = (*repo)(nil) + +func (r *repo) ListInvitationCodes(ctx context.Context, filter domain.InvitationCodeFilter) ([]*domain.InvitationCode, error) { + cond := "TRUE" + var args []any + + if filter.Code != "" { + cond += " AND code = ?" + args = append(args, filter.Code) + } + + rows, err := r.ext.QueryxContext(ctx, r.ext.Rebind(` + SELECT + ic.id AS "ic.id", + ic.code AS "ic.code", + ic.expired_at AS "ic.expired_at", + ic.created_at AS "ic.created_at", + t.id AS "t.id", + t.code AS "t.code", + t.name AS "t.name", + t.organization AS "t.organization", + t.created_at AS "t.created_at", + t.updated_at AS "t.updated_at" + FROM invitation_codes AS ic + LEFT JOIN teams AS t ON ic.team_id = t.id + WHERE `+cond, + ), args...) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []*domain.InvitationCode{}, nil + } + return nil, domain.WrapAsInternal(err, "failed to select invitation_codes") + } + defer func() { _ = rows.Close() }() + + var ( + row struct { + Team *teamRow `db:"t"` + InvitationCode *invitationCodeRow `db:"ic"` + } + invitationCodes []*domain.InvitationCode + ) + for rows.Next() { + if err := rows.StructScan(&row); err != nil { + return nil, domain.WrapAsInternal(err, "failed to scan invitation_codes row") + } + + invitationCode, err := row.InvitationCode.asDomain(row.Team) + if err != nil { + return nil, errors.Wrapf(err, "invalid invitation_code; id=%v", row.InvitationCode.ID) + } + + invitationCodes = append(invitationCodes, invitationCode) + } + return invitationCodes, nil +} + +var _ domain.InvitationCodeCreator = (*repo)(nil) + +func (r *repo) CreateInvitationCode(ctx context.Context, code *domain.InvitationCode) error { + if _, err := r.ext.ExecContext(ctx, r.ext.Rebind(` + INSERT INTO invitation_codes (id, team_id, code, expired_at, created_at) + VALUES (?, ?, ?, ?, ?) + `), code.ID(), code.Team().ID(), code.Code(), code.ExpiresAt(), code.CreatedAt()); err != nil { + return domain.WrapAsInternal(err, "failed to insert invitation_code") + } + return nil +} diff --git a/backend/scoreserver/infra/pg/invitation_code_test.go b/backend/scoreserver/infra/pg/invitation_code_test.go new file mode 100644 index 000000000..770236962 --- /dev/null +++ b/backend/scoreserver/infra/pg/invitation_code_test.go @@ -0,0 +1,109 @@ +package pg_test + +import ( + "context" + "testing" + "time" + + "github.com/gofrs/uuid/v5" + "github.com/google/go-cmp/cmp" + "github.com/ictsc/ictsc-regalia/backend/pkg/pgtest" + "github.com/ictsc/ictsc-regalia/backend/scoreserver/domain" + "github.com/ictsc/ictsc-regalia/backend/scoreserver/infra/pg" + "github.com/jmoiron/sqlx" +) + +func Test_PgRepo_InvitationCode(t *testing.T) { + t.Parallel() + + now := must(time.Parse(time.RFC3339, "2025-01-01T00:00:00Z")) + + team1 := must(domain.NewTeam(domain.TeamInput{ + ID: must(uuid.NewV4()), + Code: 1, + Name: "team1", + Organization: "org1", + })) + invitationCode := must(domain.NewInvitationCode(domain.InvitationCodeInput{ + ID: must(uuid.NewV4()), + Code: "ABCD1234EFGH5678", + Team: team1, + ExpiresAt: now.Add(24 * time.Hour), + CreatedAt: now, + })) + + //nolint:thelper //ここではテストケースを書いているため + tests := map[string]func(t *testing.T, db *sqlx.DB){ + "create": func(t *testing.T, db *sqlx.DB) { + repo := pg.NewRepository(db) + if err := repo.CreateInvitationCode(context.Background(), invitationCode); err != nil { + t.Fatalf("failed to create invitation code: %+v", err) + } + + row := db.QueryRowx("SELECT * FROM invitation_codes WHERE id = $1", invitationCode.ID()) + if row.Err() != nil { + t.Fatalf("failed to get invitation code: %+v", row.Err()) + } + got := map[string]any{} + if err := row.MapScan(got); err != nil { + t.Fatalf("failed to map scan: %+v", err) + } + if diff := cmp.Diff(got, map[string]any{ + "id": invitationCode.ID().String(), + "code": invitationCode.Code(), + "team_id": invitationCode.Team().ID().String(), + "expired_at": invitationCode.ExpiresAt(), + "created_at": invitationCode.CreatedAt(), + }); diff != "" { + t.Errorf("differs: (-got +want)\n%s", diff) + } + }, + "list": func(t *testing.T, db *sqlx.DB) { + repo := pg.NewRepository(db) + + before, err := repo.ListInvitationCodes(context.Background(), domain.InvitationCodeFilter{}) + if err != nil { + t.Fatalf("%+v", err) + } + if len(before) != 0 { + t.Errorf("unexpected invitation codes: %+v", before) + } + + if err := repo.CreateInvitationCode(context.Background(), invitationCode); err != nil { + t.Fatalf("failed to create invitation code: %+v", err) + } + + got, err := repo.ListInvitationCodes(context.Background(), domain.InvitationCodeFilter{}) + if err != nil { + t.Fatalf("failed to list invitation codes: %+v", err) + } + if diff := cmp.Diff( + got, []*domain.InvitationCode{invitationCode}, + cmp.AllowUnexported(domain.InvitationCode{}, domain.Team{}), + ); diff != "" { + t.Errorf("differs: (-got +want)\n%s", diff) + } + }, + } + + setup := func(t *testing.T, db *sqlx.DB) { + t.Helper() + + repo := pg.NewRepository(db) + if err := repo.CreateTeam(context.Background(), team1); err != nil { + t.Fatalf("failed to create team: %+v", err) + } + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + db, ok := pgtest.SetupDB(t) + if !ok { + t.FailNow() + } + setup(t, db) + test(t, db) + }) + } +} diff --git a/backend/scoreserver/infra/pg/repository_test.go b/backend/scoreserver/infra/pg/repository_test.go new file mode 100644 index 000000000..f023ac548 --- /dev/null +++ b/backend/scoreserver/infra/pg/repository_test.go @@ -0,0 +1,20 @@ +package pg_test + +import ( + "os" + "testing" + + "github.com/ictsc/ictsc-regalia/backend/pkg/pgtest" +) + +func TestMain(m *testing.M) { + run := pgtest.WrapRun(m.Run) + os.Exit(run()) +} + +func must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} From 46d95ed1be3ff2faebb46c667547457217b0c583 Mon Sep 17 00:00:00 2001 From: tosuke <13393900+tosuke@users.noreply.github.com> Date: Sun, 2 Feb 2025 05:12:38 +0900 Subject: [PATCH 3/6] refactor: expired->expires --- backend/schema.sql | 4 ++-- backend/scoreserver/infra/pg/invitation_code.go | 8 ++++---- backend/scoreserver/infra/pg/invitation_code_test.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/schema.sql b/backend/schema.sql index 0fda9c010..3e1190c60 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -16,11 +16,11 @@ CREATE TABLE invitation_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, code VARCHAR(255) NOT NULL UNIQUE, - expired_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); COMMENT ON TABLE invitation_codes IS '招待コード'; COMMENT ON COLUMN invitation_codes.id IS '招待コード ID'; COMMENT ON COLUMN invitation_codes.team_id IS 'チーム ID'; COMMENT ON COLUMN invitation_codes.code IS '招待コード'; -COMMENT ON COLUMN invitation_codes.expired_at IS '有効期限'; +COMMENT ON COLUMN invitation_codes.expires_at IS '有効期限'; diff --git a/backend/scoreserver/infra/pg/invitation_code.go b/backend/scoreserver/infra/pg/invitation_code.go index e0234aaf2..c659a67c1 100644 --- a/backend/scoreserver/infra/pg/invitation_code.go +++ b/backend/scoreserver/infra/pg/invitation_code.go @@ -13,7 +13,7 @@ import ( type invitationCodeRow struct { ID uuid.UUID `db:"id"` Code string `db:"code"` - ExpiredAt time.Time `db:"expired_at"` + ExpiresAt time.Time `db:"expires_at"` CreatedAt time.Time `db:"created_at"` } @@ -26,7 +26,7 @@ func (r *invitationCodeRow) asDomain(teamRow *teamRow) (*domain.InvitationCode, ID: r.ID, Team: team, Code: r.Code, - ExpiresAt: r.ExpiredAt, + ExpiresAt: r.ExpiresAt, CreatedAt: r.CreatedAt, }) } @@ -46,7 +46,7 @@ func (r *repo) ListInvitationCodes(ctx context.Context, filter domain.Invitation SELECT ic.id AS "ic.id", ic.code AS "ic.code", - ic.expired_at AS "ic.expired_at", + ic.expires_at AS "ic.expires_at", ic.created_at AS "ic.created_at", t.id AS "t.id", t.code AS "t.code", @@ -92,7 +92,7 @@ var _ domain.InvitationCodeCreator = (*repo)(nil) func (r *repo) CreateInvitationCode(ctx context.Context, code *domain.InvitationCode) error { if _, err := r.ext.ExecContext(ctx, r.ext.Rebind(` - INSERT INTO invitation_codes (id, team_id, code, expired_at, created_at) + INSERT INTO invitation_codes (id, team_id, code, expires_at, created_at) VALUES (?, ?, ?, ?, ?) `), code.ID(), code.Team().ID(), code.Code(), code.ExpiresAt(), code.CreatedAt()); err != nil { return domain.WrapAsInternal(err, "failed to insert invitation_code") diff --git a/backend/scoreserver/infra/pg/invitation_code_test.go b/backend/scoreserver/infra/pg/invitation_code_test.go index 770236962..06620a450 100644 --- a/backend/scoreserver/infra/pg/invitation_code_test.go +++ b/backend/scoreserver/infra/pg/invitation_code_test.go @@ -52,7 +52,7 @@ func Test_PgRepo_InvitationCode(t *testing.T) { "id": invitationCode.ID().String(), "code": invitationCode.Code(), "team_id": invitationCode.Team().ID().String(), - "expired_at": invitationCode.ExpiresAt(), + "expires_at": invitationCode.ExpiresAt(), "created_at": invitationCode.CreatedAt(), }); diff != "" { t.Errorf("differs: (-got +want)\n%s", diff) From d6c735fdbe20c4af40678d49bb544f0e920f6744 Mon Sep 17 00:00:00 2001 From: tosuke <13393900+tosuke@users.noreply.github.com> Date: Sun, 2 Feb 2025 05:58:37 +0900 Subject: [PATCH 4/6] feat: implement admin.v1.InvitationService --- backend/scoreserver/admin/auth/policy.csv | 2 + backend/scoreserver/admin/invitation.go | 89 ++++++++++++++++++++ backend/scoreserver/admin/invitation_test.go | 86 +++++++++++++++++++ backend/scoreserver/admin/server.go | 4 + 4 files changed, 181 insertions(+) create mode 100644 backend/scoreserver/admin/invitation.go create mode 100644 backend/scoreserver/admin/invitation_test.go diff --git a/backend/scoreserver/admin/auth/policy.csv b/backend/scoreserver/admin/auth/policy.csv index 3fdcde159..83f57a281 100644 --- a/backend/scoreserver/admin/auth/policy.csv +++ b/backend/scoreserver/admin/auth/policy.csv @@ -4,3 +4,5 @@ p, role:admin, teams, get p, role:admin, teams, create p, role:admin, teams, update p, role:admin, teams, delete +p, role:admin, invitation_codes, list +p, role:admin, invitation_codes, create diff --git a/backend/scoreserver/admin/invitation.go b/backend/scoreserver/admin/invitation.go new file mode 100644 index 000000000..336c41e61 --- /dev/null +++ b/backend/scoreserver/admin/invitation.go @@ -0,0 +1,89 @@ +package admin + +import ( + "context" + + "connectrpc.com/connect" + adminv1 "github.com/ictsc/ictsc-regalia/backend/pkg/proto/admin/v1" + "github.com/ictsc/ictsc-regalia/backend/pkg/proto/admin/v1/adminv1connect" + "github.com/ictsc/ictsc-regalia/backend/scoreserver/admin/auth" + "github.com/ictsc/ictsc-regalia/backend/scoreserver/domain" + "github.com/ictsc/ictsc-regalia/backend/scoreserver/infra/pg" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type InvitationServiceHandler struct { + adminv1connect.UnimplementedInvitationServiceHandler + Enforcer *auth.Enforcer + ListWorkflow *domain.InvitationCodeListWorkflow + CreateWorkflow *domain.InvitationCodeCreateWorkflow +} + +func NewInvitationServiceHandler(enforcer *auth.Enforcer, repo *pg.Repository) *InvitationServiceHandler { + return &InvitationServiceHandler{ + UnimplementedInvitationServiceHandler: adminv1connect.UnimplementedInvitationServiceHandler{}, + + Enforcer: enforcer, + ListWorkflow: &domain.InvitationCodeListWorkflow{Lister: repo}, + CreateWorkflow: &domain.InvitationCodeCreateWorkflow{ + TeamGetter: repo, + RunTx: func(ctx context.Context, f func(eff domain.InvitationCodeCreator) error) error { + return repo.RunTx(ctx, func(tx *pg.RepositoryTx) error { return f(tx) }) + }, + }, + } +} + +var _ adminv1connect.InvitationServiceHandler = (*InvitationServiceHandler)(nil) + +func (h *InvitationServiceHandler) ListInvitationCodes( + ctx context.Context, + req *connect.Request[adminv1.ListInvitationCodesRequest], +) (*connect.Response[adminv1.ListInvitationCodesResponse], error) { + if err := enforce(ctx, h.Enforcer, "invitation_codes", "list"); err != nil { + return nil, err + } + + ics, err := h.ListWorkflow.Run(ctx) + if err != nil { + return nil, connectError(err) + } + + protoICs := make([]*adminv1.InvitationCode, 0, len(ics)) + for _, ic := range ics { + protoICs = append(protoICs, convertInvitationCode(ic)) + } + + return connect.NewResponse(&adminv1.ListInvitationCodesResponse{ + InvitationCodes: protoICs, + }), nil +} + +func (h *InvitationServiceHandler) CreateInvitationCode( + ctx context.Context, + req *connect.Request[adminv1.CreateInvitationCodeRequest], +) (*connect.Response[adminv1.CreateInvitationCodeResponse], error) { + if err := enforce(ctx, h.Enforcer, "invitation_codes", "create"); err != nil { + return nil, err + } + + code, err := h.CreateWorkflow.Run(ctx, domain.InvitationCodeCreateInput{ + TeamCode: int(req.Msg.GetInvitationCode().GetTeamCode()), + ExpiresAt: req.Msg.GetInvitationCode().GetExpiresAt().AsTime(), + }) + if err != nil { + return nil, connectError(err) + } + + return connect.NewResponse(&adminv1.CreateInvitationCodeResponse{ + InvitationCode: convertInvitationCode(code), + }), nil +} + +func convertInvitationCode(ic *domain.InvitationCode) *adminv1.InvitationCode { + return &adminv1.InvitationCode{ + Code: ic.Code(), + TeamCode: int64(ic.Team().Code()), + ExpiresAt: timestamppb.New(ic.ExpiresAt()), + } +} diff --git a/backend/scoreserver/admin/invitation_test.go b/backend/scoreserver/admin/invitation_test.go new file mode 100644 index 000000000..d26d00fe2 --- /dev/null +++ b/backend/scoreserver/admin/invitation_test.go @@ -0,0 +1,86 @@ +package admin_test + +import ( + "context" + "net/http" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ictsc/ictsc-regalia/backend/pkg/pgtest" + adminv1 "github.com/ictsc/ictsc-regalia/backend/pkg/proto/admin/v1" + "github.com/ictsc/ictsc-regalia/backend/pkg/proto/admin/v1/adminv1connect" + "github.com/ictsc/ictsc-regalia/backend/scoreserver/admin" + "github.com/ictsc/ictsc-regalia/backend/scoreserver/infra/pg" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestAdminInvitationService_Create(t *testing.T) { + t.Parallel() + + now := time.Now() + + cases := map[string]struct { + in *adminv1.CreateInvitationCodeRequest + wants *adminv1.CreateInvitationCodeResponse + wantCode connect.Code + }{ + "ok": { + in: &adminv1.CreateInvitationCodeRequest{ + InvitationCode: &adminv1.InvitationCode{ + TeamCode: 1, + ExpiresAt: timestamppb.New(now.Add(24 * time.Hour)), + }, + }, + wants: &adminv1.CreateInvitationCodeResponse{ + InvitationCode: &adminv1.InvitationCode{ + TeamCode: 1, + ExpiresAt: timestamppb.New(now.Add(24 * time.Hour)), + }, + }, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + enforcer := setupEnforcer(t) + + db, ok := pgtest.SetupDB(t) + if !ok { + t.FailNow() + } + teamFixtures(db) + + mux := http.NewServeMux() + mux.Handle(adminv1connect.NewInvitationServiceHandler(admin.NewInvitationServiceHandler( + enforcer, pg.NewRepository(db), + ))) + + server := setupServer(t, mux) + client := adminv1connect.NewInvitationServiceClient(server.Client(), server.URL) + + resp, err := client.CreateInvitationCode(ctx, connect.NewRequest(tt.in)) + assertCode(t, tt.wantCode, err) + if err != nil { + return + } + + if diff := cmp.Diff( + resp.Msg, tt.wants, + cmpopts.IgnoreUnexported( + adminv1.CreateInvitationCodeResponse{}, + adminv1.InvitationCode{}, + timestamppb.Timestamp{}), + cmpopts.IgnoreFields(adminv1.CreateInvitationCodeResponse{}, "InvitationCode.Code"), + ); diff != "" { + t.Errorf("(-got, +want)\n%s", diff) + } + }) + } +} diff --git a/backend/scoreserver/admin/server.go b/backend/scoreserver/admin/server.go index 61fcf67a2..dc4a3e533 100644 --- a/backend/scoreserver/admin/server.go +++ b/backend/scoreserver/admin/server.go @@ -36,6 +36,10 @@ func New(ctx context.Context, cfg config.AdminAPI, db *sqlx.DB) (http.Handler, e NewTeamServiceHandler(enforcer, repo), connect.WithInterceptors(interceptors...), )) + mux.Handle(adminv1connect.NewInvitationServiceHandler( + NewInvitationServiceHandler(enforcer, repo), + connect.WithInterceptors(interceptors...), + )) checker := grpchealth.NewStaticChecker("admin.v1.TeamService") mux.Handle(grpchealth.NewHandler(checker)) From 8a1f7517d22c137859f5ba731196c7bce8c46883 Mon Sep 17 00:00:00 2001 From: tosuke <13393900+tosuke@users.noreply.github.com> Date: Sun, 2 Feb 2025 05:58:58 +0900 Subject: [PATCH 5/6] fix: sql otel summarize --- backend/pkg/pgxutil/open.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/pkg/pgxutil/open.go b/backend/pkg/pgxutil/open.go index f9473e662..6a3fdeb9e 100644 --- a/backend/pkg/pgxutil/open.go +++ b/backend/pkg/pgxutil/open.go @@ -84,16 +84,16 @@ func defaultAttributeGetter(_ context.Context, _ otelsql.Method, query string, _ } var ( - queryReMap = map[string]*regexp.Regexp{ - "SELECT": regexp.MustCompile(`(?i)^\s*SELECT\s+.*\s+FROM\s+([^\s]+)`), - "INSERT": regexp.MustCompile(`(?i)^\s*INSERT\s+INTO\s+([^\s]+)`), - "UPDATE": regexp.MustCompile(`(?i)^\s*UPDATE\s+([^\s]+)`), - "DELETE": regexp.MustCompile(`(?i)^\s*DELETE\s+FROM\s+([^\s]+)`), + queryRegexMap = map[string]*regexp.Regexp{ + "SELECT": regexp.MustCompile(`(?ims)^\s*SELECT\s+[\s\S]*?\sFROM\s+([^\s]+)`), + "UPDATE": regexp.MustCompile(`(?ims)^\s*UPDATE\s+([^\s]+)`), + "DELETE": regexp.MustCompile(`(?ims)^\s*DELETE\s+FROM\s+([^\s]+)`), + "INSERT": regexp.MustCompile(`(?ims)^\s*INSERT\s+INTO\s+([^\s]+)`), } ) func summarizeQuery(query string) string { - for key, re := range queryReMap { + for key, re := range queryRegexMap { if match := re.FindStringSubmatch(query); match != nil { table := strings.Trim(match[1], `"`) return fmt.Sprintf("%s %s", key, table) From b127d4ffa5a23cd18432997fdff7aed4f7dddb5b Mon Sep 17 00:00:00 2001 From: tosuke <13393900+tosuke@users.noreply.github.com> Date: Sun, 2 Feb 2025 05:59:08 +0900 Subject: [PATCH 6/6] chore: ignore funlen --- backend/.golangci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/.golangci.yaml b/backend/.golangci.yaml index 9e89f8490..9102053cb 100644 --- a/backend/.golangci.yaml +++ b/backend/.golangci.yaml @@ -13,6 +13,7 @@ linters: - ireturn - gofumpt - tagliatelle + - funlen issues: exclude-use-default: false